diff --git a/packages/rfdb-server/src/derive/stdlib.rs b/packages/rfdb-server/src/derive/stdlib.rs index 5d4cfdca..eea49ea6 100644 --- a/packages/rfdb-server/src/derive/stdlib.rs +++ b/packages/rfdb-server/src/derive/stdlib.rs @@ -2995,6 +2995,80 @@ mod tests { ); } + /// #23 / RFD — R2 type-discrimination (the dominant resolution-is-a-function + /// violation fix). The old flat last-segment fallback resolved `Foo::new` to + /// EVERY same-file `fn new` and bound std `HashSet::new` to a LOCAL `new`. + /// The reworked R2a matches `Type::assoc` to the IMPL_BLOCK-of-that-type's + /// method only; R2b keeps free-fn path calls flat. Pinned: + /// - `Foo::new` resolves to Foo::new ONLY (not Bar::new) — discrimination; + /// - `crate::a::Foo::new` resolves Foo::new via the interior-segment arm; + /// - `HashSet::new` (no local impl) resolves to NOTHING (was unsound); + /// - `MyFoo::new` does NOT match `impl Foo` (left boundary anchored); + /// - `utils::helper` still resolves the free fn `helper` (R2b preserved). + #[test] + fn rust_calls_r2_type_discriminated_assoc_fn() { + let mut v = FixtureStorageView::new(1); + named_node(&mut v, "m_x", "x", "MODULE", "x.rs"); + + // Two impls in one file, each with `fn new` (HAS_METHOD + CONTAINS, the + // real analyzer shape: rust_analyzer.rs:985 / fn-owner CONTAINS). + named_node(&mut v, "ib_foo", "Foo", "IMPL_BLOCK", "x.rs"); + named_node(&mut v, "ib_bar", "Bar", "IMPL_BLOCK", "x.rs"); + edge(&mut v, "m_x", "ib_foo", "CONTAINS"); + edge(&mut v, "m_x", "ib_bar", "CONTAINS"); + named_node(&mut v, "f_foo_new", "new", "FUNCTION", "x.rs"); + named_node(&mut v, "f_bar_new", "new", "FUNCTION", "x.rs"); + edge(&mut v, "ib_foo", "f_foo_new", "HAS_METHOD"); + edge(&mut v, "ib_foo", "f_foo_new", "CONTAINS"); + edge(&mut v, "ib_bar", "f_bar_new", "HAS_METHOD"); + edge(&mut v, "ib_bar", "f_bar_new", "CONTAINS"); + + // A free fn for the R2b arm. + named_node(&mut v, "f_helper", "helper", "FUNCTION", "x.rs"); + edge(&mut v, "m_x", "f_helper", "CONTAINS"); + + // Calls (MODULE-level so R1 cannot fire — no FUNCTION named the call path). + named_node(&mut v, "c_foo", "Foo::new", "CALL", "x.rs"); + named_node(&mut v, "c_interior", "crate::a::Foo::new", "CALL", "x.rs"); + named_node(&mut v, "c_hash", "HashSet::new", "CALL", "x.rs"); + named_node(&mut v, "c_myfoo", "MyFoo::new", "CALL", "x.rs"); + named_node(&mut v, "c_util", "utils::helper", "CALL", "x.rs"); + for c in ["c_foo", "c_interior", "c_hash", "c_myfoo", "c_util"] { + edge(&mut v, "m_x", c, "CONTAINS"); + } + + let (eval, _specs, _node_specs) = evaluate_with_materialize( + &v, + RUST_CALLS_DL, + Stats::default(), + EvalLimits::none(), + EventLog::discard(), + ) + .expect("rust_calls.dl evaluates"); + + let edges: BTreeSet<(u128, u128, String)> = triples(&eval, "rust_suffix_call"); + let expect: BTreeSet<(u128, u128, String)> = [ + ("c_foo", "f_foo_new"), // R2a: ONLY Foo::new, not Bar::new + ("c_interior", "f_foo_new"), // R2a interior segment + ("c_util", "f_helper"), // R2b: free fn preserved + ] + .iter() + .map(|(c, t)| (id_of(c), id_of(t), "rust-calls".to_string())) + .collect(); + assert_eq!( + edges, expect, + "R2a binds Type::assoc to that type's impl method only (Foo::new → \ + Foo::new, NOT Bar::new); the interior path resolves Foo::new; \ + HashSet::new (no local impl) and MyFoo::new (boundary) resolve to \ + NOTHING; utils::helper still resolves the free fn via R2b" + ); + // Explicit: the over-resolution to Bar::new is gone. + assert!( + !edges.contains(&(id_of("c_foo"), id_of("f_bar_new"), "rust-calls".to_string())), + "Foo::new must NOT resolve to Bar::new (the violation this fix removes)" + ); + } + /// Wave M (rust_calls DELTA 5): macro invocations — CALL nodes the analyzer /// stamps with metadata macro=true (rust_analyzer.rs:1433-1457, walk_macro; /// the name is the macro path WITHOUT '!') — are EXCLUDED from name diff --git a/packages/rfdb-server/src/derive/stdlib/rust_calls.dl b/packages/rfdb-server/src/derive/stdlib/rust_calls.dl index dd44f38c..bec95248 100644 --- a/packages/rfdb-server/src/derive/stdlib/rust_calls.dl +++ b/packages/rfdb-server/src/derive/stdlib/rust_calls.dl @@ -18,18 +18,22 @@ % the bare method name on the CALL node) — now SCOPE-precise rather than % flat. Receiver-typed cross-impl dispatch stays Wave 1b % (rust_cross_methods_ctor / rust_receiver_typing), deliberately NOT here. -% R2 '::'-suffix fallback, ONLY when R1 resolved nothing for that call -% (the ordered-lookup preference, encoded as \+ has_scoped): the call's -% path name ends with "::" ++ FUNCTION.name — assoc-fn / qualified paths -% (`Widget::new()`, `utils::helper()`) resolved by their last segment. -% A qualified path NAMES its type, so it is callable from anywhere and is -% NOT lexically scope-restricted — scope-walk does not apply; this arm is -% kept FLAT by design. The concat-built "::"-prefixed suffix enforces the -% segment boundary (Hs:50-54); only path calls (names containing "::") can -% match it. R2 is the EDB seam the downstream Rust packs depend on: -% rust_cross_methods_ctor / rust_receiver_typing read the committed CALLS -% edge of a `Type::new()` init call to type a variable — R2 produces those -% 5374 path resolutions and they are preserved unchanged. +% R2 path-qualified fallback, ONLY when R1 resolved nothing for that call +% (the ordered-lookup preference, encoded as \+ has_scoped). A qualified +% path NAMES its type, so it is callable from anywhere and is NOT lexically +% scope-restricted — scope-walk does not apply. But it is NOT flat by the +% last segment alone: that over-resolved `IndexEntry::new` onto EVERY same- +% file `fn new` and bound std `HashSet::new` to a LOCAL `new` (#23 — the +% dominant resolution-is-a-function violation class). R2 is now TYPE- +% DISCRIMINATED in two arms (see the suffix_call clauses below for the full +% rationale): R2a resolves `Type::assoc` to the IMPL_BLOCK-of-that-type's +% associated fn (boundary-anchored so `MyArc` ≠ `Arc`; a foreign type with +% no local impl yields NO row — correctly unresolved); R2b keeps free-fn +% path calls (`utils::helper()`) flat on the last segment (module qualifiers +% have no node — inline mods are walked inline). R2 stays the EDB seam the +% downstream Rust packs depend on (rust_cross_methods_ctor / +% rust_receiver_typing read the committed CALLS edge of a `Type::new()` init +% call to type a variable) — type-discrimination only makes that seam precise. % % SCOPE-WALK (Rust scope shape, verified in rust_analyzer.rs): % - A Rust FUNCTION IS its own scope node — NO separate SCOPE node for a fn @@ -143,17 +147,51 @@ exact_scoped(C, T) :- rs_call(C, File, N), rs_fn(File, N, T), file_module(C, M), exact_scoped(C, T) :- rs_call(C, File, N), rs_fn(File, N, T), encl_fn(C, Fn), edge(Fn, T, "CONTAINS"), neq(Fn, T). has_scoped(C) :- exact_scoped(C, T). -% R2: last-'::'-segment fallback, ONLY when R1 resolved nothing (the -% ordered-lookup preference as negation). FLAT by design (a qualified path names -% its type — not scope-restricted). The "::"-prefixed concat suffix enforces the -% segment boundary; the string_contains(N, "::") gate is IMPLIED by -% ends_with(N, "::" ++ FN) and exists so the planner prunes non-path calls -% BEFORE pairing them with every same-file FUNCTION. +% R2: path-qualified fallback, ONLY when R1 resolved nothing (the ordered-lookup +% preference as negation). Two TYPE-DISCRIMINATED arms — a path `Type::assoc` +% names its type, so it must resolve to THAT type's associated fn, not to every +% same-named fn in the file (#23 / RFD: the old flat `ends_with(N, "::"++FN)` +% over-resolved `IndexEntry::new` to EVERY `fn new` and bound std `HashSet::new` +% to LOCAL `new`s — the dominant resolution-is-a-function violation class, 1261 +% pairs on the self-graph; rust-calls is meant to be a function). +% +% R2a — ASSOCIATED FN: the target is a method of the IMPL_BLOCK whose self-type +% (IMPL_BLOCK.name, rust_analyzer.rs:792) is the call's type segment. Two +% boundary-anchored sub-clauses so `MyArc::new` never matches `impl Arc`: +% leading `Type::method` → starts_with(N, "Type::") +% interior `path::Type::method` → string_contains(N, "::Type::") +% A std/foreign `HashSet::new` names no local IMPL_BLOCK → no row (correctly +% unresolved locally), instead of the old unsound bind to a same-named local. +% Same-file only (the original R2's scope), via the IMPL_BLOCK's file gate. +% R2b — FREE FN via path (`utils::helper()`): the target is a free function +% CONTAINS'd by the file MODULE (NOT an impl method). Kept FLAT on the last +% segment — an inline `mod` is walked inline (no MODULE node, header VISIBILITY +% MODEL), so the module qualifier has nothing to match against; impl methods +% are HAS_METHOD'd by their IMPL_BLOCK, never MODULE-CONTAINS'd, so this arm +% never fires for the assoc-fn over-resolution case R2a now owns. +% +% R2 stays the EDB seam for rust_cross_methods_ctor / rust_receiver_typing (they +% read `Type::new()` init CALLS); type-discrimination only makes that seam precise. +suffix_call(C, T) :- + rs_call(C, File, N), + \+ has_scoped(C), + node(IB, "IMPL_BLOCK"), attr(IB, "file", File), attr(IB, "name", TName), neq(TName, ""), + edge(IB, T, "HAS_METHOD"), node(T, "FUNCTION"), attr(T, "name", FN), neq(FN, ""), + concat(TName, "::", TPfx), starts_with(N, TPfx), + concat("::", FN, MSuf), ends_with(N, MSuf). +suffix_call(C, T) :- + rs_call(C, File, N), + \+ has_scoped(C), + node(IB, "IMPL_BLOCK"), attr(IB, "file", File), attr(IB, "name", TName), neq(TName, ""), + edge(IB, T, "HAS_METHOD"), node(T, "FUNCTION"), attr(T, "name", FN), neq(FN, ""), + concat("::", TName, TInfL), concat(TInfL, "::", TInf), string_contains(N, TInf), + concat("::", FN, MSuf), ends_with(N, MSuf). suffix_call(C, T) :- rs_call(C, File, N), string_contains(N, "::"), \+ has_scoped(C), - rs_fn(File, FN, T), + file_module(C, M), edge(M, T, "CONTAINS"), + node(T, "FUNCTION"), attr(T, "name", FN), neq(FN, ""), concat("::", FN, Suf), ends_with(N, Suf).