# 10. Our own code graph, not a forked one

**Status:** Accepted · 2026-08-28

## Context

Two parts of the pipeline were paying for the same missing capability.

Phase 1 dispatches parallel Explore agents that re-scan the repository with
grep and read on every task, and `phase-1-analysis.md` makes the "very
thorough" tier the default for feature, refactor and component work. Phase 7
writes `~/.claude/knowledge/<project>/architecture.md` with an LLM, from the
narrow window of whatever task happened to run, and the file is treated as
stale after 90 days.

Both want the same thing: a cheap, current answer to "where does this live"
and "what depends on this".

`Graphify-Labs/graphify` is a public reference that solved this and, unlike
most projects in the space, published numbers for it. Its `BENCHMARKS.md`
measures a fixed coding agent on ERPNext (~1M lines) over a graded question
set: key-fact coverage rises from 70.8% on a grep-and-read baseline to 82.0%
with a graph tool, at ~140K tokens per query, n=6. The baseline it beats is
our Phase 1.

The obvious move was to adopt it. Two things ruled that out. Its extraction
is built on tree-sitter, which is a native npm runtime dependency and
therefore forbidden by ADR-0004. And depending on an external repository for
a capability this central puts the pipeline's core behaviour outside our
control.

## Decision

Build the capability inside the pipeline, taking graphify as a design
reference and not as a code source.

Adopted from it: extraction is deterministic and LLM-free; the artifact is a
single `graph.json` of typed nodes and edges; hub ("god-node") ranking is the
highest-signal line of the report; queries are token-budgeted traversals
rather than neighbourhood dumps; reverse traversal answers impact; and the
measurement methodology is a graded question set against a grep-and-read
baseline with the same cost accounting on both arms.

Not adopted: tree-sitter (ADR-0004), LLM-named communities (the pipeline
already has model routing for that), an MCP server (we ship our own toolkit),
and its multi-platform installers and hooks.

The engine is `pipeline/scripts/_code-graph.mjs`, with per-stack rules in
`pipeline/scripts/code-graph-rules/<stack>.json`, five entry points
(`graph-build`, `graph-query`, `graph-affected`, `graph-report`,
`validate-code-graph`), a schema at `pipeline/schemas/code-graph.schema.json`,
and the `/multi-agent:graph` command. It is off by default behind
`prefs.global.codeGraph.enabled`.

## What we deliberately gave up

Regex over comment-stripped source is not a parser. Definitions and imports
survive that trade; call graphs and type resolution do not. Two further limits
follow from the same choice and are documented in the command rather than
hidden:

- A reference resolves only when a name maps to exactly one declaration. A
  type declared in two files is dropped rather than fanned out to both, so
  `affected` under-reports on duplicated names.
- Only type-like symbols are reference targets. An early build let functions
  be targets and the hub list filled with `with`, `localized`, `size` and
  `name`: a bare lowercase name matched across files is almost never a call to
  that exact declaration. The same measurement was repeated per stack rather
  than assumed: on this repo, including JavaScript functions put `ok` at degree
  52 and `f` at 38.
- A nested declaration is a node but never a reference target. Sealed
  hierarchies name their cases after the concept they model - `Icon`, `Color`,
  `Success` - and each is declared exactly once, so the ambiguity rule does not
  catch them. Before this rule, every Kotlin file that merely mentioned the
  framework's `Color` gained an edge to one app's nested case: 4,072 false
  edges out of 74,969.

## Measurement

Gated on a large private Swift app: 4,290 sources, 29,212 nodes, 54,626 edges,
built in 3.2s, validator clean. Build time is dominated by reading the tree, so
a repo whose files are not in the page cache costs more: the first build of the
4,229-file Kotlin app took 18.2s and every later one 2.4-3.4s. Ten graded questions, both arms capped at a
30,000-token retrieval budget, ground truth derived by grep and path match so
that the impact family is stacked against the graph on purpose.

| Arm | Coverage | Tokens/question |
|---|---|---|
| grep + read | 66.0% | 24,555 |
| code graph | 80.4% | 18,465 |

The aggregate passes the gate, but the split is the useful part. On questions
naming an exact type, `grep -lw` is the oracle: it scored 100% and the graph
was marginally worse and marginally more expensive. On questions phrased in
domain words, the graph scored 63.3% against 32.0% at 10,937 tokens against
24,983. The value is in narrowing an open-ended search, not in replacing a
grep for a name you already know.

The harness, the ten graded questions and the raw per-question result live
beside the graph they measured, under `~/.claude/knowledge/<project>/gate/`.
They stay there rather than in this repo because they name the private
codebase's files and symbols, and a number nobody can re-derive is an assertion
rather than a measurement: `node run-gate.mjs` reproduces the table above.

That measurement covers the context each strategy assembles, not the quality
of an answer written from it. graphify's numbers measure the latter. The two
are not directly comparable and this ADR does not claim they are.

## Consequences

Positive:

- No new runtime dependency; ADR-0004 holds.
- Graph construction costs no API tokens, so Phase 7 can refresh it every run
  instead of ageing an LLM-written file for 90 days.
- Staleness becomes a commit comparison rather than a date heuristic.
- Every stack after the first is one rules file. Bringing up Kotlin, Python
  and JavaScript needed two engine changes, and both were engine defects that
  iOS had been getting away with rather than stack requirements: nesting was
  measured from the pattern's match column, so `public final class Foo` read as
  nested, and the import pass read a fully stripped body, which blanks a
  JavaScript module specifier because it is a string literal. iOS was re-gated
  after both and scored identically.

Negative:

- Accuracy is bounded by regex extraction and will stay below what a parser
  would give.
- Each supported stack carries a hand-written rules file that has to keep up
  with its language.
- The symbol layer is thin on stacks whose exported unit is a function. On
  Node the useful graph is the import graph between files, and the command
  says so rather than implying a richness the stack does not have.
- A 4,300-file repo produces a ~22MB JSON file. It is read whole on every
  query; that is fine at this scale and is not proven at ten times it.
