Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/rfdb-server/src/derive/stdlib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 57 additions & 19 deletions packages/rfdb-server/src/derive/stdlib/rust_calls.dl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
Loading