Evidence

Accuracy benchmark

The graph is only worth trusting if its edges are right. This page reports what the edges actually do on real projects, including the parts that do not work yet.

Headline

Across 83 labelled cases on four pinned projects: 0 edges invented. Every edge the graph claimed was correct. All 69 expected edges were produced, and all 14 negative controls stayed absent.

Precision

1.000

0 false positives. No confidently wrong edge was observed in 83 labelled cases.

Recall

1.000

All 69 expected edges were produced, across all four edge kinds and all three languages.

CALLS & dispatch

1.000 / 1.000

Call resolution and virtual dispatch are perfect on the sampled labels.

Read this before quoting the number. Precision here is sample-level: of the edges the benchmark asked about, how many the graph answered correctly. It is not a claim that every one of the ~12,000 edges in these four graphs is correct. The labelled cases were chosen to probe known-hard idioms, not to flatter the tool, and 69 positive cases is a sample, not a proof of the whole graph.

Method

Four real projects, pinned by commit, crawled in memory and scored against expectations written from their source.

Project Stack Commit Files Nodes Edges Crawl
spring-petclinic Java · Spring Boot 500158f73241 50 1,244 2,660 652 ms
spring-boot-realworld Java · Spring Boot + MyBatis ee17e31aafe7 116 1,978 5,057 978 ms
fastapi-template Python · FastAPI e4022a9502a6 138 1,798 3,514 1,019 ms
nestjs-realworld TypeScript · NestJS + TypeORM c1c2cc4e448b 36 466 865 162 ms
The four projects are hand-verified, not runtime-captured. Every case was derived by reading the pinned source, never by querying the graph, so the benchmark is able to fail. But no execution trace was recorded for these four projects: Maven is not available in the build environment and the NestJS application's dependencies are not installed, so the two Spring Boot apps and the NestJS app were crawled, not run. A case describes what the code genuinely does at runtime (for example a Lombok-generated all-arguments constructor, or a class-level @RequestMapping joined to a bare @GetMapping), but a runtime call log is stronger evidence. Virtual dispatch is the one edge type with execution-backed proof, on a small compiled fixture rather than on these projects — see runtime proof. The precision and recall numbers on this page are hand-verified, not runtime-verified.
fastapi-template is pinned to tag 0.9.0 rather than main. Upstream main and tag 0.12.0 both contain invalid Python at backend/app/api/deps.py:36 (except InvalidTokenError, ValidationError:, Python 2 syntax), and a fixture that cannot run is not a fair benchmark. At 0.9.0 the same line is correct.

By edge type

Cases include negative cases, so precision is not free.

Edge kind Cases Precision Recall TP FP FN TN
CALLS 16 1.000 1.000 11 0 0 5
DYNAMIC_DISPATCH 7 1.000 1.000 3 0 0 4
INJECTS 20 1.000 1.000 15 0 0 5
ROUTES 40 1.000 1.000 40 0 0 0

TP = expected edge present and correct. FP = graph asserted something that is not true (an invented edge, or a route with the wrong verb or path). FN = expected edge missing. TN = expected absence correctly not asserted, which is how over-approximation gets caught — a self-loop on virtual dispatch, or a dispatch edge to the wrong interface, fails here.

By language and project

All three languages now score 1.000 on the sampled labels.

Language Cases Precision Recall TP FP FN TN
java 44 1.000 1.000 36 0 0 8
python 20 1.000 1.000 17 0 0 3
typescript 19 1.000 1.000 16 0 0 3
Project Cases Precision Recall TP FP FN TN
spring-petclinic 27 1.000 1.000 22 0 0 5
spring-boot-realworld 17 1.000 1.000 14 0 0 3
fastapi-template 20 1.000 1.000 17 0 0 3
nestjs-realworld 19 1.000 1.000 16 0 0 3

Confidence distribution

Only three of the thirteen edge kinds carry a confidence value at all.

Project Edge kind Confidence buckets
spring-petclinic CALLS 0.00: 1,118 · 0.86: 5 · 0.90: 137 · 0.95: 87 · 1.00: 79
spring-petclinic INJECTS no confidence recorded: 23
spring-boot-realworld CALLS 0.00: 1,779 · 0.86: 1 · 0.90: 107 · 0.95: 338 · 1.00: 169
spring-boot-realworld DYNAMIC_DISPATCH 0.80: 19
fastapi-template CALLS 0.00: 644 · 0.80: 259 · 0.85: 230 · 0.90: 112 · 1.00: 4
fastapi-template ROUTES no confidence recorded: 23
nestjs-realworld CALLS 0.00: 97 · 0.80: 42 · 0.90: 35
nestjs-realworld INJECTS / ROUTES no confidence recorded: 21 / 21

