Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
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
`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
Expand All @@ -19,6 +61,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::<TypeName>`
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

Expand Down
66 changes: 62 additions & 4 deletions codegraph/analysis/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
7 changes: 6 additions & 1 deletion codegraph/analysis/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand All @@ -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()
Expand Down
91 changes: 89 additions & 2 deletions codegraph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -404,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
Expand All @@ -424,6 +491,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)

Expand All @@ -450,6 +523,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]")


Expand Down
Loading
Loading