Skip to content

fix(rust): 10 call-graph correctness bugs (edge recovery + phantom removal) - #205

Open
gadievron wants to merge 17 commits into
feat/rust-parserfrom
fix/rust-parser-bugs
Open

fix(rust): 10 call-graph correctness bugs (edge recovery + phantom removal)#205
gadievron wants to merge 17 commits into
feat/rust-parserfrom
fix/rust-parser-bugs

Conversation

@gadievron

@gadievron gadievron commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Ten correctness bugs in the Rust call-graph parser, found by an adversarial bug-hunt over the parser diff and each proven with a git red/green + reachability receipt. Every fix either recovers a real edge/unit the parser was dropping or removes a phantom edge to an unrelated function; none trades reachability for precision or vice versa.

All changes are confined to libs/openant-core/parsers/rust/ — zero cross-parser impact. 102 tests pass (tests/parsers/rust + test_language_registry + test_parser_registry); each fix ships a regression test.

Reachability-recovering (parser was dropping real edges/units)

A — builtin-name filter dropped resolvable calls (call_graph_builder.py · _find_calls_in_code)
Problem: the RUST_BUILTINS filter deleted any call whose name is a common std method (get/parse/push/…) unless a same-file function shadowed it — even a typed, unambiguous cross-file method call. Its own docstring said the guard was for the unknown-receiver path only.
Solution: keep a builtin-named site when its name is a known repo function/method (repo_names); still drop pure std-only names. This restores the clause the Swift parser (the port source) keeps and the Rust port had dropped.
Verification: c.get() on &Cache → edge present after fix; x.unwrap().clone() with no repo namesake → still no edge (test_rust_builtin_filter.py).

C — multi-bound generic annihilated by an impl-less marker (_resolve_generic_bound_member)
Problem: B: Shape + Marker intersected all bound-trait conformer sets; a marker/blanket/derive/cross-crate trait has an empty trait_impls, so the intersection went empty and dropped every edge.
Solution: intersect only over bounds with ≥1 known conformer (an unseen conformer set is unconstrained, not empty). The real-intersection precision case is unchanged.
Verification: total<B: Shape + Marker> → both Shape conformers; test_rust_bound_intersection.py + the existing intersection-precision test both green.

H — same-named methods collided on func_id (_handle_function (func_id collision block))
Problem: func_id = file:qualified_name with no trait discriminator, so impl Display for P {fn fmt} + impl Debug for P {fn fmt} mapped to one id and the second overwrote the first — a whole unit vanished from extraction and reachability.
Solution: on an actual collision, append the trait to the id; the first occurrence keeps its plain id (class-name resolution and existing Type.method refs unchanged).
Verification: both fmt extracted (test_rust_method_collision.py).

I — impls on non-nominal Self were skipped (_handle_impl)
Problem: _bare_type_name returns None for u32/[u8;4]/(i32,i32)/(), and the guard skipped the whole impl block — every method lost.
Solution: broaden the impl Self-type collection to non-nominal kinds and fall back to the raw type text (test_rust_nonnominal_impl.py).

D — impl-level generic bounds were invisible (build_call_graph merge + _impl_generic_bounds)
Problem: only a fn's own generics were read, so impl<T: Shape> Holder<T> { fn m(&self, x: &T) } had no bound for T; x.area() fell to a bare lookup on the letter, which a blanket impl<U> Audit for U had poisoned with a pseudo-type T.area → phantom + dropped real edge.
Solution: record each impl's own generic bounds and merge them into method resolution. Recovers the conformer edge and removes the blanket phantom (test_rust_impl_level_bounds.py).

F — let-binding mis-typed by the constructor idiom (call_graph_builder.py, function_extractor.py)
Problem: let x = Type::assoc() assumed a Type return; let w = Factory::make() where make() -> Widget mis-typed w and dropped w.process().
Solution: record return types; type the binding by the callee's actual return type, falling back to the qualifier for Self (preserving Type::new()). Also extends to free-fn calls (test_rust_assoc_return_type.py, test_rust_freefn_return_type.py).