A 0.00 bucket is not a low-confidence guess: it is the unresolved marker the crawler writes when it cannot prove a receiver, such as __unknown__:this.repository.save. Filtering it out is the honest way to read a call graph.

Runtime proof: dispatch edges against a real call log

One edge type is checked by running code instead of reading it.

DYNAMIC_DISPATCH is the edge type where static reading is most likely to be wrong: the call site names an interface method, and the target is whichever implementation happens to be live at that moment. So it is verified against execution. A fixture defines an interface Shape with three implementations (Circle, Square, Triangle); Main builds a Circle and a Square, never a Triangle, and drives all of them through the interface. Every implementation prints a line when its method actually runs, so the log is a real call log rather than a second reading of the source.

npm run bench:runtime compiles the fixture with javac, runs it, crawls the fixture directory, and reconciles the two.

Source Target Confidence Proven by execution
fixture.Shape#area fixture.Circle#area 0.80 yes
fixture.Shape#area fixture.Square#area 0.80 yes
fixture.Shape#area fixture.Triangle#area 0.80 no — never constructed
The third edge is not a false positive, and the benchmark does not score it as one. Triangle really does implement Shape, so the edge becomes true the moment any code constructs a Triangle; this particular run simply never did. Counting it as an error would understate the graph, and counting it as proven would overstate it, so it is reported separately as unexercised. That over-approximation is exactly why DYNAMIC_DISPATCH carries a flat 0.80 rather than a near-1.00 confidence: the edge set is deliberately a superset, and a single-implementation interface gives that superset nothing to add while an interface with many implementations gives it plenty.
What this proves, and what it does not. It proves soundness for the dispatch edges it exercises: every method the graph claims is reachable through the interface, and which actually ran, is an edge the graph contains. It does not turn the precision and recall numbers above into runtime measurements — those four projects are still hand-verified, and the Spring and NestJS wiring (routes, injection, proxy boundaries) is not exercised by this fixture at all. There is no Java agent here, only observable side effects in a five-class fixture. Treat it as one edge type verified deeply, not the whole graph verified broadly.

Where Code-Crawler is weak

Reproduced from the benchmark failures, in order of how much they cost you.

1. Framework wiring is modelled one edge kind at a time, and most of it is left

The benchmark covers four edge kinds. Of the wiring that breaks static-only graphs most badly, only event and messaging publication is modelled so far (EMITS: Spring @EventListener and @TransactionalEventListener, Kafka, JMS, NestJS @OnEvent, with a confidence rule per channel kind). Still producing no edge at all: Pub/Sub and RabbitMQ, proxy boundaries from @Transactional, @Async and @Cacheable, Spring Data repository derivation, @Bean factory methods, @Qualifier/@Primary disambiguation, and @Scheduled/@Async re-entry. EMITS is also not among the 69 labelled cases, and the four benchmark projects turn out to contain no event or messaging wiring at all (0 EMITS edges across all four), so it is verified by fixture tests only rather than measured. Adding a fifth project with published events, and the remaining wiring, is the next step: one edge kind at a time, with a fixture, positive and negative tests, a confidence rule and a doc entry each.

2. INJECTS and ROUTES have no confidence model

Both edge kinds are written without a confidence value, so a consumer cannot tell a proven mapping from a derived guess. The project rule is that no edge type exists without a confidence model; these two are the standing exceptions. Recall has improved enough that this now matters more, not less: a Spring @GetMapping on a bean and a Lombok-derived constructor parameter are different strengths of evidence, and the graph reports both with no confidence at all.

Proxy boundaries took the other route. @Transactional, @Async and @Cacheable facts are categorical rather than scored, because proxyBlocked can name the exact reason a container could never honour an annotation (private, static, final, final-type) and inert marks a self-invocation that bypassed the proxy. That precision is not measured here either: none of the four projects below contains a proxied self-invocation, so proxy facts are fixture-verified only.

3. Dispatch is matched by name and arity, not by proof

A virtual dispatch edge carries a flat 0.80 and is emitted to every in-project subtype with a same-named, same-arity method. It is correct on the sampled labels, but that is partly because spring-boot-realworld's ArticleRepository has exactly one implementation. With several implementations, or overloads differing only by generics, the constant would overstate certainty. There is also no dispatch for lambdas or method references.

4. An ambiguous overload is flagged, not resolved

