diff --git a/graphify/extract.py b/graphify/extract.py index 3903f431..b086feec 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3610,6 +3610,17 @@ def extract_go(path: Path) -> dict: # ── Rust extractor (custom walk) ────────────────────────────────────────────── +# Common Rust trait/stdlib method names that appear in virtually every codebase. +# Resolving these cross-file produces spurious INFERRED edges across crate +# boundaries (issue #908) — skip them from the unresolved-call queue entirely. +_RUST_TRAIT_METHOD_BLOCKLIST: frozenset[str] = frozenset({ + "new", "default", "parse", "from_str", "now", "clone", "into", "from", + "to_string", "to_owned", "len", "is_empty", "iter", "next", "build", + "start", "run", "init", "app", "get", "set", "push", "pop", "insert", + "remove", "contains", "collect", "map", "filter", "unwrap", "expect", + "ok", "err", "some", "none", "send", "recv", "lock", "read", "write", +}) + def extract_rust(path: Path) -> dict: """Extract functions, structs, enums, traits, impl methods, and use declarations from a .rs file.""" try: @@ -3740,6 +3751,7 @@ def extract_rust(path: Path) -> dict: func_node = node.child_by_field_name("function") callee_name: str | None = None is_member_call: bool = False + is_scoped_call: bool = False if func_node: if func_node.type == "identifier": callee_name = _read_text(func_node, source) @@ -3749,6 +3761,10 @@ def extract_rust(path: Path) -> dict: if field: callee_name = _read_text(field, source) elif func_node.type == "scoped_identifier": + # Type::method() — still allow in-file EXTRACTED match, but + # skip cross-file resolution: bare last-segment lookup ignores + # crate boundaries and produces spurious INFERRED edges (#908). + is_scoped_call = True name = func_node.child_by_field_name("name") if name: callee_name = _read_text(name, source) @@ -3769,7 +3785,7 @@ def extract_rust(path: Path) -> dict: "source_location": f"L{line}", "weight": 1.0, }) - else: + elif not is_scoped_call and callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST: raw_calls.append({ "caller_nid": caller_nid, "callee": callee_name, diff --git a/tests/fixtures/crate_a/Cargo.toml b/tests/fixtures/crate_a/Cargo.toml new file mode 100644 index 00000000..0e16f881 --- /dev/null +++ b/tests/fixtures/crate_a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate_a" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/crate_a/src/lib.rs b/tests/fixtures/crate_a/src/lib.rs new file mode 100644 index 00000000..6c2a7946 --- /dev/null +++ b/tests/fixtures/crate_a/src/lib.rs @@ -0,0 +1,7 @@ +pub fn start() -> bool { + true +} + +pub fn parse(s: &str) -> u32 { + s.len() as u32 +} diff --git a/tests/fixtures/crate_b/Cargo.toml b/tests/fixtures/crate_b/Cargo.toml new file mode 100644 index 00000000..e39d248d --- /dev/null +++ b/tests/fixtures/crate_b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate_b" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/crate_b/src/lib.rs b/tests/fixtures/crate_b/src/lib.rs new file mode 100644 index 00000000..7001a723 --- /dev/null +++ b/tests/fixtures/crate_b/src/lib.rs @@ -0,0 +1,18 @@ +// crate_b has no dependency on crate_a. +// These calls use Type::method() (scoped_identifier) and common names that +// previously produced spurious INFERRED edges into crate_a (#908). + +pub struct Server; + +impl Server { + pub fn run(&self) { + // Server::start() — scoped call, should not wire to crate_a::start + let _ = Server::start(); + // Url::parse() — scoped call, should not wire to crate_a::parse + let _ = Url::parse("http://example.com"); + } + + fn start() -> bool { + false + } +} diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 637dcd1a..022d7173 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -177,6 +177,27 @@ def test_rust_no_dangling_edges(): assert e["source"] in node_ids +def test_rust_no_cross_crate_spurious_edges(): + """Scoped calls (Type::method) and blocklisted names must not produce + INFERRED cross-crate calls edges (#908).""" + from graphify.extract import extract + crate_a = FIXTURES / "crate_a" / "src" / "lib.rs" + crate_b = FIXTURES / "crate_b" / "src" / "lib.rs" + r = extract([crate_a, crate_b]) + node_ids_a = {n["id"] for n in r["nodes"] if "crate_a" in (n.get("source_file") or "")} + node_ids_b = {n["id"] for n in r["nodes"] if "crate_b" in (n.get("source_file") or "")} + # No calls edge should cross from crate_b into crate_a + cross_crate_calls = [ + e for e in r["edges"] + if e["relation"] == "calls" + and e["source"] in node_ids_b + and e["target"] in node_ids_a + ] + assert cross_crate_calls == [], ( + f"Spurious cross-crate edges: {cross_crate_calls}" + ) + + # ── extract() dispatch ──────────────────────────────────────────────────────── def test_extract_dispatches_all_languages():