diff --git a/graphify/extract.py b/graphify/extract.py index 9de0c7622..f4e7bc6f7 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -135,7 +135,7 @@ _workspace_globs, ) -from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402 +from graphify.symbol_resolution import resolve_bash_source_edges, rust_scoped_call_survivors # noqa: E402 from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_member_type_table, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 @@ -5211,6 +5211,43 @@ def _looks_like_bash(result: object) -> bool: # in the corpus — exactly what #2141 must not do. if rc.get("language") == "bash": continue + # Rust module-qualified call (`module::function()`), enqueued by + # extractors/rust.py with its qualifier path. Historically these were + # dropped at extraction because BARE last-segment lookup across crate + # boundaries produced spurious INFERRED edges (#908) — which made the + # dominant idiomatic intra-crate call form invisible (a real caller of + # `scan_substrate::accumulate_observations` showed 0 dependents while + # grep found it). Resolve strictly by the qualifier instead — the + # candidate's file path must match it (see rust_scoped_call_survivors); + # require exactly one survivor or skip, and never fall through to the + # bare-name tie-breakers below, so the #908 guard is preserved. + # EXTRACTED because the module is named explicitly in source — same + # reasoning as the qualified-class-method passes (#1446/#1533). + if rc.get("is_scoped_call"): + _survivors = rust_scoped_call_survivors( + str(rc.get("scope_qualifier", "")).strip(), + global_label_to_nids.get(callee, []), + nid_to_source_file, + ) + if len(_survivors) != 1: + continue + _tgt = _survivors[0] + _caller = rc["caller_nid"] + if _tgt == _caller or (_caller, _tgt) in existing_pairs: + continue + existing_pairs.add((_caller, _tgt)) + all_edges.append({ + "source": _caller, + "target": _tgt, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + continue # Exact-case match first (case is semantic). Fold only when the CALLING # file's language is case-insensitive, and only against the folded index of # case-insensitive-language definitions — so a Python `Path()` call can never diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index b663bd625..3513a67a3 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -160,6 +160,10 @@ def emit_param_return_refs(func_node, func_nid: str, line: int) -> None: if tgt != func_nid: add_edge(func_nid, tgt, "references", line, context=ctx) + # Local name -> root path segment of the `use` that bound it + # (e.g. `use std::fs;` -> {"fs": "std"}). Read by the scoped-call pass. + use_roots: dict[str, str] = {} + def walk(node, parent_impl_nid: str | None = None) -> None: t = node.type @@ -329,6 +333,25 @@ def _emit_enum_type(type_node, at_line): if module_name: tgt_nid = _make_id(module_name) add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import") + # Record what each `use` binds locally, and from which root + # (`std`, `crate`, an extern crate...). Scoped-call resolution + # consults this: `use std::fs; fs::read()` names std's fs, and + # must not bind to an unrelated local `fs.rs`. + root = raw.split("::", 1)[0].strip() + if "{" in raw: + inner = raw.split("{", 1)[1].rsplit("}", 1)[0] + leaves = [leaf.strip() for leaf in inner.split(",")] + else: + leaves = [raw.strip()] + for leaf in leaves: + if not leaf or leaf == "*" or "{" in leaf or "}" in leaf: + continue + if " as " in leaf: + binding = leaf.rsplit(" as ", 1)[1].strip() + else: + binding = leaf.rsplit("::", 1)[-1].strip() + if binding and binding != "*" and root: + use_roots[binding] = root return for child in node.children: @@ -386,7 +409,46 @@ def walk_calls(node, caller_nid: str) -> None: "source_location": f"L{line}", "weight": 1.0, }) - elif not is_scoped_call and callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST: + elif is_scoped_call: + # WHY: dropping unresolved scoped calls entirely made every + # cross-file module-qualified call (`module::function()`) — + # the DOMINANT intra-crate call form in idiomatic Rust — + # invisible to the graph: e.g. `scan_substrate:: + # accumulate_observations(...)` in rotation_drive.rs showed + # 0 dependents while grep found the caller, forcing every + # consumer back to grep and defeating graph-first navigation. + # The original guard existed because BARE last-segment lookup + # across crates produced spurious INFERRED edges (#908). + # This keeps that guard: instead of resolving here (or bare- + # name downstream), we enqueue the call WITH its qualifier + # path so the shared cross-file pass can resolve it strictly + # against the qualifier (file-stem / mod.rs match, unique- + # candidate or bail) — see resolve_cross_file_raw_calls in + # symbol_resolution.py, same reasoning as the Python + # qualified-class-method pass (#1446/#1533): a call that + # names its module explicitly in source is an exact + # reference, not an inference. + qual_node = func_node.child_by_field_name("path") + qualifier = _read_text(qual_node, source) if qual_node else "" + # A qualifier bound by a `use` from outside the crate + # (`use std::fs; fs::read()`) names that import, not a + # sibling module file — resolving it would bind std calls + # to same-named local modules (shadowing std module names + # with wrapper modules is common Rust). + first_seg = qualifier.split("::", 1)[0].split("<", 1)[0].strip() + if use_roots.get(first_seg, "crate") not in ("crate", "super", "self"): + qualifier = "" + if qualifier: + raw_calls.append({ + "caller_nid": caller_nid, + "callee": callee_name, + "is_member_call": False, + "is_scoped_call": True, + "scope_qualifier": qualifier, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + elif callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST: raw_calls.append({ "caller_nid": caller_nid, "callee": callee_name, diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 892f31065..7e2cadae0 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -304,6 +304,62 @@ def resolve_python_import_guided_calls( return resolved_edges +def rust_scoped_call_survivors( + qualifier: str, + candidates: Sequence[str], + nid_to_source_file: dict[str, str], +) -> list[str]: + """Return the candidates a Rust ``module::function()`` qualifier verifies. + + The qualifier is matched right-to-left against each candidate's file path: + the last segment must BE the defining module (``.rs`` or + ``/mod.rs``), and every remaining segment must match the successive + parent directory. Matching stops at ``crate``/``super``/``self`` — those + are root-relative, and resolving the root needs a module-tree model this + deliberately does not attempt; segments to their right are still verified. + Matching only the last segment let ``std::fs::read()`` bind to an + unrelated local ``fs.rs`` (shadowing std module names with wrapper modules + is common Rust); the directory walk fails that on ``std`` vs ``src``. + Candidates not defined in a ``.rs`` file are skipped — a bare stem match + let a Rust ``b::helper()`` bind to a Python ``b.py``. + + Returns [] outright when the qualifier disqualifies resolution: a bare + ``crate``/``super``/``self`` names no module file, and an UpperCamelCase + segment is a type (``Type::method()`` / ``Enum::Variant`` — rustc enforces + the case convention), which keeps its pre-existing skipped behavior. + """ + segments = [s.split("<", 1)[0].strip() for s in qualifier.split("::")] + segments = [s for s in segments if s] + if not segments: + return [] + last = segments[-1] + if last in ("crate", "super", "self") or not last[:1].islower(): + return [] + survivors: list[str] = [] + for cand in candidates: + cand_file = str(nid_to_source_file.get(cand, "")) + if not cand_file.endswith(".rs"): + continue + path = Path(cand_file) + if path.stem == last: + cursor = path.parent + elif path.stem == "mod" and path.parent.name == last: + cursor = path.parent.parent + else: + continue + matched = True + for seg in reversed(segments[:-1]): + if seg in ("crate", "super", "self"): + break + if cursor.name != seg: + matched = False + break + cursor = cursor.parent + if matched: + survivors.append(cand) + return survivors + + def resolve_cross_file_raw_calls( per_file: Sequence[dict[str, Any] | None], all_nodes: list[dict[str, Any]], @@ -335,6 +391,49 @@ def resolve_cross_file_raw_calls( continue if raw_call.get("is_member_call"): continue + if raw_call.get("is_scoped_call"): + # Rust module-qualified call (`module::function()`), enqueued by + # extractors/rust.py with its qualifier path. These must NEVER fall + # through to the bare-name branch below — bare last-segment lookup + # across crate boundaries is exactly what produced the spurious + # INFERRED edges that motivated dropping scoped calls (#908). + # Instead the qualifier is verified against the candidate's file + # path (see rust_scoped_call_survivors); require exactly one + # survivor or bail (god-node guard preserved). Emitted as EXTRACTED + # because the source names the module explicitly — same reasoning + # as the Python qualified-class-method pass (#1446/#1533). + qualifier = str(raw_call.get("scope_qualifier", "")).strip() + survivors = rust_scoped_call_survivors( + qualifier, label_index.get(callee.lower(), []), nid_to_source_file + ) + if len(survivors) != 1: + continue + target = survivors[0] + caller = str(raw_call.get("caller_nid", "")) + if not caller or target == caller: + continue + pair = (caller, target, "calls") + if pair in known_pairs: + continue + known_pairs.add(pair) + resolved.append( + { + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + "metadata": sanitize_metadata({ + "resolver": "rust_scoped_module_call", + "scope_qualifier": qualifier, + }), + } + ) + continue candidates = label_index.get(callee.lower(), []) if not candidates: continue diff --git a/tests/test_rust_scoped_call_resolution.py b/tests/test_rust_scoped_call_resolution.py new file mode 100644 index 000000000..0de1b923c --- /dev/null +++ b/tests/test_rust_scoped_call_resolution.py @@ -0,0 +1,162 @@ +"""Rust module-qualified calls resolve cross-file, verified by the qualifier (#908). + +Scoped calls (`module::function()`) — the dominant intra-crate call form in +idiomatic Rust — used to be dropped at extraction: bare last-segment lookup +across crates produced spurious INFERRED edges (#908), so a real production +caller showed 0 dependents while grep found the call site immediately. + +The resolver keeps the #908 guard but uses the discarded qualifier as the +verification key: the last segment must BE the defining module (`.rs` or +`/mod.rs`), every remaining segment must match the successive parent +directory (stopping at `crate`/`super`/`self`), candidates are Rust-only, and +a qualifier bound by a `use` from outside the crate is skipped at extraction. +Exactly one survivor or no edge. + +These tests pin: the resolution positives (plain, `mod.rs`, crate-qualified, +use-bound from the crate), the std-shadowing and cross-language false +positives, and the guards (ambiguity, uppercase types, bare `super`/`self`, +untouched bare-name path). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _calls(files: list[Path], base: Path) -> set[tuple[str, str, str]]: + r = extract(files, cache_root=base, parallel=False) + # Rust function labels carry their call parens (`run()`); strip for + # comparison so assertions read as source names. + lbl = {n["id"]: n["label"].strip("()") for n in r["nodes"]} + return { + (lbl.get(e["source"], ""), lbl.get(e["target"], ""), e.get("confidence")) + for e in r["edges"] if e["relation"] == "calls" + } + + +def test_module_qualified_call_resolves_to_the_named_module(tmp_path: Path) -> None: + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + # c.rs defines a same-named helper → the qualifier, not the name, decides + _write(tmp_path / "src/c.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_mod_rs_form_resolves(tmp_path: Path) -> None: + _write(tmp_path / "src/a.rs", + "pub fn run() { util::helper(); }\n") + _write(tmp_path / "src/util/mod.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_two_matching_module_files_emit_nothing(tmp_path: Path) -> None: + # helper() in both b.rs and b/mod.rs → two survivors → skipped (god-node guard) + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + _write(tmp_path / "src/b/mod.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "helper" for s, t, _ in calls), calls + + +def test_uppercase_qualifier_is_a_type_not_a_module(tmp_path: Path) -> None: + # Uppercase qualifier is a type (`Type::method()`) → keeps the old skipped behavior + _write(tmp_path / "src/a.rs", + "pub fn run() { Helper::helper(); }\n") + _write(tmp_path / "src/helper.rs", + "pub struct Helper;\n" + "impl Helper { pub fn helper() {} }\n" + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" for s, _t, _ in calls), calls + + +def test_crate_qualified_path_verifies_against_directories(tmp_path: Path) -> None: + # Segments right of `crate` verify against directories → resolves without a module tree + _write(tmp_path / "src/a.rs", + "pub fn run() { crate::util::helper(); }\n") + _write(tmp_path / "src/util.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_bare_super_and_self_are_skipped(tmp_path: Path) -> None: + # Bare `super::`/`self::` names no module file → needs a module tree → skipped + _write(tmp_path / "src/a.rs", + "pub fn run() { super::helper(); self::helper2(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\npub fn helper2() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" for s, _t, _ in calls), calls + + +def test_std_qualified_call_does_not_bind_to_local_shadow(tmp_path: Path) -> None: + # `std::fs::read(...)` with an unrelated local fs.rs defining read → + # the directory walk fails on `std` vs `src` → no edge + _write(tmp_path / "src/a.rs", + 'pub fn run() { let _ = std::fs::read("x"); }\n') + _write(tmp_path / "src/fs.rs", + "pub fn read(p: &str) -> Vec { Vec::new() }\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "read" for s, t, _ in calls), calls + + +def test_use_bound_qualifier_from_std_is_skipped(tmp_path: Path) -> None: + # `use std::fs; fs::read()` → the qualifier names std's fs, not the + # unrelated local fs.rs → skipped at enqueue + _write(tmp_path / "src/a.rs", + "use std::fs;\n" + 'pub fn run() { let _ = fs::read("x"); }\n') + _write(tmp_path / "src/fs.rs", + "pub fn read(p: &str) -> Vec { Vec::new() }\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "read" for s, t, _ in calls), calls + + +def test_use_bound_qualifier_from_crate_still_resolves(tmp_path: Path) -> None: + # `use crate::util;` binds util from inside the crate → still resolves + _write(tmp_path / "src/a.rs", + "use crate::util;\n" + "pub fn run() { util::helper(); }\n") + _write(tmp_path / "src/util.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_rust_scoped_call_cannot_bind_across_languages(tmp_path: Path) -> None: + # Rust `b::helper()` with a Python b.py defining helper → candidates are + # Rust-only → no cross-language edge + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.py", + "def helper():\n pass\n") + files = sorted(tmp_path.rglob("*.rs")) + sorted(tmp_path.rglob("*.py")) + calls = _calls(files, tmp_path) + assert not any(s == "run" and t == "helper" for s, t, _ in calls), calls + + +def test_bare_name_calls_keep_their_existing_resolution(tmp_path: Path) -> None: + # Control: bare-name calls keep their existing INFERRED resolution + _write(tmp_path / "src/a.rs", + "pub fn run() { helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert any(s == "run" and t == "helper" for s, t, _ in calls), calls