When a call site matches several overloads equally, the edge keeps the first declaration as its target and lists the rivals in alternatives at confidence 0.6. That is a rank, not a resolution: the graph still does not know which overload ran, and a reader who ignores the field gets the same confident wrong answer as before. Rivals are only detected inside one declaration list, so an inherited overload tie goes unreported, and none of the four benchmark projects contains an ambiguous overload - the behaviour is covered by fixtures only.

5. Foreign calls are unresolved by design

Calls into framework or library types resolve to an ext: node or to an __unknown__ marker, for example Repository<ArticleEntity>.save in the NestJS project. This is the correct trade — the tool does not have TypeORM's bytecode — but it means the graph stops at the framework boundary. Event and messaging wiring that crosses that boundary is only modelled when both the publisher's call and the listener's annotation are in index; proxy wiring still is not.

6. A perfect table on 69 edges is not a proof of the graph

Precision and recall of 1.000 here mean the graph answered all 69 sampled questions correctly. The four graphs contain roughly 12,000 edges, and the labelled cases were chosen to probe specific idioms rather than to sample uniformly: routes, injections, calls and dispatch on four projects. Whole categories — reflection chains, REFERENCES, EXTENDS/IMPLEMENTS hierarchies — carry no labelled cases, so a regression in them would not fail the benchmark. The floors in the manifest are a floor on the sample, not a certificate for the tool.

7. change_impact's ranking is untested against reviewers

change_impact ranks changed declarations with a hand-picked score: risk band, then routes, entry points, injection points, advice, event flows, then raw caller count. No benchmark measures whether that order matches what a reviewer would look at first, so treat the order as a convenience and read every kept entry. What is tested is the mapping, not the ranking: diff hunks onto declarations by line span, a member collapsing into its enclosing type, repo-relative paths resolving against crawl-root-relative ones, and unmatched surviving instead of being dropped. The tool adds no edge kind and no schema field, so its accuracy is exactly the accuracy of the edges underneath it.

What the benchmark already found

Writing the first fixtures exposed a parser crash that silently deleted files. A lookup into a method table was resolving identifiers through Object.prototype, so any file containing a call to toString(), valueOf() or hasOwnProperty() threw during parsing. The crawler records parse failures per file, so those files vanished from the graph with only a log line.

On spring-petclinic this had hidden 7 of 50 files, including the Owner JPA entity: files parsed went from 43 to 50, CALLS edges from 574 to 1,426, and total edges from 1,408 to 2,637. The bug is fixed and covered by regression tests.

The Spring route cases failed on a missing feature rather than a bug. The framework descriptor declared the mapping annotations as entry points, but the Java parser never produced route registrations, so no controller could yield a ROUTES edge at all.

Spring mappings are now extracted: a class-level @RequestMapping prefix is joined with each verb annotation, covering the array form @GetMapping({"/vets"}), a named path = attribute, a bare annotation that inherits the class path, paths without a leading slash, and method = RequestMethod.PUT on the prefix-less form. Spring route recall went from 0.250 to 0.778 on its own; with the FastAPI router prefixes below, ROUTES recall reached 1.000, with no invented edge.

Injection failed the same way: the parser only recorded a parameter when an annotation asked for it. Spring has not needed one since 4.3 — the sole constructor of a bean is autowired silently — and a Lombok @AllArgsConstructor leaves the constructor out of the source entirely, so there was nothing to annotate in the first place.

Bean stereotypes now mark a class as container-managed, and its dependencies follow from that: the sole constructor's parameters when there is exactly one, the promoted fields when a Lombok annotation generates the constructor. The rules stay conservative in the ways that matter. Two unannotated constructors inject nothing, because Spring cannot choose between them either. A field initialised at its declaration is skipped. A class with no stereotype is skipped, since no container constructs it. A class that writes its own constructor alongside a Lombok generator injects nothing, because that is two constructors and the same ambiguity applies. Trusting a generator's field list blindly would have produced 0.95-confidence edges to dependencies the code never receives. Spring constructor injection alone took INJECTS recall from 0.133 to 0.667 and Java recall from 0.778 to 1.000, with precision held at 1.000 and no invented edge. Every remaining miss was FastAPI.

FastAPI failed for two independent reasons, and the benchmark had to be read carefully to separate them. First, APIRouter(prefix=...) was ignored, so every route from a prefixed router was emitted with the wrong path. Second, dependencies declared once as a module-level alias (SessionDep = Annotated[Session, Depends(get_db)]) produced no edge when a handler named the alias in its signature — and neither did a dependency reached only through another dependency function.

