From e07a63c49bd7422ee2a1523c7bbbeb2639e33a9e Mon Sep 17 00:00:00 2001 From: mochan Date: Sat, 30 May 2026 19:05:20 +0530 Subject: [PATCH 1/4] feat(metrics): split unresolved edges into external vs unresolved-local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `unresolved_edges` count conflated two very different things: 1. External imports — `fastapi`, `os`, `react`. Static analysis without a venv/site-packages parse can't resolve these and isn't supposed to. These are expected, not errors. 2. Local-unresolved — target's root module IS in the repo but the specific symbol couldn't be matched (rename drift, dynamic import, missed binding). These are the actionable ones. `compute_metrics` now classifies each unresolved edge by checking whether the target's root module appears as a MODULE node's qualname-root in the graph. New fields: `external_edges`, `unresolved_local_edges`. The `unresolved_edges` total is kept as the sum for back-compat. Surfaced + exposed in the markdown report and the MCP `metrics` tool. v0.1.2 backlog item #2. --- CHANGELOG.md | 16 ++++++++ codegraph/analysis/metrics.py | 66 ++++++++++++++++++++++++++++++-- codegraph/analysis/report.py | 7 +++- codegraph/mcp_server/server.py | 2 + tests/test_metrics_external.py | 69 ++++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 tests/test_metrics_external.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f26dc99..9182b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Edge classification: `external` vs `unresolved-local`.** The + `unresolved_edges` metric used to lump together imports of external + packages (`fastapi`, `os`, `react`) — which static analysis without a + venv/site-packages parse cannot resolve and isn't supposed to — with + edges that pointed at in-repo symbols but couldn't be matched (rename + drift, dynamic import). One number, two very different meanings. + `compute_metrics` now also returns `external_edges` (root module not + in the repo) and `unresolved_local_edges` (root is in-repo, symbol + missed). The `unresolved_edges` total is kept as the sum for + back-compat. Markdown report + MCP `metrics` tool both expose the + split. (Surfaced by a user adding polycodegraph to a FastAPI project + who saw "1,423 unresolved edges" and asked whether the tool was + broken; almost all were external imports.) + ### Fixed - **`codegraph init` now writes a robust `.mcp.json`** — uses the absolute diff --git a/codegraph/analysis/metrics.py b/codegraph/analysis/metrics.py index 6ad678c..72c77cb 100644 --- a/codegraph/analysis/metrics.py +++ b/codegraph/analysis/metrics.py @@ -17,7 +17,55 @@ class GraphMetrics: edges_by_kind: dict[str, int] = field(default_factory=dict) languages: dict[str, int] = field(default_factory=dict) top_files_by_nodes: list[tuple[str, int]] = field(default_factory=list) + # Total of `external_edges + unresolved_local_edges`. Kept for back-compat + # with consumers that pre-date the 0.1.2 split. unresolved_edges: int = 0 + # Imports whose root module is not part of this repo (e.g. `fastapi`, + # `os`, `react`). These are expected, not errors — they're cross-boundary + # references that static analysis without a venv / site-packages parse + # cannot resolve and isn't supposed to. + external_edges: int = 0 + # Unresolved edges whose root module IS in the repo but couldn't be + # matched (rename drift, dynamic import, missed binding). These are the + # actionable ones. + unresolved_local_edges: int = 0 + + +_UNRESOLVED_PREFIX = "unresolved::" + + +def _module_root(qualname: str) -> str: + """First segment of a dotted qualname, e.g. ``foo.bar.baz`` -> ``foo``. + + Also handles TS path-style names (``pkg/sub/file``) and rare ``a::b`` + forms by stripping at the first separator of any flavour. + """ + if not qualname: + return "" + head = qualname.split(".", 1)[0] + for sep in ("/", "::"): + if sep in head: + head = head.split(sep, 1)[0] + return head + + +def _collect_repo_roots(graph: nx.MultiDiGraph) -> set[str]: + """Module-name roots that live in the repo. + + Used to classify each unresolved edge as either ``external`` (root not + in the repo — e.g. ``fastapi``, ``os``) or ``unresolved_local`` (root + is a repo package but the specific symbol couldn't be matched). + """ + roots: set[str] = set() + for _nid, attrs in graph.nodes(data=True): + if _kind_str(attrs.get("kind")) != "MODULE": + continue + qn = attrs.get("qualname") + if isinstance(qn, str): + root = _module_root(qn) + if root: + roots.add(root) + return roots def compute_metrics(graph: nx.MultiDiGraph, *, top_files: int = 10) -> GraphMetrics: @@ -37,16 +85,26 @@ def compute_metrics(graph: nx.MultiDiGraph, *, top_files: int = 10) -> GraphMetr metrics.languages = dict(sorted(lang_counter.items())) metrics.top_files_by_nodes = file_counter.most_common(top_files) + repo_roots = _collect_repo_roots(graph) + edge_counter: Counter[str] = Counter() - unresolved = 0 + external = 0 + unresolved_local = 0 total = 0 for _src, dst, _key, data in graph.edges(keys=True, data=True): total += 1 ek = _kind_str(data.get("kind")) or "UNKNOWN" edge_counter[ek] += 1 - if isinstance(dst, str) and dst.startswith("unresolved::"): - unresolved += 1 + if isinstance(dst, str) and dst.startswith(_UNRESOLVED_PREFIX): + target = dst[len(_UNRESOLVED_PREFIX):] + root = _module_root(target) + if root and root in repo_roots: + unresolved_local += 1 + else: + external += 1 metrics.total_edges = total metrics.edges_by_kind = dict(sorted(edge_counter.items())) - metrics.unresolved_edges = unresolved + metrics.external_edges = external + metrics.unresolved_local_edges = unresolved_local + metrics.unresolved_edges = external + unresolved_local return metrics diff --git a/codegraph/analysis/report.py b/codegraph/analysis/report.py index f34554e..cfb05b1 100644 --- a/codegraph/analysis/report.py +++ b/codegraph/analysis/report.py @@ -64,6 +64,8 @@ def _metrics_to_dict(m: GraphMetrics) -> dict[str, Any]: "languages": dict(m.languages), "top_files_by_nodes": [list(t) for t in m.top_files_by_nodes], "unresolved_edges": m.unresolved_edges, + "external_edges": m.external_edges, + "unresolved_local_edges": m.unresolved_local_edges, } @@ -90,7 +92,10 @@ def report_to_markdown(report: AnalyzeReport) -> str: lines.append("") lines.append(f"- Nodes: **{m.total_nodes}**") lines.append(f"- Edges: **{m.total_edges}**") - lines.append(f"- Unresolved edges: **{m.unresolved_edges}**") + lines.append( + f"- Unresolved edges: **{m.unresolved_edges}** " + f"(external: {m.external_edges}, local-unresolved: {m.unresolved_local_edges})" + ) if m.nodes_by_kind: lines.append("- Nodes by kind: " + ", ".join( f"{k}={v}" for k, v in m.nodes_by_kind.items() diff --git a/codegraph/mcp_server/server.py b/codegraph/mcp_server/server.py index 8160b6b..bed6015 100644 --- a/codegraph/mcp_server/server.py +++ b/codegraph/mcp_server/server.py @@ -363,6 +363,8 @@ def tool_metrics(graph: nx.MultiDiGraph) -> dict[str, Any]: "languages": m.languages, "top_files_by_nodes": m.top_files_by_nodes, "unresolved_edges": m.unresolved_edges, + "external_edges": m.external_edges, + "unresolved_local_edges": m.unresolved_local_edges, } diff --git a/tests/test_metrics_external.py b/tests/test_metrics_external.py new file mode 100644 index 0000000..f01e618 --- /dev/null +++ b/tests/test_metrics_external.py @@ -0,0 +1,69 @@ +"""Tests for the external/unresolved-local edge classification (v0.1.2 #2).""" +from __future__ import annotations + +import networkx as nx + +from codegraph.analysis.metrics import _module_root, compute_metrics + + +def test_module_root_handles_separators() -> None: + assert _module_root("foo.bar.baz") == "foo" + assert _module_root("pkg/sub/file") == "pkg" + assert _module_root("a::b") == "a" + assert _module_root("simple") == "simple" + assert _module_root("") == "" + + +def _make_graph_with_module(module_qualname: str) -> nx.MultiDiGraph: + g = nx.MultiDiGraph() + g.add_node( + "mod:" + module_qualname, + kind="MODULE", + qualname=module_qualname, + language="python", + ) + g.add_node( + "fn:caller", + kind="FUNCTION", + qualname=module_qualname + ".caller", + language="python", + ) + return g + + +def test_external_vs_unresolved_local_classification() -> None: + g = _make_graph_with_module("myrepo") + + # External: target root is `fastapi`, not in repo + g.add_edge("fn:caller", "unresolved::fastapi.FastAPI", kind="IMPORTS") + # External: target root is `os`, not in repo + g.add_edge("fn:caller", "unresolved::os.path.join", kind="CALLS") + # Local-unresolved: target root is `myrepo`, IS in repo + g.add_edge("fn:caller", "unresolved::myrepo.utils.missing_fn", kind="CALLS") + # Bare name with no dot: treated as external since root not in repo_roots + g.add_edge("fn:caller", "unresolved::randomBareName", kind="CALLS") + + m = compute_metrics(g) + assert m.external_edges == 3 + assert m.unresolved_local_edges == 1 + # Back-compat field is the sum. + assert m.unresolved_edges == 4 + + +def test_no_modules_means_everything_external() -> None: + g = nx.MultiDiGraph() + g.add_node("fn:x", kind="FUNCTION", qualname="x") + g.add_edge("fn:x", "unresolved::anything", kind="CALLS") + m = compute_metrics(g) + assert m.external_edges == 1 + assert m.unresolved_local_edges == 0 + + +def test_no_unresolved_edges_yields_zero_external() -> None: + g = _make_graph_with_module("myrepo") + g.add_node("fn:other", kind="FUNCTION", qualname="myrepo.other") + g.add_edge("fn:caller", "fn:other", kind="CALLS") + m = compute_metrics(g) + assert m.external_edges == 0 + assert m.unresolved_local_edges == 0 + assert m.unresolved_edges == 0 From 441d3649b744e164cfada6de3c9482847bca2fe4 Mon Sep 17 00:00:00 2001 From: mochan Date: Sat, 30 May 2026 19:09:39 +0530 Subject: [PATCH 2/4] feat(parser): emit reference edges from function annotations to types FastAPI Pydantic request-body models were being flagged dead because the only reference to them came through a handler's parameter type annotation, which the Python parser didn't trace. The parser now walks each function's parameter `type` strings and the return-type string, regex-extracts capitalized identifiers, drops typing-scaffolding (`Optional`, `Annotated`, `Union`, ...) and FastAPI dependency markers (`Body`, `Depends`, ...), and emits one `CALLS` edge with `dst="unresolved::"` and `metadata.via == "annotation"` per surviving name. The existing resolver rewrites these to real `CLASS` ids when the type is in-repo, which closes the loop for dead-code detection. Framework-agnostic: every function with type annotations benefits, not just route handlers. The change reuses `EdgeKind.CALLS` (rather than introducing a new `REFERENCES` kind) because `CALLS` already flows through the resolver and the reference-kind whitelist used by dead-code, and Pydantic-body validation IS effectively a runtime call into the model class. v0.1.2 backlog item #3. --- CHANGELOG.md | 13 ++++ codegraph/parsers/python.py | 86 +++++++++++++++++++++++ tests/test_param_annotation_refs.py | 103 ++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 tests/test_param_annotation_refs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9182b7d..beb167d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pre-0.1.2 default entries are migrated forward in place; user-customised entries are left alone. (Surfaced when a user added polycodegraph to a FastAPI project — Claude reported needing to fix all three.) +- **Type-annotation-only references no longer count as dead code.** The + Python parser now emits a reference edge from a function/method to + each capitalized type name appearing in its parameter and return type + annotations. Edges go through the existing `unresolved::` + resolver pipeline, so they rewrite to real `CLASS` ids when the type + is defined in the repo. The flagship case is FastAPI Pydantic + request-body models — `def create_defect(body: RetroDefect): ...` was + flagged dead because the only reference to `RetroDefect` came through + the type annotation; that loop is now closed. Stdlib typing + scaffolding (`Optional`, `Annotated`, `Union`, …) and FastAPI + dependency markers (`Body`, `Depends`, …) are blocklisted so the + graph stays clean. Framework-agnostic: any function with type + annotations benefits, not just route handlers. ## [0.1.1] — 2026-05-24 diff --git a/codegraph/parsers/python.py b/codegraph/parsers/python.py index fc7fd22..5e8c1d9 100644 --- a/codegraph/parsers/python.py +++ b/codegraph/parsers/python.py @@ -321,6 +321,58 @@ def _simplify_arg(node: tree_sitter.Node, src: bytes) -> str: return "" +# Names that look like classes by the capital-letter heuristic but are +# either builtins, stdlib, or typing scaffolding — emitting REFERENCES +# edges to these would be noise. The dead-code analyzer doesn't care +# about them anyway. +_ANNOTATION_NAME_BLOCKLIST: frozenset[str] = frozenset({ + # typing module scaffolding + "Any", "AnyStr", "Optional", "Union", "Literal", "Final", "ClassVar", + "Annotated", "TypedDict", "TypeVar", "ParamSpec", "Concatenate", + "Type", "Tuple", "List", "Dict", "Set", "FrozenSet", "Callable", + "Iterable", "Iterator", "Generator", "AsyncIterator", "AsyncGenerator", + "Awaitable", "Coroutine", "AsyncContextManager", "ContextManager", + "Sequence", "Mapping", "MutableMapping", "MutableSequence", "MutableSet", + "Hashable", "Sized", "Container", "Collection", "Reversible", + "NamedTuple", "Self", "Never", "NoReturn", "LiteralString", "NotRequired", + "Required", "Unpack", "TypeAlias", "TypeGuard", "TypeIs", + # Pydantic / FastAPI dependency annotations users wrap their types in. + # We don't want to emit references TO `Body` / `Depends` etc.; + # we want the inner type names that come along with them. + "Body", "Depends", "Path", "Query", "Header", "Cookie", "Form", "File", + "Security", "Request", "Response", + # Common Python builtins that pass the capital-letter heuristic. + "True", "False", "None", "Ellipsis", "NotImplemented", +}) + +_TYPE_NAME_RE = re.compile(r"\b([A-Z][A-Za-z0-9_]*)\b") + + +def _extract_type_references(annotation: str | None) -> list[str]: + """Return capitalized type names referenced in an annotation string. + + Splits ``Annotated[User, Body(...)]`` / ``list[User] | None`` / + ``Optional[User]`` etc. into the candidate names ``User``. Stdlib + typing scaffolding and FastAPI dependency markers are blocklisted. + + Returns names in source-order with duplicates removed; an empty list + when ``annotation`` is None or blank. + """ + if not annotation: + return [] + seen: set[str] = set() + out: list[str] = [] + for match in _TYPE_NAME_RE.finditer(annotation): + name = match.group(1) + if name in _ANNOTATION_NAME_BLOCKLIST: + continue + if name in seen: + continue + seen.add(name) + out.append(name) + return out + + def _extract_params( params_node: tree_sitter.Node, src: bytes, @@ -1153,6 +1205,40 @@ def _handle_function( self._emit_decorator_calls(node, rel, func_id, src, edges) + # Emit reference edges for each capitalized type name appearing in + # parameter annotations and the return type. The resolver rewrites + # ``unresolved::`` to the real CLASS id if the type is + # defined in the repo. This keeps dead-code detection honest for + # types that are only referenced via type annotations — most + # notably FastAPI Pydantic request-body models, which would + # otherwise look unreferenced because handler dispatch happens + # via parameter annotations rather than direct calls. + annotation_strs: list[str] = [] + params_meta = metadata.get("params") + if isinstance(params_meta, list): + for param in params_meta: + if isinstance(param, dict): + type_str = param.get("type") + if isinstance(type_str, str): + annotation_strs.append(type_str) + return_meta = metadata.get("returns") + if isinstance(return_meta, str): + annotation_strs.append(return_meta) + emitted: set[str] = set() + for ann in annotation_strs: + for type_name in _extract_type_references(ann): + if type_name in emitted: + continue + emitted.add(type_name) + edges.append(Edge( + src=func_id, + dst=f"unresolved::{type_name}", + kind=EdgeKind.CALLS, + file=rel, + line=node.start_point[0] + 1, + metadata={"target_name": type_name, "via": "annotation"}, + )) + # DF1 — HTTP route extraction. One ROUTE edge per (method, path); # Flask's ``methods=[...]`` expands to multiple edges. for spec in _extract_route_specs(decorators): diff --git a/tests/test_param_annotation_refs.py b/tests/test_param_annotation_refs.py new file mode 100644 index 0000000..53b694a --- /dev/null +++ b/tests/test_param_annotation_refs.py @@ -0,0 +1,103 @@ +"""Tests for parameter-annotation REFERENCES (v0.1.2 #3). + +A FastAPI handler that takes a Pydantic model as a body parameter used +to look like dead code: the model class had no incoming CALLS / IMPORTS +/ INHERITS / IMPLEMENTS edges because the only "reference" was a type +annotation on the handler's parameter, which the parser didn't trace. +This module verifies the fix: handlers emit ``CALLS`` edges from the +handler to each capitalized type name in their parameter and return +type annotations, with ``metadata.via == "annotation"``. +""" +from __future__ import annotations + +from pathlib import Path + +import networkx as nx +import pytest + +from codegraph.analysis import find_dead_code +from codegraph.graph.builder import GraphBuilder +from codegraph.graph.store_networkx import to_digraph +from codegraph.graph.store_sqlite import SQLiteGraphStore +from codegraph.parsers.python import _extract_type_references + + +def test_extract_type_references_simple() -> None: + assert _extract_type_references("User") == ["User"] + assert _extract_type_references("str") == [] # not capitalized + assert _extract_type_references(None) == [] + assert _extract_type_references("") == [] + + +def test_extract_type_references_generic_and_union() -> None: + assert _extract_type_references("list[User]") == ["User"] + assert _extract_type_references("dict[str, User]") == ["User"] + assert _extract_type_references("User | None") == ["User"] + assert _extract_type_references("Optional[User]") == ["User"] + assert _extract_type_references("Annotated[User, Body(...)]") == ["User"] + + +def test_extract_type_references_drops_typing_scaffolding() -> None: + # All of these are blocklisted; only `User` should survive. + ann = "Annotated[Optional[Union[User, Admin]], Body(...)]" + assert set(_extract_type_references(ann)) == {"User", "Admin"} + + +def test_extract_type_references_deduplicates_in_order() -> None: + assert _extract_type_references("dict[User, User]") == ["User"] + assert _extract_type_references("tuple[A, B, A]") == ["A", "B"] + + +@pytest.fixture +def fastapi_repo_graph(tmp_path: Path) -> nx.MultiDiGraph: + """Tiny FastAPI-style fixture: a handler that takes a Pydantic body.""" + repo = tmp_path / "repo" + (repo / "myapp").mkdir(parents=True) + (repo / "myapp" / "__init__.py").write_text("") + (repo / "myapp" / "models.py").write_text( + "class RetroDefect:\n" + " name: str\n" + ) + (repo / "myapp" / "routes.py").write_text( + "from myapp.models import RetroDefect\n" + "\n" + "def app_get(path):\n" + " def deco(fn): return fn\n" + " return deco\n" + "\n" + "@app_get('/defect')\n" + "def create_defect(body: RetroDefect) -> RetroDefect:\n" + " return body\n" + ) + store = SQLiteGraphStore(tmp_path / "graph.db") + GraphBuilder(repo, store).build(incremental=False) + g = to_digraph(store) + store.close() + return g + + +def test_pydantic_model_not_dead_when_only_referenced_via_annotation( + fastapi_repo_graph: nx.MultiDiGraph, +) -> None: + dead_qns = {d.qualname for d in find_dead_code(fastapi_repo_graph)} + assert not any(qn.endswith("RetroDefect") for qn in dead_qns), ( + "RetroDefect is referenced by the handler's body+return annotations " + "and must not be flagged dead. dead_qns=" + repr(dead_qns) + ) + + +def test_handler_has_annotation_edge_to_model( + fastapi_repo_graph: nx.MultiDiGraph, +) -> None: + """The handler should have an outgoing edge tagged via=annotation.""" + g = fastapi_repo_graph + found_via_annotation = False + for src, _dst, _key, data in g.edges(keys=True, data=True): + attrs = g.nodes.get(src) or {} + if not str(attrs.get("qualname") or "").endswith("create_defect"): + continue + md = data.get("metadata") or {} + if isinstance(md, dict) and md.get("via") == "annotation": + found_via_annotation = True + break + assert found_via_annotation, "expected an annotation-via edge from handler" From d2b1322c4eab1084ba275f85893b0fd21fb3af5e Mon Sep 17 00:00:00 2001 From: mochan Date: Sat, 30 May 2026 19:12:01 +0530 Subject: [PATCH 3/4] feat(init): write polycodegraph guidance to CLAUDE.md and AGENTS.md Coding agents (Claude Code, Cursor, Codex) defaulted to grep even with polycodegraph's MCP server registered, because they had no hint that the MCP tools existed. `codegraph init` now prompts (default yes) and writes a `## polycodegraph` section telling the agent which MCP tool maps to each kind of structural query. Mirrors the `.mcp.json` behaviour: creates fresh, appends when missing, leaves alone when the section is already there. Both CLAUDE.md and AGENTS.md are written. v0.1.2 backlog item #4. --- CHANGELOG.md | 15 ++++++++ codegraph/cli.py | 62 ++++++++++++++++++++++++++++++++ tests/test_cli_agent_guidance.py | 52 +++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/test_cli_agent_guidance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index beb167d..457f825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`codegraph init` writes agent guidance to `CLAUDE.md` and `AGENTS.md`.** + After a fresh install on a project, coding agents (Claude Code, + Cursor, Codex, …) defaulted to grep even with polycodegraph's MCP + server registered, because they had no hint that polycodegraph was + available. Init now prompts (default: yes) and writes a short + "`## polycodegraph`" section telling the agent which MCP tool to use + for each kind of structural query (find-symbol, callers, callees, + blast-radius, dataflow-trace, untested, dead-code). Mirrors the + `.mcp.json` behaviour: creates the file when absent, appends when + present without the section, leaves alone when the section is + already there. Both files are written so Codex / GitHub Copilot CLI + (which read `AGENTS.md`) get the hint too. + ### Changed - **Edge classification: `external` vs `unresolved-local`.** The diff --git a/codegraph/cli.py b/codegraph/cli.py index 6d00bba..ed054e1 100644 --- a/codegraph/cli.py +++ b/codegraph/cli.py @@ -299,6 +299,48 @@ def _is_default_codegraph_entry(entry: object) -> bool: return "cwd" not in entry +_AGENT_GUIDANCE_HEADER = "## polycodegraph" + +_AGENT_GUIDANCE_SNIPPET = """## polycodegraph + +This project has polycodegraph installed and registered via `.mcp.json`. +For any question about code structure, prefer polycodegraph's MCP tools +over `Grep` / `Read` / `Glob`: + +- "Where is X defined?" → `mcp__codegraph__find_symbol(query: "X")` +- "Who calls Y?" → `mcp__codegraph__callers(qualname: "Y")` +- "What does Z depend on?" → `mcp__codegraph__callees(qualname: "Z")` +- "What breaks if I change this?" → `mcp__codegraph__blast_radius(qualname: "...")` +- "Trace an HTTP request end-to-end" → `mcp__codegraph__dataflow_trace(method_path: "GET /api/...")` +- "What's untested?" → `mcp__codegraph__untested()` +- "Any dead code?" → `mcp__codegraph__dead_code()` + +Each tool returns a small focused subgraph (~20-50 tokens). Fall back +to grep only for free-text searches across comments / config / strings, +or when polycodegraph returns no hits. +""" + + +def _write_agent_guidance(repo_root: Path, filename: str) -> str: + """Append the polycodegraph guidance snippet to *filename* in repo root. + + Returns "created" if the file was newly written, "appended" if the + snippet was added to an existing file, or "already-present" if the + file already contains the `## polycodegraph` section header. + """ + target = repo_root / filename + if target.exists(): + existing = target.read_text() + if _AGENT_GUIDANCE_HEADER in existing: + return "already-present" + if existing and not existing.endswith("\n"): + existing += "\n" + target.write_text(existing + "\n" + _AGENT_GUIDANCE_SNIPPET) + return "appended" + target.write_text(_AGENT_GUIDANCE_SNIPPET) + return "created" + + def _write_project_mcp_json(repo_root: Path) -> str: """Register the codegraph MCP server in the repo-local .mcp.json. @@ -424,6 +466,12 @@ def init( ).ask() or False cfg.register_mcp = register_mcp + write_agent_guidance = questionary.confirm( + "Add a polycodegraph section to CLAUDE.md and AGENTS.md? " + "(Tells coding agents to prefer polycodegraph's MCP tools over grep.)", + default=True, + ).ask() or False + save_config(repo_root, cfg) _update_gitignore(repo_root) @@ -450,6 +498,20 @@ def init( "[dim]·[/dim] .mcp.json already has a `codegraph` entry — left as-is" ) + if write_agent_guidance: + for filename in ("CLAUDE.md", "AGENTS.md"): + state = _write_agent_guidance(repo_root, filename) + if state == "created": + console.print(f"[green]✓[/green] Wrote {filename}") + elif state == "appended": + console.print( + f"[green]✓[/green] Appended polycodegraph section to {filename}" + ) + elif state == "already-present": + console.print( + f"[dim]·[/dim] {filename} already has a polycodegraph section — left as-is" + ) + console.print("Next step: [bold]codegraph build[/bold]") diff --git a/tests/test_cli_agent_guidance.py b/tests/test_cli_agent_guidance.py new file mode 100644 index 0000000..d5dd047 --- /dev/null +++ b/tests/test_cli_agent_guidance.py @@ -0,0 +1,52 @@ +"""Tests for `_write_agent_guidance` (v0.1.2 #4). + +Without a CLAUDE.md / AGENTS.md hint, coding agents default to grep +even when polycodegraph's MCP server is registered. `codegraph init` +now writes a section into those files; behaviour mirrors the +`.mcp.json` writer — create, append, or leave alone. +""" +from __future__ import annotations + +from pathlib import Path + +from codegraph.cli import _AGENT_GUIDANCE_HEADER, _write_agent_guidance + + +def test_creates_fresh_file(tmp_path: Path) -> None: + state = _write_agent_guidance(tmp_path, "CLAUDE.md") + assert state == "created" + content = (tmp_path / "CLAUDE.md").read_text() + assert _AGENT_GUIDANCE_HEADER in content + assert "mcp__codegraph__find_symbol" in content + + +def test_appends_to_existing_file_without_section(tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("# Project rules\n\nUse spaces.\n") + state = _write_agent_guidance(tmp_path, "CLAUDE.md") + assert state == "appended" + content = (tmp_path / "CLAUDE.md").read_text() + assert "Use spaces." in content + assert _AGENT_GUIDANCE_HEADER in content + # Section should be after the original content, separated by blank line. + assert content.startswith("# Project rules") + + +def test_leaves_alone_when_section_already_present(tmp_path: Path) -> None: + original = ( + "# Existing\n\n" + f"{_AGENT_GUIDANCE_HEADER}\n\nUser-tweaked content here.\n" + ) + (tmp_path / "AGENTS.md").write_text(original) + state = _write_agent_guidance(tmp_path, "AGENTS.md") + assert state == "already-present" + assert (tmp_path / "AGENTS.md").read_text() == original + + +def test_appends_with_trailing_newline_when_existing_lacks_one(tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("no trailing newline") + state = _write_agent_guidance(tmp_path, "CLAUDE.md") + assert state == "appended" + content = (tmp_path / "CLAUDE.md").read_text() + # Existing content + newline gap + section. + assert content.startswith("no trailing newline\n") + assert _AGENT_GUIDANCE_HEADER in content From 26b2729e17bc0de582f7cb5eb5e3a79ca92deaa9 Mon Sep 17 00:00:00 2001 From: mochan Date: Sat, 30 May 2026 19:14:09 +0530 Subject: [PATCH 4/4] feat(init): auto-populate ignore patterns from detected ecosystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Extra ignore patterns" free-text prompt was a footgun — a user typed `y` thinking it was yes/no and got `ignore: [- y]` in their YAML. Init now sniffs the repo for `pyproject.toml`, `package.json` (with React Native detection via dependencies/devDependencies), `go.mod`, `Cargo.toml`, `pom.xml`, `build.gradle`, and pre-fills the matching ignore patterns. The user confirms with a single yes/no. Extras prompt remains for project-specific paths. New module: `codegraph/init_presets.py` with `detect_ignore_presets`. Patterns auto + extras get de-duplicated in insertion order. v0.1.2 backlog item #5. --- CHANGELOG.md | 11 +++++ codegraph/cli.py | 29 ++++++++++- codegraph/init_presets.py | 99 ++++++++++++++++++++++++++++++++++++++ tests/test_init_presets.py | 70 +++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 codegraph/init_presets.py create mode 100644 tests/test_init_presets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 457f825..0dab3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Auto-populate ignore patterns from detected ecosystem during `init`.** + The free-text "Extra ignore patterns" prompt was a footgun — a user + once typed `y` thinking it was yes/no and got `ignore: [- y]` in + their YAML. Init now sniffs the repo for `pyproject.toml`, + `package.json` (with React Native detection), `go.mod`, `Cargo.toml`, + `pom.xml`, `build.gradle`, and pre-fills the matching ignore patterns + (`__pycache__/`, `.venv/`, `node_modules/`, `ios/Pods/`, + `android/build/`, `vendor/`, `target/`, `build/`, …). The user + confirms the auto-detected list with a single yes/no; the free-text + extras prompt is still there for project-specific paths but no longer + the only path to safety. - **`codegraph init` writes agent guidance to `CLAUDE.md` and `AGENTS.md`.** After a fresh install on a project, coding agents (Claude Code, Cursor, Codex, …) defaulted to grep even with polycodegraph's MCP diff --git a/codegraph/cli.py b/codegraph/cli.py index ed054e1..d6924d9 100644 --- a/codegraph/cli.py +++ b/codegraph/cli.py @@ -446,13 +446,38 @@ def init( ).ask() or "local" cfg.baseline = {"backend": backend} + from codegraph.init_presets import detect_ignore_presets + + auto_patterns, auto_labels = detect_ignore_presets(repo_root) + if auto_patterns: + console.print( + "\n[bold]Auto-detected ignore patterns[/bold] " + f"(from {', '.join(auto_labels)}):" + ) + for pat in auto_patterns: + console.print(f" {pat}") + accept_auto = questionary.confirm( + "Use these ignore patterns?", default=True, + ).ask() + if accept_auto is False: + auto_patterns = [] + extra_default = "" + else: + extra_default = "" extra = questionary.text( "Extra ignore patterns (comma/newline separated, optional):", - default="", + default=extra_default, ).ask() or "" - cfg.ignore = [ + extras_list = [ p.strip() for p in extra.replace("\n", ",").split(",") if p.strip() ] + # Combine auto + extras, de-duplicating in insertion order. + seen_ignore: set[str] = set() + cfg.ignore = [] + for pat in (*auto_patterns, *extras_list): + if pat not in seen_ignore: + seen_ignore.add(pat) + cfg.ignore.append(pat) install_hook = questionary.confirm( "Install git pre-push hook? (Phase 2 implementation)", default=False diff --git a/codegraph/init_presets.py b/codegraph/init_presets.py new file mode 100644 index 0000000..9efbc37 --- /dev/null +++ b/codegraph/init_presets.py @@ -0,0 +1,99 @@ +"""Per-ecosystem ignore-pattern presets, auto-applied during `codegraph init`. + +Replaces the free-text "Extra ignore patterns" prompt for common cases — +that prompt was a footgun: a user once typed "y" thinking it was yes/no +and ended up with ``ignore: [- y]`` in their YAML. +""" +from __future__ import annotations + +import json +from pathlib import Path + +# Each preset maps an ecosystem name to its canonical ignore patterns. +# Lookups are file-presence-based to keep detection cheap and robust. +_NODE_BASE: tuple[str, ...] = ("node_modules/", "dist/", "build/") +_NODE_RN: tuple[str, ...] = ("ios/Pods/", "android/build/", "android/.gradle/") +_PYTHON: tuple[str, ...] = ( + "__pycache__/", ".venv/", "venv/", ".tox/", ".pytest_cache/", + ".mypy_cache/", ".ruff_cache/", "*.egg-info/", +) +_GO: tuple[str, ...] = ("vendor/",) +_RUST: tuple[str, ...] = ("target/",) +_JAVA_MAVEN: tuple[str, ...] = ("target/",) +_JAVA_GRADLE: tuple[str, ...] = ("build/", ".gradle/") + + +def _has_react_native(package_json: Path) -> bool: + try: + data = json.loads(package_json.read_text()) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + deps = data.get("dependencies") or {} + dev_deps = data.get("devDependencies") or {} + if not isinstance(deps, dict): + deps = {} + if not isinstance(dev_deps, dict): + dev_deps = {} + return "react-native" in deps or "react-native" in dev_deps + + +def detect_ignore_presets(repo_root: Path) -> tuple[list[str], list[str]]: + """Return ``(patterns, ecosystem_labels)`` for what was detected. + + Patterns are de-duplicated in insertion order. Ecosystem labels are + short human-readable tags (e.g. ``"python"``, ``"node"``, + ``"react-native"``) used by the CLI for the "Auto-detected" line. + """ + patterns: list[str] = [] + labels: list[str] = [] + seen: set[str] = set() + + def _add(items: tuple[str, ...]) -> None: + for item in items: + if item not in seen: + seen.add(item) + patterns.append(item) + + # Python + if ( + (repo_root / "pyproject.toml").exists() + or (repo_root / "requirements.txt").exists() + or (repo_root / "setup.py").exists() + or (repo_root / "setup.cfg").exists() + ): + labels.append("python") + _add(_PYTHON) + + # Node / JS / TS + package_json = repo_root / "package.json" + if package_json.exists(): + labels.append("node") + _add(_NODE_BASE) + if _has_react_native(package_json): + labels.append("react-native") + _add(_NODE_RN) + + # Go + if (repo_root / "go.mod").exists(): + labels.append("go") + _add(_GO) + + # Rust + if (repo_root / "Cargo.toml").exists(): + labels.append("rust") + _add(_RUST) + + # Java + if (repo_root / "pom.xml").exists(): + labels.append("java-maven") + _add(_JAVA_MAVEN) + if ( + (repo_root / "build.gradle").exists() + or (repo_root / "build.gradle.kts").exists() + ): + labels.append("java-gradle") + _add(_JAVA_GRADLE) + + return patterns, labels diff --git a/tests/test_init_presets.py b/tests/test_init_presets.py new file mode 100644 index 0000000..c1ffd43 --- /dev/null +++ b/tests/test_init_presets.py @@ -0,0 +1,70 @@ +"""Tests for `detect_ignore_presets` (v0.1.2 #5).""" +from __future__ import annotations + +import json +from pathlib import Path + +from codegraph.init_presets import detect_ignore_presets + + +def test_python_project(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + patterns, labels = detect_ignore_presets(tmp_path) + assert "python" in labels + assert "__pycache__/" in patterns + assert ".venv/" in patterns + + +def test_node_project(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text(json.dumps({"name": "x"})) + patterns, labels = detect_ignore_presets(tmp_path) + assert "node" in labels + assert "node_modules/" in patterns + # No RN-specific patterns when react-native isn't a dep. + assert "ios/Pods/" not in patterns + + +def test_react_native_project(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text(json.dumps({ + "name": "rn-app", + "dependencies": {"react-native": "0.74"}, + })) + patterns, labels = detect_ignore_presets(tmp_path) + assert "react-native" in labels + assert "ios/Pods/" in patterns + assert "android/build/" in patterns + assert "node_modules/" in patterns + + +def test_go_project(tmp_path: Path) -> None: + (tmp_path / "go.mod").write_text("module x\n") + patterns, labels = detect_ignore_presets(tmp_path) + assert "go" in labels + assert "vendor/" in patterns + + +def test_multilang_project_dedupes(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text(json.dumps({"name": "x"})) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + (tmp_path / "pom.xml").write_text("") + patterns, labels = detect_ignore_presets(tmp_path) + assert "python" in labels + assert "node" in labels + assert "java-maven" in labels + # `target/` could come from java-maven and rust; here only maven → present once + assert patterns.count("target/") == 1 + + +def test_empty_project_returns_empty(tmp_path: Path) -> None: + patterns, labels = detect_ignore_presets(tmp_path) + assert patterns == [] + assert labels == [] + + +def test_malformed_package_json_doesnt_crash(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("not json {{{") + patterns, labels = detect_ignore_presets(tmp_path) + # Still detected as a node project; RN patterns omitted because deps couldn't parse. + assert "node" in labels + assert "node_modules/" in patterns + assert "ios/Pods/" not in patterns