fix(rust): 10 call-graph correctness bugs (edge recovery + phantom removal) - #205
Open
gadievron wants to merge 17 commits into
Open
fix(rust): 10 call-graph correctness bugs (edge recovery + phantom removal)#205gadievron wants to merge 17 commits into
gadievron wants to merge 17 commits into
Conversation
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
requested review from
dgeyshis,
shahar-davidson and
sounil
as code owners
August 1, 2026 13:50
gadievron
force-pushed
the
fix/rust-parser-bugs
branch
from
August 1, 2026 18:10
566008e to
3bf8f77
Compare
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
force-pushed
the
fix/rust-parser-bugs
branch
from
August 2, 2026 02:07
f474940 to
9aa5a4a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.102tests 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_BUILTINSfilter 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 + Markerintersected all bound-trait conformer sets; a marker/blanket/derive/cross-crate trait has an emptytrait_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_namewith no trait discriminator, soimpl 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.methodrefs unchanged).Verification: both
fmtextracted (test_rust_method_collision.py).I — impls on non-nominal Self were skipped (
_handle_impl)Problem:
_bare_type_namereturns None foru32/[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_graphmerge +_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 forT;x.area()fell to a bare lookup on the letter, which a blanketimpl<U> Audit for Uhad poisoned with a pseudo-typeT.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 aTypereturn;let w = Factory::make()wheremake() -> Widgetmis-typedwand droppedw.process().Solution: record return types; type the binding by the callee's actual return type, falling back to the qualifier for
Self(preservingType::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 toinit. 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_codeAST walk)A nested
fn inner<B: Logger>hadl.log()attributed toouter<B: Shape>and resolved under the wrong bound → phantomouter → Circle.log. Skip nestedfunction_itembodies; 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
&selfmethods exist and the receiver type is unknown, to avoid aDog → Cat.speakfan-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 typeslet c = load()fromload() -> 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).
_assoc_return_typenow requires a unique return type across same-named qualifiers (mirroring_free_fn_return_type). TwoFactorytypes in different files with differingmake()returns no longer resolve to an arbitrary one.let helper = || ...; let x = helper()now declines to typexfrom a same-named repofn 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.use std::thread::spawnwith a repofn spawnno longer links the barespawn()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
decoratorsinto unit metadata (parity with the Swift parser). K — add the missingSetimport (latentNameErroron future refactor).Tests
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 checkon 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
_find_calls_in_coderepo_names keep-clause (A),_resolve_generic_bound_membernon-empty intersection (C),_find_calls_in_codenested-fn skip (E),_free_fn_return_type/_assoc_return_type(F/G);_handle_functioncollision block (H),_handle_implnon-nominal fallback +_IMPL_SELF_EXTRA_KINDS(I),_impl_generic_bounds(D), thereturn_typefield (F).impl Display for P {fn fmt}+impl Debug for P {fn fmt}— before, only onefmtexisted in the graph (silent data loss); after, both do..unwrap()/.clone()with no repo namesake still create no edge.