Skip to content
Open
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
39 changes: 38 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
64 changes: 63 additions & 1 deletion graphify/extractors/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions graphify/symbol_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (``<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``.

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]],
Expand Down Expand Up @@ -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
Expand Down
Loading