Phantom-removing (parser was fabricating edges)

B — macro scan harvested calls from string literals (_scan_macro_body)
panic!("call init() first") fabricated an edge to init. Blank string/char literals before the call-shaped scan; real calls outside literals still recovered (test_rust_macro_string_literal.py).

E — nested-fn calls bled into the outer fn (_find_calls_in_code AST walk)
A nested fn inner<B: Logger> had l.log() attributed to outer<B: Shape> and resolved under the wrong bound → phantom outer → Circle.log. Skip nested function_item bodies; the nested fn is its own unit, so no edge is lost (test_rust_nested_fn_scope.py).

The one balance-sensitive case (deliberately not relaxed)

G — F2/F3/F4 unknown-receiver gate. The gate declines when ≥2 same-named &self methods exist and the receiver type is unknown, to avoid a Dog → Cat.speak fan-out. That can black out a private inherent method. Two independent reviews (a reachability lens and an adversarial lens) agreed a blanket union re-introduces the fan-out the gate exists to kill (one cross-type phantom per call site). The gate is left intact; the blackout is instead recovered wherever the receiver type is inferable — the return-type inference in fix F now types let c = load() from load() -> Cfg, so those calls resolve precisely and never reach the gate. The residual truly-ambiguous case stays declined and is documented in the gate (call_graph_builder.py _resolve_unknown_receiver_method).

Precision guards (missed-impact sweep)

An adversarial sweep of the fixes above (three lenses: downstream/integration, scale/cost, adversarial) surfaced three narrow cases where the new return-type inference (F) and the builtin keep-clause (A) could emit a wrong-target phantom on compiling Rust. Three decline-guards close them; each declines only when the inference is genuinely unreliable, so recall versus baseline is unchanged (recall-control tests verify the legitimate cases still resolve).

  • Duplicate type names. _assoc_return_type now requires a unique return type across same-named qualifiers (mirroring _free_fn_return_type). Two Factory types in different files with differing make() returns no longer resolve to an arbitrary one.
  • Closure-shadowed free fn. let helper = || ...; let x = helper() now declines to type x from a same-named repo fn helper (the call is the closure). Only closure bindings shadow a call target — a non-callable rebind (let load = 5) does not, so it never blocks inference.
  • External-import shadow. use std::thread::spawn with a repo fn spawn no longer links the bare spawn() to the repo function, unless the name is also repo-imported (use crate::..) in the same file. Same-file calls and genuine repo imports are unaffected.

Known bound: imports are tracked per file, not per lexical scope, so a file that imports the same name from both std and crate:: (in different inline modules) resolves the std-scope call to the repo namesake — a bounded over-approximation, preferred over dropping the legitimate repo edge.

Hygiene

J — thread decorators into unit metadata (parity with the Swift parser). K — add the missing Set import (latent NameError on future refactor).

Tests

$ PYTHONPATH=$PWD python -m pytest tests/parsers/rust tests/test_language_registry.py tests/test_parser_registry.py -q
102 passed in 0.08s

Each of the 10 fix commits carries a RED (pre-fix) → GREEN (post-fix) proof reproduced against its own parent commit via git worktrees; ruff check on the three changed source files passes.

Compatibility

