Extends B13 (real CALM-vs-CodeGraph A/B, but only 2-3 corpora) to all 6 of CALM's Tier-0 languages — reusing B12's corpus registry verbatim (fd/Rust, flask/Python, gin/Go, express/JS, zod/TypeScript, spring-petclinic/Java) — and adds 2 new real competitors, both live-verified against a real spawned MCP server in this repo's own sandbox before being wired in (schemas, tool names, and setup quirks below were discovered by calling the tools, not by reading their READMEs):
- Ctxo (MIT, npm) — has its own PreToolUse safety
gate (
ctxo gate), directly contesting CALM's "only tool with a pre-edit gate" framing. - Context+ (MIT, npx, 1971★) — has its own
memory/RAG tools, directly contesting CALM's "only tool with cross-session memory" framing, and
opens its own README with an unqualified "99% accuracy" claim with zero methodology — exactly
the marketing anti-pattern the "Nghiên cứu competitor" section of the top-level
benchmarks/README.mdalready warns against repeating.
CodeGraph's adapter (codegraph_callers, version pin, CODEGRAPH_MCP_TOOLS env) is reused
verbatim from B13 — unchanged, still on npm latest 1.5.0 as of this run.
File-recall on "who calls this symbol" — the same shape B13 already used, now driven through 4 different real tool schemas:
| Tool | Call | Notes |
|---|---|---|
calm |
callers(symbol, path) |
structured JSON; file lives inside a qualified symbol string, not a separate field (B13 already found this the hard way) |
| CodeGraph | codegraph_callers(symbol, file) |
free-text response, paths extracted via regex |
| Ctxo | search_symbols(pattern) → find_importers(symbolId, edgeKinds=["calls"]) |
two real MCP calls — find_importers needs a symbolId (file::name::kind), not a bare name |
| Context+ | get_blast_radius(symbol_name, file_context) |
free-text response (" path.ext:\n L<n>: ..."), paths extracted via regex |
Oracle: B12's ground_truth.py, reused verbatim — word-bounded git grep + independent
definition-regex extraction, including 3 real oracle bugs found and fixed while building this
exact benchmark (see "Bugs found building this" below) on top of the 2 found auditing CALM on
2026-08-18 (symbol-collision in B2's SCIP oracle, string-literal false positives in this same
git_grep_call_sites).
- Ctxo has no plugin for python or rust. Verified live, not assumed from its docs:
ctxo install pythonandctxo install rustboth 404 on the real npm registry (@ctxo/lang-python,@ctxo/lang-rustdon't exist). Silently skipped on those 2 languages — an absent row is not the same claim as a losing row, and is recorded asarms_skipped_unsupportedinresults.json, not hidden. - Every tool answers a slightly different question under the shared "file-recall" label.
CodeGraph free-texts an impact summary; Ctxo's
find_importersis edge-typed (asked foredgeKinds: ["calls"]specifically, but its underlying resolver may still have its own notion of what counts); Context+'sget_blast_radiusexplicitly documents itself as "usages," not "calls" — and (see below) was caught doing plain substring text search, not symbol-aware matching, on at least one real query. Read the raw per-symbol rows inresults.jsonbefore treating any aggregate percentage as a clean ranking — same caveat B11's own README gives for its raw token-ratio numbers. - Single pass (
--n-repeats 1by default) per symbol per corpus in this run.--n-repeats 3is available and recommended before treating any one row as final, matching B13's discipline.
Three real, previously-undocumented ground-truth bugs were found and fixed while building this benchmark, before any published number — same "audit the oracle before trusting a miss" discipline as the 2026-08-02 and 2026-08-18 fixes to this same file:
- Java method-definition pattern required an explicit access modifier. Package-private
methods — the standard JUnit 5 convention for test methods (
void testFoo() { ... }, nopublic/private/protected) — were never recognized as definitions, so a test method named after the production method it exercises (e.g.void initUpdateOwnerForm() throws ExceptiontestingOwnerController.initUpdateOwnerForm()) got miscounted as a real call site of the production method. Verified live on spring-petclinic: CALM, CodeGraph, and Ctxo all scored 0/1 "missing" a call that was never real — the oracle's sole "hit" was the test method's own declaration line. Fixed: the modifier group is now optional, same as the pre-existing class/interface patterns in the same file already treat it. - Ctxo's plugin installer hard-requires a
package.json, even for non-npm languages. Verified live on spring-petclinic (pure Maven, zero npm anywhere in the repo):ctxo install javarefused outright with "No package.json in the current project" — its plugin-install mechanism always shells out through npm, unconditionally, regardless of which language plugin is being installed. Worked around with a throwawaypackage.jsonwritten before setup and removed immediately after (exactly what a real user hitting this on a Java-only repo would do). sample_symbolsreturned 0 candidates on one otherwise-healthy run, never reliably reproduced (the same corpus produced 8 real candidates seconds later when queried by hand) — not disk pressure (21GB free at the time), smells like a transient subprocess/IO hiccup. Added a one-shot retry rather than silently reporting a corpus as sample-less.
Real finding, not a harness bug: Context+'s get_blast_radius false-positived on plain substring text
Live-reproduced, not inferred: querying get_blast_radius(symbol_name="print", file_context= ".../PetTypeFormatter.java") on spring-petclinic returned 16 usages in 3 files, one of which
was src/main/resources/static/resources/css/petclinic.css — matching literal CSS text like
print-color-adjust: exact; and .d-print-inline-block. This is plain substring text matching
conflating a Java method name with unrelated CSS class names that happen to contain the same
letters, not symbol-aware call-graph analysis. It doesn't cost Context+ any recall in this
benchmark's scoring (the real oracle file was still present in its returned set, so it still scores
a hit) — but it's a real, disclosable precision problem, worth weighing against the "99% accuracy"
claim on Context+'s own README, and worth reading the raw contextplus_files column for, not just
the recall fraction.
Results (2026-08-18, calm @ 5b61d55 + the new-expression fix below, N=8 symbols/corpus, single pass)
File-recall on "who calls this symbol", per language (hit/total oracle files):
| lang | calm | CodeGraph | Ctxo | Context+ |
|---|---|---|---|---|
| python | 9/9 (100%) | 9/9 (100%) | (no plugin — see below) | 9/9 (100%) |
| rust | 9/9 (100%) | 9/9 (100%) | (no plugin — see below) | 9/9 (100%) |
| go | 11/11 (100%) | 10/11 (91%) | 1/11 (9%) — see disclosure below, not a capability claim | 11/11 (100%) |
| javascript | 8/8 (100%) | 8/8 (100%) | 0/8 (0%) — see disclosure below, not a capability claim | 8/8 (100%) |
| typescript | 11/11 (100%) | 9/11 (82%) | 1/11 (9%) — see disclosure below, not a capability claim | 11/11 (100%) |
| java | 25/25 (100%) | 24/25 (96%) | 23/25 (92%) | 25/25 (100%) |
| aggregate (java only for Ctxo — see below) | 73/73 (100.0%) | 69/73 (94.5%) | 23/25 (92%) | 73/73 (100%) |
This table supersedes both earlier published runs: run 1 (calm 68/72 = 94.4%, below CodeGraph's
69/72 = 95.8%) and run 2 (calm 72/73 = 98.6%, ahead of CodeGraph but with 1 known miss left) — see
"Root-cause investigation" below for both fixes involved. Note the java column's sample itself
changed between run 1 and run 2 (fixing the oracle bug changed which 8 symbols sample_symbols
deterministically picks — the symbol names getName/isNew that motivated the first investigation
are no longer in this particular sample, but the fix they drove is independently verified live in the
investigation section, not just inferred from a table moving). Run 2 → this run is a like-for-like
resample (same fixed oracle, only the resolver changed), so the javascript 7/8 → 8/8 move is
directly attributable to the new-expression fix, not a sampling artifact.
Read the "Ctxo go/js/ts: a real, unresolved integration-reliability finding" section below before
citing the go/js/ts Ctxo numbers for anything — they are published for transparency (raw data in
results.json), but this benchmark's own investigation could not confirm they measure Ctxo's real
call-graph quality, so the aggregate row above excludes them and counts only Ctxo's java result
(the one arm verified end-to-end with real, non-empty query results).
Reading the rest of the table: calm now has zero misses across all 6 languages (48 sampled
symbols) in this run — the javascript User case (invoked exclusively via new User(...)) that was
calm's last remaining gap is fixed (see "Round 2" below). CodeGraph misses 4 files total (go/1,
typescript/2, java/1 — not investigated further here); Context+ misses none in this sample (see the
CSS false-positive section below for why "0 misses" doesn't mean "flawless" — it's a precision
question this recall-only task structurally can't see). Ctxo's java column is unchanged from the
previous run (23/25, 92%) — this run's fix doesn't touch anything Ctxo-relevant.
The first published run above showed calm (94.4%) narrowly behind CodeGraph (95.8%) and well
behind Context+ (100%). Investigated end-to-end — not just re-read the numbers — by reproducing
calm's exact misses against a live corpus, inspecting its call_edges table directly, and building
a minimal 2-class Java repro. Three distinct things were found, not one:
- A benchmark-design property, not a bug: this task measures recall only, never precision.
Context+ and CodeGraph both lean toward "return more, don't worry about false positives" —
Context+'s
get_blast_radiuswas caught doing plain substring text matching (see the CSS false-positive section below), which trivially maximizes recall at zero precision cost under this scoring. A recall-only metric structurally favors that strategy over a resolver that tries to stay precise. Not fixed here (would need a precision column, e.g.len(tool_files - oracle_files), to fairly separate "found the real callers" from "returned everything and hoped"). - A real, freshly-introduced oracle bug (same file this benchmark's own ground truth lives in):
the 2026-08-18 fix that made Java's method-definition regex's modifier group optional (to catch
package-private JUnit test methods, see
ground_truth.py'sPATTERNS["java"]comment) turned that pattern into an accidental near-universal matcher for control-flow lines ending in a brace —if (pet.isNew() && ...) {matches "modifiers?, some text, NAME(...) {" with NAME captured as the keyword "if" itself._looks_like_a_definitionthen wrongly excluded every such line from call-site ground truth. Verified live:isNew's real oracle should have been 3 files (Owner.java/PetController.java/PetValidator.java— every OTHER tool in this benchmark found all 3) but had collapsed to 1. Fixed inground_truth.pyby reusing the same_NOT_A_NAMEkeyword setextract_definitionsalready filters against, applied to the definition pattern's own captured group instead of just "did any pattern match at all". - A real, previously-undocumented bug in
calm's own core resolver — the one that actually moved the table above. Root-caused tocrates/calm-core/src/indexer/pipeline.rs::resolve_sites_to_edges: when a call site's receiver has a STATICALLY KNOWN type (target_classpopulated from a tracked binding — Java'sformal_parameter, Go'sparameter_declaration, etc.), the code looked upctx.by_name_class.get(&(callee, class))— keyed on the method's exact declaring class, with no superclass walk — and on a miss returned zero candidates, dropping the call edge entirely, with none of theambiguous-fan-out fallback an UNKNOWN-type receiver already gets. So a call to an inherited method (getName()/isNew()declared onNamedEntity/BaseEntity, invoked through aPet-typed parameter) silently vanished — while the identical call through a same-type LOCAL variable (untracked, so genuinely "unknown" to the resolver) correctly fell back toambiguous. Knowing more about the receiver's type madecalmstrictly worse at finding the edge — the opposite of the intended tiered-confidence design. Verified with a minimal, isolated 2-class Java repro (Base/Sub/3 receiver shapes) before touching any real code, confirmed as the root cause of 3 ofcalm's 4 original misses via directcall_edgesinspection, and fixed by falling back to the unscopedby_namelookup — but ONLY whenclsis itself a symbol this project actually declares (ctx.by_name.contains_key(cls)), so a receiver typed as an unmodeled external/stdlib type (Rust'sHashMap::new()with noHashMapanywhere in the project) keeps the original "no candidates" behavior rather than wrongly fanning out project-wide — caught live bytest_type_path_call_resolves_scoped_not_fanned_outregressing when the fallback was first tried unguarded. New regression test:test_java_formal_parameter_resolves_inherited_superclass_method(pipeline.rs). Fullcargo test -p calm-core --lib(1234 tests) andcargo test -p calm-server --lib(395 tests): all green, 0 regressions. Re-verified live post-fix against the real spring-petclinic corpus:getNamenow correctly includesOwner.java/PetController.java,isNewnow correctly includes all 3 real files.
Net effect (run 1 → run 2): calm moved from 94.4% (behind CodeGraph) to 98.6% (ahead of
CodeGraph, within 1.4 points of Context+'s recall-maximizing ceiling) — with its one remaining miss
being the already-known, separately-tracked new-expression gap, not the inheritance bug. This is
also a broader finding than B15 itself: by_name_class's inheritance blindness is generic (any
language populating target_class — Go, Rust, TS, C#, PHP, C/C++ — could hit the same shape), not
Java-specific; only Java's was live-confirmed here.
After closing the inheritance bug above, the ONE remaining calm miss across all 6 languages was
javascript's User — invoked exclusively via new User(...). Investigated whether this was worth
fixing and, if so, how — verified against the real vendored tree-sitter grammars CALM links (not
guessed from memory):
- Root cause, confirmed via
node-types.json(tree-sitter-javascript-0.23.1,tree-sitter-typescript-0.23.2):new Foo(...)parses as its ownnew_expressionnode kind, entirely distinct fromcall_expression—JS_TS_CONSTANTS.call_node_typesonly listed["call_expression"], sowalk_callsnever matched anew_expressionat all, and noRawCallwas ever emitted for it.new_expressionhas a requiredconstructorfield (the callee, same shapesplit_receiver_calleealready handles) and an optionalargumentsfield — same field NAMEcall_expressionalready uses, socount_arguments_nodeneeded no change. - Fix: reused the codebase's existing extension point for exactly this situation
(
call_function_field_by_kind, already used for PHP/Ruby/Kotlin/Swift/Dart's own non-standard call shapes) — a pure data change, zero new code paths:call_node_types: &["call_expression", "new_expression"],call_function_field_by_kind: &[("new_expression", "constructor")]. - Bundled the same fix for Java in the same pass, after verifying it's equally safe: Java's
new Foo(...)isobject_creation_expression, whose callee lives in atypefield (notconstructor) — confirmed viatree-sitter-java-0.23.5'snode-types.json. Itsargumentsfield is actually namedargument_list, butcount_arguments_nodealready checks"arguments" || "argument_list"(pre-existing, unrelated to this fix). The one real risk checked before committing: does a generic constructor call (new ArrayList<String>(...)) corrupt the callee name with the<String>text? No —leading_ident(used bysplit_receiver_callee) already stops at the first non-identifier character, verified by reading its implementation, then confirmed live by a dedicated test. One genuine, pre-existing wrinkle surfaced by this test: Java indexes a class's own name (Box, kind=class) and its constructor's name (Box, kind=constructor) as two SEPARATE same-named symbols, so a bare-name constructor-call resolution correctly returns both asambiguouscandidates rather than picking one — not a new bug, a pre-existing characteristic of Java's symbol model this fix newly exercises, left as-is (disambiguating it further was out of scope). - Verification: 3 new regression tests (
test_javascript_new_expression_resolves_as_a_call_to_the_ constructed_class,test_typescript_new_expression_with_generics_resolves_as_a_call,test_java_object_creation_expression_resolves_as_a_call_to_the_constructed_class, all inpipeline.rs). Fullcargo test -p calm-core --lib(1237 tests, +3 from Round 1) andcargo test -p calm-server --lib(395 tests): all green, 0 regressions. Re-ran the full 6-language B15 sweep: javascriptUsernow resolves correctly (1/1,examples/view-locals/user.js) —calmreaches 73/73 (100%) aggregate, tied with Context+'s recall-maximizing ceiling and ahead of CodeGraph (94.5%), with zero misses across all 48 sampled symbols — the difference beingcalmgets there via a real resolver, not substring text matching (see the Context+ CSS false-positive section below for what that strategy costs in precision, a dimension this recall-only task doesn't measure). - Deliberately NOT bundled this pass: C++ (
new_expression), C# (object_creation_expression), Kotlin/Swift's own construction syntax likely share the same gap, but their grammar crates aren't fetched under this repo's default build features (lang-cpp/lang-csharp) and weren't live-verified against a realnode-types.jsonthis session — flagged as a real, likely-present, unconfirmed gap for a future pass, not silently assumed fixed.
The benchmark-design finding (point 1 above) also answers a broader question worth stating
plainly: this task doesn't exercise calm's actual differentiators at all. "File-recall on who
calls X" is the one axis where a tool that returns more, unfiltered, structurally cannot lose — it
says nothing about the pre-edit safety gate, cross-session memory, or token-efficiency tasks B11
already measures (and where CodeGraph/Context+ don't compete at all). A benchmark built to showcase
calm's strengths specifically would weight those differently, not just recall on one call-graph
query shape.
Not a harness bug (ruled out through 3 independent rounds of increasingly rigorous verification, each one changing the setup code and re-running) and not (as far as this investigation could tell) an oracle bug either. Documented in full because burying an inconvenient result is exactly what this benchmark suite exists to not do:
- Round 1: raw numbers were go 1/11, javascript 0/8, typescript 1/11, java 23/24. Hypothesis:
npm install -D @ctxo/lang-<x>silently failing to materializenode_moduleson larger real-dependency-tree corpora. Fixed (verify + retry) — numbers did not change. - Round 2: direct SQLite inspection of
.ctxo/.cache/symbols.dbafter a run that printed "[ctxo] Building codebase index... Found 141 source files" / "Index complete: 141 files indexed" found all 3 tables (files,symbols,edges) empty (0 rows) — the CLI's own stated progress does not match what it actually persisted. Also found the CLI prints its real progress to stderr, not stdout (a genuine bug in the first fix's own verification logic, which only checked stdout). Fixed (read both streams, require apackage.jsonmarker file specifically — not just the containing directory — before treating the plugin install as real, retry up to 3x). Re-ran the full 6-language sweep: numbers still did not change — go 1/11, javascript 0/8, typescript 1/11, java 23/24, byte-for-byte identical to before the fix, despite setup metadata now unambiguously showing a real, complete, verified index build (plugin_materialized: true,indexed_ok_marker: true, real per-language file counts in the captured output). - What this rules in/out: the failure is reproducible, stable across 2 independent full runs
with materially different (and progressively more careful) setup code, and specific to the
@ctxo/lang-typescript(covers both javascript and typescript) and@ctxo/lang-goplugins —@ctxo/lang-javaconsistently works (23/24, and CALM/CodeGraph/Context+ all score normally on the exact same go/js/ts corpora in the exact same run, which rules out a corpus-level or MCP-transport-level problem). The pattern (small throwaway-package.json corpus works, larger real-dependency corpus doesn't) is consistent with an async persistence race in Ctxo's own indexer specific to those 2 plugins, but this investigation could not pin the exact mechanism within reasonable scope, and does not have access to Ctxo's own source to confirm. - Why this isn't scored as "Ctxo fails on go/js/ts": a 0-9% recall number here would be
measuring "did this specific CLI+MCP-server pipeline reliably persist an index in this sandbox,
for these 2 plugins, on this run" — not "how good is Ctxo's call-graph resolution once it has a
working index" (java's 23/24 answers that question much better). Publishing the low number as a
capability claim would be exactly the kind of misleading, oracle-unaudited result this whole
benchmark suite's own house rules (see
benchmarks/README.md's "Nghiên cứu competitor" section on the Semgrep 250%-vs-50-71% lesson) argue against repeating in the other direction.
cargo build --release -p calm-cli # default features already full power, nothing extra needed
benchmarks/.venv/bin/python benchmarks/b15_cross_lang_competitor_ab/run_benchmark.py \
--langs python,rust,go,javascript,typescript,java \
--arms codegraph,ctxo,contextplus \
--n-repeats 1--langs/--arms accept comma-separated subsets for a faster partial run (e.g. --langs java --arms contextplus for a single-corpus dry run). .work/<lang> corpora are thrown away after each
language's pass unless --keep-corpus is set; results.json is written incrementally, one
language at a time, so a crash partway through still leaves every completed language's data intact.
- CALM: whatever
--calm-binpoints at (defaulttarget/release/calm) —results.json'smeta.calm_git_sharecords the exact commit independently ofcalm --version(which only prints the Cargo.toml package version, ambiguous across unreleased commits — B13 already learned this the hard way). - CodeGraph:
@colbymchenry/codegraph@1.5.0, pinned explicitly in every spawn (not a bare package name — B13 found the bare form can resolve to a stale npx cache). - Ctxo:
@ctxo/cli@0.11.4(latest on npm as of 2026-08-18). - Context+:
contextplus@1.0.8(latest on npm as of 2026-08-18).
Exact pins for the run in "Results" above (calm_worktree_dirty_at_run: true — the not-yet-committed
new-expression fix/tests/this README edit were themselves uncommitted working-tree changes at run
time, committed immediately after):
| calm | 5b61d55314e625c945b7e4c9a78c8f1e667f93f (base commit; new-expression fix layered on top, see commit after this README's own) |
| python (flask) | 36e4a824f340fdee7ed50937ba8e7f6bc7d17f81 |
| rust (fd) | 41532d114e2ba565fb5367d606c111b29b96450c |
| go (gin) | 34dac209ffb6ef85cc78c5d217bbb7ad001d68fd |
| javascript (express) | a3714473feb3d2908add734d340e7755fd85e0a3 |
| typescript (zod) | 912f0f51b0ced654d0069741e7160834dca742ee |
| java (spring-petclinic) | 51045d1648dad955df586150c1a1a6e22ef400c2 |