From defe78a25559a5b26b2a162ce9a75e22ee664784 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:08:54 +0300 Subject: [PATCH 01/17] fix(rust): keep resolvable builtin-named call sites (repo_names clause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../parsers/rust/call_graph_builder.py | 22 +++++++++++-- .../parsers/rust/test_rust_builtin_filter.py | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index abdcdc7..9374eb4 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -136,7 +136,7 @@ def build_call_graph(self) -> None: var_types = self._collect_var_types(code) fn_aliases = self._collect_fn_aliases(code, name_to_ids, file_path) type_param_bounds = self._collect_type_param_bounds(code) - calls = self._find_calls_in_code(code, file_path) + calls = self._find_calls_in_code(code, file_path, name_to_ids) for call in calls: resolved_ids = self._resolve_call( @@ -237,7 +237,10 @@ def _build_name_index(self) -> Dict[str, List[str]]: # -- call-site extraction --------------------------------------------------- - def _find_calls_in_code(self, code: str, caller_file: str) -> List[dict]: + def _find_calls_in_code( + self, code: str, caller_file: str, + name_to_ids: Optional[Dict[str, List[str]]] = None, + ) -> List[dict]: """Return call-site descriptors: {"kind": "bare"|"field"|"scoped", ...}.""" sites: List[dict] = [] if not code: @@ -260,11 +263,24 @@ def _find_calls_in_code(self, code: str, caller_file: str) -> List[dict]: self._scan_macro_body(node, source, sites) stack.extend(node.children) + # The RUST_BUILTINS set is a precision guard for the UNKNOWN-receiver + # fallback path only (see `_resolve_unknown_receiver_method`), NOT a reason + # to delete a resolvable call at extraction time. A builtin-named site is + # dropped here ONLY when the name is a pure std/library name — i.e. it does + # NOT name any known repo function/method (`repo_names`) and is not shadowed + # in the caller's own file. When the name IS a real repo function/method, + # the site is kept and resolution decides precisely (typed dispatch) or + # conservatively (the unknown-receiver builtin guard). This mirrors the + # Swift parser's filter, whose `text in repo_names` clause keeps exactly + # these real calls; the Rust port had dropped that clause, silently + # deleting typed cross-file `.get()/.parse()/...`-style edges. shadowing = self._same_file_function_names(caller_file) + repo_names = set(name_to_ids) if name_to_ids else set() filtered = [] for site in sites: bare = site.get("bare_filter_name") - if bare is None or bare in shadowing or bare not in RUST_BUILTINS: + if (bare is None or bare in shadowing or bare not in RUST_BUILTINS + or bare in repo_names): filtered.append(site) return filtered diff --git a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py new file mode 100644 index 0000000..d3009ad --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py @@ -0,0 +1,31 @@ +"""Bug A: RUST_BUILTINS filter must not drop TYPED/resolvable method-call edges. + +A method call whose method name happens to be in RUST_BUILTINS (get/parse/new/...) +on a KNOWN receiver type must still resolve — the builtin guard is a precision knob +for the UNKNOWN-receiver fallback only (see _resolve_unknown_receiver_method), not a +reason to delete a fully-resolvable typed method edge at extraction time. +""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_typed_cross_file_builtin_named_method_resolves(tmp_path): + repo = { + "a.rs": "pub struct Cache;\nimpl Cache { pub fn get(&self) -> i32 { secret() } }\nfn secret() -> i32 { 1 }\n", + "b.rs": "use crate::a::Cache;\npub fn run(c: &Cache) -> i32 { c.get() }\n", + } + e = edges(build(tmp_path, repo)[1]) + # 'get' is in RUST_BUILTINS but the receiver is typed (&Cache) and Cache::get is + # unambiguous -> the edge MUST exist (previously dropped by the pre-resolution filter). + assert ("run", "Cache.get") in e, e + + +def test_bare_builtin_named_free_fn_resolves(tmp_path): + # a bare call to a free fn named like a builtin ('parse') is a real edge. + repo = { + "a.rs": "pub fn parse() -> i32 { 1 }\n", + "b.rs": "use crate::a::parse;\npub fn run() -> i32 { parse() }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run", "parse") in e, e From dbb0928ff9e77f34ea9c44fd247267ca9a196b0d Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:12:23 +0300 Subject: [PATCH 02/17] fix(rust): multi-bound generic dispatch survives impl-less marker traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../parsers/rust/call_graph_builder.py | 10 ++++++++- .../rust/test_rust_bound_intersection.py | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index 9374eb4..eca5801 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -800,7 +800,15 @@ def _resolve_generic_bound_member( if not known: return [] conformer_sets = [set(self.trait_impls.get(t, [])) for t in known] - conformers = set.intersection(*conformer_sets) if len(conformer_sets) > 1 else conformer_sets[0] + # A bound whose conformer set is EMPTY is *unconstrained*, not impossible: + # marker/blanket/derive/cross-crate impls are invisible to the extractor + # (blanket pairs are deliberately skipped in function_extractor), so an empty + # set means "conformers unknown", and must NOT annihilate the edges the other + # bounds legitimately establish (`B: Shape + Marker` still reaches Shape's + # conformers). Intersect only over bounds that actually constrain — those with + # >=1 known conformer. Reachability over-approximation, not a guess. + constraining = [s for s in conformer_sets if s] + conformers = set.intersection(*constraining) if constraining else set() allowed = conformers | set(known) # concrete conformers + trait-default bodies result: List[str] = [] for cand in name_to_ids.get(method, []): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py new file mode 100644 index 0000000..b933bbe --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py @@ -0,0 +1,21 @@ +"""Bug C: a multi-bound generic must not lose all edges when one bound trait has +no recorded conformers (marker/blanket/derive/cross-crate impls are invisible to +the extractor). An unseen conformer set is 'unconstrained', not 'empty' — it must +not annihilate the edges the other bounds establish (reachability over-approx).""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_multi_bound_survives_impl_less_marker(tmp_path): + repo = {"lib.rs": """ + pub trait Shape { fn area(&self) -> f64; } + pub struct Circle; impl Shape for Circle { fn area(&self) -> f64 { 1.0 } } + pub struct Square; impl Shape for Square { fn area(&self) -> f64 { 2.0 } } + pub trait Marker {} + impl Marker for X {} + pub fn total(b: &B) -> f64 { b.area() } + """} + e = edges(build(tmp_path, repo)[1]) + assert ("total", "Circle.area") in e, e + assert ("total", "Square.area") in e, e From ac5ea7db83a4b4284d7c1fdbed061d9b0be0723a Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:13:52 +0300 Subject: [PATCH 03/17] fix(rust): don't harvest phantom calls from macro string literals 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 --- .../parsers/rust/call_graph_builder.py | 16 ++++++++++++++++ .../rust/test_rust_macro_string_literal.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index eca5801..0062aca 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -55,6 +55,18 @@ r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:::<[^>]*>)?\s*\(" ) +# String / char literals inside a macro token tree, blanked BEFORE the call-shaped +# scan so call-looking text in a diagnostic message (`panic!("call init() first")`) +# is not harvested as a phantom edge. Covers raw strings (`r"..."`, `r#"..."#`), +# normal strings with escapes, and char literals; Rust lifetimes (`'a`, no closing +# quote) intentionally do not match. Best-effort, matching the scan itself. +_RUST_STR_LITERAL_RE = re.compile( + r'r#"(?:[^"]|"(?!#))*"#' # raw string, one hash + r'|r"[^"]*"' # raw string, no hash + r'|"(?:\\.|[^"\\])*"' # normal string with escapes + r"|'(?:\\.|[^'\\])'" # char literal +) + RUST_BUILTINS = { # core::fmt / println-family macro names (recovered via token-tree scan, # so they can appear as bare "calls" and must be filtered like any std fn) @@ -388,6 +400,10 @@ def _scan_macro_body(self, node: Node, source: bytes, sites: List[dict]) -> None if macro_name not in _SCANNABLE_MACROS or token_tree is None: return text = self._text(token_tree, source) + # Blank string/char literals first: their contents are data, not calls, so + # `println!("call init() first")` must not yield an `init` edge, while a real + # call outside the literal (`format!("{}", foo())`) is still recovered. + text = _RUST_STR_LITERAL_RE.sub(" ", text) for match in _MACRO_CALL_RE.finditer(text): call_name = match.group(1) if "." in call_name: diff --git a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py new file mode 100644 index 0000000..bdbe486 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py @@ -0,0 +1,19 @@ +"""Bug B: the macro token-tree regex scan must not harvest call-shaped text from +INSIDE string literals. `println!("call init() first")` must not fabricate an edge +to an unrelated `init`; real calls OUTSIDE the literal (`format!("{}", foo())`) +must still be recovered.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_no_phantom_from_call_shaped_string_literal(tmp_path): + repo = {"lib.rs": 'pub fn connect() { panic!("not ready: call init() first"); }\npub fn init() {}\n'} + e = edges(build(tmp_path, repo)[1]) + assert ("connect", "init") not in e, e + + +def test_real_call_outside_literal_still_recovered(tmp_path): + repo = {"lib.rs": 'pub fn caller() { println!("{}", helper()); }\npub fn helper() -> i32 { 1 }\n'} + e = edges(build(tmp_path, repo)[1]) + assert ("caller", "helper") in e, e From 9e7db3b6b50a10f86d78e9410a4c26df35e0aa6e Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:15:57 +0300 Subject: [PATCH 04/17] fix(rust): don't bleed nested-fn call sites into the outer fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug E: _find_calls_in_code walked the whole outer fn code including nested `fn` bodies, so a nested `fn inner(l:&B){ l.log() }` had its call attributed to `outer` 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 --- .../parsers/rust/call_graph_builder.py | 12 ++++++++++++ .../parsers/rust/test_rust_nested_fn_scope.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index 0062aca..e7459b4 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -264,8 +264,20 @@ def _find_calls_in_code( source = code.encode("utf-8") stack = [tree.root_node] + entered_fn = False while stack: node = stack.pop() + if node.type == "function_item": + # The outermost function_item IS this unit; a NESTED `fn` is its own + # extracted unit (`outer::inner`) that collects its own call sites + # under its own generic bounds. Do not descend into a nested fn body + # here — attributing its calls to the outer unit resolves them under + # the outer fn's (wrong) bounds and fabricates phantom edges. Closures + # (closure_expression) are NOT function_items, so they still belong to + # this unit, which is correct (they capture the outer bounds). + if entered_fn: + continue + entered_fn = True if node.type == "call_expression": callee = node.children[0] if node.children else None site = self._describe_callee(callee, source) diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py new file mode 100644 index 0000000..99ea613 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py @@ -0,0 +1,19 @@ +"""Bug E: a nested fn's call sites must be attributed to the nested unit, not bled +into the OUTER fn (and resolved under the outer fn's — wrong — generic bounds). +`fn outer(){ fn inner(l:&B){ l.log() } ... }` must not create +`outer -> Circle.log` (Circle is Shape, not Logger). The inner unit keeps its edge.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_nested_fn_calls_not_bled_into_outer(tmp_path): + repo = {"lib.rs": """ + pub trait Shape { fn area(&self); } + pub trait Logger { fn log(&self); } + pub struct Circle; impl Shape for Circle { fn area(&self){} } impl Circle { fn log(&self){} } + pub fn outer(s: &B) { fn inner(l: &B) { l.log(); } s.area(); } + """} + e = edges(build(tmp_path, repo)[1]) + assert ("outer", "Circle.area") in e, e # outer's real call survives + assert ("outer", "Circle.log") not in e, e # nested inner's call must NOT bleed to outer From 638e9b401699329e975f5f980d8e9ec2a017d2ef Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:18:35 +0300 Subject: [PATCH 05/17] fix(rust): disambiguate same-named methods so neither is silently dropped 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 --- .../parsers/rust/function_extractor.py | 17 +++++++++++ .../rust/test_rust_method_collision.py | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_method_collision.py diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index 35315d2..3e7e190 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -439,6 +439,7 @@ def _handle_impl( "module_path": ctx["module_path"], "in_test_scope": ctx["in_test_scope"], "in_trait_impl": trait_name is not None, + "impl_trait": trait_name, } worklist.append((body, new_ctx)) @@ -515,6 +516,22 @@ def _handle_function( module_name = "::".join(ctx["module_path"]) if ctx["module_path"] else None func_id = f"{file_path}:{qualified_name}" + if func_id in functions: + # Same qualified_name already taken -- e.g. `impl Display for P` and + # `impl Debug for P` both yield `P.fmt`, or an inherent method plus a + # same-named trait method. Without disambiguation the second silently + # clobbers the first (a whole unit lost from the graph AND reachability). + # Append the trait (or `impl`) so both survive. The FIRST occurrence + # keeps the plain id, so class_name-based resolution and existing + # `Type.method` references are unchanged; only the colliding sibling + # gets the `#trait` suffix. + disc = ctx.get("impl_trait") or "impl" + candidate = f"{file_path}:{qualified_name}#{disc}" + n = 2 + while candidate in functions: + candidate = f"{file_path}:{qualified_name}#{disc}{n}" + n += 1 + func_id = candidate functions[func_id] = { "name": name, "qualified_name": qualified_name, diff --git a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py new file mode 100644 index 0000000..cf91d2d --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py @@ -0,0 +1,28 @@ +"""Bug H: two same-named methods on one type (the ubiquitous `impl Display for P` ++ `impl Debug for P`, both `fn fmt`) must both be extracted. Previously both mapped +to func_id `file:P.fmt` and the second silently clobbered the first (data loss: +a whole unit vanished from the graph and reachability).""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import extract # noqa: E402 + + +def test_same_name_methods_both_extracted(tmp_path): + ext = extract(tmp_path, {"a.rs": """ + pub struct P; + impl std::fmt::Display for P { fn fmt(&self) {} } + impl std::fmt::Debug for P { fn fmt(&self) {} } + """}) + fmts = [f for f in ext["functions"].values() if f["name"] == "fmt" and f["class_name"] == "P"] + assert len(fmts) == 2, [f["qualified_name"] for f in fmts] + + +def test_inherent_plus_trait_same_name_both_extracted(tmp_path): + ext = extract(tmp_path, {"a.rs": """ + pub trait Draw { fn render(&self); } + pub struct W; + impl W { fn render(&self) -> i32 { 1 } } + impl Draw for W { fn render(&self) {} } + """}) + renders = [f for f in ext["functions"].values() if f["name"] == "render" and f["class_name"] == "W"] + assert len(renders) == 2, [f["qualified_name"] for f in renders] From 3ce264f16cca79a1af380ca12a19091231e7cca7 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:20:04 +0300 Subject: [PATCH 06/17] fix(rust): extract impls on non-nominal Self types (primitive/array/tuple/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 --- .../parsers/rust/function_extractor.py | 21 ++++++++++++++++++- .../parsers/rust/test_rust_nonnominal_impl.py | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index 3e7e190..a403fb6 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -75,6 +75,17 @@ def _load_rust_language() -> Language: # `impl` block, or as the RHS of a `let x: = ...` annotation. _TYPE_NODE_KINDS = ("type_identifier", "generic_type", "scoped_type_identifier") +# Non-nominal Self-type node kinds that are absent from _TYPE_NODE_KINDS but CAN be +# an impl target: `impl Trait for u32 / [u8;4] / (i32,i32) / () / &T / *const T / +# dyn X`. Collected in _handle_impl so their methods are extracted (bug I); for the +# genuinely non-nominal ones _bare_type_name returns None and _handle_impl falls +# back to the raw type text, while reference/dynamic types unwrap to their nominal +# base as usual. +_IMPL_SELF_EXTRA_KINDS = ( + "primitive_type", "array_type", "tuple_type", "unit_type", + "reference_type", "pointer_type", "dynamic_type", +) + def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]: """Reduce a type node to its bare (unqualified, non-generic) name. @@ -403,7 +414,7 @@ def _handle_impl( if cc.type == "type_identifier": impl_generics.add(_text(cc, source)) break - if child.type in _TYPE_NODE_KINDS: + if child.type in _TYPE_NODE_KINDS or child.type in _IMPL_SELF_EXTRA_KINDS: type_nodes.append((child, seen_for)) elif child.type == "declaration_list": body = child @@ -418,6 +429,14 @@ def _handle_impl( self_node = type_nodes[0][0] if type_nodes else None self_type = _bare_type_name(self_node, source) + if not self_type and self_node is not None: + # Non-nominal Self type (primitive `u32`, array `[u8; 4]`, tuple + # `(i32, i32)`, unit `()`): `_bare_type_name` only names nominal types, + # so `impl Serialize for u32` would be dropped ENTIRELY -- every method + # in the block lost from extraction, the graph, and reachability. Fall + # back to the raw type text so the methods are still extracted (keyed by + # that type spelling). + self_type = _text(self_node, source).strip() trait_name = _bare_type_name(trait_node, source) if not self_type or body is None: diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py new file mode 100644 index 0000000..f6054d3 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py @@ -0,0 +1,18 @@ +"""Bug I: trait/inherent impls on non-nominal Self types (primitive/array/tuple/ +unit) must still have their methods extracted. `_bare_type_name` names only nominal +types, so `impl Serialize for u32` was dropped entirely (every method lost).""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import extract # noqa: E402 + + +def test_nonnominal_self_impls_extracted(tmp_path): + ext = extract(tmp_path, {"a.rs": """ + pub trait Enc { fn enc(&self); } + impl Enc for u32 { fn enc(&self) {} } + impl Enc for [u8; 4] { fn enc(&self) {} } + impl Enc for (i32, i32) { fn enc(&self) {} } + pub struct Ok1; impl Enc for Ok1 { fn enc(&self) {} } + """}) + encs = [f for f in ext["functions"].values() if f["name"] == "enc"] + assert len(encs) == 4, sorted(f["class_name"] for f in encs) From 0325b5716723898165821cd8e4d13adaab257142 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:24:28 +0300 Subject: [PATCH 07/17] fix(rust): impl-level generic bounds dispatch to conformers (blanket-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 Holder { 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 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 --- .../parsers/rust/call_graph_builder.py | 10 +++- .../parsers/rust/function_extractor.py | 52 +++++++++++++++++++ .../rust/test_rust_impl_level_bounds.py | 24 +++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index e7459b4..3bfdfbe 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -147,7 +147,15 @@ def build_call_graph(self) -> None: var_types = self._collect_var_types(code) fn_aliases = self._collect_fn_aliases(code, name_to_ids, file_path) - type_param_bounds = self._collect_type_param_bounds(code) + # Merge the enclosing impl's generic bounds (`impl Foo`, + # recorded by the extractor) with the fn's OWN generics; the fn's own + # bound wins on a letter collision (inner shadows outer). Without the + # impl-level bounds a receiver typed as the impl's `T` would fall to a + # bare lookup on the letter and be hijacked by a blanket pseudo-type (D). + type_param_bounds = { + **func_info.get("impl_type_param_bounds", {}), + **self._collect_type_param_bounds(code), + } calls = self._find_calls_in_code(code, file_path, name_to_ids) for call in calls: diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index a403fb6..cfbf363 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -140,6 +140,53 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]: return None +def _impl_generic_bounds(impl_node: Node, source: bytes) -> Dict[str, List[str]]: + """Map an impl block's OWN generic param letter -> its bound trait(s). + + `impl Foo` and `impl Foo where T: Shape` both yield + `{"T": ["Shape", ...]}`. Only the impl header's own generics (the `<...>` before + the self type, plus the where-clause) are read. Threaded onto each method so a + receiver typed as the impl's generic param (`x: &T`) dispatches to the bound + trait's conformers -- the SAME reachability-safe closure fn-level bounds use -- + instead of falling to a bare lookup on the letter `T` (which a blanket impl's + pseudo-type `T` would poison). Mirrors CallGraphBuilder._collect_type_param_bounds. + """ + bounds: Dict[str, List[str]] = {} + + def _traits(tb: Node) -> List[str]: + return [_text(c, source) for c in tb.children if c.type == "type_identifier"] + + def _add(param: Optional[str], tb: Node) -> None: + if not param: + return + bounds.setdefault(param, []) + for t in _traits(tb): + if t not in bounds[param]: + bounds[param].append(t) + + def _param(node: Node) -> None: + pid = None + for cc in node.children: + if cc.type == "type_identifier" and pid is None: + pid = _text(cc, source) + elif cc.type == "trait_bounds": + _add(pid, cc) + + seen_for = False + for child in impl_node.children: + if child.type == "for": + seen_for = True + elif child.type == "type_parameters" and not seen_for: + for tp in child.children: + if tp.type in ("type_parameter", "constrained_type_parameter"): + _param(tp) + elif child.type == "where_clause": + for wp in child.children: + if wp.type == "where_predicate": + _param(wp) + return bounds + + def _text(node: Optional[Node], source: bytes) -> str: if node is None: return "" @@ -459,6 +506,7 @@ def _handle_impl( "in_test_scope": ctx["in_test_scope"], "in_trait_impl": trait_name is not None, "impl_trait": trait_name, + "impl_type_param_bounds": _impl_generic_bounds(node, source), } worklist.append((body, new_ctx)) @@ -565,6 +613,10 @@ def _handle_function( "is_exported": is_exported, "has_self": has_self, "decorators": attrs, + # Bounds of the enclosing impl's own generics (`impl Foo`), + # so a receiver typed as `T` in this method dispatches to the trait's + # conformers (bug D). Empty for free functions / inherent-non-generic impls. + "impl_type_param_bounds": ctx.get("impl_type_param_bounds", {}), } block = None diff --git a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py new file mode 100644 index 0000000..fe67cbe --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py @@ -0,0 +1,24 @@ +"""Bug D: a method receiver typed as an IMPL-level generic param (`impl +Holder { fn m(&self, x: &T) { x.area() } }`) must dispatch to the bound trait's +conformers -- same as a fn-level bound. Previously the impl-level bound was invisible +(only fn-level generics were read), so `x: T` fell to a bare-name lookup on the +letter `T`, which an unrelated blanket `impl Audit for U` had poisoned with a +pseudo-type `T.area` -> phantom edge + the real conformer edge dropped.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_impl_level_bound_dispatches_to_conformers(tmp_path): + repo = {"lib.rs": """ + pub trait Shape { fn area(&self) -> f64; } + pub struct Circle; impl Shape for Circle { fn area(&self) -> f64 { 1.0 } } + pub trait Debugx {} + pub trait Audit { fn area(&self); } + impl Audit for U { fn area(&self) {} } + pub struct Holder { x: T } + impl Holder { pub fn measure(&self, x: &T) -> f64 { x.area() } } + """} + e = edges(build(tmp_path, repo)[1]) + assert ("Holder.measure", "Circle.area") in e, e # real conformer edge recovered + assert ("Holder.measure", "T.area") not in e, e # blanket pseudo-type phantom gone From 5c1b68da033f184b602cfbfef2087003372dde31 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:26:49 +0300 Subject: [PATCH 08/17] chore(rust): add missing Set import (K) + decorators in unit metadata (J) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../parsers/rust/function_extractor.py | 2 +- .../parsers/rust/unit_generator.py | 3 +++ .../parsers/rust/test_rust_unit_decorators.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index cfbf363..8f0b189 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -26,7 +26,7 @@ import re from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from utilities.file_io import write_json diff --git a/libs/openant-core/parsers/rust/unit_generator.py b/libs/openant-core/parsers/rust/unit_generator.py index 82e4007..a96c7d7 100644 --- a/libs/openant-core/parsers/rust/unit_generator.py +++ b/libs/openant-core/parsers/rust/unit_generator.py @@ -160,6 +160,9 @@ def _generate_unit(self, func_id: str, func_info: Dict[str, Any]) -> Dict[str, A "generator": "rust_unit_generator.py", "direct_calls": direct_calls, "direct_callers": direct_callers, + # Parity with the Swift parser's unit metadata: thread the function's + # attributes/decorators through so dataset units carry them too (bug J). + "decorators": func_info.get("decorators", []), }, } diff --git a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py new file mode 100644 index 0000000..46eea45 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py @@ -0,0 +1,18 @@ +"""Bug J: Rust unit metadata must carry `decorators` (parity with the Swift parser), +so dataset units expose the function's attributes like every sibling parser.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 + + +def test_unit_metadata_carries_decorators(tmp_path): + ext = extract(tmp_path, {"lib.rs": "#[inline]\npub fn f() {}\n"}) + cg = CallGraphBuilder(ext).build() + dataset, _ = UnitGenerator(cg, str(tmp_path)).generate() + units = dataset["units"] + assert units, "no units generated" + for u in units: + assert "decorators" in u["metadata"], u["metadata"].keys() + # the #[inline] attribute must survive into unit metadata (not just call_graph) + assert any(u["metadata"]["decorators"] == ["inline"] for u in units), \ + [u["metadata"]["decorators"] for u in units] From 7dba2b6bfc32c911f80000906b82ddd005ae19cb Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:30:04 +0300 Subject: [PATCH 09/17] fix(rust): type let-bindings by the assoc fn's real return type 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 --- .../parsers/rust/call_graph_builder.py | 38 ++++++++++++++++--- .../parsers/rust/function_extractor.py | 7 ++++ .../rust/test_rust_assoc_return_type.py | 28 ++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index 3bfdfbe..eb8b19a 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -145,7 +145,7 @@ def build_call_graph(self) -> None: file_path = func_info.get("file_path", "") caller_class = func_info.get("class_name") - var_types = self._collect_var_types(code) + var_types = self._collect_var_types(code, name_to_ids) fn_aliases = self._collect_fn_aliases(code, name_to_ids, file_path) # Merge the enclosing impl's generic bounds (`impl Foo`, # recorded by the extractor) with the fn's OWN generics; the fn's own @@ -455,7 +455,9 @@ def _text(self, node: Node, source: bytes) -> str: # -- receiver static-type inference ---------------------------------------- - def _collect_var_types(self, code: str) -> Dict[str, str]: + def _collect_var_types( + self, code: str, name_to_ids: Optional[Dict[str, List[str]]] = None, + ) -> Dict[str, str]: """Map local variable/parameter name -> its declared/inferred type. Sources, in order of confidence: parameter type annotations @@ -514,11 +516,33 @@ def _set(name: Optional[str], typ: Optional[str]) -> None: name = self._text(c, source) break if name: - _set(name, self._infer_let_type(node, source)) + _set(name, self._infer_let_type(node, source, name_to_ids)) stack.extend(node.children) return var_types - def _infer_let_type(self, node: Node, source: bytes) -> Optional[str]: + def _assoc_return_type( + self, qualifier: str, leaf: Optional[str], + name_to_ids: Optional[Dict[str, List[str]]], + ) -> Optional[str]: + """Return the recorded return type of associated fn `qualifier::leaf`, if + known and not `Self`. Lets `let w = Factory::make()` type `w` as make()'s + real return type (Widget) instead of the constructor-idiom guess (Factory). + `Self` returns fall through to the qualifier (the `Type::new() -> Self` case). + """ + if not leaf or not name_to_ids: + return None + for cand in name_to_ids.get(leaf, []): + info = self.functions.get(cand, {}) + if info.get("class_name") == qualifier: + rt = info.get("return_type") + if rt and rt != "Self": + return rt + return None + + def _infer_let_type( + self, node: Node, source: bytes, + name_to_ids: Optional[Dict[str, List[str]]] = None, + ) -> Optional[str]: from .function_extractor import _bare_type_name # `let x: Type = ...;` -- explicit annotation wins. @@ -553,7 +577,11 @@ def _infer_let_type(self, node: Node, source: bytes) -> Optional[str]: if callee is not None and callee.type == "scoped_identifier": qualifier, _leaf = self._split_scoped(callee, source) if qualifier and qualifier not in ("Self",): - return qualifier + # Prefer the assoc fn's ACTUAL return type (`make() -> Widget`) + # over the constructor-idiom assumption that it returns the + # qualifier (`Factory`). Falls back to the qualifier when the + # return type is unknown or `Self` (the `Type::new()` case). + return self._assoc_return_type(qualifier, _leaf, name_to_ids) or qualifier return None def _collect_fn_aliases( diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index 8f0b189..5bafe94 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -582,6 +582,12 @@ def _handle_function( module_name = "::".join(ctx["module_path"]) if ctx["module_path"] else None + # Bare return-type name (`-> Widget` -> "Widget"), so a binding + # `let w = Type::assoc()` can be typed by the assoc fn's ACTUAL return type + # rather than the constructor-idiom assumption that it returns `Type` (bug F). + rt_node = node.child_by_field_name("return_type") + return_type = _bare_type_name(rt_node, source) if rt_node is not None else None + func_id = f"{file_path}:{qualified_name}" if func_id in functions: # Same qualified_name already taken -- e.g. `impl Display for P` and @@ -617,6 +623,7 @@ def _handle_function( # so a receiver typed as `T` in this method dispatches to the trait's # conformers (bug D). Empty for free functions / inherent-non-generic impls. "impl_type_param_bounds": ctx.get("impl_type_param_bounds", {}), + "return_type": return_type, } block = None diff --git a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py new file mode 100644 index 0000000..0a6d96d --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py @@ -0,0 +1,28 @@ +"""Bug F: `let x = Type::assoc()` should type `x` by the assoc fn's ACTUAL return +type, not the blanket assumption that `Type::assoc()` returns `Type`. `Factory:: +make() -> Widget` must type the binding as Widget (recovering `w.process()` -> +Widget.process and NOT fabricating Factory.process). The dominant `Type::new() -> +Self` idiom must still resolve to Type.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_factory_return_type_used_for_receiver(tmp_path): + repo = {"lib.rs": """ + pub struct Widget; impl Widget { pub fn process(&self) {} } + pub struct Factory; impl Factory { pub fn make() -> Widget { Widget } pub fn process(&self) {} } + pub fn run() { let w = Factory::make(); w.process(); } + """} + e = edges(build(tmp_path, repo)[1]) + assert ("run", "Widget.process") in e, e # real: w is a Widget + assert ("run", "Factory.process") not in e, e # phantom: w is NOT a Factory + + +def test_new_returns_self_still_resolves(tmp_path): + # the dominant constructor idiom (Type::new() -> Self) must keep working. + repo = {"lib.rs": """ + pub struct Point; impl Point { pub fn new() -> Self { Point } pub fn dist(&self) -> f64 { 0.0 } } + pub fn run() { let p = Point::new(); p.dist(); } + """} + assert ("run", "Point.dist") in edges(build(tmp_path, repo)[1]) From 3bf8f779878a3155a3ed9cda3563ed27c9228445 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 16:36:41 +0300 Subject: [PATCH 10/17] fix(rust): infer let-binding type from a free-fn return type (blackout 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 --- .../parsers/rust/call_graph_builder.py | 37 +++++++++++++++++++ .../rust/test_rust_freefn_return_type.py | 20 ++++++++++ 2 files changed, 57 insertions(+) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index eb8b19a..396e8a1 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -539,6 +539,26 @@ def _assoc_return_type( return rt return None + def _free_fn_return_type( + self, fn_name: str, name_to_ids: Optional[Dict[str, List[str]]], + ) -> Optional[str]: + """Return the recorded return type of a FREE function `fn_name`, if a single + unambiguous non-`Self` type. Lets `let c = load()` (where `load() -> Cfg`) + type `c` as Cfg, so a later `c.method()` resolves precisely instead of hitting + the unknown-receiver gate and blacking out. Precise (typed) -> zero phantom. + """ + if not name_to_ids: + return None + rts = set() + for cand in name_to_ids.get(fn_name, []): + info = self.functions.get(cand, {}) + if info.get("class_name"): # must be a free function, not a method + continue + rt = info.get("return_type") + if rt and rt != "Self": + rts.add(rt) + return next(iter(rts)) if len(rts) == 1 else None + def _infer_let_type( self, node: Node, source: bytes, name_to_ids: Optional[Dict[str, List[str]]] = None, @@ -582,6 +602,13 @@ def _infer_let_type( # qualifier (`Factory`). Falls back to the qualifier when the # return type is unknown or `Self` (the `Type::new()` case). return self._assoc_return_type(qualifier, _leaf, name_to_ids) or qualifier + if callee is not None and callee.type == "identifier": + # `let c = load()` where `load() -> Cfg` -> type c as Cfg, so a + # later `c.method()` resolves precisely (recovering the + # unknown-receiver blackout) with zero phantom. + rt = self._free_fn_return_type(self._text(callee, source), name_to_ids) + if rt: + return rt return None def _collect_fn_aliases( @@ -916,6 +943,16 @@ def _resolve_unknown_receiver_method( return same_file if len(candidates) == 1: return candidates + # Known limitation (deliberate, not a bug): when >=2 same-named `&self` + # methods remain and the receiver type is unknown, we decline. Exported + # candidates self-seed so declining them is free; a PRIVATE inherent method + # whose sole edge is declined can black out. That blackout is RECOVERED + # upstream wherever the receiver type is inferable -- e.g. `let c = load()` + # with `load() -> Cfg` now types the receiver (see _free_fn_return_type), so + # the call never reaches this gate. A blanket union of the residual truly + # ambiguous case was measured to add one cross-type phantom per call site + # (Dog->Cat.speak; precision 1/N) for a contrived reachability gain, so it is + # intentionally NOT done here (Sol/Fable reconcile, 2026-08-01). return [] def _resolve_typed_member( diff --git a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py new file mode 100644 index 0000000..a28fc36 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py @@ -0,0 +1,20 @@ +"""Bug G (recovery, phantom-free): a receiver bound from a free-function call +`let c = load()` where `load() -> Cfg` must type `c` as Cfg, so `c.validate()` +resolves PRECISELY to Cfg.validate -- recovering the unknown-receiver blackout the +F2/F3/F4 gate would otherwise cause, with NO phantom to a same-named method on an +unrelated type. This makes the receiver KNOWN rather than relaxing the gate.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _helpers import build, edges # noqa: E402 + + +def test_free_fn_return_type_types_the_binding(tmp_path): + repo = { + "a.rs": "pub struct Cfg; impl Cfg { fn validate(&self) {} }\n" + "pub struct Form; impl Form { fn validate(&self) {} }\n" + "pub fn load() -> Cfg { Cfg }\n", + "b.rs": "use crate::a::{Cfg, Form, load};\npub fn run() { let c = load(); c.validate(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run", "Cfg.validate") in e, e # recovered precisely via load() -> Cfg + assert ("run", "Form.validate") not in e, e # no phantom to the other same-named type From 44d4f9865c70f533e82cf630f4fb06dc02694650 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 21:59:56 +0300 Subject: [PATCH 11/17] chore(rust): drop bug-id/session provenance from fix comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../openant-core/parsers/rust/call_graph_builder.py | 13 +++++-------- .../openant-core/parsers/rust/function_extractor.py | 6 +++--- libs/openant-core/parsers/rust/unit_generator.py | 4 ++-- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index 396e8a1..c2645db 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -420,9 +420,7 @@ def _scan_macro_body(self, node: Node, source: bytes, sites: List[dict]) -> None if macro_name not in _SCANNABLE_MACROS or token_tree is None: return text = self._text(token_tree, source) - # Blank string/char literals first: their contents are data, not calls, so - # `println!("call init() first")` must not yield an `init` edge, while a real - # call outside the literal (`format!("{}", foo())`) is still recovered. + # Blank literals so a call-shaped substring inside a string is not scanned. text = _RUST_STR_LITERAL_RE.sub(" ", text) for match in _MACRO_CALL_RE.finditer(text): call_name = match.group(1) @@ -948,11 +946,10 @@ def _resolve_unknown_receiver_method( # candidates self-seed so declining them is free; a PRIVATE inherent method # whose sole edge is declined can black out. That blackout is RECOVERED # upstream wherever the receiver type is inferable -- e.g. `let c = load()` - # with `load() -> Cfg` now types the receiver (see _free_fn_return_type), so - # the call never reaches this gate. A blanket union of the residual truly - # ambiguous case was measured to add one cross-type phantom per call site - # (Dog->Cat.speak; precision 1/N) for a contrived reachability gain, so it is - # intentionally NOT done here (Sol/Fable reconcile, 2026-08-01). + # with `load() -> Cfg` types the receiver (see _free_fn_return_type), so the + # call never reaches this gate. A blanket union of the residual truly-ambiguous + # case would add a cross-type phantom (Dog->Cat.speak) per call site, so it is + # not done here. return [] def _resolve_typed_member( diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index 5bafe94..fcc547e 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -77,7 +77,7 @@ def _load_rust_language() -> Language: # Non-nominal Self-type node kinds that are absent from _TYPE_NODE_KINDS but CAN be # an impl target: `impl Trait for u32 / [u8;4] / (i32,i32) / () / &T / *const T / -# dyn X`. Collected in _handle_impl so their methods are extracted (bug I); for the +# dyn X`. Collected in _handle_impl so their methods are extracted; for the # genuinely non-nominal ones _bare_type_name returns None and _handle_impl falls # back to the raw type text, while reference/dynamic types unwrap to their nominal # base as usual. @@ -584,7 +584,7 @@ def _handle_function( # Bare return-type name (`-> Widget` -> "Widget"), so a binding # `let w = Type::assoc()` can be typed by the assoc fn's ACTUAL return type - # rather than the constructor-idiom assumption that it returns `Type` (bug F). + # rather than the constructor-idiom assumption that it returns `Type`. rt_node = node.child_by_field_name("return_type") return_type = _bare_type_name(rt_node, source) if rt_node is not None else None @@ -621,7 +621,7 @@ def _handle_function( "decorators": attrs, # Bounds of the enclosing impl's own generics (`impl Foo`), # so a receiver typed as `T` in this method dispatches to the trait's - # conformers (bug D). Empty for free functions / inherent-non-generic impls. + # conformers. Empty for free functions / inherent-non-generic impls. "impl_type_param_bounds": ctx.get("impl_type_param_bounds", {}), "return_type": return_type, } diff --git a/libs/openant-core/parsers/rust/unit_generator.py b/libs/openant-core/parsers/rust/unit_generator.py index a96c7d7..1293705 100644 --- a/libs/openant-core/parsers/rust/unit_generator.py +++ b/libs/openant-core/parsers/rust/unit_generator.py @@ -160,8 +160,8 @@ def _generate_unit(self, func_id: str, func_info: Dict[str, Any]) -> Dict[str, A "generator": "rust_unit_generator.py", "direct_calls": direct_calls, "direct_callers": direct_callers, - # Parity with the Swift parser's unit metadata: thread the function's - # attributes/decorators through so dataset units carry them too (bug J). + # Parity with the Swift parser's unit metadata: carry the function's + # attributes/decorators onto dataset units. "decorators": func_info.get("decorators", []), }, } From ae8fc4a4476c439fe000791fccc75188d3a5cef1 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 22:32:52 +0300 Subject: [PATCH 12/17] chore(rust): drop bug-id/session labels from test docstrings 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 --- .../tests/parsers/rust/test_rust_assoc_return_type.py | 2 +- .../tests/parsers/rust/test_rust_bound_intersection.py | 2 +- .../tests/parsers/rust/test_rust_builtin_filter.py | 2 +- .../tests/parsers/rust/test_rust_freefn_return_type.py | 4 ++-- .../tests/parsers/rust/test_rust_impl_level_bounds.py | 2 +- .../tests/parsers/rust/test_rust_macro_string_literal.py | 2 +- .../tests/parsers/rust/test_rust_method_collision.py | 2 +- .../tests/parsers/rust/test_rust_nested_fn_scope.py | 2 +- .../tests/parsers/rust/test_rust_nonnominal_impl.py | 2 +- .../tests/parsers/rust/test_rust_unit_decorators.py | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py index 0a6d96d..d2a67e2 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py @@ -1,4 +1,4 @@ -"""Bug F: `let x = Type::assoc()` should type `x` by the assoc fn's ACTUAL return +"""`let x = Type::assoc()` should type `x` by the assoc fn's ACTUAL return type, not the blanket assumption that `Type::assoc()` returns `Type`. `Factory:: make() -> Widget` must type the binding as Widget (recovering `w.process()` -> Widget.process and NOT fabricating Factory.process). The dominant `Type::new() -> diff --git a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py index b933bbe..f44c398 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py @@ -1,4 +1,4 @@ -"""Bug C: a multi-bound generic must not lose all edges when one bound trait has +"""a multi-bound generic must not lose all edges when one bound trait has no recorded conformers (marker/blanket/derive/cross-crate impls are invisible to the extractor). An unseen conformer set is 'unconstrained', not 'empty' — it must not annihilate the edges the other bounds establish (reachability over-approx).""" diff --git a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py index d3009ad..5e15b8c 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py @@ -1,4 +1,4 @@ -"""Bug A: RUST_BUILTINS filter must not drop TYPED/resolvable method-call edges. +"""RUST_BUILTINS filter must not drop TYPED/resolvable method-call edges. A method call whose method name happens to be in RUST_BUILTINS (get/parse/new/...) on a KNOWN receiver type must still resolve — the builtin guard is a precision knob diff --git a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py index a28fc36..d19836b 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py @@ -1,7 +1,7 @@ -"""Bug G (recovery, phantom-free): a receiver bound from a free-function call +"""a receiver bound from a free-function call `let c = load()` where `load() -> Cfg` must type `c` as Cfg, so `c.validate()` resolves PRECISELY to Cfg.validate -- recovering the unknown-receiver blackout the -F2/F3/F4 gate would otherwise cause, with NO phantom to a same-named method on an +unknown-receiver decline gate would otherwise cause, with NO phantom to a same-named method on an unrelated type. This makes the receiver KNOWN rather than relaxing the gate.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) diff --git a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py index fe67cbe..04ab540 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py @@ -1,4 +1,4 @@ -"""Bug D: a method receiver typed as an IMPL-level generic param (`impl +"""a method receiver typed as an IMPL-level generic param (`impl Holder { fn m(&self, x: &T) { x.area() } }`) must dispatch to the bound trait's conformers -- same as a fn-level bound. Previously the impl-level bound was invisible (only fn-level generics were read), so `x: T` fell to a bare-name lookup on the diff --git a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py index bdbe486..249528e 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py @@ -1,4 +1,4 @@ -"""Bug B: the macro token-tree regex scan must not harvest call-shaped text from +"""the macro token-tree regex scan must not harvest call-shaped text from INSIDE string literals. `println!("call init() first")` must not fabricate an edge to an unrelated `init`; real calls OUTSIDE the literal (`format!("{}", foo())`) must still be recovered.""" diff --git a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py index cf91d2d..d9866c3 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py @@ -1,4 +1,4 @@ -"""Bug H: two same-named methods on one type (the ubiquitous `impl Display for P` +"""two same-named methods on one type (the ubiquitous `impl Display for P` + `impl Debug for P`, both `fn fmt`) must both be extracted. Previously both mapped to func_id `file:P.fmt` and the second silently clobbered the first (data loss: a whole unit vanished from the graph and reachability).""" diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py index 99ea613..ef9b09c 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py @@ -1,4 +1,4 @@ -"""Bug E: a nested fn's call sites must be attributed to the nested unit, not bled +"""a nested fn's call sites must be attributed to the nested unit, not bled into the OUTER fn (and resolved under the outer fn's — wrong — generic bounds). `fn outer(){ fn inner(l:&B){ l.log() } ... }` must not create `outer -> Circle.log` (Circle is Shape, not Logger). The inner unit keeps its edge.""" diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py index f6054d3..4ac5d2e 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py @@ -1,4 +1,4 @@ -"""Bug I: trait/inherent impls on non-nominal Self types (primitive/array/tuple/ +"""trait/inherent impls on non-nominal Self types (primitive/array/tuple/ unit) must still have their methods extracted. `_bare_type_name` names only nominal types, so `impl Serialize for u32` was dropped entirely (every method lost).""" import pathlib, sys diff --git a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py index 46eea45..a1f6304 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py @@ -1,4 +1,4 @@ -"""Bug J: Rust unit metadata must carry `decorators` (parity with the Swift parser), +"""Rust unit metadata must carry `decorators` (parity with the Swift parser), so dataset units expose the function's attributes like every sibling parser.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) From 185e480e59d80808e832c20c2f3ddb3508def556 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 23:17:18 +0300 Subject: [PATCH 13/17] chore(rust): drop inherited session labels from parser comments 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 --- libs/openant-core/parsers/rust/call_graph_builder.py | 10 +++++----- libs/openant-core/parsers/rust/function_extractor.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index c2645db..d3a4b35 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -616,7 +616,7 @@ def _collect_fn_aliases( `let p: fn() = tgt; p();` and `let q = tgt; q();` bind a callable to a variable; a later `p()`/`q()` is a real edge to `tgt` that bare-name - resolution misses (val_3_18). Only a RHS that is a *bare identifier + resolution misses. Only a RHS that is a *bare identifier naming a known FREE function* creates an alias -- never a call, closure, method, or arbitrary expression. The RHS is resolved through the SAME gate a bare call uses -- free functions only (a bare identifier in value @@ -682,7 +682,7 @@ def _collect_type_param_bounds(self, code: str) -> Dict[str, List[str]]: dispatch to the trait's conformers via the SAME `trait_impls` closure the `&dyn Trait` path uses -- nominal typing makes the conformer set knowable and bounded, so this is a reachability-safe over-approximation, not a - guess (val_3_19 extended from `dyn` to generic bounds; the Swift parser's + guess (extended from `dyn` to generic bounds; the Swift parser's protocol-conformer dispatch is the reference). """ bounds: Dict[str, List[str]] = {} @@ -758,7 +758,7 @@ def _resolve_bare( fn_aliases: Optional[Dict[str, List[str]]] = None, ) -> List[str]: # A local variable bound to a function value: `let p = tgt; p()` -> tgt - # (val_3_18). Checked before name resolution because `p` is not itself a + # Checked before name resolution because `p` is not itself a # function name; the alias only exists when RHS named a known function. if fn_aliases and call_name in fn_aliases: return fn_aliases[call_name] @@ -845,7 +845,7 @@ def _resolve_field( # Generic trait-bound receiver (`item: &B` where `B: Shape`) # is resolved FIRST and never via a concrete-type lookup on the # letter: dispatch to the intersection of the bound traits' - # conformers (val_3_19 for generics). Checking bounds before the + # conformers (for generics). Checking bounds before the # concrete lookup is what stops a blanket `impl Y for T` # (which mints a pseudo-type `"T"`) from hijacking every generic # receiver named `T`/`B` repo-wide. @@ -928,7 +928,7 @@ def _resolve_unknown_receiver_method( if not candidates: return [] same_file = [c for c in candidates if self._in_file(c, caller_file)] - # F2/F3/F4: only emit an edge when the receiver-less resolution is + # Unknown-receiver gate: only emit an edge when the receiver-less resolution is # UNAMBIGUOUS. When several same-named `&self` methods live in the same # file (e.g. Dog::speak and Cat::speak) and the receiver's static type is # unknown (inferred from a fn-return, struct field, `dyn Trait`, or a diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index fcc547e..463030e 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -117,7 +117,7 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]: return _text(last, source) if last is not None else None if t == "reference_type": # `&Point` / `&mut Point` / `&'a Point` -> unwrap to Point; - # `&dyn Shape` -> unwrap through the dynamic_type to Shape (val_3_19). + # `&dyn Shape` -> unwrap through the dynamic_type to Shape. for child in node.children: if child.type in _TYPE_NODE_KINDS or child.type == "dynamic_type": return _bare_type_name(child, source) @@ -125,8 +125,8 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]: if t == "dynamic_type": # `dyn Shape` / `dyn Shape + Send` -> the trait's bare name, so a # `&dyn Shape` receiver types as `Shape` and dispatches to Shape's - # conformers via trait_impls (val_3_19; `dyn Shape` is a `dynamic_type` - # node, verified against the installed grammar per pr_2_1). + # conformers via trait_impls (`dyn Shape` is a `dynamic_type` + # node, verified against the installed grammar). for child in node.children: r = _bare_type_name(child, source) if r: From c02f3a42abe47d9dd757fce099c463c2fb9b22c3 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 23:39:06 +0300 Subject: [PATCH 14/17] test(rust): plant a .rs file in the deep-nest scanner-contract case 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 --- libs/openant-core/tests/test_scanner_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/openant-core/tests/test_scanner_contract.py b/libs/openant-core/tests/test_scanner_contract.py index cc744a5..69a1746 100644 --- a/libs/openant-core/tests/test_scanner_contract.py +++ b/libs/openant-core/tests/test_scanner_contract.py @@ -165,7 +165,7 @@ def test_deeply_nested_code_is_scanned_or_recorded_as_a_gap(language, tmp_path): repo.mkdir() ext = {"python": ".py", "c": ".c", "php": ".php", "ruby": ".rb", "zig": ".zig", "javascript": ".js", "go": ".go", - "swift": ".swift"}.get(language, ".py") + "swift": ".swift", "rust": ".rs"}.get(language, ".py") build_deep_nest(repo / "deep", 600, f"planted{ext}", "x = 1\n") try: From 0791d781241b7055a37294ac1833e420bf3e4250 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sat, 1 Aug 2026 23:53:22 +0300 Subject: [PATCH 15/17] test(rust): rename rust test helper to avoid _helpers sys.modules collision 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 --- .../tests/parsers/rust/{_helpers.py => _rust_helpers.py} | 0 .../tests/parsers/rust/test_rust_assoc_return_type.py | 2 +- .../tests/parsers/rust/test_rust_bound_intersection.py | 2 +- .../openant-core/tests/parsers/rust/test_rust_builtin_filter.py | 2 +- .../tests/parsers/rust/test_rust_callgraph_symmetry.py | 2 +- .../tests/parsers/rust/test_rust_freefn_return_type.py | 2 +- .../tests/parsers/rust/test_rust_generic_dispatch.py | 2 +- .../tests/parsers/rust/test_rust_impl_level_bounds.py | 2 +- .../tests/parsers/rust/test_rust_macro_string_literal.py | 2 +- .../tests/parsers/rust/test_rust_method_collision.py | 2 +- .../tests/parsers/rust/test_rust_nested_fn_scope.py | 2 +- .../tests/parsers/rust/test_rust_nonnominal_impl.py | 2 +- .../tests/parsers/rust/test_rust_schema_completeness.py | 2 +- .../tests/parsers/rust/test_rust_unit_decorators.py | 2 +- 14 files changed, 13 insertions(+), 13 deletions(-) rename libs/openant-core/tests/parsers/rust/{_helpers.py => _rust_helpers.py} (100%) diff --git a/libs/openant-core/tests/parsers/rust/_helpers.py b/libs/openant-core/tests/parsers/rust/_rust_helpers.py similarity index 100% rename from libs/openant-core/tests/parsers/rust/_helpers.py rename to libs/openant-core/tests/parsers/rust/_rust_helpers.py diff --git a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py index d2a67e2..d335512 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py @@ -5,7 +5,7 @@ Self` idiom must still resolve to Type.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_factory_return_type_used_for_receiver(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py index f44c398..ba55c4f 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py @@ -4,7 +4,7 @@ not annihilate the edges the other bounds establish (reachability over-approx).""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_multi_bound_survives_impl_less_marker(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py index 5e15b8c..7d0ec7d 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py @@ -7,7 +7,7 @@ """ import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_typed_cross_file_builtin_named_method_resolves(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py b/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py index 2eb2edd..0a428d2 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py @@ -10,7 +10,7 @@ import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build # noqa: E402 +from _rust_helpers import build # noqa: E402 _REPO = { "lib.rs": """ diff --git a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py index d19836b..152aac6 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py @@ -5,7 +5,7 @@ unrelated type. This makes the receiver KNOWN rather than relaxing the gate.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_free_fn_return_type_types_the_binding(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py b/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py index aacf2ac..e1fd1d3 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py @@ -13,7 +13,7 @@ import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_inline_bound_dispatches_to_conformers(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py index 04ab540..e4f78ed 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py @@ -6,7 +6,7 @@ pseudo-type `T.area` -> phantom edge + the real conformer edge dropped.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_impl_level_bound_dispatches_to_conformers(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py index 249528e..4fc6e3b 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py @@ -4,7 +4,7 @@ must still be recovered.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_no_phantom_from_call_shaped_string_literal(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py index d9866c3..98a6036 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py @@ -4,7 +4,7 @@ a whole unit vanished from the graph and reachability).""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import extract # noqa: E402 +from _rust_helpers import extract # noqa: E402 def test_same_name_methods_both_extracted(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py index ef9b09c..93ad8d1 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py @@ -4,7 +4,7 @@ `outer -> Circle.log` (Circle is Shape, not Logger). The inner unit keeps its edge.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import build, edges # noqa: E402 +from _rust_helpers import build, edges # noqa: E402 def test_nested_fn_calls_not_bled_into_outer(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py index 4ac5d2e..59a519e 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py @@ -3,7 +3,7 @@ types, so `impl Serialize for u32` was dropped entirely (every method lost).""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import extract # noqa: E402 +from _rust_helpers import extract # noqa: E402 def test_nonnominal_self_impls_extracted(tmp_path): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py b/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py index 68db14f..f3f7294 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py @@ -10,7 +10,7 @@ import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 +from _rust_helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 _SRC = { "widget.rs": """ diff --git a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py index a1f6304..805bd8d 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py @@ -2,7 +2,7 @@ so dataset units expose the function's attributes like every sibling parser.""" import pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 +from _rust_helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 def test_unit_metadata_carries_decorators(tmp_path): From a0bd007b13c25b21f6872cfb9f430aa334628550 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sun, 2 Aug 2026 00:00:29 +0300 Subject: [PATCH 16/17] test(go): add rust to the Go registry parity expected-languages list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/openant-cli/internal/languages/registry_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/openant-cli/internal/languages/registry_test.go b/apps/openant-cli/internal/languages/registry_test.go index 69e6340..e25f81c 100644 --- a/apps/openant-cli/internal/languages/registry_test.go +++ b/apps/openant-cli/internal/languages/registry_test.go @@ -28,7 +28,7 @@ func TestSupportedMatchesConfig(t *testing.T) { if err != nil { t.Fatalf("Supported() error: %v", err) } - want := []string{"c", "go", "javascript", "php", "python", "ruby", "swift", "zig"} + want := []string{"c", "go", "javascript", "php", "python", "ruby", "rust", "swift", "zig"} if len(got) != len(want) { t.Fatalf("Supported() = %v, want %v", got, want) } From 9aa5a4a19df8ec10a2ccd22c146be8b489608941 Mon Sep 17 00:00:00 2001 From: gadievron Date: Sun, 2 Aug 2026 04:53:43 +0300 Subject: [PATCH 17/17] Decline return-type/import inference on ambiguity or shadowing 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 --- .../parsers/rust/call_graph_builder.py | 79 +++++++++++++++++-- .../rust/test_rust_returntype_ambiguity.py | 74 +++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index d3a4b35..067eef5 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -490,6 +490,27 @@ def _set(name: Optional[str], typ: Optional[str]) -> None: return var_types source = code.encode("utf-8") + # A local `let` bound to a CLOSURE shadows a same-named free function as + # the call target: `let helper = || ...; let x = helper()` calls the + # closure, not a repo `fn helper`. Collect only closure bindings -- a + # non-callable rebind (`let load = 5`) can never be `load()`-called, so it + # does not shadow a function call and must not block return-type inference. + local_closures: Set[str] = set() + lstack = [tree.root_node] + while lstack: + n = lstack.pop() + if n.type == "let_declaration": + name = None + seen_eq = False + for c in n.children: + if c.type == "identifier" and name is None and not seen_eq: + name = self._text(c, source) + elif c.type == "=": + seen_eq = True + elif seen_eq and c.type == "closure_expression" and name: + local_closures.add(name) + lstack.extend(n.children) + stack = [tree.root_node] while stack: node = stack.pop() @@ -514,7 +535,8 @@ def _set(name: Optional[str], typ: Optional[str]) -> None: name = self._text(c, source) break if name: - _set(name, self._infer_let_type(node, source, name_to_ids)) + _set(name, self._infer_let_type( + node, source, name_to_ids, local_closures)) stack.extend(node.children) return var_types @@ -529,13 +551,18 @@ def _assoc_return_type( """ if not leaf or not name_to_ids: return None + rts = set() for cand in name_to_ids.get(leaf, []): info = self.functions.get(cand, {}) if info.get("class_name") == qualifier: rt = info.get("return_type") if rt and rt != "Self": - return rt - return None + rts.add(rt) + # Two same-named types (`Factory` in different files) with differing + # return types is unresolvable here -- decline rather than guess the + # first, which would fabricate a wrong-type edge. Mirrors the + # uniqueness guard in `_free_fn_return_type`. + return next(iter(rts)) if len(rts) == 1 else None def _free_fn_return_type( self, fn_name: str, name_to_ids: Optional[Dict[str, List[str]]], @@ -560,6 +587,7 @@ def _free_fn_return_type( def _infer_let_type( self, node: Node, source: bytes, name_to_ids: Optional[Dict[str, List[str]]] = None, + local_closures: Optional[Set[str]] = None, ) -> Optional[str]: from .function_extractor import _bare_type_name @@ -603,10 +631,14 @@ def _infer_let_type( if callee is not None and callee.type == "identifier": # `let c = load()` where `load() -> Cfg` -> type c as Cfg, so a # later `c.method()` resolves precisely (recovering the - # unknown-receiver blackout) with zero phantom. - rt = self._free_fn_return_type(self._text(callee, source), name_to_ids) - if rt: - return rt + # unknown-receiver blackout) with zero phantom. Skip when the + # name is a local closure binding shadowing the free fn -- + # typing from the free fn would be a wrong-type phantom. + fname = self._text(callee, source) + if not (local_closures and fname in local_closures): + rt = self._free_fn_return_type(fname, name_to_ids) + if rt: + return rt return None def _collect_fn_aliases( @@ -793,6 +825,13 @@ def _free(ids): same_file = [c for c in candidates if self._in_file(c, caller_file)] if same_file: return same_file + # A bare call to a name imported from an EXTERNAL crate (`use + # std::thread::spawn`) is that external symbol, not a cross-file repo + # function of the same name -- linking to the repo namesake is a + # wrong-target phantom. Same-file (checked above) still wins; a genuine + # repo import (`use crate::..`) has no external root and is unaffected. + if self._name_is_externally_imported(call_name, caller_file): + return [] if len(candidates) == 1: return candidates return [] @@ -804,6 +843,32 @@ def _resolve_via_use(self, imp: dict, name_to_ids: Dict[str, List[str]]) -> List candidates = name_to_ids.get(leaf, []) return candidates if len(candidates) == 1 else [] + # Crate roots that are unambiguously NOT part of the analyzed repo. A `use` + # rooted here binds an external symbol; `crate`/`super`/`self`-rooted paths + # (and bare local-mod paths) are repo-internal and never match. + _EXTERNAL_CRATE_ROOTS = frozenset({"std", "core", "alloc", "proc_macro", "test"}) + + def _name_is_externally_imported(self, call_name: str, caller_file: str) -> bool: + """True if `call_name` is brought into `caller_file` by a `use` rooted in a + known external crate (`std`/`core`/`alloc`/...) AND is not ALSO repo-imported + (`use crate::`/`self`/`super`) in the same file. Such a bare call is the + external symbol, so it must not resolve to a same-named repo function. A + coexisting repo import means some scope legitimately calls the repo function + -- imports are tracked per file, not per scope, so we can't tell which call + is which; decline to force-drop and let candidate resolution decide.""" + ext = repo = False + for imp in self.imports.get(caller_file, []): + if imp.get("kind") != "use": + continue + if (imp.get("alias") or imp.get("leaf")) != call_name: + continue + root = (imp.get("path") or "").split("::", 1)[0] + if root in self._EXTERNAL_CRATE_ROOTS: + ext = True + elif root in ("crate", "self", "super"): + repo = True + return ext and not repo + def _resolve_field( self, site: dict, caller_file: str, caller_class: Optional[str], name_to_ids: Dict[str, List[str]], var_types: Dict[str, str], diff --git a/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py b/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py new file mode 100644 index 0000000..f989762 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py @@ -0,0 +1,74 @@ +"""Regressions from return-type inference: it must DECLINE (not guess) when the +producer is ambiguous or locally shadowed, otherwise it fabricates a wrong-type edge.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _rust_helpers import build, edges # noqa: E402 + + +def test_assoc_return_ambiguous_duplicate_type_declines(tmp_path): + # Two types both named Factory with different-return make(): don't guess one. + repo = { + "f1.rs": "pub struct Widget; impl Widget { pub fn spin(&self) {} }\n" + "pub struct Factory; impl Factory { pub fn make() -> Widget { Widget } }\n", + "f2.rs": "pub struct Gear; impl Gear { pub fn spin(&self) {} }\n" + "pub struct Factory; impl Factory { pub fn make() -> Gear { Gear } }\n", + "main.rs": "use crate::f2::Factory;\npub fn caller() { let x = Factory::make(); x.spin(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("caller", "Widget.spin") not in e, e # must NOT guess f1's Widget + assert ("caller", "Gear.spin") not in e, e # can't know it's f2's Gear either -> decline both + + +def test_free_fn_return_local_closure_shadow_declines(tmp_path): + repo = { + "cfg.rs": "pub struct Cfg; impl Cfg { pub fn spin(&self) {} }\npub fn helper() -> Cfg { Cfg }\n", + "w.rs": "pub struct Widget; impl Widget { pub fn new() -> Widget { Widget } pub fn spin(&self) {} }\n", + "main.rs": "use crate::w::Widget;\npub fn caller() { let helper = || Widget::new(); let x = helper(); x.spin(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("caller", "Cfg.spin") not in e, e # helper is a local closure, NOT the free fn + + +def test_std_imported_name_does_not_link_to_repo_namesake(tmp_path): + # `use std::thread::spawn` makes bare `spawn()` a std call, NOT the repo `fn spawn`. + repo = { + "workers.rs": "pub fn spawn(n: i32) -> i32 { n }\n", + "main.rs": "use std::thread::spawn;\npub fn run_thread() { let h = spawn(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run_thread", "spawn") not in e, e + + +def test_crate_imported_name_still_links_to_repo_fn(tmp_path): + # Control: a genuine repo import must STILL resolve (no over-decline / recall loss). + repo = { + "workers.rs": "pub fn spawn(n: i32) -> i32 { n }\n", + "main.rs": "use crate::workers::spawn;\npub fn run_thread() { let h = spawn(1); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run_thread", "spawn") in e, e + + +def test_free_fn_return_survives_noncallable_name_reuse(tmp_path): + # `let load = 5` reuses the name but is not callable -- must NOT block typing + # `let c = load()` from the free fn. Decoy Other::method forces receiver typing. + repo = { + "lib.rs": "pub struct Cfg; impl Cfg { pub fn method(&self) {} }\n" + "pub struct Other; impl Other { pub fn method(&self) {} }\n" + "pub fn load() -> Cfg { Cfg }\n", + "caller.rs": "fn go() { let c = load(); c.method(); let load = 5; let _ = load + 1; }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("go", "Cfg.method") in e, e + + +def test_repo_import_survives_coexisting_std_import_other_scope(tmp_path): + # mod b legitimately `use crate::worker::take`; mod a's std import must not + # poison it (imports are file-scoped). + repo = { + "worker.rs": "pub fn take() -> i32 { 0 }\n", + "caller.rs": "mod a { use std::mem::take; pub fn f() { let mut v = vec![1]; let _ = take(&mut v); } }\n" + "mod b { use crate::worker::take; pub fn g() { take(); } }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("b::g", "take") in e, e