diff --git a/graphify/cache.py b/graphify/cache.py index 83d3ecf3c..232454175 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -37,7 +37,7 @@ except Exception: _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 15 # Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. +_AST_CACHE_SCHEMA = 16 # Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); prior (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids. # Per-process memo: distributions() scans site-packages metadata, which is diff --git a/graphify/extract.py b/graphify/extract.py index 6f08bb7ed..18a63fd47 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9933,7 +9933,22 @@ def extract( except Exception as exc: import logging logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc) - _rewire_unique_stub_nodes(all_nodes, all_edges) + # An incremental build re-extracts only the changed files, so the rewire also + # needs the unchanged ones as candidates, or it resolves less than a full build. + _fresh_ids = {n.get("id") for n in all_nodes} + # #4439: a Rust `use` reached its declaration only when a type annotation had left + # a sourceless node under the same id. Lend the rewire one for each imported name + # that has none; they are not kept, so an unresolved import dangles as before. + _use_stubs = { + e["target"]: {"id": e["target"], "label": e["_rust_use_name"], "file_type": "code", "source_file": ""} + for e in all_edges + if e.get("_rust_use_name", "").isidentifier() and e["target"] not in _fresh_ids + } + _rewire_view = all_nodes + list(_use_stubs.values()) + [ + n for n in resolution_context_nodes or [] if n.get("id") not in _fresh_ids] + _rewire_unique_stub_nodes(_rewire_view, all_edges) + _kept = {id(n) for n in _rewire_view} - {id(n) for n in _use_stubs.values()} + all_nodes[:] = [n for n in all_nodes if id(n) in _kept] # Cross-file Java import resolution java_paths = [p for p in paths if effective_suffix(p) == ".java"] @@ -10561,6 +10576,7 @@ def extract( all_edges[:] = [e for e in all_edges if not e.pop("_rust_use_group", False)] for e in all_edges: e.pop("_rust_use_binding", None) + e.pop("_rust_use_name", None) # Relativize source_file fields so paths are portable across machines (#555). # When the node's id was itself minted from the absolute path, remap it to a diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index e99f63410..aae59c67d 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -721,17 +721,24 @@ def extract_rust(path: Path) -> dict: if t == "use_declaration": arg = node.child_by_field_name("argument") if arg: + def name_use(written: str, path: str) -> None: + # The name as written, for the corpus rewire (#4439): the id folds its + # case. Not for the standard library, whose `File` is never the corpus's. + if path.lstrip(": ").split("::")[0].strip() not in ("std", "core", "alloc"): + edges[-1]["_rust_use_name"] = written.split(" as ")[0].strip() module_name = _rust_use_module(_read_text(arg, source)) grouped = arg.type in ("use_list", "scoped_use_list") if module_name: tgt_nid = _make_id(module_name) add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import") + name_use(module_name, _read_text(arg, source)) if grouped: # call evidence as in v8, then dropped (extract.py) edges[-1]["_rust_use_group"] = True # A group imports what the plain `use` of each of its bindings would. for binding, line in _rust_use_bindings(arg, source) if grouped else []: if binding_name := _rust_use_module(binding): add_edge(file_nid, _make_id(binding_name), "imports_from", line, context="import") + name_use(binding_name, binding) edges[-1]["_rust_use_binding"] = True # not call evidence: v8 had no such edge return diff --git a/tests/test_rust_use_trees.py b/tests/test_rust_use_trees.py index 5dc040ab7..e3f10e0b0 100644 --- a/tests/test_rust_use_trees.py +++ b/tests/test_rust_use_trees.py @@ -102,11 +102,12 @@ def test_each_binding_gets_the_plain_use_target(tmp_path: Path): edges = sorted((e["relation"], e["target"], e["source_location"]) for e in result["edges"] if e.get("context") == "import") assert edges == sorted([ - ("imports_from", "util", "L1"), ("imports_from", "parse", "L1"), ("imports_from", "render_as_draw", "L1"), + # `parse` and `render` are declared once in the corpus, so they land there (#4439) + ("imports_from", "util", "L1"), ("imports_from", "src_util_parse", "L1"), ("imports_from", "src_util_render", "L1"), ("imports_from", "deep", "L1"), ("imports_from", "nested", "L1"), ("imports_from", "b", "L2"), ("imports_from", "c", "L2"), # plain declarations, exactly as v8 emits them - ("imports_from", "parse", "L3"), ("imports_from", "read", "L4"), ("imports_from", "bar_as_baz", "L5"), + ("imports_from", "src_util_parse", "L3"), ("imports_from", "read", "L4"), ("imports_from", "bar_as_baz", "L5"), ("imports_from", "util", "L6"), ("imports_from", "fmt", "L7")]) sources = {e["source"] for e in result["edges"] if e.get("context") == "import"} file_node = next(n["id"] for n in result["nodes"] if n.get("label") == "user.rs") @@ -120,7 +121,7 @@ def test_a_file_whose_only_use_is_a_root_group_gets_edges_from_its_file_node(tmp result = _extract(tmp_path, {**_FILES, "src/user.rs": "use {crate::util::parse, std::io};\n"}) file_node = next(n["id"] for n in result["nodes"] if n.get("label") == "user.rs") assert sorted((e["source"], e["target"]) for e in result["edges"] if e.get("context") == "import") == [ - (file_node, "io"), (file_node, "parse")] + (file_node, "io"), (file_node, "src_util_parse")] # Files at the scan root, so file node ids are bare stems: the binding `util` @@ -206,4 +207,37 @@ def test_a_cache_entry_from_before_the_bindings_is_not_served(tmp_path: Path, mo result = extract(sorted(tmp_path / name for name in files), root=tmp_path, cache_root=tmp_path / "graphify-out", parallel=False) assert sorted(e["target"] for e in result["edges"] - if e.get("context") == "import" and e["source_file"].endswith("user.rs")) == ["parse", "render"] + if e.get("context") == "import" and e["source_file"].endswith("user.rs")) == ["src_util_parse", "src_util_render"] + + +def _imports(result: dict, name: str) -> list[str]: + return sorted(e["target"] for e in result["edges"] + if e.get("context") == "import" and e["source_file"].endswith(name)) + + +def test_a_use_lands_on_the_declaration_without_an_annotation(tmp_path: Path): + """#4439: the import reached `Invoice` only when a type annotation left a + sourceless node for the rewire. Constructing the type left the edge on a + bare `invoice`, which build then minted as an external stub tied with it.""" + result = _extract(tmp_path, { + "src/lib.rs": "pub mod billing;\npub mod model;\n", "src/model.rs": "pub struct Invoice(pub u64);\n", + "src/billing.rs": "use crate::model::Invoice;\nuse std::fmt;\n\npub fn make() -> u64 {\n Invoice(1).0\n}\n"}) + invoice = next(n["id"] for n in result["nodes"] if n["label"] == "Invoice") + # `fmt` has no declaration here: it stays a dangling target, with no node left behind. + assert _imports(result, "billing.rs") == sorted(["fmt", invoice]) + assert [n["label"] for n in result["nodes"] if not n.get("source_file")] == [] + assert not any(k.startswith("_rust_use") for n in result["nodes"] for k in n) + + +def test_a_use_of_a_name_declared_twice_stays_unresolved(tmp_path: Path): + result = _extract(tmp_path, { + "src/lib.rs": "pub mod a;\npub mod b;\npub mod user;\n", "src/a.rs": "pub struct Thing;\n", + "src/b.rs": "pub struct Thing;\n", "src/user.rs": "use crate::a::Thing;\n"}) + assert _imports(result, "user.rs") == ["thing"] + + +def test_a_std_use_does_not_land_on_a_same_named_declaration(tmp_path: Path): + result = _extract(tmp_path, { + "src/lib.rs": "pub mod a;\npub mod user;\n", "src/a.rs": "pub struct File;\npub struct Command;\n", + "src/user.rs": "use std::fs::File;\nuse std::{process::Command, io};\n"}) + assert _imports(result, "user.rs") == ["command", "file", "io"]