Both are now extracted, and writing the cases forced two corrections that were not obvious from the code. An injection belongs to the callable the framework populates, not to the module that declares it, so the edge now starts at read_items rather than at module:...items. And the dependency is the provider the framework calls, not the type it returns: Depends(get_service) is an edge to get_service, not to OrderService. Retargeting to the provider is strictly more accurate — the returned object is produced by that callable at run time — and it changed no property name or edge kind. INJECTS recall went from 0.667 to 1.000 and Python recall from 0.588 to 1.000, with precision held at 1.000.

The work also exposed an incremental-crawl bug in the same area: dependency aliases live in a Map, the parse cache round-trips through JSON, and JSON.stringify turns a Map into {}. Replayed files therefore carried no aliases, and — because an unchanged file's edges were never rebuilt when a different file changed — an edited alias left a stale, confidently wrong injection edge pointing at the previous provider. Alias maps are now packed explicitly, injection edges are rebuilt project-wide whenever anything changes (as DYNAMIC_DISPATCH and ADVISES already were), and both behaviours are covered by tests that crawl twice and then edit a Depends(...) target.

Agent cost: graph versus grep-and-read

Accuracy only matters if the answer arrives inside a context window. Ten structural questions, measured on both paths.

Task Project Graph calls Graph tokens Compact tokens Grep calls Grep tokens
Who calls OwnerRepository.findAll? spring-petclinic 1 698 188 8 7,698
What runs on GET /owners/{ownerId}? spring-petclinic 2 4,435 1,486 7 12,616
What breaks if I change the Pet entity? spring-petclinic 1 6,002 2,448 13 18,230
Which package-level declarations are dead? spring-petclinic 1 2,245 826 30 18,328
Who calls User.getId? spring-boot-realworld 1 592 113 21 21,705
What does ArticleQueryService.findById reach? spring-boot-realworld 1 1,832 840 21 14,506
Who depends on get_current_user? fastapi-template 1 2,362 814 2 491
What runs on POST /items? fastapi-template 2 4,058 1,475 7 5,903
Who calls ArticleService.create? nestjs-realworld 1 214 109 4 3,161
What breaks if I change ArticleEntity? nestjs-realworld 1 469 254 7 3,120
Total 4 projects 12 22,907 8,553 120 105,758

The graph answered all ten with a payload that is the answer, in 12 tool calls instead of 120, reading 78% fewer tokens than grep-and-read and 92% fewer with compact: true. One row goes the other way: on FastAPI, Depends(get_current_user) appears in a single file, so two searches beat a query — and the graph still wins on calls.

Method. Every graph number is the real MCP server running in-process over the pinned projects, counting the exact JSON text each tool returns. The baseline models an agent with only text search: one search per query, then opening every file the search hit, because the matching lines do not say which enclosing method matched or who dispatches to it. For the dead-code task it searches each declaration named in the answer set one at a time — the answer set is handed over for free, so the baseline is under-charged. Reads are capped at 20 files, which under-charges again: two tasks had 38 and 32 matching files. Both sides use the same four-characters-per-token heuristic the server uses for its own budget, and the checkout prefix is stripped from payloads so a CI runner measures the same graph the same way.

Where plain search wins. Two textual questions were measured the same way and are not in the totals above. "Which templates use the i18n key welcome?" has no graph answer at all — the graph indexes symbols, not template text, so search_symbols returns same-named symbols and grep answers in one call for 340 tokens. "Where is the owner search SQL defined?" is a one-call lookup either way, and grep is competitive. If the question is about text — a literal, a config key, a template — search is the right tool.

Reproduce it

One command. No services, no credentials, no database server.

shell
git clone https://github.com/nithin554/code-crawler
cd code-crawler
npm ci
npm run bench:accuracy
npm run bench:runtime
npm run bench:ergonomics

The runner clones each project into a gitignored benchmarks/.cache/ at the commit in benchmarks/manifest.json, crawls it into an in-memory SQLite database, scores the labelled cases and writes benchmarks/reports/accuracy.md and accuracy.json. It exits non-zero when precision or recall drops below the floors in the manifest, and CI runs it on every pull request, so an accuracy regression fails the build. bench:runtime is the separate dispatch proof described above: it needs a JDK 17 or newer (javac on PATH) and writes benchmarks/reports/runtime.md. CI installs Temurin 21 for it. bench:ergonomics re-measures the token and tool-call comparison above against the same four cached projects and writes benchmarks/reports/ergonomics.md; it clones nothing, so run bench:accuracy once first.

Thresholds in the manifest are ratchets, not targets: they sit at the value last measured, so a drop fails CI. Every floor is now 1.000 — precision because a confidently wrong edge is worse than a missing one, and recall because these four edge kinds are now extracted from the declarations rather than guessed from a name.

Ready to index?

Install, crawl a project, and register the server with your MCP client.

Quickstart →