diff --git a/graphify/cache.py b/graphify/cache.py index 2a47c0c21c..4b54f9cb1b 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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() diff --git a/graphify/cli.py b/graphify/cli.py index 5503a9185a..4d79d6f04f 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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") diff --git a/graphify/extract.py b/graphify/extract.py index d54b841d95..095ae75f6e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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` vs `Bucket`). + That path additionally requires exactly one bare declaration and never + falls back to bare-label pooling when marker context is missing. """ raw = [ rc @@ -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 @@ -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 @@ -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 diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 4936d73cef..7e04ec7d9a 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -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", @@ -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: @@ -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( + 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": @@ -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": @@ -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: @@ -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` types # `self` as `Foo`, not the literal `Foo` text. @@ -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": @@ -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": @@ -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 = [] diff --git a/graphify/watch.py b/graphify/watch.py index f5fd10c2f1..386f6bd414 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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") diff --git a/tests/test_incremental.py b/tests/test_incremental.py index 62c1203257..4f5fe22185 100644 --- a/tests/test_incremental.py +++ b/tests/test_incremental.py @@ -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 { value: T }\n", encoding="utf-8" + ) + (proj / "method.rs").write_text( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n", + encoding="utf-8", + ) + caller = proj / "caller.rs" + caller.write_text( + "impl Bucket {\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 Bucket {\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.""" diff --git a/tests/test_rust_self_member_calls.py b/tests/test_rust_self_member_calls.py index c4d2d6ddce..d251cae796 100644 --- a/tests/test_rust_self_member_calls.py +++ b/tests/test_rust_self_member_calls.py @@ -16,7 +16,10 @@ from pathlib import Path +import pytest + from graphify.extract import extract +from graphify.extractors.rust import extract_rust def _calls(tmp_path: Path, files: dict[str, str]): @@ -86,6 +89,323 @@ def test_self_call_same_file_control_is_unaffected(tmp_path: Path): assert (caller, callee) in calls +def test_generic_self_call_resolves_across_alpha_renamed_impls(tmp_path: Path): + """Parameter spelling must not split one simple generic impl family.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "read.rs": ( + "use crate::state::Bucket;\n" + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "run.rs": ( + "use crate::state::Bucket;\n" + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "run_bucket_u") + callee = _find(result, ".fetch_value()", "read_bucket_t") + assert (caller, callee) in calls + assert calls[(caller, callee)]["confidence"] == "EXTRACTED" + + +def test_generic_self_call_does_not_cross_unrelated_same_named_families( + tmp_path: Path, +): + """The marker is not proof when two declarations share owner and arity.""" + calls, result = _calls(tmp_path, { + "a/state.rs": "pub struct Bucket { value: T }\n", + "a/read.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "b/state.rs": "pub struct Bucket { value: T }\n", + "b/run.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "b_run_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_without_a_declaration_fails_closed(tmp_path: Path): + """Impl blocks alone do not prove that same-named owners are one family.""" + calls, result = _calls(tmp_path, { + "method.rs": ( + "impl Bucket { pub fn fetch_value(&self) {} }\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_counts_collapsed_same_file_declarations( + tmp_path: Path, +): + """Nested modules must not hide two unrelated declarations behind one ID.""" + calls, result = _calls(tmp_path, { + "state.rs": ( + "pub mod a { pub struct Bucket(pub T); }\n" + "pub mod b { pub struct Bucket(pub T); }\n" + ), + "a_impl.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "fallback.rs": ( + "pub trait Fallback { fn fetch_value(&self) {} }\n" + ), + "b_impl.rs": ( + "impl Fallback for Bucket {}\n" + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "b_impl_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +@pytest.mark.parametrize( + ("declaration", "callee_impl", "caller_impl"), + [ + ( + "pub struct Bucket { value: T }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket where T: Clone", + "impl Bucket where U: Clone", + ), + ( + ( + "pub struct Bucket { value: T }\n" + "trait Fetch { fn fetch_value(&self); }\n" + ), + "impl Fetch for Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket<'a, T> { value: &'a T }\n", + "impl<'a, T> Bucket<'a, T>", + "impl<'b, U> Bucket<'b, U>", + ), + ( + "pub struct Bucket { value: [u8; N] }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket>", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Pair { left: T, right: U }\n", + "impl Pair", + "impl Pair", + ), + ( + "pub struct Pair { left: T, right: U }\n", + "impl Pair", + "impl Pair", + ), + ], + ids=[ + "bounded", "where", "trait", "lifetime", "const", "nested", + "concrete", "repeated", "permuted", + ], +) +def test_unsupported_generic_impl_shapes_fail_closed( + tmp_path: Path, + declaration: str, + callee_impl: str, + caller_impl: str, +): + """Unsupported type semantics get neither a marker nor a guessed edge.""" + method = tmp_path / "method.rs" + method.write_text( + f"{callee_impl} {{\n pub fn fetch_value(&self) {{}}\n}}\n", + encoding="utf-8", + ) + impl_nodes = [ + node for node in extract_rust(method)["nodes"] + if node.get("label", "").startswith(("Bucket", "Pair")) + ] + assert impl_nodes + assert all("_rust_impl_key" not in node for node in impl_nodes) + + calls, result = _calls(tmp_path / "corpus", { + "state.rs": declaration, + "method.rs": f"{callee_impl} {{\n pub fn fetch_value(&self) {{}}\n}}\n", + "caller.rs": ( + f"{caller_impl} {{\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller") + assert not {target for (source, target) in calls if source == caller} + + +@pytest.mark.parametrize("deferred_first", [False, True]) +def test_mixed_simple_and_bounded_impl_blocks_fail_closed( + tmp_path: Path, + deferred_first: bool, +): + """A shared impl node cannot lend eligibility to a bounded block.""" + simple_target = "impl Bucket { fn marker_a(&self) {} }\n" + bounded_target = ( + "impl Bucket { pub fn fetch_value(&self) {} }\n" + ) + simple_caller = "impl Bucket { fn marker_b(&self) {} }\n" + bounded_caller = ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ) + def order(simple: str, deferred: str) -> str: + return deferred + simple if deferred_first else simple + deferred + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "target.rs": order(simple_target, bounded_target), + "caller.rs": order(simple_caller, bounded_caller), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + target_impl = next( + node for node in extract_rust(tmp_path / "target.rs")["nodes"] + if node.get("label") == "Bucket" + ) + assert "_rust_impl_key" not in target_impl + raw_call = next( + call for call in extract_rust(tmp_path / "caller.rs")["raw_calls"] + if call.get("callee") == "fetch_value" + ) + assert "rust_self_impl_key" not in raw_call + + +def test_mixed_simple_and_trait_impl_target_fails_closed(tmp_path: Path): + """A coalesced trait impl must not expose its methods as inherent ones.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "target.rs": ( + "pub trait Fetch { fn fetch_value(&self); }\n" + "impl Bucket { fn marker(&self) {} }\n" + "impl Fetch for Bucket {\n" + " fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + target_impl = next( + node for node in extract_rust(tmp_path / "target.rs")["nodes"] + if node.get("label") == "Bucket" + ) + assert "_rust_impl_key" not in target_impl + + +def test_scoped_self_call_remains_deferred_across_impl_files(tmp_path: Path): + """`Self::method()` is a scoped-call form, outside this resolver slice.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "method.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { Self::fetch_value(self); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_invalidates_markerless_ast_cache( + tmp_path: Path, + monkeypatch, +): + """A same-version cache from before the marker contract must be missed.""" + import graphify.cache as cache_mod + from graphify.cache import save_cached + + files = { + "state.rs": "pub struct Bucket { value: T }\n", + "method.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + } + paths = [] + current_schema = cache_mod._AST_CACHE_SCHEMA + assert current_schema >= 4 + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema - 1) + monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set()) + for name, body in files.items(): + path = tmp_path / name + path.write_text(body, encoding="utf-8") + paths.append(path) + stale = extract_rust(path) + for node in stale["nodes"]: + node.pop("_rust_impl_key", None) + for raw_call in stale.get("raw_calls", []): + raw_call.pop("rust_self_impl_key", None) + save_cached( + path, + stale, + root=tmp_path, + cache_root=tmp_path, + kind="ast", + ) + + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema) + monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set()) + result = extract(paths, root=tmp_path, cache_root=tmp_path) + caller = _find(result, ".run()", "caller_bucket_u") + callee = _find(result, ".fetch_value()", "method_bucket_t") + assert any( + edge.get("relation") == "calls" + and edge.get("source") == caller + and edge.get("target") == callee + for edge in result["edges"] + ) + + def test_self_call_to_ambiguous_type_name_yields_no_edge(tmp_path: Path): """Two DIFFERENT structs across the corpus happen to share the bare name `Config`, and both define a same-named method -- the exactly-one-candidate diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f44..6d687bfdde 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3514,6 +3514,144 @@ def test_incremental_rebuild_preserves_python_call_to_unchanged_target(tmp_path) assert sorted(_2406_calls(_2406_graph(corpus))) == sorted(full) +# --- Rust generic self calls into unchanged impls --------------------------- + +_RUST_GENERIC_STATE = "pub struct Bucket { value: T }\n" +_RUST_GENERIC_METHOD = ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" +) +_RUST_GENERIC_CALLER = ( + "impl Bucket {\n" + " pub fn run(&self) {\n%s self.fetch_value();\n" + " }\n" + "}\n" +) + + +def _rust_generic_seed(tmp_path): + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir(parents=True) + (corpus / "state.rs").write_text(_RUST_GENERIC_STATE, encoding="utf-8") + (corpus / "method.rs").write_text(_RUST_GENERIC_METHOD, encoding="utf-8") + (corpus / "caller.rs").write_text( + _RUST_GENERIC_CALLER % "", encoding="utf-8" + ) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + return corpus + + +def _rust_generic_call(graph): + caller = _2406_nid(graph, ".run()", "caller.rs") + callee = _2406_nid(graph, ".fetch_value()", "method.rs") + return (caller, callee) in _2406_calls(graph) + + +def test_incremental_rust_generic_self_call_uses_unchanged_impl_context(tmp_path): + """A changed generic caller retains its call into an unchanged impl block.""" + from graphify.watch import _rebuild_code + + corpus = _rust_generic_seed(tmp_path) + assert _rust_generic_call(_2406_graph(corpus)) + + caller = corpus / "caller.rs" + caller.write_text( + _RUST_GENERIC_CALLER % " let marker = 1;\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert _rust_generic_call(_2406_graph(corpus)) + + +def test_incremental_rust_generic_self_call_legacy_marker_fails_closed(tmp_path): + """A pre-marker graph does not guess; re-extraction restores the edge.""" + from graphify.watch import _rebuild_code + + corpus = _rust_generic_seed(tmp_path) + graph_path = corpus / "graphify-out" / "graph.json" + legacy = _2406_graph(corpus) + assert any(node.get("_rust_impl_key") for node in legacy["nodes"]) + for node in legacy["nodes"]: + node.pop("_rust_impl_key", None) + graph_path.write_text(json.dumps(legacy), encoding="utf-8") + + caller = corpus / "caller.rs" + caller.write_text( + _RUST_GENERIC_CALLER % " let marker = 1;\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert not _rust_generic_call(_2406_graph(corpus)) + + state = corpus / "state.rs" + method = corpus / "method.rs" + state.write_text(_RUST_GENERIC_STATE + "// refreshed\n", encoding="utf-8") + method.write_text(_RUST_GENERIC_METHOD + "// refreshed\n", encoding="utf-8") + assert _rebuild_code( + corpus, + changed_paths=[state, method, caller], + no_cluster=True, + acquire_lock=False, + ) is True + assert _rust_generic_call(_2406_graph(corpus)) + + +def test_incremental_rust_generic_self_call_keeps_module_ambiguity(tmp_path): + """A collapsed same-file declaration count survives context projection.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "state.rs").write_text( + "pub mod a { pub struct Bucket(pub T); }\n" + "pub mod b { pub struct Bucket(pub T); }\n", + encoding="utf-8", + ) + (corpus / "a_impl.rs").write_text( + "impl Bucket { pub fn fetch_value(&self) {} }\n", + encoding="utf-8", + ) + (corpus / "fallback.rs").write_text( + "pub trait Fallback { fn fetch_value(&self) {} }\n", + encoding="utf-8", + ) + caller = corpus / "b_impl.rs" + caller.write_text( + "impl Fallback for Bucket {}\n" + "impl Bucket { pub fn run(&self) { self.fetch_value(); } }\n", + encoding="utf-8", + ) + + def has_call(): + graph = _2406_graph(corpus) + run_id = _2406_nid(graph, ".run()", "b_impl.rs") + return any( + edge.get("relation") == "calls" and edge.get("source") == run_id + for edge in graph.get("links", graph.get("edges", [])) + ) + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + assert not has_call() + caller.write_text( + "impl Fallback for Bucket {}\n" + "impl Bucket {\n" + " pub fn run(&self) { let marker = 1; self.fetch_value(); }\n" + "}\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert not has_call() + + # --- #3567: inherited Ruby calls into unchanged ancestry --------------------