No API or signature changes to public callers. Behavior change is confined to which call-graph edges/units the Rust parser emits: more real edges/units, fewer phantom edges. Call-graph symmetry (callGraph.keys() ⊆ functions.keys()) holds on every test. Reviewer note: recovering real edges raises reachability recall, which correctly increases the set of units downstream analysis considers (a recall/cost trade the project's doctrine resolves in favor of recall).

Author notes

  • Q1 (which lines implement it): the anchors (by method, drift-proof): _find_calls_in_code repo_names keep-clause (A), _resolve_generic_bound_member non-empty intersection (C), _find_calls_in_code nested-fn skip (E), _free_fn_return_type/_assoc_return_type (F/G); _handle_function collision block (H), _handle_impl non-nominal fallback + _IMPL_SELF_EXTRA_KINDS (I), _impl_generic_bounds (D), the return_type field (F).
  • Q2 (one concrete input previously wrong): impl Display for P {fn fmt} + impl Debug for P {fn fmt} — before, only one fmt existed in the graph (silent data loss); after, both do.
  • Q3 (most likely pushback + answer): "does keeping more builtin-named sites add phantoms?" — no: the unknown-receiver builtin path still returns same-file matches only, and typed dispatch resolves precisely; verified that pure-std .unwrap()/.clone() with no repo namesake still create no edge.

gadievron and others added 10 commits August 1, 2026 16:08
Bug A: the RUST_BUILTINS filter in _find_calls_in_code dropped ANY bare/field
call whose name is a common std method (get/parse/new/push/...) unless shadowed
in the caller's file — BEFORE resolution ran. So a typed, unambiguous cross-file
method call (c.get() with c:&Cache) or a bare call to a real free fn (parse())
was silently deleted, against the codebase's over-approximation stance and the
filter's own docstring (builtins are a guard for the UNKNOWN-receiver path only).

Root cause: the port from swift/call_graph_builder.py dropped Swift's
`text in repo_names` clause that keeps calls naming a real repo function. Restore
the equivalent: keep a builtin-named site when its name is a known repo
function/method; still drop pure std-only names. The unknown-receiver builtin
guard (_resolve_unknown_receiver_method) remains the precision knob it was
documented to be.

Reachability: recovers real edges; verified no new phantoms (pure-std
.unwrap()/.clone() with no repo namesake still create no edge).

Tests: tests/parsers/rust/test_rust_builtin_filter.py (2 new). 84 passed
(was 82). Verified firsthand via scratchpad/verify_all.py scenario A.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug C: _resolve_generic_bound_member intersected the conformer sets of ALL bound
traits. A marker/blanket/derive/cross-crate trait has an EMPTY trait_impls entry
(the extractor deliberately skips blanket pairs), so `B: Shape + Marker` computed
intersection([{Circle,Square}, set()]) = ∅ and dropped every real edge — the exact
`T: Shape + Send`-style pattern real code uses.

Fix: an empty conformer set means "conformers unknown" (unconstrained), not
"impossible". Intersect only over bounds that actually constrain (>=1 known
conformer). Preserves the real-intersection precision when data is complete
(test_multi_bound_uses_conformer_intersection still passes) and recovers the
edges an invisible marker bound was wrongly annihilating.

Tests: tests/parsers/rust/test_rust_bound_intersection.py. 85 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug B: _scan_macro_body regexed the entire macro token-tree text, including
string-literal contents, so `panic!("call init() first")` fabricated an edge to
an unrelated `init`. Blank string/char literals (raw, normal, char) before the
call-shaped scan. Real calls outside the literal (`format!("{}", foo())`) are
still recovered.

Tests: tests/parsers/rust/test_rust_macro_string_literal.py (2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug E: _find_calls_in_code walked the whole outer fn code including nested `fn`
bodies, so a nested `fn inner<B: Logger>(l:&B){ l.log() }` had its call attributed
to `outer<B: Shape>` and resolved under outer's (wrong) bounds -> phantom
`outer -> Circle.log`. Skip descending into nested function_item bodies: the
nested fn is its own extracted unit (`outer::inner`) that collects its calls under
its own bounds, so no edge is lost. Closures still belong to the outer unit
(correct — they capture outer bounds).

Tests: tests/parsers/rust/test_rust_nested_fn_scope.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pped

Bug H: func_id = f"{file}:{qualified_name}" and a method's qualified_name is
"{Self}.{name}" with no impl/trait discriminator. Two same-named methods on one
type -- the ubiquitous `impl Display for P { fn fmt }` + `impl Debug for P { fn
fmt }`, or an inherent method plus a same-named trait method -- collided on one
func_id; the second overwrote the first, silently deleting a whole unit from
extraction, the call graph, and reachability.

Fix: on an actual func_id collision, append the trait (or `impl`) so both survive.
The FIRST occurrence keeps its plain id, so class_name-based resolution and
existing `Type.method` references are unchanged; only the colliding sibling gets a
`#trait` suffix. qualified_name is left intact (both keep `P.fmt`), so `p.fmt()`
over-approximates to both impls (correct: either trait's fmt is reachable).

Tests: tests/parsers/rust/test_rust_method_collision.py (2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uple/unit)

Bug I: _handle_impl computes self_type via _bare_type_name, which names only
nominal types and returns None for primitives (`u32`), arrays (`[u8; 4]`), tuples
(`(i32, i32)`), and unit (`()`). The `if not self_type: return` guard then skipped
the ENTIRE impl block -- every method (e.g. `impl Serialize for u32`) lost from
extraction, the call graph, and reachability.

Fix: when _bare_type_name yields nothing but a Self node exists, fall back to the
raw type text so the methods are still extracted (keyed by that type spelling).

Tests: tests/parsers/rust/test_rust_nonnominal_impl.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…safe)

Bug D: _collect_type_param_bounds read only a fn's OWN generics, so a method
receiver typed as an IMPL-level generic param -- `impl<T: Shape> Holder<T> { fn
m(&self, x: &T) { x.area() } }` -- had no bound for `T` and fell to a bare lookup
on the letter, which an unrelated blanket `impl<U: _> Audit for U` had poisoned
with a pseudo-type `T.area`. Result: phantom `Holder.measure -> T.area` and the
real `Circle.area` conformer edge dropped.

Fix: the extractor records each impl's own generic bounds (_impl_generic_bounds)
on every method (impl_type_param_bounds); the builder merges them with the fn's own
bounds (fn wins on shadow). A `T`-typed receiver now dispatches to Shape's
conformers via the same reachability-safe closure fn-level bounds use -- recovering
the real edge AND removing the blanket phantom.

Tests: tests/parsers/rust/test_rust_impl_level_bounds.py; all generic-dispatch
tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (J)

Bug K: function_extractor.py annotates `impl_generics: Set[str]` but never imported
Set (currently harmless — a local annotation Python doesn't evaluate — but a
NameError the moment it moves to a signature/module scope). Add Set to the typing
import.

Bug J: Rust unit metadata omitted `decorators` while Swift threads it; add it for
cross-parser parity. No functional consumer today (reachability reads call_graph
functions, which already retain decorators), so K has no behavioral red/green; J is
covered by test_rust_unit_decorators.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug F: _infer_let_type assumed `let x = Type::assoc()` returns `Type`. For a
non-constructor associated fn (`Factory::make() -> Widget`) that mis-typed the
binding as Factory: the real `w.process() -> Widget.process` edge was dropped (F1
blocks the fallback because Factory is a known type lacking `process`) and, if
Factory had a same-named method, a phantom `run -> Factory.process` was fabricated.

Fix: the extractor records each fn's bare return type; _infer_let_type consults the
callee's actual return type and only falls back to the qualifier when it is unknown
or `Self` (preserving the dominant `Type::new() -> Self` idiom).

Tests: tests/parsers/rust/test_rust_assoc_return_type.py (2, incl. the Self idiom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t recovery)

Bug G reconcile (Sol + Fable, 2026-08-01): the F2/F3/F4 unknown-receiver gate can
black out a PRIVATE inherent method when its sole edge is declined. Both reviewers
rejected relaxing the gate (a blanket union re-introduces the cross-type
`Dog->Cat.speak` fan-out the gate exists to kill: 1 phantom per call site, 1/N
precision). The phantom-FREE recovery is to make the receiver KNOWN: extend
let-type inference so `let c = load()` with `load() -> Cfg` types `c` as Cfg (using
the return_type the extractor already records), so `c.validate()` resolves
precisely and never reaches the gate.

Recovers the common blackout shape with a precise typed edge (0 phantom). The gate
is left intact; the residual truly-ambiguous case (>=2 same-named private methods,
uninferable receiver) stays declined and is documented as a known limitation. A
gate relaxation is deferred (measured precision trade, out of scope).

Tests: tests/parsers/rust/test_rust_freefn_return_type.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gadievron and others added 7 commits August 1, 2026 21:59
Strip internal tracker ids and review-session lineage that leaked into the code
comments added by the preceding fixes (the "(bug D/F/I/J)" tags and a
persona/date tail on the unknown-receiver gate note), and tighten two comments
that restated their adjacent docstring. Comment RATIONALE is unchanged — only the
provenance and redundancy are removed. No behavior change; 96 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The regression-test docstrings led with internal tracker labels ("Bug A:".."Bug K:")
and a session gate label ("F2/F3/F4"); replace with functional descriptions of the
invariant each test asserts. No test logic change; 96 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip prior-session experiment/fix labels (val_3_18/19, F2/F3/F4, pr_2_1) that
were inherited in the parser's own comments; reword to functional descriptions
(e.g. "unknown-receiver gate"). Comment rationale is unchanged; no behavior
change. 96 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Registering rust as a scanner auto-parametrized test_scanner_contract.py over
rust, but test_deeply_nested_code_is_scanned_or_recorded_as_a_gap's ext map had
no "rust" entry, so it planted a .py file the rust scanner correctly ignores;
where the directory walk completes without hitting a path-length limit (Linux
CI), it then found no file and recorded no gap, tripping the assertion. Add
"rust": ".rs" so a real Rust file is planted. Verified: the rust scanner finds
the deep file or records a coverage gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lision

The rust test suite shipped tests/parsers/rust/_helpers.py and imported it as the
bare module `from _helpers import ...`. The swift suite already ships a
tests/parsers/swift/_helpers.py imported the same way. Under pytest's default
prepend import mode both resolve to a single sys.modules["_helpers"]; whichever
test dir is collected first wins, and the other parser's tests then run against
the WRONG helper (the other parser's build/extract), producing empty output and
~69 spurious failures (all swift stage/scanner/reachability tests). This is why
master (swift only) is green but the feature branch (swift + rust) is red.

Rename the rust helper to _rust_helpers and update the imports so the two suites
no longer share a module name. Full suite: 2526 passed / 0 failed (was 70 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
registry.go Supported() reads config/languages.json dynamically, so it began
returning "rust" once the parser was registered — but the Go parity test
TestSupportedMatchesConfig hard-codes the expected set and was never updated,
so it failed (got [... rust ...], want [... no rust ...]). This is the Go half
of the cross-runtime parity the rust registration needed. Add "rust" in sorted
position. Verified locally: go vet clean, go test ./... all pass, go build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three narrow precision guards close wrong-target phantom edges the
return-type-inference and builtin-keep-clause changes could emit on
compiling Rust:

- _assoc_return_type now requires a unique return type across same-named
  qualifiers, mirroring _free_fn_return_type. Two `Factory` types in
  different files with differing `make()` returns no longer resolve to
  the first one's return type.
- _infer_let_type skips free-fn return typing when the callee name is a
  local closure binding (`let helper = || ...; let x = helper()`), which
  shadows a same-named repo `fn helper`. Only closure bindings shadow a
  call target -- a non-callable rebind (`let load = 5`) does not, so it
  never blocks inference.
- _resolve_bare declines a cross-file repo namesake when the bare name is
  imported from an external crate (std/core/alloc) and is not also
  repo-imported (`use crate::..`) in the same file: `use std::thread::spawn`
  with a repo `fn spawn` no longer links to the repo function. Same-file
  calls and genuine repo imports are unaffected.

Each guard declines only when the inference is genuinely unreliable, so
recall versus the pre-change baseline is unchanged; the recall controls in
the new test verify the legitimate cases still resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gadievron
gadievron force-pushed the fix/rust-parser-bugs branch from f474940 to 9aa5a4a Compare August 2, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant