Adds the audit, literature rationale, experiment contract, labeled-corpus results, Fold/Paperbridge/Brick/YAMS coverage and dead-code spot checks, model changes, identity changes, and remaining limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
10 KiB
C/C++ and Rust call resolution for agents
Problem
A dead-code audit of a real C++ repository found that 93% of symbols had no resolved incoming edge. Brick could not tell "unresolved" from "unused", so reporting those symbols as dead would have produced the false findings the audit was looking for. The audit on 2026-09-22 found these root causes:
| Cause | Where |
|---|---|
C++ bare calls resolved only within one file; #include never authorized a cross-file call |
scan.rs owner-chain lookup |
Header prototypes not recorded; out-of-line A::f named "A::f" with a file owner |
source.rs declaration |
T& f() named "& f(...)" (fieldless reference_declarator) |
source.rs |
Every member call (obj.f(), this->f(), self.m()) External |
scan.rs |
Calls inside Rust macros (assert!, format!, vec!) dropped (token_tree skipped) |
source.rs |
std::lock_guard<M> g(f()); hides f() (most vexing parse) |
tree-sitter-cpp |
static_cast<T>(x) recorded as a call |
source.rs |
| No unresolved reason; per-caller External identity; no coverage metric | model |
| Linear node/edge scans and per-node ancestor walks | scan.rs, source.rs |
Literature (paperbridge, arXiv/OpenAlex, 2026-09-22)
- Murphy, Notkin et al., "An empirical study of static call graph extractors", TOSEM 1998, doi:10.1145/279310.279314. Extractors disagree widely, so each edge keeps its backend and the resolution method that produced it.
- Grove, DeFouw, Dean, Chambers, "Call graph construction in object-oriented languages", OOPSLA 1997, doi:10.1145/263698.264352; Grove and Chambers, TOPLAS 2001, doi:10.1145/506315.506316. Receiver type sets bound dispatch. Brick uses declared static types only, and treats virtual and trait targets as unknown rather than enumerating overrides.
- Srivastava, "Unreachable procedures in object-oriented programming", LOPLAS
1992, doi:10.1145/161494.161517. Deadness is relative to roots and dispatch
assumptions.
brick_callerstherefore names its reasons and its evidence basis instead of issuing a verdict. - Creager and van Antwerpen, "Stack graphs: Name resolution at scale", arXiv:2211.01224. This is file-local, build-free scope resolution. The C++ include-closure lookup and the Rust impl index follow the same idea at much lower fidelity.
- Cai and Zhang, "A Cocktail Approach to Practical Call Graph Construction", OOPSLA 2023, doi:10.1145/3622833, and Li, He et al., Rupta, CC 2024, doi:10.1145/3640537.3641574. Function pointers (C) and trait/dyn dispatch (Rust) need pointer or MIR analysis. That work belongs to Fold's compiler lane, not to syntax.
- LocAgent (ACL 2025, doi:10.18653/v1/2025.acl-long.426) and CodexGraph (NAACL 2025). Agents navigate code graphs effectively, which makes edge provenance and uncertainty part of the agent contract.
Experiment contract
{
"hypothesis": "Include-visible C++ lookup, declared-receiver typing, Rust impl indexing and macro re-parsing raise labeled call recall without clean-negative regressions, and reasons make zero-caller symbols distinguishable from unresolved ones",
"owner": "source extractor and heuristic resolver; caller-status query",
"fixtures": ["tests/fixtures/cpp_xtu", "tests/fixtures/rust_methods", "tests/fixtures/languages", "tests/fixtures/rust_imports"],
"expected": {
"increases": ["labeled C++/Rust relationship precision and recall", "resolved call-site share on unlabeled repositories"],
"preserves": ["schema 4 reader contract", "Heuristic lane and 0.65 confidence", "existing labeled corpora", "stable IDs of unrelated and out-of-line C++ declarations"]
},
"decision": "promote in the default extractor"
}
Results
The labeled corpora were authored before the implementation. Each has 3 repeats with stable full-model JSON, and the quality gate runs both.
| Corpus | Baseline (050b1b1) relationships |
Candidate |
|---|---|---|
cpp_xtu (headers, out-of-line methods, namespaces, overloads, internal linkage, macros, receivers, bases, a duplicate-program clean negative, std::string / untyped receiver negatives) |
12/17, 6 unexpected (first label version) | 24/24 exact |
rust_methods (Self::, fallible constructors, Arc fields, macro bodies, dyn trait, competing trait impls, std::fs::File shadowing a repository File, generics) |
13/23, precision 0.65 | 23/23 exact |
source_languages, rust_imports |
exact | exact |
Coverage on unlabeled repositories. These are coverage observations, not precision claims:
| Repository | Functions with no resolved caller | Resolved call sites | Scan time |
|---|---|---|---|
Fold src/+include/ (C++, 2341 functions) |
66.3% → 39.0% | 9.5% → 23.1% | 8.8 s → 2.3 s |
| Paperbridge (Rust, 1175 functions) | 66.7% → 50.6% | 985 → 1606 sites | 2.5 s → 0.8 s |
| Brick (Rust, 329 functions) | 45.3% → 28.9% | 575 → 723 sites | 1.2 s → 0.4 s |
Precision spot checks drew random samples of receiver-typed edges: 12 from Fold and 14 from Paperbridge. Every sampled edge was plausible, and the three Fold receiver declarations that were checked by hand were correct.
brick_uncalled on Fold (open world) lists 5 unreferenced functions, and
grep confirms that all 5 have no uses. It first listed 8; the 3 false
candidates were vexing-parse calls, which extraction now recovers.
On Paperbridge (open world) it lists 0 unreferenced functions. In closed world
it lists 8, all of which are used only by the excluded tests/ directories.
The candidates that exposed framework-attribute (#[tool], #[test]) and
deserialize_with string uses are now reported as unknown with those
reasons.
YAMS validation (C++, 15,607 functions including tests)
Baseline (050b1b1) |
Candidate | |
|---|---|---|
| Scan time | 575 s | 23 s |
| Functions with no resolved caller | 84.5% | 49.7% |
| Resolved call sites | 2.5% | 13.9% |
| Files reported as incomplete syntax | 616 | 316 |
The YAMS run exposed six defects, each fixed with a regression test:
- tree-sitter-cpp 0.23.4 parses a
= {}default argument as a compound literal with a zero-width MISSING type. The syntax is otherwise intact, so it is now benign. Before, it dropped every such prototype and flagged half the files. Of the 910 production files, 70 still have real errors, mostly#ifblocks that split braces. using namespace N;andusing N::f;were ignored. They now extend C++ lookup through the include closure, which resolved about 4,500 more sites.- The receiver type of a template parameter (
template <class Ptr> f(Ptr p) { p->g(); }) was classified as a foreign type. That is unsound, because it hid a real caller. Such receivers are nowreceiver-type-unknown. Type aliases and typedefs resolve to their target, through a smart pointer where applicable. - Functions reachable only through a macro body (
#define dlopen dlopen_impl) now count as referenced by name. So do identifiers inside syntax-error regions. - A file with real syntax errors can misplace scope and linkage: the error
recovery for an
#ifthat splits a brace moved a public function into an anonymous namespace. Its functions now carrysource.parse-incomplete, andbrick_callersreports that reason. - These are now
implicit-invocation: coroutine protocol members (await_ready,final_suspend, and the rest), range-forbegin/end, ADLswap, and constructors of explicit specializations (Expected<void>). Out-of-line members of classes that are not in the repository (tinyfsm::Fsm<..>::set_initial_state) areoutside-class-member.
brick_uncalled results after the fixes:
- Open world (default): 2 candidates, and both are unused. One is
neutralText. The other is a Windows-only staticdescribeProcess: every call is made inside aDaemonCommandmethod, where the static member of the same name wins under C++ class-scope lookup. - Closed world: 357 candidates in
src/andinclude/.- The first random sample of 25 was 20 correct; its five errors led to fixes 3 and 6 above.
- A second, independent sample of 30 was 29 correct, and the error was the specialization constructor, now fixed.
- "Correct" means the function has no mentions outside its own declarations, comments, log strings, or unrelated same-named functions.
Model changes (schema 4, optional fields)
- Function node attributes:
source.parse-incomplete;source.qualifier(out-of-lineA::f, whose node name is nowf; the ID still hashesA::f);source.qualified-name(C++);source.linkage: internal;source.virtual;source.implements(Rust trait impl);source.attributes(Rust);source.referenced-by-name.
- Call edge attributes:
source.unresolved-reason, with valuesnot-visible,declared-only,ambiguous,macro,receiver-type-unknown,receiver-type-external,member-not-found,qualifier-unknown,member-receiver,not-in-repositoryandruntime-parameter-binding;source.resolution(declared-receiver-type,self-receiver,impl-type,using-directive);source.extraction(macro-reparse,vexing-parse).
metrics.resolution: per language, the counts of call sites, resolved sites, unresolved sites by reason, functions, and functions without resolved callers.
Intentional identity changes
- C++ and Rust External targets now include the unresolved reason in their identity, so two differently failing sites never merge and lose a reason.
- Reference-returning C++ functions get correct names and therefore new IDs.
- The labeled-corpus runner renders
source.qualifierin symbol identities.
Limits
- All inferred relationships remain Heuristic.
- C++: no preprocessing, overload resolution, template instantiation,
ADL,
autoinference beyondnew T/make_unique<T>, orcompile_commands.jsoninclude paths. - Rust: types are matched by leaf name, and there is no Cargo workspace,
modtree, or re-export resolution. - Dispatch: virtual and trait dispatch reach only the statically declared
method; overrides and trait implementations are reported as
unknown, never as dead. - The compiler lane (Fold
ProjectCallGraph,fold-rustMIR callees) and candidate edges are the next stages (seedocs/ROADMAP.md).