diff --git a/site/app/cli/page.tsx b/site/app/cli/page.tsx
new file mode 100644
index 000000000..a05ee267e
--- /dev/null
+++ b/site/app/cli/page.tsx
@@ -0,0 +1,252 @@
+import {
+ ArrowLeft,
+ Binary,
+ Edit3,
+ FileCode2,
+ Network,
+ Search,
+ TerminalSquare,
+} from "lucide-react";
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { Wordmark } from "@/components/logo";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+
+export const metadata: Metadata = {
+ title: "CLI reference | Graph-sitter",
+ description:
+ "High-level graph and codemod commands for inspecting and editing repositories with graph-sitter.",
+};
+
+const graphCommands = [
+ {
+ icon: FileCode2,
+ name: "inspect",
+ signature: "graph-sitter inspect FILE [PATH]",
+ text: "Shows line counts, imports, classes, functions, and per-function call summaries for a file.",
+ example:
+ "uvx graph-sitter inspect packages/app/src/index.ts ./repo --level calls",
+ },
+ {
+ icon: Search,
+ name: "symbols",
+ signature: "graph-sitter symbols [QUERY] [PATH]",
+ text: "Finds functions, classes, and symbols and prints copyable target strings for later commands.",
+ example:
+ "uvx graph-sitter symbols runInference ./repo --kind function --backend rust",
+ },
+ {
+ icon: Network,
+ name: "callgraph",
+ signature: "graph-sitter callgraph TARGET [PATH]",
+ text: "Traces outbound callees or inbound callers with clean local, resolved, deduped edges by default.",
+ example:
+ "uvx graph-sitter callgraph packages/app/src/index.ts.main ./repo --depth 2",
+ },
+ {
+ icon: Binary,
+ name: "using",
+ signature: "graph-sitter using TARGET [PATH]",
+ text: "Traces the functions and methods a target calls, recursively up to the requested depth.",
+ example:
+ "uvx graph-sitter using src/app.py:handler ./repo --depth 3 --resolved-only",
+ },
+ {
+ icon: Network,
+ name: "usages",
+ signature: "graph-sitter usages TARGET [PATH]",
+ text: "Finds callers and usage sites for a target, with optional recursive inbound traversal.",
+ example:
+ "uvx graph-sitter usages src/app.py:helper ./repo --depth 2 --dedupe",
+ },
+ {
+ icon: Edit3,
+ name: "rename",
+ signature: "graph-sitter rename TARGET --to NAME [PATH]",
+ text: "Applies a graph-aware rename and reports affected files in check mode before writing.",
+ example:
+ "uvx graph-sitter rename src/app.py:helper ./repo --to execute_helper --check",
+ },
+];
+
+const parseOptions = [
+ ["--backend python|rust|auto", "Select the graph backend."],
+ ["--fallback python|error", "Control behavior when Rust is unavailable."],
+ ["--language auto|python|typescript", "Set language detection explicitly."],
+ ["--subdir PATH", "Limit parsing to one or more repo-relative paths."],
+ [
+ "--format summary|json",
+ "Choose human-readable or machine-readable output.",
+ ],
+];
+
+export default function CliReferencePage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Home
+
+
+
+
+ uvx graph-sitter
+
+
+ CLI reference for graph-aware agents.
+
+
+ Use the command line to parse repositories, inspect symbols,
+ trace call relationships, and run focused codemods without
+ writing a one-off script first.
+
+
+
+
+
+
+
+
+ {graphCommands.map((command) => (
+
+
+
+
+ {command.name}
+
+
+
+ {command.signature}
+
+
+ {command.text}
+
+
+ {command.example}
+
+
+ ))}
+
+
+
+
+
+
+
+ Common parse controls
+
+
+ These options are shared by the graph commands and make the CLI
+ useful on large monorepos.
+
+
+
+ {parseOptions.map(([option, text]) => (
+
+ {option}
+ {text}
+
+ ))}
+
+
+
+
+
+
+
+
+ Full-repo TypeScript
+
+
+ Use the Rust backend for broad discovery and outbound call graph
+ traversal. Scope to a package with the Python backend when you
+ need function-level inbound caller recursion.
+
+
+
+
+
+
+
+ );
+}
+
+function CodeBlock({ lines }: { lines: string[] }) {
+ return (
+
+
+
+
+
+ terminal
+
+
+
+
+ {lines.map((line) => (
+
+ $ {" "}
+ {line.split(" ")[0]}
+ {line.slice(line.indexOf(" "))}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/site/app/page.tsx b/site/app/page.tsx
index 0ab99db4a..4fd08893b 100644
--- a/site/app/page.tsx
+++ b/site/app/page.tsx
@@ -76,6 +76,14 @@ export default function Home() {
+
+ CLI
+
-
+
- Get started with uvx
+ Open CLI reference
@@ -253,6 +261,9 @@ export default function Home() {
+
+ CLI
+
Docs
diff --git a/site/scripts/gen-nextjs-depgraph.py b/site/scripts/gen-nextjs-depgraph.py
index ff27b65db..cad083606 100644
--- a/site/scripts/gen-nextjs-depgraph.py
+++ b/site/scripts/gen-nextjs-depgraph.py
@@ -13,15 +13,15 @@
from collections import defaultdict
from pathlib import Path
+from graph_sitter.codebase.config import ProjectConfig
+from graph_sitter.configs.models.codebase import CodebaseConfig, GraphBackend, RustFallbackMode
+from graph_sitter.core.codebase import Codebase
+
REPO = os.environ.get("NEXTJS_REPO", "/Users/jayhack/.codex/worktrees/0554/nextjs-sample")
SUBDIR = "packages/next/src/"
PREFIX = "packages/next/src/"
OUT = Path(__file__).resolve().parents[1] / "lib" / "data" / "nextjs-depgraph.json"
-from graph_sitter.codebase.config import ProjectConfig
-from graph_sitter.configs.models.codebase import CodebaseConfig, GraphBackend, RustFallbackMode
-from graph_sitter.core.codebase import Codebase
-
def module_of(rel: str) -> str:
parts = rel.split("/")
diff --git a/src/graph_sitter/cli/cli.py b/src/graph_sitter/cli/cli.py
index fa5a7dd47..91dca8d99 100644
--- a/src/graph_sitter/cli/cli.py
+++ b/src/graph_sitter/cli/cli.py
@@ -1,6 +1,7 @@
import rich_click as click
from rich.traceback import install
+from graph_sitter.cli.commands.callgraph.main import callgraph_command
from graph_sitter.cli.commands.config.main import config_command
from graph_sitter.cli.commands.create.main import create_command
from graph_sitter.cli.commands.diagnose.main import diagnose_command
@@ -16,6 +17,7 @@
from graph_sitter.cli.commands.run.main import run_command
from graph_sitter.cli.commands.start.main import start_command
from graph_sitter.cli.commands.style_debug.main import style_debug_command
+from graph_sitter.cli.commands.symbols.main import symbols_command
from graph_sitter.cli.commands.transform.main import transform_command
from graph_sitter.cli.commands.update.main import update_command
from graph_sitter.cli.commands.usages.main import usages_command
@@ -37,6 +39,8 @@ def main():
main.add_command(diagnose_command)
main.add_command(parse_command)
main.add_command(inspect_command)
+main.add_command(symbols_command)
+main.add_command(callgraph_command)
main.add_command(usages_command)
main.add_command(using_command)
main.add_command(rename_command)
diff --git a/src/graph_sitter/cli/commands/callgraph/main.py b/src/graph_sitter/cli/commands/callgraph/main.py
new file mode 100644
index 000000000..f5946eddd
--- /dev/null
+++ b/src/graph_sitter/cli/commands/callgraph/main.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+
+import rich_click as click
+
+from graph_sitter.cli.commands.graph.common import (
+ GRAPH_COMMAND_JSON_SCHEMA_VERSION,
+ emit_json,
+ filter_edge_records,
+ graph_options,
+ load_codebase,
+ print_edge_table,
+ resolve_target,
+ trace_edges,
+)
+
+
+@click.command(name="callgraph")
+@click.argument("target", type=str)
+@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path("."))
+@click.option("--direction", type=click.Choice(["outbound", "inbound"]), default="outbound", show_default=True, help="Trace callees or callers.")
+@click.option("--depth", type=click.IntRange(min=0), default=2, show_default=True, help="Recursion depth through resolved call edges.")
+@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum call edges to print.")
+@click.option("--raw", is_flag=True, help="Include unresolved runtime/library calls and repeated call sites.")
+@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.")
+@graph_options
+def callgraph_command(
+ target: str,
+ path: Path,
+ direction: str,
+ depth: int,
+ max_results: int,
+ raw: bool,
+ output_format: str,
+ backend: str,
+ fallback: str,
+ language: str,
+ subdirectories: tuple[str, ...],
+) -> None:
+ """Trace a clean first-party call graph for a target."""
+ codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json")
+ resolved = resolve_target(codebase, target)
+ trace_limit = max_results if raw else max_results * 5
+ edges = trace_edges(resolved.symbol, direction=direction, depth=depth, max_results=trace_limit)
+ if not raw:
+ edges = filter_edge_records(edges, resolved_only=True, local_only=True, hide_runtime=True, dedupe=True)
+ edges = edges[:max_results]
+ payload = {
+ "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION,
+ "direction": direction,
+ "target": target,
+ "depth": depth,
+ "max_results": max_results,
+ "raw": raw,
+ "edges": edges,
+ }
+
+ if output_format == "json":
+ emit_json(payload)
+ return
+
+ print_edge_table("Graph-sitter callgraph", target, edges, inbound=direction == "inbound")
diff --git a/src/graph_sitter/cli/commands/graph/common.py b/src/graph_sitter/cli/commands/graph/common.py
index e77a8adbb..f3470f80f 100644
--- a/src/graph_sitter/cli/commands/graph/common.py
+++ b/src/graph_sitter/cli/commands/graph/common.py
@@ -5,13 +5,63 @@
from pathlib import Path
from typing import Any
+import rich
import rich_click as click
+from rich.table import Table
from graph_sitter.cli.commands.parse.main import _parse_language, _project_for_parse, _suppress_parse_logs
from graph_sitter.configs.models.codebase import CodebaseConfig, GraphBackend, RustFallbackMode
from graph_sitter.core.codebase import Codebase
GRAPH_COMMAND_JSON_SCHEMA_VERSION = 1
+COMMON_RUNTIME_CALLS = {
+ "Any",
+ "Array",
+ "Boolean",
+ "Date",
+ "Error",
+ "Map",
+ "Number",
+ "Object",
+ "Promise",
+ "Set",
+ "String",
+ "append",
+ "assign",
+ "cast",
+ "entries",
+ "error",
+ "exit",
+ "filter",
+ "flat",
+ "forEach",
+ "fromEntries",
+ "getattr",
+ "has",
+ "hasattr",
+ "isArray",
+ "isinstance",
+ "items",
+ "join",
+ "keys",
+ "log",
+ "map",
+ "parseInt",
+ "push",
+ "repr",
+ "slice",
+ "sort",
+ "split",
+ "startsWith",
+ "str",
+ "stringify",
+ "toISOString",
+ "trim",
+ "tuple",
+ "type",
+ "values",
+ "warning",
+}
@dataclass(frozen=True)
@@ -29,6 +79,14 @@ def graph_options(func: CallableType) -> CallableType:
return func
+def trace_filter_options(func: CallableType) -> CallableType:
+ func = click.option("--dedupe", is_flag=True, help="Collapse repeated edges with the same source, target, and call name.")(func)
+ func = click.option("--hide-runtime", is_flag=True, help="Hide unresolved calls to common language and runtime helpers.")(func)
+ func = click.option("--local-only", is_flag=True, help="Only include edges whose source and target are parsed local files.")(func)
+ func = click.option("--resolved-only", is_flag=True, help="Only include edges resolved to parsed symbols.")(func)
+ return func
+
+
def load_codebase(path: Path, backend: str, fallback: str, language: str, subdirectories: tuple[str, ...], *, quiet: bool = True) -> Codebase:
config = CodebaseConfig(
graph_backend=GraphBackend(backend),
@@ -58,6 +116,11 @@ def safe_attr(obj: Any, name: str, default: Any = None) -> Any:
def as_list(value: Any) -> list[Any]:
if value is None:
return []
+ if callable(value):
+ try:
+ value = value()
+ except TypeError:
+ return []
try:
return list(value)
except TypeError:
@@ -106,6 +169,8 @@ def symbol_name(symbol: Any) -> str:
name = safe_attr(symbol, "name")
if name:
return str(name)
+ if type(symbol).__name__.endswith("Function"):
+ return "anonymous"
return type(symbol).__name__
@@ -139,6 +204,31 @@ def symbol_record(symbol: Any | None) -> dict[str, Any] | None:
}
+def all_symbol_records(codebase: Codebase, *, query: str | None = None, kind: str = "all", max_results: int = 200) -> list[dict[str, Any]]:
+ query_lower = query.lower() if query else None
+ records: list[dict[str, Any]] = []
+ seen: set[str] = set()
+
+ for source_file in sorted(codebase.files, key=file_path_of):
+ for symbol in _symbols_for_kind(source_file, kind):
+ key = symbol_key(symbol)
+ if key in seen:
+ continue
+ record = symbol_record(symbol)
+ if record is None:
+ continue
+ record["target"] = target_string(symbol)
+ searchable = " ".join(str(record.get(field) or "") for field in ("name", "qualified_name", "kind", "file")).lower()
+ if query_lower and query_lower not in searchable:
+ continue
+ records.append(record)
+ seen.add(key)
+ if len(records) >= max_results:
+ return records
+
+ return records
+
+
def call_record(call: Any) -> dict[str, Any]:
definitions = []
for definition in as_list(safe_attr(call, "function_definitions")):
@@ -242,18 +332,89 @@ def inbound_edges(symbol: Any) -> list[dict[str, Any]]:
edges: list[dict[str, Any]] = []
for call in as_list(safe_attr(symbol, "call_sites")):
edges.append({"source": caller_for_call(call), "target": symbol, "call": call})
+ if edges:
+ return edges
+ for usage in as_list(safe_attr(symbol, "usages")):
+ usage_file = safe_attr(usage, "file")
+ edges.append({"source": usage_file or usage, "target": symbol, "call": usage, "call_name": symbol_name(symbol)})
return edges
def edge_record(edge: dict[str, Any], depth: int) -> dict[str, Any]:
+ call = call_record(edge["call"])
+ if not call["name"] and edge.get("call_name"):
+ call["name"] = str(edge["call_name"])
return {
"depth": depth,
"source": symbol_record(edge["source"]),
"target": symbol_record(edge["target"]),
- "call": call_record(edge["call"]),
+ "call": call,
}
+def filter_edge_records(
+ edges: list[dict[str, Any]],
+ *,
+ resolved_only: bool = False,
+ local_only: bool = False,
+ hide_runtime: bool = False,
+ dedupe: bool = False,
+) -> list[dict[str, Any]]:
+ filtered: list[dict[str, Any]] = []
+ seen: set[tuple[int, str, str, str]] = set()
+
+ for edge in edges:
+ source = edge.get("source")
+ target = edge.get("target")
+ call = edge.get("call") or {}
+ call_name = str(call.get("name") or "")
+
+ if resolved_only and (source is None or target is None):
+ continue
+ if local_only and (not source or not target or not source.get("file") or not target.get("file")):
+ continue
+ if hide_runtime and target is None and call_name in COMMON_RUNTIME_CALLS:
+ continue
+
+ if dedupe:
+ source_name = _record_label(source)
+ target_name = _record_label(target) if target is not None else "unresolved"
+ key = (int(edge.get("depth") or 0), source_name, target_name, call_name)
+ if key in seen:
+ continue
+ seen.add(key)
+
+ filtered.append(edge)
+
+ return filtered
+
+
+def print_edge_table(title: str, target: str, edges: list[dict[str, Any]], *, inbound: bool = False) -> None:
+ rich.print(f"[bold]{title}[/bold] {target}")
+ if not edges:
+ rich.print("No call edges found.")
+ return
+
+ table = Table(show_header=True, header_style="bold", expand=True)
+ table.add_column("Depth", justify="right", no_wrap=True)
+ table.add_column("Caller" if inbound else "From", overflow="fold")
+ table.add_column("Target" if inbound else "To", overflow="fold")
+ table.add_column("Call", overflow="fold")
+ table.add_column("Location", overflow="fold")
+ for edge in edges:
+ source = edge.get("source") or {}
+ target_record = edge.get("target") or {}
+ call = edge.get("call") or {}
+ table.add_row(
+ str(edge.get("depth", "")),
+ _record_label(source),
+ _record_label(target_record) if target_record else "unresolved",
+ str(call.get("name") or ""),
+ str(call.get("location") or ""),
+ )
+ rich.print(table)
+
+
def trace_edges(symbol: Any, *, direction: str, depth: int, max_results: int) -> list[dict[str, Any]]:
queue: list[tuple[Any, int]] = [(symbol, 0)]
visited_nodes = {symbol_key(symbol)}
@@ -384,6 +545,18 @@ def _all_symbols_in_file(source_file: Any) -> list[Any]:
return _dedupe_symbols(symbols)
+def _symbols_for_kind(source_file: Any, kind: str) -> list[Any]:
+ if kind == "function":
+ return all_functions_in_file(source_file)
+ if kind == "class":
+ return _dedupe_symbols(as_list(safe_attr(source_file, "classes")))
+ if kind == "symbol":
+ return _dedupe_symbols(as_list(safe_attr(source_file, "symbols")))
+ symbols = _all_symbols_in_file(source_file)
+ symbols.extend(as_list(safe_attr(source_file, "classes")))
+ return _dedupe_symbols(symbols)
+
+
def _global_symbol_matches(codebase: Codebase, symbol_ref: str) -> list[Any]:
matches: list[Any] = []
for source_file in codebase.files:
@@ -413,6 +586,20 @@ def _target_label(symbol: Any) -> str:
return f"{file_path_of(symbol)}:{qualified_name(symbol)}"
+def target_string(symbol: Any) -> str:
+ filepath = file_path_of(symbol)
+ name = qualified_name(symbol)
+ if not filepath:
+ return name
+ return f"{filepath}.{name}"
+
+
+def _record_label(record: dict[str, Any] | None) -> str:
+ if not record:
+ return ""
+ return str(record.get("qualified_name") or record.get("name") or record.get("file") or "")
+
+
def _canonical_function(function: Any, call: Any) -> Any | None:
source_file = safe_attr(function, "file") or safe_attr(call, "file")
if source_file is None:
diff --git a/src/graph_sitter/cli/commands/rename/main.py b/src/graph_sitter/cli/commands/rename/main.py
index 0360d87aa..380fffbda 100644
--- a/src/graph_sitter/cli/commands/rename/main.py
+++ b/src/graph_sitter/cli/commands/rename/main.py
@@ -8,6 +8,7 @@
GRAPH_COMMAND_JSON_SCHEMA_VERSION,
as_list,
emit_json,
+ file_path_of,
graph_options,
load_codebase,
resolve_target,
@@ -47,6 +48,7 @@ def rename_command(
old_name = str(safe_attr(symbol, "name", target))
reference_count = len(as_list(safe_attr(symbol, "usages")))
call_site_count = len(as_list(safe_attr(symbol, "call_sites")))
+ affected_files = _affected_files(symbol)
applied = bool(write)
if write:
@@ -64,6 +66,7 @@ def rename_command(
symbol=symbol,
reference_count=reference_count,
call_site_count=call_site_count,
+ affected_files=affected_files,
applied=applied,
)
@@ -82,6 +85,7 @@ def _payload(
symbol: Any,
reference_count: int,
call_site_count: int,
+ affected_files: list[str],
applied: bool,
) -> dict[str, Any]:
return {
@@ -92,10 +96,20 @@ def _payload(
"symbol": symbol_record(symbol),
"references": reference_count,
"call_sites": call_site_count,
+ "affected_files": affected_files,
"applied": applied,
}
+def _affected_files(symbol: Any) -> list[str]:
+ paths = {file_path_of(symbol)}
+ for usage in as_list(safe_attr(symbol, "usages")):
+ paths.add(file_path_of(usage))
+ for call_site in as_list(safe_attr(symbol, "call_sites")):
+ paths.add(file_path_of(call_site))
+ return sorted(path for path in paths if path)
+
+
def _print_summary(payload: dict[str, Any]) -> None:
symbol = payload.get("symbol") or {}
mode = "Applied" if payload["applied"] else "Dry run"
@@ -103,5 +117,9 @@ def _print_summary(payload: dict[str, Any]) -> None:
rich.print(f"Target: {symbol.get('location') or payload['target']}")
rich.print(f"Rename: {payload['old_name']} -> {payload['new_name']}")
rich.print(f"References: {payload['references']} Call sites: {payload['call_sites']}")
+ if payload["affected_files"]:
+ rich.print("Affected files:")
+ for path in payload["affected_files"]:
+ rich.print(f" - {path}")
if not payload["applied"]:
rich.print("Pass --write to apply this rename.")
diff --git a/src/graph_sitter/cli/commands/symbols/main.py b/src/graph_sitter/cli/commands/symbols/main.py
new file mode 100644
index 000000000..65d22ce1f
--- /dev/null
+++ b/src/graph_sitter/cli/commands/symbols/main.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+
+import rich
+import rich_click as click
+from rich.table import Table
+
+from graph_sitter.cli.commands.graph.common import (
+ GRAPH_COMMAND_JSON_SCHEMA_VERSION,
+ all_symbol_records,
+ emit_json,
+ graph_options,
+ load_codebase,
+)
+
+
+@click.command(name="symbols")
+@click.argument("query", required=False, type=str)
+@click.argument("path", required=False, type=click.Path(path_type=Path, exists=True, file_okay=False), default=Path("."))
+@click.option("--kind", type=click.Choice(["all", "function", "class", "symbol"]), default="all", show_default=True, help="Symbol kind to list.")
+@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum symbols to print.")
+@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.")
+@graph_options
+def symbols_command(
+ query: str | None,
+ path: Path,
+ kind: str,
+ max_results: int,
+ output_format: str,
+ backend: str,
+ fallback: str,
+ language: str,
+ subdirectories: tuple[str, ...],
+) -> None:
+ """List parsed symbols and target strings for graph commands."""
+ codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json")
+ symbols = all_symbol_records(codebase, query=query, kind=kind, max_results=max_results)
+ payload = {
+ "schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION,
+ "query": query,
+ "kind": kind,
+ "max_results": max_results,
+ "symbols": symbols,
+ }
+
+ if output_format == "json":
+ emit_json(payload)
+ return
+
+ _print_symbols(payload)
+
+
+def _print_symbols(payload: dict) -> None:
+ title = "Graph-sitter symbols"
+ if payload.get("query"):
+ title = f"{title} {payload['query']}"
+ rich.print(f"[bold]{title}[/bold]")
+ symbols = payload.get("symbols") or []
+ if not symbols:
+ rich.print("No symbols found.")
+ return
+
+ table = Table(show_header=True, header_style="bold", expand=True)
+ table.add_column("Target", overflow="fold")
+ table.add_column("Kind", overflow="fold")
+ table.add_column("Line", justify="right", no_wrap=True)
+ table.add_column("Name", overflow="fold")
+ for symbol in symbols:
+ table.add_row(
+ str(symbol.get("target") or ""),
+ str(symbol.get("kind") or ""),
+ str(symbol.get("line") or ""),
+ str(symbol.get("qualified_name") or symbol.get("name") or ""),
+ )
+ rich.print(table)
diff --git a/src/graph_sitter/cli/commands/usages/main.py b/src/graph_sitter/cli/commands/usages/main.py
index 51083edd8..dc3b89501 100644
--- a/src/graph_sitter/cli/commands/usages/main.py
+++ b/src/graph_sitter/cli/commands/usages/main.py
@@ -1,17 +1,17 @@
from pathlib import Path
-from typing import Any
-import rich
import rich_click as click
-from rich.table import Table
from graph_sitter.cli.commands.graph.common import (
GRAPH_COMMAND_JSON_SCHEMA_VERSION,
emit_json,
+ filter_edge_records,
graph_options,
load_codebase,
+ print_edge_table,
resolve_target,
trace_edges,
+ trace_filter_options,
)
@@ -21,6 +21,7 @@
@click.option("--depth", type=click.IntRange(min=0), default=1, show_default=True, help="Recursion depth through inbound callers.")
@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum call edges to print.")
@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.")
+@trace_filter_options
@graph_options
def usages_command(
target: str,
@@ -28,6 +29,10 @@ def usages_command(
depth: int,
max_results: int,
output_format: str,
+ resolved_only: bool,
+ local_only: bool,
+ hide_runtime: bool,
+ dedupe: bool,
backend: str,
fallback: str,
language: str,
@@ -36,13 +41,21 @@ def usages_command(
"""Trace call sites that use a target."""
codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json")
resolved = resolve_target(codebase, target)
- edges = trace_edges(resolved.symbol, direction="inbound", depth=depth, max_results=max_results)
+ trace_limit = max_results * 5 if resolved_only or local_only or hide_runtime or dedupe else max_results
+ edges = trace_edges(resolved.symbol, direction="inbound", depth=depth, max_results=trace_limit)
+ edges = filter_edge_records(edges, resolved_only=resolved_only, local_only=local_only, hide_runtime=hide_runtime, dedupe=dedupe)[:max_results]
payload = {
"schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION,
"direction": "inbound",
"target": target,
"depth": depth,
"max_results": max_results,
+ "filters": {
+ "resolved_only": resolved_only,
+ "local_only": local_only,
+ "hide_runtime": hide_runtime,
+ "dedupe": dedupe,
+ },
"edges": edges,
}
@@ -50,30 +63,4 @@ def usages_command(
emit_json(payload)
return
- _print_edges("Graph-sitter usages", target, edges)
-
-
-def _print_edges(title: str, target: str, edges: list[dict[str, Any]]) -> None:
- rich.print(f"[bold]{title}[/bold] {target}")
- if not edges:
- rich.print("No inbound call edges found.")
- return
-
- table = Table(show_header=True, header_style="bold")
- table.add_column("Depth", justify="right")
- table.add_column("Caller")
- table.add_column("Target")
- table.add_column("Call")
- table.add_column("Location")
- for edge in edges:
- source = edge.get("source") or {}
- target_record = edge.get("target") or {}
- call = edge.get("call") or {}
- table.add_row(
- str(edge.get("depth", "")),
- str(source.get("qualified_name") or source.get("name") or source.get("file") or ""),
- str(target_record.get("qualified_name") or target_record.get("name") or ""),
- str(call.get("name") or ""),
- str(call.get("location") or ""),
- )
- rich.print(table)
+ print_edge_table("Graph-sitter usages", target, edges, inbound=True)
diff --git a/src/graph_sitter/cli/commands/using/main.py b/src/graph_sitter/cli/commands/using/main.py
index e8d0fd28e..aa4526329 100644
--- a/src/graph_sitter/cli/commands/using/main.py
+++ b/src/graph_sitter/cli/commands/using/main.py
@@ -1,17 +1,17 @@
from pathlib import Path
-from typing import Any
-import rich
import rich_click as click
-from rich.table import Table
from graph_sitter.cli.commands.graph.common import (
GRAPH_COMMAND_JSON_SCHEMA_VERSION,
emit_json,
+ filter_edge_records,
graph_options,
load_codebase,
+ print_edge_table,
resolve_target,
trace_edges,
+ trace_filter_options,
)
@@ -21,6 +21,7 @@
@click.option("--depth", type=click.IntRange(min=0), default=1, show_default=True, help="Recursion depth through resolved outbound calls.")
@click.option("--max-results", type=click.IntRange(min=1), default=200, show_default=True, help="Maximum call edges to print.")
@click.option("--format", "output_format", type=click.Choice(["summary", "json"]), default="summary", show_default=True, help="Output format.")
+@trace_filter_options
@graph_options
def using_command(
target: str,
@@ -28,6 +29,10 @@ def using_command(
depth: int,
max_results: int,
output_format: str,
+ resolved_only: bool,
+ local_only: bool,
+ hide_runtime: bool,
+ dedupe: bool,
backend: str,
fallback: str,
language: str,
@@ -36,13 +41,21 @@ def using_command(
"""Trace functions and symbols used by a target."""
codebase = load_codebase(path, backend, fallback, language, subdirectories, quiet=output_format == "json")
resolved = resolve_target(codebase, target)
- edges = trace_edges(resolved.symbol, direction="outbound", depth=depth, max_results=max_results)
+ trace_limit = max_results * 5 if resolved_only or local_only or hide_runtime or dedupe else max_results
+ edges = trace_edges(resolved.symbol, direction="outbound", depth=depth, max_results=trace_limit)
+ edges = filter_edge_records(edges, resolved_only=resolved_only, local_only=local_only, hide_runtime=hide_runtime, dedupe=dedupe)[:max_results]
payload = {
"schema_version": GRAPH_COMMAND_JSON_SCHEMA_VERSION,
"direction": "outbound",
"target": target,
"depth": depth,
"max_results": max_results,
+ "filters": {
+ "resolved_only": resolved_only,
+ "local_only": local_only,
+ "hide_runtime": hide_runtime,
+ "dedupe": dedupe,
+ },
"edges": edges,
}
@@ -50,30 +63,4 @@ def using_command(
emit_json(payload)
return
- _print_edges("Graph-sitter using", target, edges)
-
-
-def _print_edges(title: str, target: str, edges: list[dict[str, Any]]) -> None:
- rich.print(f"[bold]{title}[/bold] {target}")
- if not edges:
- rich.print("No resolved outbound call edges found.")
- return
-
- table = Table(show_header=True, header_style="bold")
- table.add_column("Depth", justify="right")
- table.add_column("From")
- table.add_column("To")
- table.add_column("Call")
- table.add_column("Location")
- for edge in edges:
- source = edge.get("source") or {}
- target_record = edge.get("target") or {}
- call = edge.get("call") or {}
- table.add_row(
- str(edge.get("depth", "")),
- str(source.get("qualified_name") or source.get("name") or source.get("file") or ""),
- str(target_record.get("qualified_name") or target_record.get("name") or "unresolved"),
- str(call.get("name") or ""),
- str(call.get("location") or ""),
- )
- rich.print(table)
+ print_edge_table("Graph-sitter using", target, edges)
diff --git a/src/graph_sitter/git/repo_operator/repo_operator.py b/src/graph_sitter/git/repo_operator/repo_operator.py
index f08fd5c11..6c76beb02 100644
--- a/src/graph_sitter/git/repo_operator/repo_operator.py
+++ b/src/graph_sitter/git/repo_operator/repo_operator.py
@@ -6,13 +6,14 @@
from datetime import UTC, datetime
from functools import cached_property
from time import perf_counter
-from typing import Self
+from typing import Literal, Self
from codeowners import CodeOwners as CodeOwnersParser
from git import Commit as GitCommit
from git import Diff, GitCommandError, InvalidGitRepositoryError, Remote
from git import Repo as GitCLI
from git.remote import PushInfoList
+from github.GithubObject import NotSet
from github.IssueComment import IssueComment
from github.PullRequest import PullRequest
@@ -70,7 +71,10 @@ def __init__(
else:
os.makedirs(self.repo_path, exist_ok=True)
- GitCLI.init(self.repo_path)
+ try:
+ GitCLI(self.repo_path, search_parent_directories=True)
+ except InvalidGitRepositoryError:
+ GitCLI.init(self.repo_path)
self._local_git_repo = LocalGitRepo(repo_path=repo_config.repo_path)
if self.repo_config.full_name is None:
self.repo_config.full_name = self._local_git_repo.full_name
@@ -137,18 +141,14 @@ def _unset_bot_username(self, git_cli: GitCLI) -> None:
def git_cli(self) -> GitCLI:
git_cli = GitCLI(self.repo_path)
username = None
- user_level = None
email = None
- email_level = None
- levels = ["system", "global", "user", "repository"]
+ levels: tuple[Literal["system", "global", "user", "repository"], ...] = ("system", "global", "user", "repository")
for level in levels:
with git_cli.config_reader(level) as reader:
if reader.has_option("user", "name") and not username:
username = username or reader.get("user", "name")
- user_level = user_level or level
if reader.has_option("user", "email") and not email:
email = email or reader.get("user", "email")
- email_level = email_level or level
# We need a username and email to commit, so if they're not set, set them to the bot's
if not username or self.bot_commit:
@@ -156,13 +156,6 @@ def git_cli(self) -> GitCLI:
if not email or self.bot_commit:
self._set_bot_email(git_cli)
- # If user config is set at a level above the repo level: unset it
- if not self.bot_commit:
- if username and username != CODEGEN_BOT_NAME and user_level != "repository":
- self._unset_bot_username(git_cli)
- if email and email != CODEGEN_BOT_EMAIL and email_level != "repository":
- self._unset_bot_email(git_cli)
-
return git_cli
@property
@@ -354,17 +347,18 @@ def checkout_commit(self, commit_hash: str | GitCommit, remote_name: str = "orig
"""Checks out the relevant commit
TODO: handle the environment being dirty
"""
- logger.info(f"Checking out commit: {commit_hash}")
- if not self.git_cli.is_valid_object(commit_hash, "commit"):
- self.fetch_remote(remote_name=remote_name, refspec=commit_hash)
- if not self.git_cli.is_valid_object(commit_hash, "commit"):
+ commit_ref = commit_hash.hexsha if isinstance(commit_hash, GitCommit) else commit_hash
+ logger.info(f"Checking out commit: {commit_ref}")
+ if not self.git_cli.is_valid_object(commit_ref, "commit"):
+ self.fetch_remote(remote_name=remote_name, refspec=commit_ref)
+ if not self.git_cli.is_valid_object(commit_ref, "commit"):
return CheckoutResult.NOT_FOUND
if self.git_cli.is_dirty():
- logger.info(f"Environment is dirty, discarding changes before checking out commit: {commit_hash}")
+ logger.info(f"Environment is dirty, discarding changes before checking out commit: {commit_ref}")
self.discard_changes()
- self.git_cli.git.checkout(commit_hash)
+ self.git_cli.git.checkout(commit_ref)
return CheckoutResult.SUCCESS
def get_active_branch_or_commit(self) -> str:
@@ -580,7 +574,7 @@ def get_file(self, path: str) -> str:
return content
except UnicodeDecodeError:
print(f"Warning: Unable to decode file {file_path}. Skipping.")
- return None
+ return ""
def write_file(self, relpath: str, content: str) -> None:
"""Writes file content to disk"""
@@ -724,13 +718,14 @@ def get_modified_files_in_last_n_days(self, days: int = 1) -> tuple[list[str], l
commit = repo.commit(commit_sha)
files_changed = commit.stats.files
for file, stats in files_changed.items():
+ file_path = os.fspath(file)
if stats["deletions"] == stats["lines"]:
- deleted_files.append(file)
- if file in modified_files:
- modified_files.remove(file)
+ deleted_files.append(file_path)
+ if file_path in modified_files:
+ modified_files.remove(file_path)
else:
- if file not in modified_files and file[-3:] in allowed_extensions:
- modified_files.append(file)
+ if file_path not in modified_files and file_path[-3:] in allowed_extensions:
+ modified_files.append(file_path)
return modified_files, deleted_files
@cached_property
@@ -752,7 +747,8 @@ def stash_pop(self) -> None:
def get_pr_data(self, pr_number: int) -> dict:
"""Returns the data associated with a PR"""
- return self.remote_git_repo.get_pr_data(pr_number)
+ pr = self.remote_git_repo.get_pull_safe(pr_number)
+ return pr.raw_data if pr is not None else {}
def create_pr_comment(self, pr_number: int, body: str) -> IssueComment:
"""Create a general comment on a pull request.
@@ -765,6 +761,8 @@ def create_pr_comment(self, pr_number: int, body: str) -> IssueComment:
if pr:
comment = self.remote_git_repo.create_issue_comment(pr, body)
return comment
+ msg = f"Pull request {pr_number} not found"
+ raise ValueError(msg)
def create_pr_review_comment(
self,
@@ -796,7 +794,7 @@ def create_pr_review_comment(
body=body,
commit=commit,
path=path,
- line=line,
+ line=line if line is not None else NotSet,
side=side,
)
diff --git a/src/graph_sitter/typescript/config_parser.py b/src/graph_sitter/typescript/config_parser.py
index 9a47c2a08..5ec856aa1 100644
--- a/src/graph_sitter/typescript/config_parser.py
+++ b/src/graph_sitter/typescript/config_parser.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from graph_sitter.codebase.config_parser import ConfigParser
from graph_sitter.core.file import File
@@ -25,16 +25,33 @@ def __init__(self, codebase_context: "CodebaseContext", default_config_name: str
self.ctx = codebase_context
self.default_config_name = default_config_name
- def get_config(self, config_path: os.PathLike) -> TSConfig | None:
- path = self.ctx.to_absolute(config_path)
+ def get_config(self, config_path: os.PathLike | str) -> TSConfig | None:
+ path = self._normalize_config_path(self.ctx.to_absolute(config_path))
+ if path is None:
+ return None
if path in self.config_files:
return self.config_files[path]
if path.exists():
- self.config_files[path] = TSConfig(File.from_content(config_path, path.read_text(), self.ctx, sync=False), self)
- return self.config_files.get(path)
+ config_file = File.from_content(path, path.read_text(), self.ctx, sync=False)
+ if config_file is None:
+ return None
+ self.config_files[path] = TSConfig(config_file, self)
+ return self.config_files[path]
return None
- def parse_configs(self):
+ def _normalize_config_path(self, path: Path) -> Path | None:
+ if path.is_dir():
+ path = path / self.default_config_name
+ elif not path.exists() and path.suffix != ".json":
+ json_path = path.with_suffix(".json")
+ if json_path.exists():
+ path = json_path
+
+ if path.exists() and not path.is_file():
+ return None
+ return path
+
+ def parse_configs(self, codebase_context: "CodebaseContext | None" = None) -> None:
# This only yields a 0.05s speedup, but its funny writing dynamic programming code
@cache
def get_config_for_dir(dir_path: Path) -> TSConfig | None:
@@ -52,11 +69,11 @@ def get_config_for_dir(dir_path: Path) -> TSConfig | None:
# Get all the files in the codebase
for file in self.ctx.get_nodes(NodeType.FILE):
- file: TSFile # This should be safe because we only call this on TSFiles
+ ts_file = cast("TSFile", file) # This should be safe because we only call this on TSFiles
# Get the config for the directory the file is in
- config = get_config_for_dir(file.path.parent)
+ config = get_config_for_dir(ts_file.path.parent)
# Set the config for the file
- file.ts_config = config
+ ts_file.ts_config = config
# Loop through all the configs and precompute their import aliases
for config in self.config_files.values():
diff --git a/src/graph_sitter/typescript/ts_config.py b/src/graph_sitter/typescript/ts_config.py
index fe05a3c54..12ff78ec3 100644
--- a/src/graph_sitter/typescript/ts_config.py
+++ b/src/graph_sitter/typescript/ts_config.py
@@ -1,7 +1,7 @@
import os
from functools import cache
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, cast
import pyjson5
@@ -29,7 +29,7 @@ class TSConfig:
config_file: File
config_parser: "TSConfigParser"
- config: dict
+ config: dict[str, Any]
# Base config values
_base_config: "TSConfig | None" = None
@@ -46,7 +46,7 @@ class TSConfig:
_self_root_dir: str | None = None
_self_root_dirs: list[str] = []
_self_paths: dict[str, list[str]] = {}
- _self_references: list[Directory | File] = []
+ _self_references: list[tuple[str, Directory | File]] = []
# Precomputed import aliases
_computed_path_import_aliases: bool = False
@@ -77,10 +77,16 @@ def _precompute_config_values(self):
"""Precomputes the base config, base url, paths, and references."""
# Precompute the base config
self._base_config = None
+ compiler_options = self.config.get("compilerOptions", {})
+ if not isinstance(compiler_options, dict):
+ compiler_options = {}
+
extends = self.config.get("extends", None)
if isinstance(extends, list):
# TODO: Support multiple extends
extends = extends[0] # Grab the first config in the list
+ if not isinstance(extends, str):
+ extends = None
base_config_path = self._parse_parent_config_path(extends)
if base_config_path and base_config_path.exists():
@@ -89,57 +95,67 @@ def _precompute_config_values(self):
# Precompute the base url
self._base_url = None
self._self_base_url = None
- if base_url := self.config.get("compilerOptions", {}).get("baseUrl", None):
+ base_url = compiler_options.get("baseUrl", None)
+ if isinstance(base_url, str) and base_url:
self._base_url = base_url
self._self_base_url = base_url
- elif base_url := {} if self.base_config is None else self.base_config.base_url:
+ elif self.base_config is not None and self.base_config.base_url:
+ base_url = self.base_config.base_url
self._base_url = base_url
# Precompute the outDir
self._out_dir = None
self._self_out_dir = None
- if out_dir := self.config.get("compilerOptions", {}).get("outDir", None):
+ out_dir = compiler_options.get("outDir", None)
+ if isinstance(out_dir, str) and out_dir:
self._out_dir = out_dir
self._self_out_dir = out_dir
- elif out_dir := {} if self.base_config is None else self.base_config.out_dir:
+ elif self.base_config is not None and self.base_config.out_dir:
+ out_dir = self.base_config.out_dir
self._out_dir = out_dir
# Precompute the rootDir
self._root_dir = None
self._self_root_dir = None
- if root_dir := self.config.get("compilerOptions", {}).get("rootDir", None):
+ root_dir = compiler_options.get("rootDir", None)
+ if isinstance(root_dir, str) and root_dir:
self._root_dir = root_dir
self._self_root_dir = root_dir
- elif root_dir := {} if self.base_config is None else self.base_config.root_dir:
+ elif self.base_config is not None and self.base_config.root_dir:
+ root_dir = self.base_config.root_dir
self._root_dir = root_dir
# Precompute the rootDirs
self._root_dirs = []
self._self_root_dirs = []
- if root_dirs := self.config.get("compilerOptions", {}).get("rootDirs", None):
+ root_dirs = compiler_options.get("rootDirs", None)
+ if isinstance(root_dirs, list):
+ root_dirs = [root_dir for root_dir in root_dirs if isinstance(root_dir, str)]
self._root_dirs = root_dirs
self._self_root_dirs = root_dirs
- elif root_dirs := [] if self.base_config is None else self.base_config.root_dirs:
+ elif self.base_config is not None and self.base_config.root_dirs:
+ root_dirs = self.base_config.root_dirs
self._root_dirs = root_dirs
# Precompute the paths
base_paths = {} if self.base_config is None else self.base_config.paths
- self_paths = self.config.get("compilerOptions", {}).get("paths", {})
+ raw_self_paths = compiler_options.get("paths", {})
+ self_paths = raw_self_paths if isinstance(raw_self_paths, dict) else {}
self._paths = {**base_paths, **self_paths}
self._self_paths = self_paths
# Precompute the references
- self_references = []
+ self_references: list[tuple[str, Directory | File]] = []
references = self.config.get("references", None)
- if references is not None:
+ if isinstance(references, list):
for reference in references:
- if ref_path := reference.get("path", None):
+ if isinstance(reference, dict) and isinstance(ref_path := reference.get("path", None), str):
abs_ref_path = str(self.config_file.ctx.to_relative(self._relative_to_absolute_directory_path(ref_path)))
if directory := self.config_file.ctx.get_directory(self.config_file.ctx.to_absolute(abs_ref_path)):
self_references.append((ref_path, directory))
elif ts_config := self.config_parser.get_config(abs_ref_path):
self_references.append((ref_path, ts_config.config_file))
- elif file := self.config_file.ctx.get_file(abs_ref_path):
+ elif file := self.config_file.ctx.get_file(Path(abs_ref_path)):
self_references.append((ref_path, file))
self._references = [*self_references] # MAYBE add base references here? This breaks the reference chain though.
self._self_references = self_references
@@ -180,7 +196,7 @@ def _precompute_import_aliases(self):
# TODO: THIS ENTIRE PROCESS IS KINDA HACKY.
# If the reference is a file, get its directory.
if isinstance(reference, File):
- reference_dir = self.config_file.ctx.get_directory(os.path.dirname(reference.filepath))
+ reference_dir = self.config_file.ctx.get_directory(Path(os.path.dirname(reference.filepath)))
elif isinstance(reference, Directory):
reference_dir = reference
else:
@@ -188,14 +204,21 @@ def _precompute_import_aliases(self):
continue
# With the directory, try to grab the next available file and get its tsconfig.
- if reference_dir and reference_dir.files(recursive=True):
- next_file: TSFile = reference_dir.files(recursive=True)[0]
+ if reference_dir is None:
+ reference_files = []
+ else:
+ reference_files = cast("list[TSFile]", reference_dir.files(recursive=True)) # ty: ignore[missing-argument]
+ if reference_files:
+ next_file = reference_files[0]
else:
- logger.warning(f"No next file found for reference during self_reference_import_aliases computation in _precompute_import_aliases: {reference.dirpath}")
+ reference_path = getattr(reference, "dirpath", getattr(reference, "filepath", reference))
+ logger.warning(f"No next file found for reference during self_reference_import_aliases computation in _precompute_import_aliases: {reference_path}")
continue
+ assert reference_dir is not None
target_ts_config = next_file.ts_config
if target_ts_config is None:
- logger.warning(f"No tsconfig found for reference during self_reference_import_aliases computation in _precompute_import_aliases: {reference.dirpath}")
+ reference_path = getattr(reference, "dirpath", getattr(reference, "filepath", reference))
+ logger.warning(f"No tsconfig found for reference during self_reference_import_aliases computation in _precompute_import_aliases: {reference_path}")
continue
# With the tsconfig, grab its rootDirs and outDir
@@ -447,7 +470,7 @@ def paths(self) -> dict[str, list[str]]:
return self._paths
@property
- def references(self) -> list[Directory | File]:
+ def references(self) -> list[tuple[str, Directory | File]]:
"""Returns a list of directories that this TypeScript configuration file depends on.
The references are defined in the 'references' field of the tsconfig.json file. These directories
diff --git a/tests/unit/cli/commands/graph/test_graph_commands.py b/tests/unit/cli/commands/graph/test_graph_commands.py
index aa1ba6c06..3b2517484 100644
--- a/tests/unit/cli/commands/graph/test_graph_commands.py
+++ b/tests/unit/cli/commands/graph/test_graph_commands.py
@@ -92,6 +92,62 @@ def test_using_command_traces_outbound_call_graph(tmp_path):
assert ("helper", "leaf", 2) in edges
+def test_using_command_can_filter_to_resolved_deduped_edges(tmp_path):
+ _write_call_graph_repo(tmp_path)
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "using",
+ "app.py:entry",
+ str(tmp_path),
+ "--language",
+ "python",
+ "--backend",
+ "python",
+ "--format",
+ "json",
+ "--depth",
+ "2",
+ "--resolved-only",
+ "--dedupe",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["filters"]["resolved_only"] is True
+ edges = {(edge["source"]["name"], edge["target"]["name"], edge["depth"]) for edge in payload["edges"]}
+ assert edges == {("entry", "helper", 1), ("helper", "leaf", 2)}
+
+
+def test_callgraph_command_defaults_to_clean_resolved_edges(tmp_path):
+ _write_call_graph_repo(tmp_path)
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "callgraph",
+ "app.py.entry",
+ str(tmp_path),
+ "--language",
+ "python",
+ "--backend",
+ "python",
+ "--format",
+ "json",
+ "--depth",
+ "2",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["raw"] is False
+ edges = {(edge["source"]["name"], edge["target"]["name"], edge["depth"]) for edge in payload["edges"]}
+ assert edges == {("entry", "helper", 1), ("helper", "leaf", 2)}
+
+
def test_usages_command_traces_inbound_call_graph(tmp_path):
_write_call_graph_repo(tmp_path)
@@ -119,6 +175,31 @@ def test_usages_command_traces_inbound_call_graph(tmp_path):
assert ("entry", "helper", 2) in edges
+def test_symbols_command_lists_copyable_targets(tmp_path):
+ _write_call_graph_repo(tmp_path)
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "symbols",
+ "help",
+ str(tmp_path),
+ "--language",
+ "python",
+ "--backend",
+ "python",
+ "--format",
+ "json",
+ "--kind",
+ "function",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert [symbol["target"] for symbol in payload["symbols"]] == ["app.py.helper"]
+
+
def test_rename_command_applies_function_rename_when_write_is_passed(tmp_path):
app = _write_call_graph_repo(tmp_path)
@@ -142,6 +223,7 @@ def test_rename_command_applies_function_rename_when_write_is_passed(tmp_path):
assert dry_run.exit_code == 0, dry_run.output
dry_run_payload = json.loads(dry_run.output)
assert dry_run_payload["applied"] is False
+ assert dry_run_payload["affected_files"] == ["app.py"]
assert "def leaf()" in app.read_text()
result = CliRunner().invoke(
diff --git a/tests/unit/cli/commands/parse/test_parse.py b/tests/unit/cli/commands/parse/test_parse.py
index da16c4308..334b6c255 100644
--- a/tests/unit/cli/commands/parse/test_parse.py
+++ b/tests/unit/cli/commands/parse/test_parse.py
@@ -307,6 +307,52 @@ def test_parse_command_summarizes_typescript_repo_as_json(tmp_path):
assert payload["dependencies"] >= 1
+def test_parse_command_accepts_typescript_directory_project_references(tmp_path):
+ _init_repo(tmp_path)
+ (tmp_path / "packages" / "app" / "src").mkdir(parents=True)
+ (tmp_path / "packages" / "shared" / "src").mkdir(parents=True)
+ (tmp_path / "packages" / "app" / "tsconfig.json").write_text(
+ json.dumps(
+ {
+ "compilerOptions": {"rootDir": "src", "outDir": "dist"},
+ "include": ["src/**/*"],
+ "references": [{"path": "../shared"}],
+ }
+ )
+ )
+ (tmp_path / "packages" / "shared" / "tsconfig.json").write_text(
+ json.dumps(
+ {
+ "compilerOptions": {"rootDir": "src", "outDir": "dist"},
+ "include": ["src/**/*"],
+ }
+ )
+ )
+ (tmp_path / "packages" / "app" / "src" / "app.ts").write_text("export function run() {\n return 1;\n}\n")
+ (tmp_path / "packages" / "shared" / "src" / "shared.ts").write_text("export function helper() {\n return 1;\n}\n")
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "parse",
+ str(tmp_path),
+ "--language",
+ "typescript",
+ "--backend",
+ "python",
+ "--subdir",
+ "packages/app/src",
+ "--format",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["files"] == 1
+ assert payload["functions"] == 1
+
+
def test_parse_command_json_stdout_is_machine_readable(tmp_path):
_init_repo(tmp_path)
(tmp_path / "app.py").write_text("import os\n\ndef run():\n return os.getcwd()\n")