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
2 changes: 1 addition & 1 deletion graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
_EXTRACTOR_VERSION = "unknown"

# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 3 # Terraform directory-scoped IDs + module-source facts; Ruby inherited-lookup metadata.
_AST_CACHE_SCHEMA = 4 # Rust generic-impl identity markers; prior extractor/cache contracts.

# Version dirs already swept this process — cleanup runs once per (base, version).
_cleaned_ast_dirs: set[str] = set()
Expand Down
7 changes: 6 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3818,7 +3818,12 @@ def _ctx_identity(source_file) -> str | None:
"file_type": _node.get("file_type"),
"type": _node.get("type"),
}
for _marker in ("_callable", "_callable_class", "_elixir_module"):
# Keep bounded resolver identity for unchanged nodes;
# these markers cannot be reconstructed from labels.
for _marker in (
"_callable", "_callable_class", "_elixir_module",
"_rust_impl_key", "_rust_declaration_count",
):
if _node.get(_marker):
_ctx_node[_marker] = _node[_marker]
_metadata = _node.get("metadata")
Expand Down
36 changes: 33 additions & 3 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4753,6 +4753,11 @@ def _resolve_rust_self_member_calls(

Only `self.` receivers are handled: a non-self receiver needs local type
inference this pass does not attempt, left for a future extension.

Simple unbounded generic impls use a persisted owner/arity marker instead
of their parameter-spelling-sensitive labels (`Bucket<T>` vs `Bucket<U>`).
That path additionally requires exactly one bare declaration and never
falls back to bare-label pooling when marker context is missing.
"""
raw = [
rc
Expand All @@ -4765,9 +4770,13 @@ def _resolve_rust_self_member_calls(

node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
nids_by_label: dict[str, list[str]] = {}
nids_by_rust_impl_key: dict[str, list[str]] = {}
for n in all_nodes:
if str(n.get("source_file") or "").endswith(".rs"):
nids_by_label.setdefault(n.get("label", ""), []).append(n.get("id"))
impl_key = n.get("_rust_impl_key")
if isinstance(impl_key, str) and impl_key:
nids_by_rust_impl_key.setdefault(impl_key, []).append(n.get("id"))

# Pooling methods across every same-labeled node is safe when they are all
# impl blocks for ONE real type spread across files, but not when the bare
Expand All @@ -4782,9 +4791,20 @@ def _resolve_rust_self_member_calls(
# -- however many files its impl blocks are spread across -- is safe to
# pool, which is the split-impl-block shape this pass exists for.
declared_type_count: dict[str, int] = {}
generic_declared_type_count: dict[str, int] = {}
contains_targets = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
for label, nids in nids_by_label.items():
declared_type_count[label] = sum(1 for nid in nids if nid in contains_targets)
generic_declared_type_count[label] = sum(
count
for nid in nids
if isinstance(
count := node_by_id.get(nid, {}).get("_rust_declaration_count"),
int,
)
and not isinstance(count, bool)
and count > 0
)

# (impl/type node id, bare method name) -> method node id(s), from `method`
# edges. A set, not a single overwritten value: two distinct method nodes
Expand Down Expand Up @@ -4814,10 +4834,20 @@ def _resolve_rust_self_member_calls(
caller = rc["caller_nid"]
callee = rc["callee"]
self_type = rc["rust_self_type"]
if declared_type_count.get(self_type, 0) >= 2:
continue # the type name itself is ambiguous -- two unrelated types share it
impl_key = rc.get("rust_self_impl_key")
if isinstance(impl_key, str) and impl_key:
# A generic owner/arity marker proves family identity only with one
# declaration in the corpus. Never fall back to bare-label pooling
# when persisted marker context is absent or ambiguous.
if generic_declared_type_count.get(self_type, 0) != 1:
continue
owner_nids = nids_by_rust_impl_key.get(impl_key, [])
else:
if declared_type_count.get(self_type, 0) >= 2:
continue # two unrelated types share this bare name
owner_nids = nids_by_label.get(self_type, [])
candidates: set[str] = set()
for nid in nids_by_label.get(self_type, []):
for nid in owner_nids:
candidates |= method_index.get((nid, callee), set())
if len(candidates) != 1: # zero or ambiguous -> no edge (god-node guard)
continue
Expand Down
108 changes: 100 additions & 8 deletions graphify/extractors/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,55 @@ def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[
if c.is_named:
_rust_collect_type_refs(c, source, generic, out)


def _rust_simple_generic_impl_key(node, source: bytes) -> str | None:
"""Return a stable owner/arity key for a deliberately narrow impl shape."""
if node.child_by_field_name("trait") is not None:
return None
parameters = node.child_by_field_name("type_parameters")
owner_type = node.child_by_field_name("type")
if parameters is None or owner_type is None or owner_type.type != "generic_type":
return None
if any(child.type == "where_clause" for child in node.named_children):
return None

parameter_names: list[str] = []
for parameter in parameters.named_children:
if parameter.type != "type_parameter":
return None
named = parameter.named_children
if len(named) != 1 or named[0].type != "type_identifier":
return None
name = _read_text(named[0], source)
if not name or name in parameter_names:
return None
parameter_names.append(name)
if not parameter_names:
return None

owner = owner_type.child_by_field_name("type")
if owner is None or owner.type != "type_identifier":
return None
arguments = next(
(child for child in owner_type.named_children if child.type == "type_arguments"),
None,
)
if arguments is None:
return None
argument_names = [
_read_text(argument, source)
for argument in arguments.named_children
if argument.type == "type_identifier"
]
if len(argument_names) != len(arguments.named_children):
return None
if argument_names != parameter_names:
return None

owner_name = _read_text(owner, source)
return f"{owner_name}/{len(parameter_names)}" if owner_name else None


_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",
Expand Down Expand Up @@ -80,7 +129,8 @@ def extract_rust(path: Path) -> dict:
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object, str | None]] = []
function_bodies: list[tuple[str, object, str | None, str | None]] = []
impl_keys: dict[str, str | None] = {}

def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
Expand Down Expand Up @@ -160,7 +210,12 @@ 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)

def walk(node, parent_impl_nid: str | None = None, parent_impl_type: str | None = None) -> None:
def walk(

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 regressionwalk()

fans out to 9 callees (efferent coupling).

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

node,
parent_impl_nid: str | None = None,
parent_impl_type: str | None = None,
parent_impl_key: str | None = None,
) -> None:
t = node.type

if t == "function_item":
Expand All @@ -179,7 +234,12 @@ def walk(node, parent_impl_nid: str | None = None, parent_impl_type: str | None
emit_param_return_refs(node, func_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body, parent_impl_type))
function_bodies.append((
func_nid,
body,
parent_impl_type,
parent_impl_key,
))
return

if t == "function_signature_item":
Expand Down Expand Up @@ -210,6 +270,10 @@ def walk(node, parent_impl_nid: str | None = None, parent_impl_type: str | None
line = node.start_point[0] + 1
item_nid = _make_id(stem, item_name)
add_node(item_nid, item_name, line)
declaration_node = next(n for n in nodes if n["id"] == item_nid)
declaration_node["_rust_declaration_count"] = (
declaration_node.get("_rust_declaration_count", 0) + 1
)
add_edge(file_nid, item_nid, "contains", line)
if t == "trait_item":
for c in node.children:
Expand Down Expand Up @@ -359,10 +423,12 @@ def _emit_enum_type(type_node, at_line):
trait_node = node.child_by_field_name("trait")
impl_nid: str | None = None
impl_type_bare: str | None = None
impl_key: str | None = None
if type_node:
type_name = _read_text(type_node, source).strip()
impl_nid = _make_id(stem, type_name)
add_node(impl_nid, type_name, node.start_point[0] + 1)
impl_key = _rust_simple_generic_impl_key(node, source)
# Bare name (generics stripped) for typing a `self.` receiver
# inside this block's methods (#2234) — `impl Foo<T>` types
# `self` as `Foo`, not the literal `Foo<T>` text.
Expand All @@ -381,8 +447,27 @@ def _emit_enum_type(type_node, at_line):
context="generic_arg")
body = node.child_by_field_name("body")
if body:
has_methods = any(
child.type in ("function_item", "function_signature_item")
for child in body.children
)
if impl_nid is not None and has_methods:
if impl_nid not in impl_keys:
impl_keys[impl_nid] = impl_key
elif impl_keys[impl_nid] != impl_key:
impl_keys[impl_nid] = None
impl_node = next(n for n in nodes if n["id"] == impl_nid)
if impl_keys[impl_nid]:
impl_node["_rust_impl_key"] = impl_keys[impl_nid]
else:
impl_node.pop("_rust_impl_key", None)
for child in body.children:
walk(child, parent_impl_nid=impl_nid, parent_impl_type=impl_type_bare)
walk(
child,
parent_impl_nid=impl_nid,
parent_impl_type=impl_type_bare,
parent_impl_key=impl_key,
)
return

if t == "use_declaration":
Expand Down Expand Up @@ -410,7 +495,12 @@ def _emit_enum_type(type_node, at_line):
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []

def walk_calls(node, caller_nid: str, self_type: str | None = None) -> None:
def walk_calls(
node,
caller_nid: str,
self_type: str | None = None,
self_impl_key: str | None = None,
) -> None:
if node.type == "function_item":
return
if node.type == "call_expression":
Expand Down Expand Up @@ -465,12 +555,14 @@ def walk_calls(node, caller_nid: str, self_type: str | None = None) -> None:
}
if is_self_call and self_type:
rc_entry["rust_self_type"] = self_type
if self_impl_key:
rc_entry["rust_self_impl_key"] = self_impl_key
raw_calls.append(rc_entry)
for child in node.children:
walk_calls(child, caller_nid, self_type)
walk_calls(child, caller_nid, self_type, self_impl_key)

for caller_nid, body_node, impl_type in function_bodies:
walk_calls(body_node, caller_nid, impl_type)
for caller_nid, body_node, impl_type, impl_key in function_bodies:
walk_calls(body_node, caller_nid, impl_type, impl_key)

valid_ids = seen_ids
clean_edges = []
Expand Down
11 changes: 7 additions & 4 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,10 +1684,13 @@ def _add_deleted_source(path: Path) -> None:
"file_type": node.get("file_type"),
"type": node.get("type"),
}
# #2438: the persisted callability markers are the only
# thing that lets an unchanged target pass the
# indirect_call guard — never re-derived from the label.
for marker in ("_callable", "_callable_class", "_elixir_module"):
# Persisted resolver markers are never re-derived from a
# label: callability protects indirect calls (#2438), and
# Rust impl identity connects alpha-renamed generic blocks.
for marker in (
"_callable", "_callable_class", "_elixir_module",
"_rust_impl_key", "_rust_declaration_count",
):
if node.get(marker):
ctx_node[marker] = node[marker]
metadata = node.get("metadata")
Expand Down
56 changes: 56 additions & 0 deletions tests/test_incremental.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,62 @@ def test_extract_no_cluster_incremental_changed_file_preserves_unchanged_files(t
assert e.get("target") in after_ids, f"dangling target: {e}"


def test_update_preserves_generic_rust_self_call_to_unchanged_impl(tmp_path):
"""The CLI incremental context carries Rust impl-family identity."""
proj = tmp_path / "proj"
proj.mkdir()
(proj / "state.rs").write_text(
"pub struct Bucket<T> { value: T }\n", encoding="utf-8"
)
(proj / "method.rs").write_text(
"impl<T> Bucket<T> {\n"
" pub fn fetch_value(&self) {}\n"
"}\n",
encoding="utf-8",
)
caller = proj / "caller.rs"
caller.write_text(
"impl<U> Bucket<U> {\n"
" pub fn run(&self) { self.fetch_value(); }\n"
"}\n",
encoding="utf-8",
)

first = _run(
["extract", str(proj), "--code-only", "--no-cluster"], tmp_path
)
assert first.returncode == 0, first.stderr

def has_call() -> bool:
graph = json.loads(
(proj / "graphify-out" / "graph.json").read_text(encoding="utf-8")
)
nodes = {
(node.get("label"), node.get("source_file")): node["id"]
for node in graph.get("nodes", [])
}
pair = (
nodes[(".run()", "caller.rs")],
nodes[(".fetch_value()", "method.rs")],
)
return any(
edge.get("relation") == "calls"
and (edge.get("source"), edge.get("target")) == pair
for edge in graph.get("links", graph.get("edges", []))
)

assert has_call()
caller.write_text(
"impl<U> Bucket<U> {\n"
" pub fn run(&self) { let marker = 1; self.fetch_value(); }\n"
"}\n",
encoding="utf-8",
)
second = _run(["update", str(proj), "--no-cluster"], tmp_path)
assert second.returncode == 0, second.stderr
assert has_call()


def test_extract_no_cluster_incremental_code_only_preserves_doc_nodes(tmp_path):
"""#2169: an incremental --code-only --no-cluster run over a mixed corpus
must carry forward doc-sourced nodes it did not re-extract."""
Expand Down
Loading
Loading