Benchmark · 2026-09-28

How accurate is a call graph built without a type checker?

agent-lens analyze function-graph resolves calls from syntax alone. We scored it against a real type checker for each language — go-vta, pyright, rust-analyzer, and tsc — on eight open-source projects. Resolved edges are right 98.3% of the time, it finds 85.0% of statically dispatched calls, and it does so 281× faster than the oracles.

Precision
0.983
Static recall
0.850
agent-lens, 8 repos
589 ms
Oracles, 8 repos
165.5 s

Why measure it

Half of agent-lens stands on one data structure. hubs, impact, layers, risk, reach and the function graph viewer all read the same static call graph, built by a resolver that parses each file (syn for Rust, oxc for TypeScript, ruff's parser for Python, tree-sitter for Go) and never runs a type checker, a build, or a language server. That is what keeps the binary fast and dependency-free — and it is also a guess. A graph that invents edges tells an agent that a harmless change has a blast radius; one that drops edges tells it dead code is safe to delete.

So the guess needs a number. Hand-labelled suites such as PyCG's micro-benchmark are small and use their own edge definitions, so instead each language gets a mechanical oracle: the type checker the ecosystem already trusts. Humans only look at the pairs where the two sides disagree.

How it is scored

  1. Pin small, well-tested projects by full commit SHA, two per language.
  2. Run the oracle: go-vta over the packages and their tests; pyright's language server asked for the definition at every call site; rust-analyzer's call hierarchy; the TypeScript checker walking every call expression.
  3. Normalise both sides to function → function pairs: the innermost named function around the call site, and the function that runs. Builtins, stdlib and third-party code are out; constructors map to __init__ / constructor.
  4. Score. Precision is the share of agent-lens resolved edges the oracle confirms. Recall is over the oracle's static dispatch edges — a direct call or a method on a concrete type — since interface and trait calls are not expected to resolve from syntax; agent-lens reports those as candidate sets instead.
  5. Adjudicate disagreements in a TOML file keyed by qualified name, so a verdict survives line shifts and is reused on every run.

Everything below counts every unadjudicated agent-lens-only pair as a false positive, so the precision is a lower bound. Full edge definition, oracle contracts and scoring rules: docs/callgraph-accuracy.md; the harness: scripts/callgraph-accuracy.

Accuracy

Measured 2026-09-28 on agent-lens 2a26836 with mise run callgraph-accuracy.

  • Precision
  • Static recall
spf13/pflag · go-vta
0.985
0.841
google/go-cmp · go-vta
1.000
0.830
more-itertools · pyright
0.999
0.971
pytoolz/toolz · pyright
0.972
0.968
dtolnay/semver · rust-analyzer
1.000
0.762
rust-lang/log · rust-analyzer
0.796
0.491
supermacro/neverthrow · tsc
1.000
0.808
pmndrs/zustand · tsc
0.978
0.973
Precision: share of resolved agent-lens edges the oracle confirms. Static recall: share of the oracle's static-dispatch edges agent-lens resolved.
Show as table
ProjectOraclePrecisionStatic recallLeft as candidates
spf13/pflag v1.0.10go-vta0.985 (1098 / 1115)0.841 (1094 / 1301)30
google/go-cmp v0.7.0go-vta1.000 (376 / 376)0.830 (376 / 453)63
more-itertools v11.1.0pyright0.999 (885 / 886)0.971 (885 / 911)6
pytoolz/toolz 1.1.0pyright0.972 (278 / 286)0.968 (272 / 281)8
dtolnay/semver 1.0.28rust-analyzer1.000 (157 / 157)0.762 (157 / 206)13
rust-lang/log 0.4.34rust-analyzer0.796 (144 / 181)0.491 (131 / 267)85
supermacro/neverthrow v8.2.0tsc1.000 (699 / 699)0.808 (698 / 864)148
pmndrs/zustand v5.0.15tsc0.978 (220 / 225)0.973 (220 / 226)0
All eight (micro-averaged)0.983 (3857 / 3925)0.850 (3833 / 4509)353

"Left as candidates" are static calls agent-lens saw but would not commit to: it emitted an ambiguous edge listing the possible callees, the right one among them. That is the resolver's deliberate trade: when a name is shared, report the set rather than guess, so precision stays high and an agent can still follow the candidates.

What the oracles found

The point of an oracle is the disagreement list, not the headline. Reading it turned up concrete resolver bugs in every language; each fix landed with a metamorphic test and the numbers moved accordingly (first run against each oracle vs today, same targets):

  • First run against the oracle
  • Today

Precision

Go
Python
Rust
TypeScript

Static recall

Go
Python
Rust
TypeScript
Same two pinned targets per language, before and after the resolver fixes each oracle's disagreement list led to. The axis starts at 0.2.
Show as table
LanguageTargetsPrecisionStatic recall
Gopflag + go-cmp0.973 → 0.9890.536 → 0.838
Pythonmore-itertools + toolz0.810 → 0.9920.313 → 0.971
Rustsemver + log0.924 → 0.8910.550 → 0.609
TypeScriptneverthrow + zustand0.995 → 0.9950.704 → 0.842
  • Go. Calls inside func literals were dropped; a method on a call's result (NewDecoder(r).Decode(v)) was read as the package-level function of that name; wg.Add(1) could bind to a free function Add.
  • Python. The biggest jump. import more_itertools as mi over a from .more import * barrel left almost every test call unresolved; from itertools import count was bound to an in-repo count; A() did not reach A.__init__; super().m() could bind to the caller's own override.
  • TypeScript. Functions inside a namespace were named without it, new C() recorded no call site, and calls through a local closure (const parse = (s) => …) fell through to name matching.
  • Rust. Calls in macro arguments (assert_eq!(f(x), …), format!("{}", g())) were invisible, and two V::fmt in one file (Display and Debug) lost all their edges. Making macro arguments visible is also why log's precision fell: it exposed calls to methods a macro generates, which have no node to resolve to.

Speed against accuracy

Wall-clock for the whole repository, warm caches, on a 4-vCPU Xeon @ 2.10GHz: median of 10 runs of agent-lens analyze function-graph --format json, median of 3 runs of each oracle. The Go oracle is timed as a prebuilt binary, so its compile is excluded; pyright and rust-analyzer include starting the server and indexing, which is what a caller pays.

  • agent-lens function-graph
  • Type-checker oracle
agent-lens
27–154 ms
rust-lang/log (macros)type-checker oracles · 1.5–101 s
x: wall-clock for the whole repository, log scale (each gridline is 10×). y: F1 of precision and static recall against the oracle; the oracle is the reference, so it sits at 1.0 by definition. Each line joins one project's pair. Up and to the left is better.
Show as table
ProjectGraphagent-lensOracleSpeedupF1
spf13/pflag925 fn · 2599 calls106 ms1.8 s go-vta17×0.907
google/go-cmp404 fn · 1436 calls154 ms1.7 s go-vta11×0.907
more-itertools1004 fn · 3971 calls147 ms101.0 s pyright687×0.985
pytoolz/toolz379 fn · 1053 calls33 ms25.3 s pyright767×0.970
dtolnay/semver139 fn · 466 calls27 ms13.1 s rust-analyzer485×0.865
rust-lang/log388 fn · 747 calls42 ms19.2 s rust-analyzer458×0.607
supermacro/neverthrow681 fn · 1816 calls30 ms1.5 s tsc50×0.894
pmndrs/zustand1378 fn · 2573 calls50 ms1.9 s tsc38×0.976
All eight589 ms165.5 s281×0.912

The two sides sit in opposite corners. agent-lens keeps F1 at 0.87–0.99 on seven of the eight projects (rust-lang/log, at 0.61, is the outlier its macros explain above) while spending one to three orders of magnitude less time; the oracle buys the last few points of accuracy with a type checker or language server. Every project finishes in under 160 ms, which is what lets the graph analyzers run inside a hook or on every agent turn. The gap is smallest against go-vta and tsc (11–50×), which type-check a module in a single process, and largest against pyright and rust-analyzer (450–760×), where the oracle is a language server answering one request per call site. Construction cost on synthetic 1,024-function corpora for all four languages is gated in CI by the function_graph Criterion benchmark, so a slowdown fails the pull request that causes it.

Limits

  • Dynamic dispatch is not resolved. Interface, trait and virtual calls are under 3% recalled as resolved edges in Go, Rust and TypeScript; they show up as candidate sets, which contain the right callee 73–100% of the time.
  • Types still matter. The remaining static misses are method chains on returned values (ok(1).andThen(…)), methods inherited from a base class, and re-exports through pub use m::* — things only a type or a class hierarchy answers.
  • Eight projects is a sample. The discovery runs behind each fix covered 20 more repositories (listed in the doc), where precision held at 0.97–0.99 per language; they are not in the pinned set and none of their disagreements are adjudicated.

Reproduce with mise run callgraph-accuracy [target…]. It needs network access for the checkouts, the Go module proxy, PyPI and npm; output lands in target/callgraph-accuracy/.