Skip to content
Closed
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
46 changes: 45 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,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 PYTEST_FIXTURE_UNTYPED, 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_namespace_id, _csharp_namespace_name, _csharp_pick_overload, _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

Expand Down Expand Up @@ -10982,6 +10982,7 @@ def _looks_like_bash(result: object) -> bool:
# nid -> raw source_file string, for the ambiguous-name tie-breakers below
# (test/non-test classification + path proximity). Kept separate from the
# file-node-id map because tie-breaking compares the actual file paths.
_rust_nid_to_label: "dict[str, str] | None" = None
nid_to_source_file: dict[str, str] = {}
for n in resolution_nodes:
sf = n.get("source_file")
Expand Down Expand Up @@ -11086,6 +11087,49 @@ def _looks_like_bash(result: object) -> bool:
# `close` and `delete` are ordinary method names elsewhere (#2296).
if rc.get("language") == "go" and callee in _GO_PREDECLARED_FUNCS:
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"):
if _rust_nid_to_label is None:
_rust_nid_to_label = {
str(n.get("id")): str(n.get("label", "")) for n in resolution_nodes
}
_survivors = rust_scoped_call_survivors(
str(rc.get("scope_qualifier", "")).strip(),
global_label_to_nids.get(callee, []),
nid_to_source_file,
caller_file=str(rc.get("source_file", "")),
nid_to_label=_rust_nid_to_label,
)
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
Expand Down
45 changes: 44 additions & 1 deletion graphify/extractors/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,17 @@ def _scan_local_types(scan_node) -> None:
# are resolved through their own registry rather than the function/type
# label_to_nid to avoid cross-binding.
macro_nids_by_name: dict[str, str] = {}
# `mod m { .. }` blocks declared in this file, so a `m::f()` call can bind
# in-file; `mod m;` declares a module that lives in another file.
inline_mods: set[str] = set()
_mod_stack = [root]
while _mod_stack:
_n = _mod_stack.pop()
if _n.type == "mod_item" and _n.child_by_field_name("body") is not None:
_name = _n.child_by_field_name("name")
if _name is not None:
inline_mods.add(_read_text(_name, source))
_mod_stack.extend(_n.children)

# (bare impl type, method name) -> the method nodes this file's `impl` blocks
# define, so an in-file `Type::f()` / `self.f()` binds to a method of that type.
Expand Down Expand Up @@ -938,6 +949,16 @@ def walk_calls(
tgt_nid = named if named in type_nids else None
elif is_member_call:
tgt_nid = method_label_to_nid.get(callee_name)
elif is_scoped_call:
# `m::f()` names module m. It is in this file only for
# `self::f()` or an inline `mod m { .. }` declared here; any
# other module lives in another file, so the bare-name match
# would bind `crate::b::helper()` to this file's own helper.
path_node = func_node.child_by_field_name("path") if func_node else None
qual = _read_text(path_node, source) if path_node is not None else ""
head = qual.split("::")[-1].strip()
tgt_nid = label_to_nid.get(callee_name) if (
qual == "self" or head in inline_mods) else None
else:
tgt_nid = label_to_nid.get(callee_name)
if tgt_nid and tgt_nid != caller_nid:
Expand All @@ -956,7 +977,29 @@ def walk_calls(
"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:
# A module-qualified call (`crate::m::f()`, `m::f()`, `super::m::f()`)
# goes to the cross-file pass with its qualifier, which binds it only
# when the qualifier matches the candidate's module path and exactly
# one candidate survives. Bare last-segment lookup stays off (#908).
qual_node = func_node.child_by_field_name("path")
qualifier = _read_text(qual_node, source) if qual_node else ""
# `use std::fs; fs::read()` names std's fs, never a local `fs.rs`.
first_seg = qualifier.split("::", 1)[0].split("<", 1)[0].strip()
roots = use_roots.get(first_seg, {"crate"})
if not roots <= {"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:
rc_entry = {
"caller_nid": caller_nid,
"callee": callee_name,
Expand Down
133 changes: 133 additions & 0 deletions graphify/symbol_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import unicodedata
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from collections.abc import Sequence
from typing import Any
Expand Down Expand Up @@ -325,6 +326,92 @@ def resolve_python_import_guided_calls(
return resolved_edges


@lru_cache(maxsize=4096)
def _rust_crate_root(source_file: str) -> str | None:
"""The crate a Rust file belongs to: the nearest directory holding a
``Cargo.toml``, else the directory above the nearest ``src``. None when
neither is found (a flat layout this pass cannot place)."""
path = Path(source_file)
for parent in path.parents:
try:
if (parent / "Cargo.toml").is_file():
return str(parent)
except OSError:
break
parts = path.parts
if "src" in parts[:-1]:
idx = len(parts) - 1 - parts[::-1].index("src")
return str(Path(*parts[:idx])) if idx else ""
return None


def rust_scoped_call_survivors(
qualifier: str,
candidates: Sequence[str],
nid_to_source_file: dict[str, str],
caller_file: str = "",
nid_to_label: "dict[str, str] | None" = None,
) -> 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 (``<seg>.rs`` or
``<seg>/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``.

A candidate must live in the caller's crate (see ``_rust_crate_root``):
``crate::util::f()`` in crate_a never means crate_b's ``util.rs``. Methods
are skipped, since a module path names a free function, never an impl's.

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 last[:1].isupper():
return []
caller_crate = _rust_crate_root(caller_file) if caller_file else None
survivors: list[str] = []
for cand in candidates:
cand_file = str(nid_to_source_file.get(cand, ""))
if not cand_file.endswith(".rs"):
continue
if nid_to_label is not None and nid_to_label.get(cand, "").startswith("."):
continue
if caller_file and _rust_crate_root(cand_file) != caller_crate:
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — resolve_cross_file_raw_calls()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

per_file: Sequence[dict[str, Any] | None],
all_nodes: list[dict[str, Any]],
Expand All @@ -348,6 +435,7 @@ def resolve_cross_file_raw_calls(
for n in all_nodes
if n.get("id")
}
nid_to_label = {str(n.get("id")): str(n.get("label", "")) for n in all_nodes if n.get("id")}
resolved: list[dict[str, Any]] = []

for raw_call in iter_raw_calls(per_file):
Expand All @@ -356,6 +444,51 @@ 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,
caller_file=str(raw_call.get("source_file", "")),
nid_to_label=nid_to_label,
)
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
Expand Down
Loading
Loading