From 0820968618adf92ef76125777f167d2d3ea383b6 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Fri, 28 Aug 2026 10:10:34 -0400 Subject: [PATCH 01/25] fix: Parse Svelte script blocks with the SFC masker Feeding a whole .svelte component to the JS grammar makes the markup a top-level ERROR node, so import_statement and declaration nodes are never reached. Every component extracted as a stub: a regex pass rescued import specifiers and nothing else, no symbols and no participation in the call graph. On a SvelteKit codebase this covered 613 files. Apply the treatment .vue already gets. The Vue script masker is renamed _sfc_mask_non_script and shared (the old name stays as an alias); both extractors blank the non-script regions, keeping newlines so line numbers stay accurate, and parse the script with the grammar its lang implies. Every script block survives the mask, so a Svelte 5 close tag pos = m.end() if lang is None: - lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1)) + lang_m = _SFC_SCRIPT_LANG_RE.search(m.group(1)) if lang_m: lang = lang_m.group(1).lower() out.append(_blank(src[pos:])) return "".join(out), lang +_vue_mask_non_script = _sfc_mask_non_script + +# Single-file-component suffixes whose script blocks need masking before a +# JS/TS grammar can parse them. +_SFC_SUFFIXES = (".vue", ".svelte") + def _source_key(source_file: str, root: Path) -> str: if not source_file: return "" @@ -1104,18 +1118,19 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup def _parse_js_tree(path: Path): try: from tree_sitter import Language, Parser - # .vue embeds the script in non-JS markup; mask it out and parse the - # \n" + "\n" + "
{msg}
\n" + "\n" + "\n" + ) + masked, lang = _sfc_mask_non_script(src) + assert lang == "ts" + # Same number of lines (newlines preserved) so line numbers are stable. + assert masked.count("\n") == src.count("\n") + # Markup and style are gone; the script body survives verbatim. + assert "div" not in masked + assert "color: red" not in masked + assert "const msg = 'hi'" in masked + # The script body sits on the same line it does in the source (line 2). + assert masked.splitlines()[1].strip() == "const msg = 'hi'" + + +def test_static_imports_resolve(tmp_path): + _write(tmp_path / "Icon.svelte", "\n") + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Card.svelte", + '\n" + "\n" + "{fmt('x')}\n", + ) + result = extract_svelte(component) + targets = _targets(result, relation="imports_from") + assert _make_id(str(tmp_path / "Icon.svelte")) in targets + # Extensionless specifier probes real on-disk extensions (./format -> .ts). + assert _make_id(str(tmp_path / "format.ts")) in targets + + +def test_symbols_extracted_with_correct_lines(tmp_path): + component = _write( + tmp_path / "Band.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + labels = _labels(result) + assert "Level" in labels + assert "toggle()" in labels + # Masking keeps newlines, so reported lines match the real source lines. + lines = { + str(n.get("label")): n.get("line") + for n in result.get("nodes", []) + if n.get("line") is not None + } + if "toggle()" in lines: + assert lines["toggle()"] == 4 + + +def test_module_and_instance_scripts_both_parsed(tmp_path): + """Svelte 5 ``\n" + "\n" + '\n" + "\n" + "

hi

\n", + ) + result = extract_svelte(component) + targets = _targets(result, relation="imports_from") + assert _make_id(str(tmp_path / "shared.ts")) in targets + assert _make_id(str(tmp_path / "local.ts")) in targets + labels = _labels(result) + assert "helper()" in labels + assert "render()" in labels + + +def test_svelte_4_context_module_script_parsed(tmp_path): + """Svelte 4 spells the module block ``context="module"``.""" + _write(tmp_path / "shared.ts", "export const shared = 1\n") + component = _write( + tmp_path / "Legacy.svelte", + '\n" + "\n" + "

hi

\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "shared.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_dynamic_import_in_template_recovered(tmp_path): + """``{#await import('./X.svelte')}`` lives in markup the mask blanks out, + so the regex pass must scan the raw source, not the masked one.""" + _write(tmp_path / "Heavy.svelte", "\n") + component = _write( + tmp_path / "Lazy.svelte", + "\n" + "\n" + "{#await import('./Heavy.svelte') then Mod}\n" + " \n" + "{/await}\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "Heavy.svelte")) in _targets( + result, relation="dynamic_import" + ) + + +def test_typed_props_reference_imported_type(tmp_path): + _write(tmp_path / "types.ts", "export type Risk = { id: string }\n") + component = _write( + tmp_path / "RiskCard.svelte", + '\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "types.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_plain_js_script_block(tmp_path): + """No ``lang`` attribute: the TS grammar is a superset, so JS still parses.""" + _write(tmp_path / "util.js", "export const u = 1\n") + component = _write( + tmp_path / "Plain.svelte", + "\n" + "\n" + "{go()}\n", + ) + result = extract_svelte(component) + assert _make_id(str(tmp_path / "util.js")) in _targets( + result, relation="imports_from" + ) + assert "go()" in _labels(result) + + +def test_markup_only_file_does_not_crash(tmp_path): + component = _write(tmp_path / "Static.svelte", "

hello

\n") + result = extract_svelte(component) + assert isinstance(result.get("nodes"), list) + assert isinstance(result.get("edges"), list) + + +def test_runes_do_not_break_the_ts_grammar(tmp_path): + """Svelte 5 runes (``$state``/``$derived``/``$props``) are syntactically + ordinary calls, so the TS grammar walks past them to the real symbols.""" + component = _write( + tmp_path / "Runes.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + assert "bump()" in _labels(result) + + +def test_whole_file_to_js_grammar_would_extract_nothing(tmp_path): + """Regression guard for #713: the unmasked path loses everything but the + file node, which is what made every .svelte file a stub in the graph.""" + from graphify.extract import _JS_CONFIG, _extract_generic + + component = _write( + tmp_path / "Guard.svelte", + '\n" + "\n" + "
{visible()}
\n" + "\n" + "\n", + ) + unmasked = _extract_generic(component, _JS_CONFIG) + assert "visible()" not in _labels(unmasked) + + masked = extract_svelte(component) + assert "visible()" in _labels(masked) + + +def test_svelte_joins_cross_file_symbol_resolution(tmp_path): + """A ``.svelte`` calling an imported function wires to the real symbol across + files, so the component is a participant in the call graph rather than a + leaf stub. Exercises the masked ``_parse_js_tree`` path. + """ + helper = _write(tmp_path / "helper.ts", "export function helper() {}\n") + comp = _write( + tmp_path / "Caller.svelte", + ''' + + +''', + ) + result = extract([comp, helper], cache_root=tmp_path) + by_label = {n["label"]: n["id"] for n in result["nodes"]} + edges = {(e["source"], e["target"], e["relation"]) for e in result["edges"]} + assert (by_label["go()"], by_label["helper()"], "calls") in edges From 42acff9d90df7d98c483e69f518210abc9a95883 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Fri, 28 Aug 2026 10:10:34 -0400 Subject: [PATCH 02/25] fix: Node destructured bindings, not the pattern text A destructuring declarator binds identifiers, but its name field is the pattern source. Reading that field verbatim minted one node labelled { a, b: renamed, c = 1, ...rest } -- text that names no symbol and can never be a reference target. Svelte 5 makes the shape universal, since every component destructures $props(), but the declarator is ordinary JS/TS and the junk node appeared for both. Walk the binding side instead: a pair_pattern's value (b: renamed binds renamed, not the key b), an assignment pattern's left operand (c = $bindable() binds c, not $bindable), recursing through nested patterns and rest elements. A destructured require is excluded. const { doWork } = require('./lib') binds an import, not a local definition, and noding it shadowed the real cross-file target so the call resolved to a local stub. The exclusion tests the callee name: _find_require_call matches the call shape only -- any identifier(...) -- and leaves the name check to its callers. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/engine.py | 105 +++++++++++++++++++- tests/test_js_exported_scalar_bindings.py | 116 ++++++++++++++++++++++ 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3c8a0238d..75694ce7c 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1912,6 +1912,20 @@ def _find_require_call(value_node): return _find_require_call(obj) return None +def _is_require_initializer(value_node, source: bytes) -> bool: + """True when a declarator's initializer is a literal ``require(...)`` call. + + ``_find_require_call`` matches the call *shape* only — any + ``identifier(...)`` — and leaves the callee-name check to its callers, so + it must not be used alone to recognise a CJS import. + """ + call = _find_require_call(value_node) + if call is None: + return False + fn = call.child_by_field_name("function") + return fn is not None and _read_text(fn, source) == "require" + + def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges: list, str_path: str) -> bool: """Detect CommonJS require imports inside lexical_declaration / variable_declaration. @@ -2130,6 +2144,61 @@ def _js_member_assignment_target(left, source: bytes): return ("prototype", inner_obj_name, member_name) return None +_JS_PATTERN_TYPES = frozenset({"object_pattern", "array_pattern"}) + + +def _js_pattern_bound_names(name_node, source: bytes) -> list[str]: + """Return the identifiers a destructuring declarator actually binds. + + ``const { a, b: renamed, c = 1, ...rest } = x`` binds ``a``, ``renamed``, + ``c`` and ``rest`` — not the text of the pattern. Reading the declarator's + ``name`` field verbatim instead mints one node labelled with the whole + pattern source (``{ a, b: renamed, c = 1, ...rest }``), which names no + symbol and can never be the target of a reference. Svelte 5 makes the shape + universal — every component destructures ``$props()`` — but the same + declarator shape is ordinary JS/TS. + + Walks only the binding side: a ``pair_pattern``'s value (``b: renamed`` + binds ``renamed``, not the property key ``b``) and an assignment pattern's + left operand (``c = $bindable()`` binds ``c``, not ``$bindable``). Nested + patterns recurse, so ``{ deep: { inner } }`` binds ``inner``. Returns an + empty list for a non-pattern node. + """ + if name_node is None or name_node.type not in _JS_PATTERN_TYPES: + return [] + names: list[str] = [] + + def visit(node) -> None: + t = node.type + if t in ("identifier", "shorthand_property_identifier_pattern"): + text = _read_text(node, source) + if text and text not in names: + names.append(text) + return + if t == "pair_pattern": + # `key: target` — only the value side is bound. + value = node.child_by_field_name("value") + if value is not None: + visit(value) + return + if t in ("object_assignment_pattern", "assignment_pattern"): + # `target = default` — the default is an expression, not a binding. + left = node.child_by_field_name("left") + if left is None: + left = node.named_children[0] if node.named_children else None + if left is not None: + visit(left) + return + if t in ("rest_pattern", "object_pattern", "array_pattern"): + for child in node.named_children: + visit(child) + return + # Anything else (type annotations, holes in `[a, , c]`) binds nothing. + + visit(name_node) + return names + + def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, @@ -2303,13 +2372,39 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, ): # Simple exported identifiers are part of the module API # regardless of initializer shape. Keep other scalar noise suppressed. + const_nid = None if name_node: - const_name = _read_text(name_node, source) line = child.start_point[0] + 1 - const_nid = _make_id(stem, const_name) - add_node_fn(const_nid, const_name, line) - add_edge_fn(file_nid, const_nid, "contains", line) - const_found = True + # A destructuring declarator binds several names and + # its `name` field is the pattern source, not a + # symbol — node each identifier it actually binds. + const_names = _js_pattern_bound_names(name_node, source) + if const_names and _is_require_initializer(value, source): + # `const { doWork } = require('./lib')` binds an + # IMPORT, not a local definition. `_require_imports_js` + # already edges those names at the file level; noding + # them here would shadow the real cross-file target, + # so a call to `doWork()` would resolve to this file's + # stub instead of the callee's definition. + const_names = [] + elif not const_names: + const_names = [_read_text(name_node, source)] + for const_name in const_names: + # A name that normalizes to nothing would collapse + # the id to the absolute file-stem and leak the + # scan path (#1899); skip it, as the arrow branch does. + if not const_name or not normalize_id(const_name): + continue + nid = _make_id(stem, const_name) + add_node_fn(nid, const_name, line) + add_edge_fn(file_nid, nid, "contains", line) + const_found = True + if const_nid is None: + # Closures in the initializer are attributed to + # the first binding; a destructured initializer + # has no single owning symbol. + const_nid = nid + if const_nid is not None: # #2552: `const handler = wrapper(async (req) => …)` # created the const node above but, unlike the arrow # branch, never tracked the callback's body — so diff --git a/tests/test_js_exported_scalar_bindings.py b/tests/test_js_exported_scalar_bindings.py index 7b6ca9681..1444627f0 100644 --- a/tests/test_js_exported_scalar_bindings.py +++ b/tests/test_js_exported_scalar_bindings.py @@ -94,3 +94,119 @@ def test_exported_scalar_binding_satisfies_named_import_target(tmp_path): assert import_targets assert import_targets <= node_ids + + +def test_destructuring_declarator_nodes_bound_names_not_pattern_text(tmp_path): + """A destructuring declarator binds identifiers; its ``name`` field is the + pattern source. Reading that field verbatim minted a node labelled + ``{ a, b: renamed, c = 1, ...rest }`` — text that names no symbol and can + never be a reference target.""" + source = tmp_path / "patterns.ts" + source.write_text( + """ +type Cfg = { a: number; b: number; c: number; deep: { inner: number } }; +const { a, b: renamed, c = 1, ...rest } = obj as Cfg; +const [first, , third] = arr as number[]; +const { deep: { inner } } = obj as Cfg; +""", + encoding="utf-8", + ) + + result = extract_js(source) + labels = {node["label"] for node in result["nodes"]} + + # Each bound identifier is its own node. + assert {"a", "renamed", "c", "rest", "first", "third", "inner"} <= labels + # The pattern source is never a label. + assert not any(label.startswith(("{", "[")) for label in labels) + # `b` is a property key, not a binding — `b: renamed` binds only `renamed`. + assert "b" not in labels + # `deep` is a key too; the nested pattern binds `inner`. + assert "deep" not in labels + # Array holes bind nothing and must not mint an empty-labelled node. + assert "" not in labels + + +def test_destructured_rune_props_do_not_mint_a_pattern_node(tmp_path): + """The shape that made this universal: every Svelte 5 component + destructures ``$props()``, so the pattern text was one junk node per + component.""" + source = tmp_path / "props.ts" + source.write_text( + """ +let { levels, selected = $bindable(), ariaLabel, disabled = false } = $props(); +""", + encoding="utf-8", + ) + + result = extract_js(source) + labels = {node["label"] for node in result["nodes"]} + + assert {"levels", "selected", "ariaLabel", "disabled"} <= labels + # The default expressions are not bindings. + assert "$bindable" not in labels + assert not any(label.startswith("{") for label in labels) + + +def test_destructuring_initializer_closures_still_tracked(tmp_path): + """Closures in a destructured initializer are attributed to the first + binding, so their calls are still walked (#2552 behaviour preserved).""" + source = tmp_path / "closures.ts" + source.write_text( + """ +function target() {} +const { handler } = wrapper(() => { target(); }); +""", + encoding="utf-8", + ) + + result = extract_js(source) + labels = {node["label"] for node in result["nodes"]} + assert "handler" in labels + by_label = {n["label"]: n["id"] for n in result["nodes"]} + calls = { + (e["source"], e["target"]) + for e in result["edges"] + if e.get("relation") == "calls" + } + assert (by_label["handler"], by_label["target()"]) in calls + + +def test_destructured_require_binds_the_import_not_a_local_node(tmp_path): + """``const { doWork } = require('./lib')`` binds an import. Noding it as a + local symbol would shadow the real cross-file definition, so the call in + ``run()`` would resolve to this file's stub instead of ``lib.js``.""" + caller = tmp_path / "caller.js" + callee = tmp_path / "lib.js" + caller.write_text( + "const { doWork } = require('./lib');\n" + "function run() { doWork(); }\n", + encoding="utf-8", + ) + callee.write_text( + "function doWork() { return 1; }\n" + "module.exports = { doWork };\n", + encoding="utf-8", + ) + + result = extract([caller, callee], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + calls = [ + e for e in result["edges"] + if e["relation"] == "calls" + and nodes[e["source"]]["label"] == "run()" + and nodes[e["target"]]["label"] == "doWork()" + ] + assert len(calls) == 1 + # The call resolves into the callee's file, not a local stub. + assert nodes[calls[0]["target"]]["source_file"].endswith("lib.js") + + +def test_non_require_call_initializer_still_binds(tmp_path): + """The require exclusion keys on the callee name, not on the call shape — + any other ``identifier(...)`` initializer still binds its names.""" + source = tmp_path / "runes.ts" + source.write_text("let { levels, disabled } = $props();\n", encoding="utf-8") + + labels = {n["label"] for n in extract_js(source)["nodes"]} + assert {"levels", "disabled"} <= labels From c4d6b3b388be3831f950e31ef44c9920fb682c7d Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Fri, 28 Aug 2026 10:10:34 -0400 Subject: [PATCH 03/25] fix: Resolve Rust use declarations through the AST The use_declaration branch read a declaration by string-splitting its text: everything before the first brace, then the last :: segment. A braced list collapsed to its shared prefix and every name inside it was lost; an as clause was never parsed, leaving the alias glued to the symbol (Entity as Risk); and the resulting id was a bare name no node carried, so the edge dangled and was dropped at build time. That last part made a crate prelude a false hub rather than a dead end. Every external prelude import in a codebase -- use sea_orm::entity:: prelude::*, use loco_rs::prelude::* -- stripped to the segment prelude, whose id collided with a local prelude.rs file node. One SeaORM model tree showed 466 inbound edges that were all spurious, 0 outbound, and 75 generated entity modules orphaned from the code using them. Walk the use tree instead. _rust_use_leaves flattens a declaration into one leaf per bound name, handling use_as_clause, use_list, scoped_use_list, use_wildcard and arbitrary nesting. _resolve_rust_use_path resolves crate/super/self-anchored paths through the module tree to a file on disk, encoding that mod.rs, lib.rs and main.rs are their module while foo.rs keeps its children in a sibling foo/. A use path's trailing segment is usually a symbol, so the tail is retried as a symbol inside the module the rest resolves to. Each leaf emits a file-level imports_from plus a symbol-level edge, both stamped with target_file so the shared canonicalization repoints them. pub use emits re_exports, so the corpus-level barrel collapse follows a consumer through a prelude to the defining symbol the way it already does for a JS barrel. Paths that resolve to nothing -- external crates -- get a sourceless stub rather than a phantom target. The extractor's own edge filter allowed dangling targets for imports and imports_from but not re_exports, dropping the new edges before the corpus pass saw them; it now matches the shared engine filter. A renamed re-export still does not chain symbol-level: the collapse keys on a name derived from the target id, with nowhere to record the alias. The file-level edges resolve either way. JS has the same limitation. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 252 +++++++++++++++++++++++++++- tests/test_rust_use_reexports.py | 280 +++++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 tests/test_rust_use_reexports.py diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index b663bd625..4adc93ba7 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -58,6 +58,187 @@ def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[ "ok", "err", "some", "none", "send", "recv", "lock", "read", "write", }) +# Rust module roots: a file that IS its module (rather than a child of one). +_RUST_MODULE_ROOT_FILES = ("mod.rs", "lib.rs", "main.rs") + + +def _rust_path_segments(node, source: bytes) -> list[str]: + """Flatten a use-path node into its segments. + + ``crate::models::prelude`` parses as nested ``scoped_identifier``s; the + leading ``crate``/``super``/``self`` keywords are their own node types, not + ``identifier``, so read text rather than filtering on type. + """ + if node is None: + return [] + if node.type == "scoped_identifier": + segments: list[str] = [] + for child in node.children: + if child.type == "::": + continue + segments.extend(_rust_path_segments(child, source)) + return segments + text = _read_text(node, source).strip() + return [text] if text else [] + + +def _rust_use_leaves(node, source: bytes, prefix: tuple[str, ...] = ()) -> list[tuple[tuple[str, ...], str | None, bool]]: + """Flatten a ``use`` tree into ``(path_segments, alias, is_wildcard)`` leaves. + + One declaration can bind many names — ``use crate::x::{a, b::C, d as D}`` + is three leaves, and lists nest arbitrarily. The previous string-splitting + approach took everything before the first ``{`` and kept the last ``::`` + segment, so a braced list collapsed to a single edge naming the shared + prefix and every name inside it was lost. ``use_as_clause`` was not parsed + at all, leaving the alias glued to the symbol (``Entity as Risk``). + """ + if node is None: + return [] + t = node.type + if t == "use_as_clause": + path_node = node.child_by_field_name("path") + alias_node = node.child_by_field_name("alias") + if path_node is None or alias_node is None: + named = [c for c in node.named_children] + path_node = path_node or (named[0] if named else None) + alias_node = alias_node or (named[1] if len(named) > 1 else None) + segments = tuple(prefix) + tuple(_rust_path_segments(path_node, source)) + alias = _read_text(alias_node, source).strip() if alias_node is not None else None + return [(segments, alias or None, False)] if segments else [] + if t == "use_wildcard": + segments: tuple[str, ...] = tuple(prefix) + for child in node.children: + if child.type in ("::", "*"): + continue + segments = segments + tuple(_rust_path_segments(child, source)) + return [(segments, None, True)] if segments else [] + if t == "use_list": + leaves: list[tuple[tuple[str, ...], str | None, bool]] = [] + for child in node.named_children: + leaves.extend(_rust_use_leaves(child, source, prefix)) + return leaves + if t == "scoped_use_list": + inner_prefix = tuple(prefix) + list_node = None + for child in node.children: + if child.type == "::": + continue + if child.type == "use_list": + list_node = child + else: + inner_prefix = inner_prefix + tuple(_rust_path_segments(child, source)) + return _rust_use_leaves(list_node, source, inner_prefix) if list_node else [] + if t in ("scoped_identifier", "identifier", "crate", "super", "self", "metavariable"): + segments = tuple(prefix) + tuple(_rust_path_segments(node, source)) + return [(segments, None, False)] if segments else [] + return [] + + +def _rust_crate_src_root(path: Path) -> Path | None: + """The ``src`` directory of the crate owning ``path``, if there is one.""" + probe = path.parent + while True: + if (probe / "Cargo.toml").is_file(): + src = probe / "src" + return src if src.is_dir() else probe + if probe.parent == probe: + return None + probe = probe.parent + + +def _rust_module_file(directory: Path, name: str) -> Path | None: + """Resolve one module segment inside ``directory``: ``name.rs`` or ``name/mod.rs``.""" + candidate = directory / f"{name}.rs" + if candidate.is_file(): + return candidate + candidate = directory / name / "mod.rs" + if candidate.is_file(): + return candidate + return None + + +def _rust_module_dirs(path: Path) -> tuple[Path, Path]: + """Return ``(self_dir, super_dir)`` for the module ``path`` defines. + + ``mod.rs``/``lib.rs``/``main.rs`` ARE their module, so their own directory + holds their children and the parent directory is ``super``. Any other file + ``foo.rs`` is a module whose children live in a sibling ``foo/`` directory, + and whose ``super`` is the directory it sits in. + """ + if path.name in _RUST_MODULE_ROOT_FILES: + return path.parent, path.parent.parent + sibling = path.parent / path.stem + return (sibling if sibling.is_dir() else path.parent), path.parent + + +def _resolve_rust_use_path( + segments: tuple[str, ...], path: Path +) -> "tuple[Path, str | None] | None": + """Resolve a ``use`` path to ``(module_file, symbol_name)`` on disk. + + Walks ``crate``/``super``/``self``-anchored paths through the crate's module + tree. The trailing segment of a ``use`` is usually a SYMBOL rather than a + module (``crate::models::prelude::Risk``), so when the full path does not + name a file the last segment is retried as a symbol inside the module the + rest resolves to. Returns ``None`` for anything not on disk — an external + crate (``std``, ``sea_orm``) or a path this resolver cannot follow. + """ + if not segments: + return None + self_dir, super_dir = _rust_module_dirs(path) + src_root = _rust_crate_src_root(path) + + index = 0 + anchor: Path | None = None + first = segments[0] + if first == "crate": + anchor, index = src_root, 1 + elif first == "self": + anchor, index = self_dir, 1 + elif first == "super": + anchor, index = super_dir, 1 + # `super::super::x` walks further up one directory per keyword. + while index < len(segments) and segments[index] == "super": + anchor = anchor.parent if anchor is not None else None + index += 1 + if anchor is None and first not in ("crate", "self", "super"): + # 2018-edition paths may be crate-relative without the `crate` prefix; + # try the crate root, then the current module. An external crate simply + # resolves to nothing at either. + for candidate_anchor in (src_root, self_dir): + if candidate_anchor is None: + continue + resolved = _walk_rust_segments(candidate_anchor, segments) + if resolved is not None: + return resolved + return None + if anchor is None: + return None + return _walk_rust_segments(anchor, segments[index:]) + + +def _walk_rust_segments( + anchor: Path, segments: tuple[str, ...] +) -> "tuple[Path, str | None] | None": + """Walk module segments from ``anchor``; the tail may name a symbol.""" + if not segments: + return None + directory = anchor + resolved: Path | None = None + for position, segment in enumerate(segments): + found = _rust_module_file(directory, segment) + if found is None: + # Not a module. If everything before it resolved, the remainder is a + # symbol path inside that module (`…::prelude::Risk` -> `Risk`), and + # only a single trailing segment is a name we can attribute. + if resolved is not None and position == len(segments) - 1: + return resolved, segment + return None + resolved = found + directory = found.parent if found.name == "mod.rs" else found.parent / found.stem + return (resolved, None) if resolved is not None else None + + def extract_rust(path: Path) -> dict: """Extract functions, structs, enums, traits, impl methods, and use declarations from a .rs file.""" try: @@ -160,6 +341,55 @@ 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 emit_use_leaf(segments, alias, is_wildcard: bool, is_reexport: bool, line: int) -> None: + """Emit the edges for one resolved leaf of a ``use`` declaration. + + Two edges where the path resolves on disk: a file-level ``imports_from`` + so the module graph connects, and a symbol-level edge stamped with + ``target_file`` so the shared canonicalization repoints it at the real + definition. Unresolved paths (external crates) get a sourceless stub + rather than a bare-name target — the old code emitted an id no node ever + carried, so the edge dangled and was dropped at build time, which is why + a prelude showed inbound edges and no outbound ones. + """ + if not segments: + return + resolution = _resolve_rust_use_path(tuple(segments), path) + if resolution is None: + # External crate or unresolvable path. Mint a sourceless stub for the + # leaf name so the edge has a real endpoint and the corpus-level + # rewire can still collapse it onto a definition if one shows up. + name = alias or segments[-1] + if not is_wildcard and name: + add_edge(file_nid, ensure_named_node(name, line), "imports_from", + line, context="import") + return + module_file, symbol = resolution + module_nid = _make_id(str(module_file)) + file_edge = { + "source": file_nid, "target": module_nid, "relation": "imports_from", + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0, "context": "import", + "target_file": str(module_file), + } + edges.append(file_edge) + if symbol is None or is_wildcard: + # `use super::risk;` names the module itself, and a glob re-export + # (`pub use super::risk::*;`) publishes an unknown set of names — + # neither identifies one symbol to point at. + return + # Build the id the DEFINING file gives its own symbols (`_make_id(stem, + # name)`), so this edge lands on that node rather than a look-alike the + # corpus rewire has to guess at. + symbol_nid = _make_id(_file_stem(module_file), symbol) + edges.append({ + "source": file_nid, "target": symbol_nid, + "relation": "re_exports" if is_reexport else "imports", + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0, + "target_file": str(module_file), + }) + def walk(node, parent_impl_nid: str | None = None) -> None: t = node.type @@ -323,12 +553,16 @@ def _emit_enum_type(type_node, at_line): if t == "use_declaration": arg = node.child_by_field_name("argument") if arg: - raw = _read_text(arg, source) - clean = raw.split("{")[0].rstrip(":").rstrip("*").rstrip(":") - module_name = clean.split("::")[-1].strip() - if module_name: - tgt_nid = _make_id(module_name) - add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import") + line = node.start_point[0] + 1 + # `pub use` is a RE-EXPORT: the module publishes someone else's + # symbol under its own path. The corpus-level barrel collapse + # keys on this relation to follow consumers through to the + # defining file, so a prelude stops being a dead end. + is_reexport = any( + child.type == "visibility_modifier" for child in node.children + ) + for segments, alias, is_wildcard in _rust_use_leaves(arg, source): + emit_use_leaf(segments, alias, is_wildcard, is_reexport, line) return for child in node.children: @@ -404,7 +638,11 @@ def walk_calls(node, caller_nid: str) -> None: clean_edges = [] for edge in edges: src, tgt = edge["source"], edge["target"] - if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + # A cross-file import target is not a node this file owns, so these + # relations are allowed to point outside `valid_ids` and are resolved + # corpus-wide later. `re_exports` belongs with them — a barrel names a + # symbol it does not define — and matches the shared engine filter. + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")): clean_edges.append(edge) return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py new file mode 100644 index 000000000..ca27b07ef --- /dev/null +++ b/tests/test_rust_use_reexports.py @@ -0,0 +1,280 @@ +"""Tests for Rust ``use`` declaration resolution. + +The extractor used to read a ``use`` declaration by string-splitting its text: +everything before the first ``{``, then the last ``::`` segment. That collapsed +a braced list to its shared prefix, glued an alias to its symbol +(``Entity as Risk``), and pointed every edge at a bare-name id no node ever +carried — so the edge dangled and was dropped at build time. A crate's prelude +therefore showed inbound edges and no outbound ones, orphaning every module it +re-exported. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract +from graphify.extractors.rust import ( + _resolve_rust_use_path, + _rust_module_dirs, + _rust_use_leaves, +) + + +def _leaves(body: str): + from tree_sitter import Language, Parser + + import tree_sitter_rust as tsrust + + source = body.encode("utf-8") + root = Parser(Language(tsrust.language())).parse(source).root_node + out = [] + for decl in root.children: + if decl.type != "use_declaration": + continue + out.extend(_rust_use_leaves(decl.child_by_field_name("argument"), source)) + return out + + +def _crate(tmp_path: Path) -> Path: + """A crate laid out like a SeaORM-generated model tree.""" + (tmp_path / "src" / "models" / "_entities").mkdir(parents=True) + (tmp_path / "Cargo.toml").write_text("[package]\nname = \"demo\"\n", encoding="utf-8") + (tmp_path / "src" / "lib.rs").write_text("pub mod models;\n", encoding="utf-8") + (tmp_path / "src" / "models" / "mod.rs").write_text( + "pub mod _entities;\npub mod service;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "_entities" / "mod.rs").write_text( + "pub mod prelude;\npub mod risk;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "_entities" / "risk.rs").write_text( + "pub struct Entity;\nimpl Entity { pub fn find_risk() -> u32 { 1 } }\n", + encoding="utf-8", + ) + return tmp_path + + +def _graph(root: Path): + result = extract(sorted(root.rglob("*.rs")), cache_root=root) + nodes = {n["id"]: n for n in result["nodes"]} + return result, nodes + + +def _edges(result, nodes, relation: str): + return { + ( + nodes.get(e["source"], {}).get("label"), + nodes.get(e["target"], {}).get("label"), + ) + for e in result["edges"] + if e["relation"] == relation + } + + +# ── use-tree parsing ────────────────────────────────────────────────────────── + +def test_braced_list_yields_one_leaf_per_name(): + leaves = _leaves("use super::{a, b::C};\n") + assert [(segs, alias) for segs, alias, _ in leaves] == [ + (("super", "a"), None), + (("super", "b", "C"), None), + ] + + +def test_nested_braces_and_alias_inside_a_list(): + leaves = _leaves("use crate::x::y::{z::{Deep}, other as O};\n") + assert [(segs, alias) for segs, alias, _ in leaves] == [ + (("crate", "x", "y", "z", "Deep"), None), + (("crate", "x", "y", "other"), "O"), + ] + + +def test_as_clause_separates_symbol_from_alias(): + (segments, alias, wildcard), = _leaves("pub use super::risk::Entity as Risk;\n") + assert segments == ("super", "risk", "Entity") + assert alias == "Risk" + assert wildcard is False + + +def test_wildcard_is_flagged_and_keeps_its_module_path(): + (segments, alias, wildcard), = _leaves("use crate::models::prelude::*;\n") + assert segments == ("crate", "models", "prelude") + assert alias is None + assert wildcard is True + + +# ── module resolution ───────────────────────────────────────────────────────── + +def test_module_dirs_for_mod_rs_and_for_a_plain_file(tmp_path): + _crate(tmp_path) + entities = tmp_path / "src" / "models" / "_entities" + # `mod.rs` IS its module: children live beside it, `super` is one level up. + self_dir, super_dir = _rust_module_dirs(entities / "mod.rs") + assert self_dir == entities + assert super_dir == entities.parent + # A plain file's `super` is the directory holding it. + self_dir, super_dir = _rust_module_dirs(entities / "risk.rs") + assert super_dir == entities + + +def test_super_path_resolves_to_sibling_module_and_symbol(tmp_path): + _crate(tmp_path) + prelude = tmp_path / "src" / "models" / "_entities" / "prelude.rs" + prelude.write_text("pub use super::risk::Entity;\n", encoding="utf-8") + resolved = _resolve_rust_use_path(("super", "risk", "Entity"), prelude) + assert resolved is not None + module_file, symbol = resolved + assert module_file == tmp_path / "src" / "models" / "_entities" / "risk.rs" + assert symbol == "Entity" + + +def test_crate_path_resolves_from_the_crate_root(tmp_path): + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text("pub fn run() {}\n", encoding="utf-8") + resolved = _resolve_rust_use_path( + ("crate", "models", "_entities", "risk", "Entity"), service + ) + assert resolved is not None + module_file, symbol = resolved + assert module_file == tmp_path / "src" / "models" / "_entities" / "risk.rs" + assert symbol == "Entity" + + +def test_module_without_symbol_tail_resolves_to_the_module(tmp_path): + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text("pub fn run() {}\n", encoding="utf-8") + resolved = _resolve_rust_use_path(("crate", "models", "_entities", "risk"), service) + assert resolved is not None + module_file, symbol = resolved + assert module_file.name == "risk.rs" + assert symbol is None + + +def test_external_crate_does_not_resolve(tmp_path): + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text("pub fn run() {}\n", encoding="utf-8") + assert _resolve_rust_use_path(("std", "collections", "HashMap"), service) is None + + +# ── emitted edges ───────────────────────────────────────────────────────────── + +def test_no_use_edge_points_at_a_phantom_node(tmp_path): + """Regression: the old id scheme produced targets no node carried, so the + edges were silently dropped and the module looked like a dead end.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::Entity as Risk;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use std::collections::HashMap;\n" + "use crate::models::_entities::{prelude, risk};\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + dangling = [ + e for e in result["edges"] + if e["target"] not in nodes and e["relation"] in ("imports_from", "imports") + ] + assert dangling == [] + + +def test_prelude_gains_outbound_edges_to_what_it_reexports(tmp_path): + """The symptom that started this: 466 inbound edges, 0 outbound.""" + _crate(tmp_path) + prelude = tmp_path / "src" / "models" / "_entities" / "prelude.rs" + prelude.write_text("pub use super::risk::Entity as Risk;\n", encoding="utf-8") + result, nodes = _graph(tmp_path) + # Look the node up by label: the corpus pass canonicalizes absolute-path + # prefixes out of ids, so the extraction-time id is not the final one. + prelude_nid = next( + nid for nid, n in nodes.items() + if n.get("label") == "prelude.rs" and str(n.get("source_file", "")).endswith("prelude.rs") + ) + outbound = [e for e in result["edges"] if e["source"] == prelude_nid] + assert outbound, "prelude re-exports a module but has no outbound edge" + assert ("prelude.rs", "risk.rs") in _edges(result, nodes, "imports_from") + + +def test_pub_use_is_a_reexport_and_lands_on_the_defining_symbol(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::Entity as Risk;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "Entity") in _edges(result, nodes, "re_exports") + + +def test_plain_use_is_an_import_not_a_reexport(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::risk::Entity;\n" + "pub fn run() -> u32 { Entity::find_risk() }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "Entity") in _edges(result, nodes, "imports") + assert not any( + src == "service.rs" for src, _ in _edges(result, nodes, "re_exports") + ) + + +def test_consumer_resolves_through_the_barrel_to_the_definition(tmp_path): + """A consumer importing through a prelude reaches the defining symbol, the + same way a JS barrel re-export resolves.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::Entity;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::prelude::Entity;\n" + "pub fn run() -> u32 { Entity::find_risk() }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + imports = _edges(result, nodes, "imports") + assert ("service.rs", "Entity") in imports + # And the file-level hop through the barrel is kept, not collapsed away. + assert ("service.rs", "prelude.rs") in _edges(result, nodes, "imports_from") + + +def test_braced_import_edges_reach_every_named_module(tmp_path): + """The old prefix-splitting emitted ONE edge naming the shared prefix.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::Entity;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::{prelude, risk};\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("service.rs", "prelude.rs") in imports_from + assert ("service.rs", "risk.rs") in imports_from + + +def test_glob_reexport_edges_the_module_but_names_no_symbol(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::*;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "risk.rs") in _edges(result, nodes, "imports_from") + # A glob publishes an unknown set of names, so no single symbol is claimed. + assert not _edges(result, nodes, "re_exports") + + +def test_external_crate_import_still_edges_a_named_stub(tmp_path): + """`use std::collections::HashMap` cannot resolve on disk, but must still + produce a real endpoint rather than a dropped edge.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use std::collections::HashMap;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "HashMap") in _edges(result, nodes, "imports_from") From 690f0ef5ca01a0189d635a681d5351007f7f6b35 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Tue, 1 Sep 2026 16:43:17 -0400 Subject: [PATCH 04/25] fix: Rescue Svelte static imports when the script fails to parse Masking the markup fixed the common case, but a script the grammar still rejects reaches no `import_statement`, and the regex rescue this branch removed was the only thing recovering those imports. The rescue is back, gated on `parse_errors` so a clean parse does not double-emit what the AST already edged, and scanned over the masked source, whose surviving text is exactly the script regions. A recovered parse can edge some imports before the error node, so duplicate `(source, target, relation)` edges are dropped. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 42 ++++++++++++++++ tests/test_svelte_extraction.py | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index ac8709173..12a7d7f5e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1616,6 +1616,10 @@ def extract_svelte(path: Path) -> dict: pass does not edge and which legally live in markup-layer template syntax such as ``{#await import('./X.svelte')}`` — outside every script block, and so blanked out of the masked source the AST sees. + + Static imports are edged by the AST pass, so the old regex rescue for them + would double-emit and is used only when the script fails to parse, where + the AST pass reaches no ``import_statement`` at all. """ try: src = path.read_text(encoding="utf-8", errors="replace") @@ -1654,11 +1658,49 @@ def extract_svelte(path: Path) -> dict: result, existing_ids, file_node_id, path, raw, "dynamic_import", aliases, base_url, ) + if result.get("parse_errors"): + # The masked script did not parse cleanly, so `import_statement` + # nodes may never have been reached and the AST pass edged nothing. + # Fall back to the regex rescue the pre-mask extractor relied on. + # Gated on the failure so a clean parse does not double-emit: the + # AST already edges those specifiers. Scanned over the MASKED + # source, whose only surviving text is the script regions. + static_import_re = _re.compile( + r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]""" + ) + for m in static_import_re.finditer(masked): + raw = m.group(1) + if not raw: + continue + _emit_rescued_import( + result, existing_ids, file_node_id, path, raw, + "imports_from", aliases, base_url, + ) + # A recovered parse can still have edged SOME imports before the + # error node, so drop any duplicate the rescue re-emitted. + _dedupe_edges(result) except Exception: pass return result +def _dedupe_edges(result: dict) -> None: + """Drop repeat ``(source, target, relation)`` edges, keeping the first. + + The first occurrence is the AST-derived edge, which carries ``target_file`` + and richer context than a regex rescue's. + """ + seen: set[tuple[str, str, str]] = set() + kept = [] + for edge in result.get("edges", []): + key = (edge.get("source"), edge.get("target"), edge.get("relation")) + if key in seen: + continue + seen.add(key) + kept.append(edge) + result["edges"] = kept + + def extract_astro(path: Path) -> dict: """Extract imports from .astro files: frontmatter (TS) + template regex fallback. diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index e48f5fff9..2a0a27719 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -285,3 +285,91 @@ def test_svelte_joins_cross_file_symbol_resolution(tmp_path): by_label = {n["label"]: n["id"] for n in result["nodes"]} edges = {(e["source"], e["target"], e["relation"]) for e in result["edges"]} assert (by_label["go()"], by_label["helper()"], "calls") in edges + + +def test_static_imports_rescued_when_the_script_fails_to_parse(tmp_path): + """A script the grammar cannot parse reaches no ``import_statement``. + + Masking fixed the common case, but a genuine syntax error (or a construct + tree-sitter-typescript mishandles) still leaves the AST pass with nothing, + and the pre-mask regex rescue is the only thing that recovers the import. + """ + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Broken.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + assert result.get("parse_errors"), "test needs a script the grammar rejects" + assert _make_id(str(tmp_path / "format.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_clean_parse_does_not_double_emit_static_imports(tmp_path): + """The rescue is gated on failure, and duplicates are dropped regardless.""" + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Card.svelte", + '\n" + "\n" + "{fmt('x')}\n", + ) + result = extract_svelte(component) + assert not result.get("parse_errors") + target = _make_id(str(tmp_path / "format.ts")) + matching = [ + e for e in result["edges"] + if e.get("target") == target and e.get("relation") == "imports_from" + ] + assert len(matching) == 1 + + +def test_rescue_does_not_duplicate_a_partially_parsed_import(tmp_path): + """A recovered parse can edge some imports before the error node.""" + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + _write(tmp_path / "later.ts", "export const later = 1\n") + component = _write( + tmp_path / "Partial.svelte", + '\n" + "\n" + "\n", + ) + result = extract_svelte(component) + assert result.get("parse_errors") + for name in ("format.ts", "later.ts"): + target = _make_id(str(tmp_path / name)) + matching = [ + e for e in result["edges"] + if e.get("target") == target and e.get("relation") == "imports_from" + ] + assert len(matching) == 1, name + + +def test_dynamic_import_rescue_still_runs_on_a_clean_parse(tmp_path): + """Gating the STATIC rescue must not gate the dynamic one.""" + _write(tmp_path / "Lazy.svelte", "\n") + component = _write( + tmp_path / "Host.svelte", + '\n" + "\n" + "{#await import('./Lazy.svelte')}{/await}\n", + ) + result = extract_svelte(component) + assert not result.get("parse_errors") + assert _make_id(str(tmp_path / "Lazy.svelte")) in _targets( + result, relation="dynamic_import" + ) From aab82ec9a29a771698128eaf22257ce9f530dd09 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Tue, 1 Sep 2026 16:43:17 -0400 Subject: [PATCH 05/25] fix: Document that a require initializer may be a member access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_find_require_call` unwraps `member_expression`, so `require('./m').sub` satisfies `_is_require_initializer` despite the docstring claiming a literal call. That is the intended reading — `_require_imports_js` edges the same form, and if the two disagreed a destructured name would be both imported and shadowed by a local stub. Behavior is unchanged; the docstring now says so, and a test pins the member-access form resolving cross-file with no local stub. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/engine.py | 7 ++++- tests/test_js_exported_scalar_bindings.py | 38 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 75694ce7c..f662a9059 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1913,7 +1913,12 @@ def _find_require_call(value_node): return None def _is_require_initializer(value_node, source: bytes) -> bool: - """True when a declarator's initializer is a literal ``require(...)`` call. + """True when a declarator's initializer is a ``require(...)`` call. + + The call may be reached through member access — ``require('./m').sub`` is + still a CJS import, and :func:`_require_imports_js` edges that form, so + both sides must agree on what counts or a destructured binding would be + both imported and shadowed by a local stub. ``_find_require_call`` matches the call *shape* only — any ``identifier(...)`` — and leaves the callee-name check to its callers, so diff --git a/tests/test_js_exported_scalar_bindings.py b/tests/test_js_exported_scalar_bindings.py index 1444627f0..87ea3739b 100644 --- a/tests/test_js_exported_scalar_bindings.py +++ b/tests/test_js_exported_scalar_bindings.py @@ -210,3 +210,41 @@ def test_non_require_call_initializer_still_binds(tmp_path): labels = {n["label"] for n in extract_js(source)["nodes"]} assert {"levels", "disabled"} <= labels + + +def test_member_access_require_is_still_an_import(tmp_path): + """``const { doWork } = require('./lib').utils`` is a CJS import too. + + ``_require_imports_js`` edges the member-access form, so + ``_is_require_initializer`` must recognise it as well — otherwise the + destructured name would be both imported and shadowed by a local stub, + and the call in ``run()`` would resolve to the stub. + """ + caller = tmp_path / "caller.js" + callee = tmp_path / "lib.js" + caller.write_text( + "const { doWork } = require('./lib').utils;\n" + "function run() { doWork(); }\n", + encoding="utf-8", + ) + callee.write_text( + "function doWork() { return 1; }\n" + "module.exports = { utils: { doWork } };\n", + encoding="utf-8", + ) + + result = extract([caller, callee], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + local_stubs = [ + n for n in result["nodes"] + if n["label"] == "doWork" and n["source_file"].endswith("caller.js") + ] + assert not local_stubs + calls = [ + e for e in result["edges"] + if e["relation"] == "calls" + and nodes[e["source"]]["label"] == "run()" + and nodes[e["target"]]["label"] == "doWork()" + ] + assert len(calls) == 1 + assert nodes[calls[0]["target"]]["source_file"].endswith("lib.js") From a412fbf290cb17e1ed580e612eed2035cbde51d2 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Tue, 1 Sep 2026 16:43:17 -0400 Subject: [PATCH 06/25] fix: Correct three Rust use-declaration edge cases `pub(self) use` is exactly as private as a bare `use`, but every visibility modifier counted as public, so it was published as a re-export. `use foo::bar::{self, Baz}` binds the module `foo::bar`; the leaf walk appended `self` as a path segment, resolving nothing and minting a node labelled `self`. `pub use anyhow::Result;` resolves to no file, and the unresolved branch ignored `is_reexport`, recording an external re-export as a plain import. The barrel collapse could not follow a consumer through it. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 30 ++++++++++-- tests/test_rust_use_reexports.py | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 4adc93ba7..b69aba19c 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -1,7 +1,7 @@ """Rust extractor. Moved verbatim from graphify/extract.py.""" from __future__ import annotations - +import re from pathlib import Path from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text @@ -61,6 +61,10 @@ def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[ # Rust module roots: a file that IS its module (rather than a child of one). _RUST_MODULE_ROOT_FILES = ("mod.rs", "lib.rs", "main.rs") +# `pub(self)` restricts to the current module, which is what a bare `use` +# already does — it publishes nothing and so is not a re-export. +_RUST_PUB_SELF_RE = re.compile(r"^pub\s*\(\s*self\s*\)$") + def _rust_path_segments(node, source: bytes) -> list[str]: """Flatten a use-path node into its segments. @@ -128,6 +132,13 @@ def _rust_use_leaves(node, source: bytes, prefix: tuple[str, ...] = ()) -> list[ else: inner_prefix = inner_prefix + tuple(_rust_path_segments(child, source)) return _rust_use_leaves(list_node, source, inner_prefix) if list_node else [] + if t == "self" and prefix: + # Inside a use list, `self` names the MODULE the prefix already spells: + # `use foo::bar::{self, Baz}` binds `foo::bar`, not a symbol called + # `self`. Appending the segment would resolve nothing and mint a node + # labelled `self`. A leading `self::` path has an empty prefix and + # falls through below, where `_resolve_rust_use_path` anchors it. + return [(tuple(prefix), None, False)] if t in ("scoped_identifier", "identifier", "crate", "super", "self", "metavariable"): segments = tuple(prefix) + tuple(_rust_path_segments(node, source)) return [(segments, None, False)] if segments else [] @@ -361,8 +372,14 @@ def emit_use_leaf(segments, alias, is_wildcard: bool, is_reexport: bool, line: i # rewire can still collapse it onto a definition if one shows up. name = alias or segments[-1] if not is_wildcard and name: - add_edge(file_nid, ensure_named_node(name, line), "imports_from", - line, context="import") + stub_nid = ensure_named_node(name, line) + add_edge(file_nid, stub_nid, "imports_from", line, context="import") + if is_reexport: + # `pub use anyhow::Result;` republishes an external name. + # Without the symbol-level `re_exports` the barrel collapse + # cannot follow a consumer through this module, which is the + # whole point of resolving preludes. + add_edge(file_nid, stub_nid, "re_exports", line) return module_file, symbol = resolution module_nid = _make_id(str(module_file)) @@ -558,8 +575,13 @@ def _emit_enum_type(type_node, at_line): # symbol under its own path. The corpus-level barrel collapse # keys on this relation to follow consumers through to the # defining file, so a prelude stops being a dead end. + # `pub(self)` is exactly as private as a bare `use`, so it is + # NOT a re-export. `pub(crate)`/`pub(super)`/`pub(in path)` + # genuinely republish within a scope and still count. is_reexport = any( - child.type == "visibility_modifier" for child in node.children + child.type == "visibility_modifier" + and not _RUST_PUB_SELF_RE.match(_read_text(child, source)) + for child in node.children ) for segments, alias, is_wildcard in _rust_use_leaves(arg, source): emit_use_leaf(segments, alias, is_wildcard, is_reexport, line) diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index ca27b07ef..ce2385e40 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -278,3 +278,81 @@ def test_external_crate_import_still_edges_a_named_stub(tmp_path): ) result, nodes = _graph(tmp_path) assert ("service.rs", "HashMap") in _edges(result, nodes, "imports_from") + + +def test_pub_self_use_is_not_a_reexport(tmp_path): + """`pub(self)` restricts to the current module — as private as a bare `use`.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub(self) use super::risk::Entity;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "Entity") in _edges(result, nodes, "imports") + assert ("prelude.rs", "Entity") not in _edges(result, nodes, "re_exports") + + +def test_pub_crate_use_is_still_a_reexport(tmp_path): + """`pub(crate)` republishes within the crate, so consumers can follow it.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub(crate) use super::risk::Entity;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "Entity") in _edges(result, nodes, "re_exports") + + +def test_use_list_self_names_the_module_not_a_symbol(): + """`use foo::bar::{self, Baz}` binds `foo::bar`, not a symbol called `self`.""" + leaves = _leaves("use crate::models::risk::{self, Entity};\n") + segments = {leaf[0] for leaf in leaves} + assert ("crate", "models", "risk") in segments + assert ("crate", "models", "risk", "self") not in segments + assert ("crate", "models", "risk", "Entity") in segments + + +def test_use_list_self_edges_the_module_file(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::risk::{self, Entity};\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "risk.rs") in _edges(result, nodes, "imports_from") + # No node is minted for a symbol named `self`. + assert "self" not in {n.get("label") for n in result["nodes"]} + + +def test_leading_self_path_still_resolves(tmp_path): + """A bare `self::` prefix anchors at the current module and is unaffected.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "use self::risk::Entity;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "risk.rs") in _edges(result, nodes, "imports_from") + + +def test_external_pub_use_keeps_the_reexport_relation(tmp_path): + """`pub use anyhow::Result;` republishes an external name. + + Without the symbol-level `re_exports` the barrel collapse cannot follow a + consumer through the module, which is the point of resolving preludes. + """ + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use anyhow::Result;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "Result") in _edges(result, nodes, "re_exports") + assert ("prelude.rs", "Result") in _edges(result, nodes, "imports_from") + + +def test_external_plain_use_is_not_a_reexport(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use anyhow::Result;\npub fn run() -> u32 { 1 }\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "Result") in _edges(result, nodes, "imports_from") + assert not _edges(result, nodes, "re_exports") From cbad248db9c7881ac9aac2785e7e47e3319b00ec Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 10:35:46 -0400 Subject: [PATCH 07/25] fix: Bound Rust use-path resolution to the crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `self::x` names a CHILD of the current module. `_rust_module_dirs` falls back to the containing directory for a plain `foo.rs` with no sibling `foo/`, which is what the 2018-edition crate-relative heuristic wants but makes `self::` behave like `super::` and edge a sibling module the path cannot legally reach. The `self` keyword now resolves through `_rust_self_module_dir`, which is None when the module has no children. `super::super::…` walked one directory up per keyword with no floor, so a path with more `super`s than the module has ancestors left the crate and could resolve an unrelated file higher up the filesystem. The walk now stops at the crate root and falls back to a sourceless stub. `_walk_rust_segments` returned a symbol only for a single trailing segment, dropping the edge entirely for `use …::Status::Active`. The first unresolved segment is the item the module defines, so it is attributed and the rest — which names something inside that item — is dropped instead of the whole edge. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 49 +++++++++++++---- tests/test_rust_use_reexports.py | 92 ++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index b69aba19c..bb64c3125 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -169,12 +169,18 @@ def _rust_module_file(directory: Path, name: str) -> Path | None: def _rust_module_dirs(path: Path) -> tuple[Path, Path]: - """Return ``(self_dir, super_dir)`` for the module ``path`` defines. + """Return ``(search_dir, super_dir)`` for the module ``path`` defines. ``mod.rs``/``lib.rs``/``main.rs`` ARE their module, so their own directory holds their children and the parent directory is ``super``. Any other file ``foo.rs`` is a module whose children live in a sibling ``foo/`` directory, and whose ``super`` is the directory it sits in. + + ``search_dir`` falls back to the containing directory when a plain + ``foo.rs`` has no sibling ``foo/``, which is what the 2018-edition + crate-relative *heuristic* wants to probe. It is NOT the module's ``self`` + — use :func:`_rust_self_module_dir` for a path anchored on the ``self`` + keyword, which must not reach the module's siblings. """ if path.name in _RUST_MODULE_ROOT_FILES: return path.parent, path.parent.parent @@ -182,6 +188,21 @@ def _rust_module_dirs(path: Path) -> tuple[Path, Path]: return (sibling if sibling.is_dir() else path.parent), path.parent +def _rust_self_module_dir(path: Path) -> Path | None: + """The directory holding ``path``'s CHILD modules, or ``None`` if it has none. + + ``self::x`` names a child of this module, so it resolves inside the + module's own directory. A plain ``foo.rs`` with no sibling ``foo/`` has no + children at all, and falling back to its containing directory would + silently make ``self::`` behave like ``super::`` — resolving a sibling + module the ``self`` path cannot legally reach. + """ + if path.name in _RUST_MODULE_ROOT_FILES: + return path.parent + sibling = path.parent / path.stem + return sibling if sibling.is_dir() else None + + def _resolve_rust_use_path( segments: tuple[str, ...], path: Path ) -> "tuple[Path, str | None] | None": @@ -196,7 +217,7 @@ def _resolve_rust_use_path( """ if not segments: return None - self_dir, super_dir = _rust_module_dirs(path) + search_dir, super_dir = _rust_module_dirs(path) src_root = _rust_crate_src_root(path) index = 0 @@ -205,18 +226,24 @@ def _resolve_rust_use_path( if first == "crate": anchor, index = src_root, 1 elif first == "self": - anchor, index = self_dir, 1 + anchor, index = _rust_self_module_dir(path), 1 elif first == "super": anchor, index = super_dir, 1 - # `super::super::x` walks further up one directory per keyword. + # `super::super::x` walks further up one directory per keyword, but the + # crate root has no `super`: without the clamp the walk leaves the + # crate and can resolve an unrelated file higher up the filesystem. while index < len(segments) and segments[index] == "super": - anchor = anchor.parent if anchor is not None else None + if anchor is None or (src_root is not None and anchor == src_root): + return None + anchor = anchor.parent index += 1 + if src_root is not None and src_root not in (anchor, *anchor.parents): + return None if anchor is None and first not in ("crate", "self", "super"): # 2018-edition paths may be crate-relative without the `crate` prefix; # try the crate root, then the current module. An external crate simply # resolves to nothing at either. - for candidate_anchor in (src_root, self_dir): + for candidate_anchor in (src_root, search_dir): if candidate_anchor is None: continue resolved = _walk_rust_segments(candidate_anchor, segments) @@ -239,10 +266,12 @@ def _walk_rust_segments( for position, segment in enumerate(segments): found = _rust_module_file(directory, segment) if found is None: - # Not a module. If everything before it resolved, the remainder is a - # symbol path inside that module (`…::prelude::Risk` -> `Risk`), and - # only a single trailing segment is a name we can attribute. - if resolved is not None and position == len(segments) - 1: + # Not a module, so the remainder is a symbol path inside the module + # that resolved (`…::prelude::Risk` -> `Risk`). The FIRST unresolved + # segment is the item the module defines; anything after it names + # something inside that item (`…::Status::Active` -> `Status`), so + # attribute the item and drop the rest rather than the whole edge. + if resolved is not None: return resolved, segment return None resolved = found diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index ce2385e40..d6c4d2250 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -323,14 +323,100 @@ def test_use_list_self_edges_the_module_file(tmp_path): assert "self" not in {n.get("label") for n in result["nodes"]} -def test_leading_self_path_still_resolves(tmp_path): - """A bare `self::` prefix anchors at the current module and is unaffected.""" +def test_leading_self_path_resolves_a_child_of_a_mod_rs(tmp_path): + """`self::x` in `_entities/mod.rs` names its child `_entities::risk`.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "mod.rs").write_text( + "pub mod prelude;\npub mod risk;\nuse self::risk::Entity;\n", + encoding="utf-8", + ) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "pub use super::risk::Entity;\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("mod.rs", "risk.rs") in _edges(result, nodes, "imports_from") + + +def test_leading_self_path_does_not_reach_a_sibling_module(tmp_path): + """`self::risk` inside `_entities/prelude.rs` is `_entities::prelude::risk`. + + `prelude.rs` has no sibling `prelude/` directory, so it declares no child + modules and the path resolves to nothing. Falling back to the containing + directory would make `self::` behave like `super::` and wrongly edge the + sibling `_entities/risk.rs`. + """ _crate(tmp_path) (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( "use self::risk::Entity;\n", encoding="utf-8" ) result, nodes = _graph(tmp_path) - assert ("prelude.rs", "risk.rs") in _edges(result, nodes, "imports_from") + assert ("prelude.rs", "risk.rs") not in _edges(result, nodes, "imports_from") + + +def test_self_path_reaches_a_child_in_a_sibling_directory(tmp_path): + """A plain `foo.rs` WITH a sibling `foo/` does have children.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "service").mkdir(parents=True) + (tmp_path / "src" / "models" / "service" / "helper.rs").write_text( + "pub struct Helper;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "pub mod helper;\nuse self::helper::Helper;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "helper.rs") in _edges(result, nodes, "imports_from") + + +def test_super_walk_stops_at_the_crate_root(tmp_path): + """`super::super::…` past the crate root must resolve to nothing. + + Unclamped, the walk leaves `src/` and can resolve an unrelated file higher + up the filesystem — `models.rs` next to `Cargo.toml`, say. + """ + _crate(tmp_path) + (tmp_path / "models.rs").write_text("pub struct Outside;\n", encoding="utf-8") + (tmp_path / "src" / "models" / "service.rs").write_text( + "use super::super::super::models::Outside;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("service.rs", "models.rs") not in imports_from + # It falls back to a sourceless stub so the edge still has an endpoint. + assert ("service.rs", "Outside") in imports_from + + +def test_super_still_resolves_within_the_crate(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "use super::super::service::run;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "pub fn run() -> u32 { 1 }\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("prelude.rs", "service.rs") in _edges(result, nodes, "imports_from") + + +def test_multi_segment_symbol_tail_attributes_the_item(tmp_path): + """`use crate::…::Status::Active` names the enum the module defines. + + Requiring a SINGLE trailing segment dropped the whole edge, so an enum + variant import contributed nothing. + """ + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "risk.rs").write_text( + "pub enum Status { Active }\npub struct Entity;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::risk::Status::Active;\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "risk.rs") in _edges(result, nodes, "imports_from") + assert ("service.rs", "Status") in _edges(result, nodes, "imports") def test_external_pub_use_keeps_the_reexport_relation(tmp_path): From 5aa5c49fc07ad561b043c0f2e304f2cb4883ebba Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 11:06:01 -0400 Subject: [PATCH 08/25] fix: Dedupe rescued Svelte imports on every path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_dedupe_edges` ran only inside the parse-failure branch, so a specifier the dynamic-import regex matched twice — `import('./X')` in two markup branches — emitted two identical edges on a clean parse. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 7 ++++--- tests/test_svelte_extraction.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 12a7d7f5e..1bc121a29 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1676,9 +1676,10 @@ def extract_svelte(path: Path) -> dict: result, existing_ids, file_node_id, path, raw, "imports_from", aliases, base_url, ) - # A recovered parse can still have edged SOME imports before the - # error node, so drop any duplicate the rescue re-emitted. - _dedupe_edges(result) + # Both rescues can repeat an edge: a recovered parse edges some + # imports before the error node, and a specifier imported twice in one + # file (`import('./X')` in two markup branches) is matched twice. + _dedupe_edges(result) except Exception: pass return result diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index 2a0a27719..704d8ccec 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -373,3 +373,27 @@ def test_dynamic_import_rescue_still_runs_on_a_clean_parse(tmp_path): assert _make_id(str(tmp_path / "Lazy.svelte")) in _targets( result, relation="dynamic_import" ) + + +def test_repeated_dynamic_import_emits_one_edge(tmp_path): + """The same specifier in two markup branches is matched twice by the regex.""" + _write(tmp_path / "Lazy.svelte", "\n") + component = _write( + tmp_path / "Host.svelte", + '\n" + "\n" + "{#if ready}\n" + " {#await import('./Lazy.svelte')}{/await}\n" + "{:else}\n" + " {#await import('./Lazy.svelte')}{/await}\n" + "{/if}\n", + ) + result = extract_svelte(component) + target = _make_id(str(tmp_path / "Lazy.svelte")) + matching = [ + e for e in result["edges"] + if e.get("target") == target and e.get("relation") == "dynamic_import" + ] + assert len(matching) == 1 From 2b5df718a4a26d3b56cd30c041d8ff9cb08dfb27 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 11:06:01 -0400 Subject: [PATCH 09/25] fix: Resolve Rust use paths anchored on their own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_walk_rust_segments` required at least one module segment to resolve before it would attribute a symbol, so `use crate::Config` — an item of `lib.rs` rather than of a child module — dropped its edge. The anchor's own file is now seeded as the fallback owner. It is deliberately NOT seeded for a bare crate-relative path, which is only a guess: that would resolve every external crate to a symbol named after it inside `lib.rs`. `use self::Config;` resolves against the file itself, which needs no child directory. `use foo::bar::{self as bar_mod, Baz}` aliases the module the prefix spells; the alias branch appended `self` as a path segment, resolving nothing and minting a node labelled `self`. The crate-root clamp also guards `anchor is None` explicitly. It is unreachable today — `_rust_module_dirs` always returns paths — but the clamp should not depend on that. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 45 +++++++++++++++++++--- tests/test_rust_use_reexports.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index bb64c3125..dfa086eb3 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -106,7 +106,12 @@ def _rust_use_leaves(node, source: bytes, prefix: tuple[str, ...] = ()) -> list[ named = [c for c in node.named_children] path_node = path_node or (named[0] if named else None) alias_node = alias_node or (named[1] if len(named) > 1 else None) - segments = tuple(prefix) + tuple(_rust_path_segments(path_node, source)) + if path_node is not None and path_node.type == "self" and prefix: + # `use foo::bar::{self as bar_mod, Baz}` aliases the MODULE the + # prefix spells, exactly as the unaliased `self` does. + segments = tuple(prefix) + else: + segments = tuple(prefix) + tuple(_rust_path_segments(path_node, source)) alias = _read_text(alias_node, source).strip() if alias_node is not None else None return [(segments, alias or None, False)] if segments else [] if t == "use_wildcard": @@ -237,6 +242,8 @@ def _resolve_rust_use_path( return None anchor = anchor.parent index += 1 + if anchor is None: + return None if src_root is not None and src_root not in (anchor, *anchor.parents): return None if anchor is None and first not in ("crate", "self", "super"): @@ -246,23 +253,51 @@ def _resolve_rust_use_path( for candidate_anchor in (src_root, search_dir): if candidate_anchor is None: continue + # No anchor_file here: a bare path is only a GUESS at being + # crate-relative, and seeding the anchor's own file would make + # every external crate (`use anyhow::Result`) resolve to a symbol + # named `anyhow` inside `lib.rs`. resolved = _walk_rust_segments(candidate_anchor, segments) if resolved is not None: return resolved return None if anchor is None: + if first == "self" and len(segments) == 2: + # `use self::Config;` names an item of THIS module, which needs no + # child directory to live in. + return path, segments[1] return None - return _walk_rust_segments(anchor, segments[index:]) + anchor_file = path if first == "self" else _rust_module_root_file(anchor) + return _walk_rust_segments(anchor, segments[index:], anchor_file=anchor_file) + + +def _rust_module_root_file(directory: Path) -> Path | None: + """The file that IS the module owning ``directory``, if one is present. + + A symbol imported straight off an anchor (``use crate::Config``) is defined + in that module's own file rather than in a child module, so the anchor + needs a file to attribute it to. + """ + for name in _RUST_MODULE_ROOT_FILES: + candidate = directory / name + if candidate.is_file(): + return candidate + return None def _walk_rust_segments( - anchor: Path, segments: tuple[str, ...] + anchor: Path, segments: tuple[str, ...], anchor_file: Path | None = None ) -> "tuple[Path, str | None] | None": - """Walk module segments from ``anchor``; the tail may name a symbol.""" + """Walk module segments from ``anchor``; the tail may name a symbol. + + ``anchor_file`` is the file backing ``anchor``'s own module, so a symbol + that is not inside any child module (``use crate::Config``, defined in + ``lib.rs``) still resolves instead of dropping its edge. + """ if not segments: return None directory = anchor - resolved: Path | None = None + resolved: Path | None = anchor_file for position, segment in enumerate(segments): found = _rust_module_file(directory, segment) if found is None: diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index d6c4d2250..cc5934e5e 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -442,3 +442,67 @@ def test_external_plain_use_is_not_a_reexport(tmp_path): result, nodes = _graph(tmp_path) assert ("service.rs", "Result") in _edges(result, nodes, "imports_from") assert not _edges(result, nodes, "re_exports") + + +def test_symbol_defined_in_the_anchor_module_itself_resolves(tmp_path): + """`use crate::Config` names an item of `lib.rs`, not of a child module. + + Requiring at least one module segment to resolve first dropped the edge. + """ + _crate(tmp_path) + (tmp_path / "src" / "lib.rs").write_text( + "pub mod models;\npub struct Config;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::Config;\npub fn run() -> u32 { 1 }\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "lib.rs") in _edges(result, nodes, "imports_from") + assert ("service.rs", "Config") in _edges(result, nodes, "imports") + + +def test_self_prefixed_own_item_resolves_without_a_child_directory(tmp_path): + """`use self::Config;` in a childless `foo.rs` names that file's own item.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use self::Config;\npub struct Config;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "Config") in _edges(result, nodes, "imports") + + +def test_external_crate_does_not_resolve_to_the_crate_root_file(tmp_path): + """A bare path is a GUESS at being crate-relative. + + Seeding the anchor's own file for that guess would make every external + crate resolve to a symbol named after it inside `lib.rs`. + """ + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use anyhow::Result;\npub fn run() -> u32 { 1 }\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("service.rs", "lib.rs") not in imports_from + assert ("service.rs", "Result") in imports_from + + +def test_aliased_self_in_a_use_list_names_the_module(): + """`use foo::bar::{self as bar_mod, Baz}` aliases the module `foo::bar`.""" + leaves = _leaves("use crate::models::risk::{self as risk_mod, Entity};\n") + by_alias = {leaf[1]: leaf[0] for leaf in leaves} + assert by_alias["risk_mod"] == ("crate", "models", "risk") + assert ("crate", "models", "risk", "self") not in {leaf[0] for leaf in leaves} + + +def test_aliased_self_edges_the_module_file(tmp_path): + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::risk::{self as risk_mod, Entity};\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "risk.rs") in _edges(result, nodes, "imports_from") + assert "self" not in {n.get("label") for n in result["nodes"]} From f702e17c9fdde857f5c6dea4aa59a53aeaa8de4e Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 11:17:03 -0400 Subject: [PATCH 10/25] fix: Anchor crate:: at the crate root that owns the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .rs file directly inside one of Cargo's auto-discovered target directories — `src/bin/tool.rs`, `examples/demo.rs`, `tests/it.rs`, `benches/bench.rs` — is its own crate root. `_rust_crate_src_root` returned the package `src` for every file, so `crate::helper` in `src/bin/tool.rs` edged `src/helper.rs` instead of `src/bin/tool/helper.rs`. The directory form (`src/bin/tool/main.rs`) already resolved correctly and is untouched. With no `Cargo.toml` above the file there is no root to clamp `super` against, and the clamp was skipped entirely, so the walk climbed the filesystem unbounded. No walking is allowed in that case. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 50 ++++++++++++++++-- tests/test_rust_use_reexports.py | 88 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index dfa086eb3..22994e204 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -150,18 +150,60 @@ def _rust_use_leaves(node, source: bytes, prefix: tuple[str, ...] = ()) -> list[ return [] +# Cargo's conventional auto-discovered target directories. A .rs file sitting +# directly in one of these IS a crate root of its own, so `crate::` inside it +# refers to that file's module tree, not to the library's `src/`. +_RUST_AUTO_TARGET_DIRS = ("bin", "examples", "tests", "benches") + + def _rust_crate_src_root(path: Path) -> Path | None: - """The ``src`` directory of the crate owning ``path``, if there is one.""" + """The root directory ``crate::`` resolves against for ``path``. + + Usually the owning package's ``src``. But a file directly inside one of + Cargo's auto-discovered target directories — ``src/bin/tool.rs``, + ``examples/demo.rs``, ``tests/it.rs``, ``benches/bench.rs`` — is its OWN + crate root, so ``crate::helper`` there means ``src/bin/tool/helper.rs``, + not ``src/helper.rs``. A directory form (``src/bin/tool/main.rs``) is + already handled by the plain ``src`` answer plus the module walk. + + Returns ``None`` when no ``Cargo.toml`` is above ``path`` — there is no + crate to resolve against, and callers must not walk the filesystem. + """ probe = path.parent while True: if (probe / "Cargo.toml").is_file(): src = probe / "src" - return src if src.is_dir() else probe + base = src if src.is_dir() else probe + own = _rust_own_crate_root_dir(path, base, probe) + return own if own is not None else base if probe.parent == probe: return None probe = probe.parent +def _rust_own_crate_root_dir(path: Path, src: Path, package: Path) -> Path | None: + """The module directory of ``path`` when ``path`` is itself a crate root. + + ``src/bin/tool.rs`` is a binary crate whose children live in a sibling + ``src/bin/tool/``. ``examples``/``tests``/``benches`` sit at the package + root rather than under ``src``. Returns ``None`` when ``path`` is an + ordinary module of the library crate. + """ + if path.name in _RUST_MODULE_ROOT_FILES: + return None + parent = path.parent + is_auto_target = ( + (parent.parent == src and parent.name == "bin") + or (parent.parent == package and parent.name in _RUST_AUTO_TARGET_DIRS) + ) + if not is_auto_target: + return None + # The binary's children live in a sibling directory named after it. If it + # does not exist the crate has no child modules, and the module walk finds + # nothing there — which is right, and better than falling back to `src`. + return parent / path.stem + + def _rust_module_file(directory: Path, name: str) -> Path | None: """Resolve one module segment inside ``directory``: ``name.rs`` or ``name/mod.rs``.""" candidate = directory / f"{name}.rs" @@ -237,8 +279,10 @@ def _resolve_rust_use_path( # `super::super::x` walks further up one directory per keyword, but the # crate root has no `super`: without the clamp the walk leaves the # crate and can resolve an unrelated file higher up the filesystem. + # With no `Cargo.toml` above the file there is no root to clamp + # against, so no walking is allowed at all. while index < len(segments) and segments[index] == "super": - if anchor is None or (src_root is not None and anchor == src_root): + if anchor is None or src_root is None or anchor == src_root: return None anchor = anchor.parent index += 1 diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index cc5934e5e..e414826e2 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -506,3 +506,91 @@ def test_aliased_self_edges_the_module_file(tmp_path): result, nodes = _graph(tmp_path) assert ("service.rs", "risk.rs") in _edges(result, nodes, "imports_from") assert "self" not in {n.get("label") for n in result["nodes"]} + + +def test_named_bin_resolves_crate_against_its_own_module_tree(tmp_path): + """`src/bin/tool.rs` is its own crate root. + + `crate::helper` there means `src/bin/tool/helper.rs`, not the library's + `src/helper.rs` — resolving it against `src/` edges the wrong file. + """ + _crate(tmp_path) + (tmp_path / "src" / "helper.rs").write_text( + "pub struct Wrong;\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool").mkdir(parents=True) + (tmp_path / "src" / "bin" / "tool" / "helper.rs").write_text( + "pub struct Helper;\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool.rs").write_text( + "mod helper;\nuse crate::helper::Helper;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("tool.rs", "helper.rs") in imports_from + helper_targets = { + nodes[e["target"]]["source_file"] + for e in result["edges"] + if nodes.get(e["source"], {}).get("label") == "tool.rs" + and e["relation"] == "imports_from" + and nodes.get(e["target"], {}).get("label") == "helper.rs" + } + assert helper_targets == {"src/bin/tool/helper.rs"} + + +def test_example_crate_root_does_not_reach_the_library_src(tmp_path): + """`examples/demo.rs` is its own crate too, and sits beside `src/`.""" + _crate(tmp_path) + (tmp_path / "src" / "helper.rs").write_text( + "pub struct Wrong;\n", encoding="utf-8" + ) + (tmp_path / "examples").mkdir() + (tmp_path / "examples" / "demo.rs").write_text( + "use crate::helper::Wrong;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("demo.rs", "helper.rs") not in _edges(result, nodes, "imports_from") + + +def test_library_module_still_resolves_crate_against_src(tmp_path): + """The bin/example carve-out must not disturb an ordinary library module.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "service.rs").write_text( + "use crate::models::_entities::risk::Entity;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "risk.rs") in _edges(result, nodes, "imports_from") + + +def test_bin_directory_form_is_unaffected(tmp_path): + """`src/bin/tool/main.rs` IS its module, so `src` plus the walk suffices.""" + _crate(tmp_path) + (tmp_path / "src" / "bin" / "tool").mkdir(parents=True) + (tmp_path / "src" / "bin" / "tool" / "helper.rs").write_text( + "pub struct Helper;\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool" / "main.rs").write_text( + "mod helper;\nuse self::helper::Helper;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("main.rs", "helper.rs") in _edges(result, nodes, "imports_from") + + +def test_super_walk_without_a_cargo_toml_resolves_nothing(tmp_path): + """No `Cargo.toml` above the file means no root to clamp against. + + The clamp was skipped entirely when `_rust_crate_src_root` returned None, + so the walk climbed the filesystem unbounded. + """ + (tmp_path / "models.rs").write_text("pub struct Outside;\n", encoding="utf-8") + loose = tmp_path / "a" / "b" / "c" + loose.mkdir(parents=True) + (loose / "service.rs").write_text( + "use super::super::super::models::Outside;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("service.rs", "models.rs") not in imports_from + assert ("service.rs", "Outside") in imports_from From 739d54d40edff0b4ccbc94aac3c2e655996fe4f2 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 13:33:04 -0400 Subject: [PATCH 11/25] fix: Anchor auto-target crate modules and record use aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module nested inside an auto-target crate (`src/bin/tool/helper.rs`) still anchored `crate::` at the library's `src`, so only the crate root file itself was handled. The crate-root directory is now found by walking up to the nearest auto-target directory, which also gives the `super::` clamp the right floor inside a bin crate. A package with both `src/lib.rs` and `src/main.rs` has two roots in one directory, and the first name present won. `crate::` inside a root file now attributes to that file. `use …::Entity as Risk;` parsed the alias and discarded it on every resolved path. It is recorded as `local_alias`, the transient hint the corpus-level receiver resolution already reads, on the symbol edge for a symbol alias and on the file edge for a module alias. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 80 ++++++++++++------ tests/test_rust_use_reexports.py | 134 +++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 23 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 22994e204..0cc396cd7 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -174,34 +174,49 @@ def _rust_crate_src_root(path: Path) -> Path | None: if (probe / "Cargo.toml").is_file(): src = probe / "src" base = src if src.is_dir() else probe - own = _rust_own_crate_root_dir(path, base, probe) + own = _rust_auto_target_crate_dir(path, base, probe) return own if own is not None else base if probe.parent == probe: return None probe = probe.parent -def _rust_own_crate_root_dir(path: Path, src: Path, package: Path) -> Path | None: - """The module directory of ``path`` when ``path`` is itself a crate root. +def _rust_is_auto_target_dir(directory: Path, src: Path, package: Path) -> bool: + """True when ``directory`` is one of Cargo's auto-discovered target dirs. - ``src/bin/tool.rs`` is a binary crate whose children live in a sibling - ``src/bin/tool/``. ``examples``/``tests``/``benches`` sit at the package - root rather than under ``src``. Returns ``None`` when ``path`` is an - ordinary module of the library crate. + ``bin`` lives under ``src``; ``examples``/``tests``/``benches`` sit at the + package root. """ - if path.name in _RUST_MODULE_ROOT_FILES: - return None - parent = path.parent - is_auto_target = ( - (parent.parent == src and parent.name == "bin") - or (parent.parent == package and parent.name in _RUST_AUTO_TARGET_DIRS) - ) - if not is_auto_target: - return None - # The binary's children live in a sibling directory named after it. If it - # does not exist the crate has no child modules, and the module walk finds - # nothing there — which is right, and better than falling back to `src`. - return parent / path.stem + if directory.name == "bin" and directory.parent == src: + return True + return directory.name in _RUST_AUTO_TARGET_DIRS and directory.parent == package + + +def _rust_auto_target_crate_dir(path: Path, src: Path, package: Path) -> Path | None: + """The crate-root directory when ``path`` belongs to an auto-target crate. + + Two shapes reach the same answer. ``src/bin/tool.rs`` IS the crate, so its + module tree is the sibling ``src/bin/tool/``; a module of that crate + (``src/bin/tool/helper.rs``, or deeper) resolves ``crate::`` against the + same directory. Returns ``None`` for an ordinary module of the library + crate, which anchors at ``src``. + """ + if path.name not in _RUST_MODULE_ROOT_FILES and _rust_is_auto_target_dir( + path.parent, src, package + ): + # The crate root file itself. The sibling directory holds its children; + # if it does not exist the crate simply has none, which the module walk + # discovers — better than falling back to `src` and edging a namesake + # module of the library. + return path.parent / path.stem + # A module nested inside an auto-target crate: the crate root is the + # directory whose own parent is the auto-target directory. + probe = path.parent + while probe != package and probe.parent != probe: + if _rust_is_auto_target_dir(probe.parent, src, package): + return probe + probe = probe.parent + return None def _rust_module_file(directory: Path, name: str) -> Path | None: @@ -311,7 +326,15 @@ def _resolve_rust_use_path( # child directory to live in. return path, segments[1] return None - anchor_file = path if first == "self" else _rust_module_root_file(anchor) + if first == "self": + anchor_file = path + elif path.parent == anchor and path.name in _RUST_MODULE_ROOT_FILES: + # A crate with both `src/lib.rs` and `src/main.rs` has two roots in one + # directory. `crate::` inside `main.rs` is `main.rs`, so prefer the + # file doing the importing over the first name that happens to exist. + anchor_file = path + else: + anchor_file = _rust_module_root_file(anchor) return _walk_rust_segments(anchor, segments[index:], anchor_file=anchor_file) @@ -497,6 +520,10 @@ def emit_use_leaf(segments, alias, is_wildcard: bool, is_reexport: bool, line: i "source_location": f"L{line}", "weight": 1.0, "context": "import", "target_file": str(module_file), } + if symbol is None and alias and alias != module_file.stem: + # `use crate::models::risk as risk_model;` aliases the MODULE, so + # the alias belongs on the file-level edge. + file_edge["local_alias"] = alias edges.append(file_edge) if symbol is None or is_wildcard: # `use super::risk;` names the module itself, and a glob re-export @@ -507,13 +534,20 @@ def emit_use_leaf(segments, alias, is_wildcard: bool, is_reexport: bool, line: i # name)`), so this edge lands on that node rather than a look-alike the # corpus rewire has to guess at. symbol_nid = _make_id(_file_stem(module_file), symbol) - edges.append({ + symbol_edge = { "source": file_nid, "target": symbol_nid, "relation": "re_exports" if is_reexport else "imports", "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", "weight": 1.0, "target_file": str(module_file), - }) + } + if alias and alias != symbol: + # `use …::Entity as Risk;` — this file spells the symbol `Risk`. + # `local_alias` is the field the corpus-level receiver resolution + # already reads (#2082), so the alias resolves like the bare name + # instead of being parsed and then dropped. + symbol_edge["local_alias"] = alias + edges.append(symbol_edge) def walk(node, parent_impl_nid: str | None = None) -> None: t = node.type diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index e414826e2..5b124f985 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -15,6 +15,7 @@ from graphify.extract import extract from graphify.extractors.rust import ( _resolve_rust_use_path, + extract_rust, _rust_module_dirs, _rust_use_leaves, ) @@ -594,3 +595,136 @@ def test_super_walk_without_a_cargo_toml_resolves_nothing(tmp_path): imports_from = _edges(result, nodes, "imports_from") assert ("service.rs", "models.rs") not in imports_from assert ("service.rs", "Outside") in imports_from + + +def _use_edges(path: Path): + """Edges straight from the extractor. + + `local_alias` is a transient import-resolution hint that `extract()` pops + once the language resolvers have run (see the note beside the `pop` in + extract.py), so the alias has to be asserted at this layer. + """ + return [ + e for e in extract_rust(path)["edges"] + if e["relation"] in ("imports", "imports_from", "re_exports") + ] + + +def test_resolved_symbol_alias_is_recorded_on_the_edge(tmp_path): + """`use …::Entity as Risk;` — the alias was parsed and then dropped. + + `local_alias` is the field the corpus-level receiver resolution already + reads (#2082), so recording it there is what lets an aliased receiver + match the import edge it came from. + """ + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use crate::models::_entities::risk::Entity as Risk;\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + aliased = [e for e in _use_edges(service) if e.get("local_alias") == "Risk"] + assert len(aliased) == 1 + assert aliased[0]["relation"] == "imports" + assert aliased[0]["target_file"].endswith("risk.rs") + + +def test_module_alias_is_recorded_on_the_file_edge(tmp_path): + """`use crate::…::risk as risk_model;` aliases the MODULE, not a symbol.""" + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use crate::models::_entities::risk as risk_model;\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + aliased = [e for e in _use_edges(service) if e.get("local_alias") == "risk_model"] + assert len(aliased) == 1 + assert aliased[0]["relation"] == "imports_from" + assert aliased[0]["target_file"].endswith("risk.rs") + + +def test_unresolved_alias_still_names_the_local_binding(tmp_path): + """`use anyhow::Result as AnyResult;` stubs under the ALIAS, not `Result`.""" + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use anyhow::Result as AnyResult;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + labels = {n["label"] for n in extract_rust(service)["nodes"]} + assert "AnyResult" in labels + assert "Result" not in labels + + +def test_unaliased_import_records_no_alias(tmp_path): + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use crate::models::_entities::risk::Entity;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + assert not [e for e in _use_edges(service) if e.get("local_alias")] + + +def test_module_under_a_named_bin_anchors_crate_at_that_bin(tmp_path): + """`crate::` inside `src/bin/tool/helper.rs` is the bin crate, not `src`.""" + _crate(tmp_path) + (tmp_path / "src" / "shared.rs").write_text("pub struct Wrong;\n", encoding="utf-8") + (tmp_path / "src" / "bin" / "tool").mkdir(parents=True) + (tmp_path / "src" / "bin" / "tool" / "shared.rs").write_text( + "pub struct Shared;\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool" / "helper.rs").write_text( + "use crate::shared::Shared;\npub fn go() -> u32 { 1 }\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool.rs").write_text( + "mod helper;\nmod shared;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + targets = { + nodes[e["target"]]["source_file"] + for e in result["edges"] + if nodes.get(e["source"], {}).get("label") == "helper.rs" + and e["relation"] == "imports_from" + and nodes.get(e["target"], {}).get("label") == "shared.rs" + } + assert targets == {"src/bin/tool/shared.rs"} + + +def test_super_still_resolves_inside_a_named_bin_crate(tmp_path): + """The clamp must accept a bin crate's own root as the floor.""" + _crate(tmp_path) + (tmp_path / "src" / "bin" / "tool" / "deep").mkdir(parents=True) + (tmp_path / "src" / "bin" / "tool" / "shared.rs").write_text( + "pub struct Shared;\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool" / "deep" / "mod.rs").write_text( + "use super::shared::Shared;\npub fn go() -> u32 { 1 }\n", encoding="utf-8" + ) + (tmp_path / "src" / "bin" / "tool.rs").write_text( + "mod deep;\nmod shared;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("mod.rs", "shared.rs") in _edges(result, nodes, "imports_from") + + +def test_main_rs_prefers_its_own_crate_root_over_lib_rs(tmp_path): + """A package with both roots in `src/` must not attribute main's item to lib.""" + _crate(tmp_path) + (tmp_path / "src" / "lib.rs").write_text( + "pub mod models;\npub struct Config;\n", encoding="utf-8" + ) + (tmp_path / "src" / "main.rs").write_text( + "use crate::Config;\npub struct Config;\nfn main() {}\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + targets = { + nodes[e["target"]]["source_file"] + for e in result["edges"] + if nodes.get(e["source"], {}).get("label") == "main.rs" + and e["relation"] == "imports" + and nodes.get(e["target"], {}).get("label") == "Config" + } + assert targets == {"src/main.rs"} From 8ef89c6c4eba453dd3039e3a20976a5a516811c0 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 13:48:48 -0400 Subject: [PATCH 12/25] fix: Attribute deep self:: paths and mod.rs module aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `use self::Status::Active;` was dropped: the own-module fallback fired only for exactly two segments. A longer path names something inside the item, so the item is attributed and the rest dropped — the rule `_walk_rust_segments` already applies. The module-alias check compared against the target file's stem, which is `mod` for a `mod.rs`, so a redundant `use crate::…::_entities as _entities;` was recorded as a rename. It compares against the last path segment, the module's name as written. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 12 ++++++--- tests/test_rust_use_reexports.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 0cc396cd7..c4df07cee 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -321,9 +321,11 @@ def _resolve_rust_use_path( return resolved return None if anchor is None: - if first == "self" and len(segments) == 2: + if first == "self" and len(segments) >= 2: # `use self::Config;` names an item of THIS module, which needs no - # child directory to live in. + # child directory to live in. A longer path names something inside + # that item (`self::Status::Active`), so the item is attributed and + # the rest dropped — the same rule `_walk_rust_segments` applies. return path, segments[1] return None if first == "self": @@ -520,9 +522,11 @@ def emit_use_leaf(segments, alias, is_wildcard: bool, is_reexport: bool, line: i "source_location": f"L{line}", "weight": 1.0, "context": "import", "target_file": str(module_file), } - if symbol is None and alias and alias != module_file.stem: + if symbol is None and alias and alias != segments[-1]: # `use crate::models::risk as risk_model;` aliases the MODULE, so - # the alias belongs on the file-level edge. + # the alias belongs on the file-level edge. Compared against the + # last PATH segment, which is the module's name as written: a + # `mod.rs` file's stem is `mod`, not the module it defines. file_edge["local_alias"] = alias edges.append(file_edge) if symbol is None or is_wildcard: diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index 5b124f985..fb66ef50a 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -728,3 +728,46 @@ def test_main_rs_prefers_its_own_crate_root_over_lib_rs(tmp_path): and nodes.get(e["target"], {}).get("label") == "Config" } assert targets == {"src/main.rs"} + + +def test_self_prefixed_multi_segment_path_attributes_the_item(tmp_path): + """`use self::Status::Active;` names this module's own enum. + + Requiring exactly two segments dropped the edge for anything deeper. + """ + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use self::Status::Active;\npub enum Status { Active }\n" + "pub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + result, nodes = _graph(tmp_path) + assert ("service.rs", "Status") in _edges(result, nodes, "imports") + + +def test_mod_rs_module_alias_compares_against_the_written_name(tmp_path): + """A `mod.rs` file's stem is `mod`, not the module it defines. + + Comparing the alias against the stem stamped a redundant alias + (`as _entities`) as though it renamed something. + """ + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use crate::models::_entities as _entities;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + assert not [e for e in _use_edges(service) if e.get("local_alias")] + + +def test_mod_rs_module_alias_is_still_recorded_when_it_renames(tmp_path): + _crate(tmp_path) + service = tmp_path / "src" / "models" / "service.rs" + service.write_text( + "use crate::models::_entities as ents;\npub fn run() -> u32 { 1 }\n", + encoding="utf-8", + ) + aliased = [e for e in _use_edges(service) if e.get("local_alias") == "ents"] + assert len(aliased) == 1 + assert aliased[0]["target_file"].endswith("_entities/mod.rs") From 973a5b834c58908d6f200c8df83e3deab27a2653 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:01:49 -0400 Subject: [PATCH 13/25] fix: Close the remaining use-path resolution gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bin` is auto-discovered only under `src`, but it was also matched at the package root, so a top-level `bin/tool.rs` was treated as a crate root and `crate::` inside it resolved against `bin/tool/` instead of `src/`. The `super` walk enforced the crate floor only when a floor existed, skipping it entirely with no `Cargo.toml` above the file. No `super` is honoured without a floor. The anchor's own module file was seeded as the fallback owner for any unresolved segment, so `crate::models::risk::X` in a crate with no `models/` resolved to a symbol named `models` inside `lib.rs`. Only a LAST segment is attributed: an unresolved segment with more behind it was meant to be a module, and the path simply does not resolve. Adds a matrix over every on-disk crate layout crossed with every anchor keyword and path depth, pinning the exact resolutions and two invariants — a resolution never escapes the crate, and a `crate::` path never attributes a module name as a symbol. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 46 ++++++--- tests/test_rust_use_reexports.py | 157 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index c4df07cee..936fb1f12 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -153,7 +153,10 @@ def _rust_use_leaves(node, source: bytes, prefix: tuple[str, ...] = ()) -> list[ # Cargo's conventional auto-discovered target directories. A .rs file sitting # directly in one of these IS a crate root of its own, so `crate::` inside it # refers to that file's module tree, not to the library's `src/`. -_RUST_AUTO_TARGET_DIRS = ("bin", "examples", "tests", "benches") +# `bin` is only auto-discovered under `src`; the others sit at the package +# root. A top-level `bin/` is just a directory and holds no crate roots. +_RUST_PACKAGE_TARGET_DIRS = ("examples", "tests", "benches") +_RUST_SRC_TARGET_DIRS = ("bin",) def _rust_crate_src_root(path: Path) -> Path | None: @@ -187,9 +190,12 @@ def _rust_is_auto_target_dir(directory: Path, src: Path, package: Path) -> bool: ``bin`` lives under ``src``; ``examples``/``tests``/``benches`` sit at the package root. """ - if directory.name == "bin" and directory.parent == src: - return True - return directory.name in _RUST_AUTO_TARGET_DIRS and directory.parent == package + if directory.name in _RUST_SRC_TARGET_DIRS: + # Only under `src`, and only when `src` is a real directory — with no + # `src/` the package root stands in for it, and a top-level `bin/` + # there is not an auto-target. + return src.name == "src" and directory.parent == src + return directory.name in _RUST_PACKAGE_TARGET_DIRS and directory.parent == package def _rust_auto_target_crate_dir(path: Path, src: Path, package: Path) -> Path | None: @@ -290,20 +296,20 @@ def _resolve_rust_use_path( elif first == "self": anchor, index = _rust_self_module_dir(path), 1 elif first == "super": + # `super` walks one module up per keyword, but the crate root has no + # `super`. Every step and the final anchor are checked against the + # crate floor, so the walk can never leave the crate and resolve an + # unrelated file higher up the filesystem. With no `Cargo.toml` above + # the file there is no floor, and no `super` is honoured at all. + if src_root is None: + return None anchor, index = super_dir, 1 - # `super::super::x` walks further up one directory per keyword, but the - # crate root has no `super`: without the clamp the walk leaves the - # crate and can resolve an unrelated file higher up the filesystem. - # With no `Cargo.toml` above the file there is no root to clamp - # against, so no walking is allowed at all. while index < len(segments) and segments[index] == "super": - if anchor is None or src_root is None or anchor == src_root: + if anchor is None or anchor == src_root: return None anchor = anchor.parent index += 1 - if anchor is None: - return None - if src_root is not None and src_root not in (anchor, *anchor.parents): + if anchor is None or src_root not in (anchor, *anchor.parents): return None if anchor is None and first not in ("crate", "self", "super"): # 2018-edition paths may be crate-relative without the `crate` prefix; @@ -366,10 +372,22 @@ def _walk_rust_segments( if not segments: return None directory = anchor - resolved: Path | None = anchor_file + resolved: Path | None = None for position, segment in enumerate(segments): found = _rust_module_file(directory, segment) if found is None: + if ( + resolved is None + and anchor_file is not None + and position == len(segments) - 1 + ): + # `use crate::Config` — the only segment is not a module, so it + # is an item of the anchor's own module file. Restricted to a + # LAST segment: an unresolved segment with more behind it was + # meant to be a module (`crate::models::risk::X` where + # `models` does not exist), and attributing that module name as + # an item of `lib.rs` invents a symbol nothing defines. + return anchor_file, segment # Not a module, so the remainder is a symbol path inside the module # that resolved (`…::prelude::Risk` -> `Risk`). The FIRST unresolved # segment is the item the module defines; anything after it names diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index fb66ef50a..4bf79bc35 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -10,6 +10,9 @@ """ from __future__ import annotations +import os + +import pytest from pathlib import Path from graphify.extract import extract @@ -771,3 +774,157 @@ def test_mod_rs_module_alias_is_still_recorded_when_it_renames(tmp_path): aliased = [e for e in _use_edges(service) if e.get("local_alias") == "ents"] assert len(aliased) == 1 assert aliased[0]["target_file"].endswith("_entities/mod.rs") + + +# ── Exhaustive resolver sweep ───────────────────────────────────────────────── +# Four review rounds running found successively narrower edge cases in +# _resolve_rust_use_path, each one in a layout or path shape the previous fix +# had not considered. This matrix enumerates the input space instead: every +# crate layout Cargo supports on disk, crossed with every anchor keyword and +# path depth. It pins two invariants over the whole product — a resolved path +# never escapes the crate, and a resolved symbol is never a module name — plus +# the per-case answers, so a regression names itself. + +_SWEEP_LAYOUTS: dict[str, list[str]] = { + "lib": [ + "src/lib.rs", "src/shared.rs", + "src/models/mod.rs", "src/models/risk.rs", + ], + "lib_and_main": ["src/lib.rs", "src/main.rs", "src/shared.rs"], + "no_src": ["lib.rs", "shared.rs", "sub/mod.rs"], + "src_bin": [ + "src/lib.rs", "src/shared.rs", + "src/bin/tool.rs", "src/bin/tool/helper.rs", "src/bin/tool/shared.rs", + ], + "top_level_bin": ["src/lib.rs", "src/shared.rs", "bin/tool.rs"], + "examples": [ + "src/lib.rs", "src/shared.rs", + "examples/demo.rs", "examples/demo/helper.rs", + ], +} + +_SWEEP_PATHS: tuple[tuple[str, ...], ...] = ( + ("crate", "X"), + ("crate", "shared", "X"), + ("crate", "models", "risk", "X"), + ("crate", "models", "risk", "Status", "Active"), + ("self", "X"), + ("self", "X", "Y"), + ("self", "helper", "X"), + ("super", "shared", "X"), + ("super", "super", "shared", "X"), + ("super", "super", "super", "super", "outside", "X"), + ("shared", "X"), + ("anyhow", "Result"), +) + + +def _sweep_crate(tmp_path: Path, files: list[str]) -> Path: + """Lay the crate out under `pkg/`, with bait namesakes above it. + + The bait must sit OUTSIDE the package: a package with no `src/` treats its + own root as the source root, so bait placed there would be legitimately + in-crate and the escape check would be vacuous. + """ + root = tmp_path / "pkg" + root.mkdir() + (root / "Cargo.toml").write_text('[package]\nname = "d"\n', encoding="utf-8") + for bait in ("outside.rs", "shared.rs", "lib.rs"): + (tmp_path / bait).write_text("pub struct X;\n", encoding="utf-8") + for rel in files: + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("pub struct X;\n", encoding="utf-8") + return root + + +def _sweep(tmp_path: Path, layout: str) -> dict[tuple[str, str], tuple[str, str | None]]: + """Every (origin file, use path) that resolves, as repo-relative answers.""" + root = _sweep_crate(tmp_path, _SWEEP_LAYOUTS[layout]) + out: dict[tuple[str, str], tuple[str, str | None]] = {} + for origin in _SWEEP_LAYOUTS[layout]: + for segments in _SWEEP_PATHS: + resolved = _resolve_rust_use_path(segments, root / origin) + if resolved is None: + continue + module_file, symbol = resolved + # Relative to the crate root, so an escape shows up as `../…`. + rel = os.path.relpath(module_file, root) + out[(origin, "::".join(segments))] = (rel, symbol) + return out + + +@pytest.mark.parametrize("layout", sorted(_SWEEP_LAYOUTS)) +def test_sweep_never_escapes_the_crate(tmp_path, layout): + """No resolution may land outside the crate's own source tree. + + `outside.rs` and a top-level `shared.rs` sit beside the crate as bait for + a `super::` walk or a crate-relative guess that climbs too far. + """ + for (origin, path_text), (target, _symbol) in _sweep(tmp_path, layout).items(): + assert not target.startswith(".."), ( + f"{layout}: {origin} {path_text} escaped to {target}" + ) + + +@pytest.mark.parametrize("layout", sorted(_SWEEP_LAYOUTS)) +def test_sweep_never_attributes_a_module_name_as_a_symbol(tmp_path, layout): + """A `crate::`-anchored path must not attribute a module name as a symbol. + + `crate::models::risk::X` in a layout with no `models/` used to resolve to + `(lib.rs, "models")` — a symbol nothing defines. Restricted to `crate::` + because a CHILDLESS module may legitimately define an item that shares a + name with one of its siblings, so `self::helper::X` resolving to + `(helper.rs, "helper")` is a real answer, not a leaked module name. + """ + module_names = {Path(f).stem for f in _SWEEP_LAYOUTS[layout]} - {"mod", "lib", "main"} + for (origin, path_text), (target, symbol) in _sweep(tmp_path, layout).items(): + if not path_text.startswith("crate::"): + continue + assert symbol not in module_names, ( + f"{layout}: {origin} {path_text} -> {target} sym={symbol}" + ) + + +@pytest.mark.parametrize("layout", sorted(_SWEEP_LAYOUTS)) +def test_sweep_external_crate_never_resolves(tmp_path, layout): + """`use anyhow::Result` has no on-disk answer in any layout.""" + resolved = _sweep(tmp_path, layout) + assert not [k for k in resolved if k[1] == "anyhow::Result"] + + +def test_sweep_answers_are_pinned(tmp_path): + """The exact resolutions for the library layout, so a change is visible.""" + assert _sweep(tmp_path, "lib") == { + ('src/lib.rs', 'crate::X'): ('src/lib.rs', 'X'), + ('src/lib.rs', 'crate::models::risk::Status::Active'): ('src/models/risk.rs', 'Status'), + ('src/lib.rs', 'crate::models::risk::X'): ('src/models/risk.rs', 'X'), + ('src/lib.rs', 'crate::shared::X'): ('src/shared.rs', 'X'), + ('src/lib.rs', 'self::X'): ('src/lib.rs', 'X'), + ('src/lib.rs', 'shared::X'): ('src/shared.rs', 'X'), + ('src/models/mod.rs', 'crate::X'): ('src/lib.rs', 'X'), + ('src/models/mod.rs', 'crate::models::risk::Status::Active'): ('src/models/risk.rs', 'Status'), + ('src/models/mod.rs', 'crate::models::risk::X'): ('src/models/risk.rs', 'X'), + ('src/models/mod.rs', 'crate::shared::X'): ('src/shared.rs', 'X'), + ('src/models/mod.rs', 'self::X'): ('src/models/mod.rs', 'X'), + ('src/models/mod.rs', 'shared::X'): ('src/shared.rs', 'X'), + ('src/models/mod.rs', 'super::shared::X'): ('src/shared.rs', 'X'), + ('src/models/risk.rs', 'crate::X'): ('src/lib.rs', 'X'), + ('src/models/risk.rs', 'crate::models::risk::Status::Active'): ('src/models/risk.rs', 'Status'), + ('src/models/risk.rs', 'crate::models::risk::X'): ('src/models/risk.rs', 'X'), + ('src/models/risk.rs', 'crate::shared::X'): ('src/shared.rs', 'X'), + ('src/models/risk.rs', 'self::X'): ('src/models/risk.rs', 'X'), + ('src/models/risk.rs', 'self::X::Y'): ('src/models/risk.rs', 'X'), + ('src/models/risk.rs', 'self::helper::X'): ('src/models/risk.rs', 'helper'), + ('src/models/risk.rs', 'shared::X'): ('src/shared.rs', 'X'), + ('src/models/risk.rs', 'super::super::shared::X'): ('src/shared.rs', 'X'), + ('src/shared.rs', 'crate::X'): ('src/lib.rs', 'X'), + ('src/shared.rs', 'crate::models::risk::Status::Active'): ('src/models/risk.rs', 'Status'), + ('src/shared.rs', 'crate::models::risk::X'): ('src/models/risk.rs', 'X'), + ('src/shared.rs', 'crate::shared::X'): ('src/shared.rs', 'X'), + ('src/shared.rs', 'self::X'): ('src/shared.rs', 'X'), + ('src/shared.rs', 'self::X::Y'): ('src/shared.rs', 'X'), + ('src/shared.rs', 'self::helper::X'): ('src/shared.rs', 'helper'), + ('src/shared.rs', 'shared::X'): ('src/shared.rs', 'X'), + ('src/shared.rs', 'super::shared::X'): ('src/shared.rs', 'X'), + } From 050c4b54abaee913e5bb480f5cca1d2b532434aa Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:28:52 -0400 Subject: [PATCH 14/25] fix: Make the SFC mask byte-preserving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter reports byte offsets, but the mask replaced each masked character with one space, so any non-ASCII markup — an accented word or an emoji in the template — shifted every byte offset after it and misreported the column of everything in the script below. A masked character now becomes as many spaces as its UTF-8 encoding takes. Line numbers were already correct and stay so. Applies to `.vue` as well, which shares the masker. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/resolution.py | 12 ++++++++++- tests/test_svelte_extraction.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 8dbc361a9..63699fcc0 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -628,9 +628,19 @@ def _sfc_mask_non_script(src: str) -> tuple[str, str | None]: /instance pair (or a Vue ``setup``/options pair) is parsed as one unit. Returns ``(masked_source, lang)``; ``lang`` is the first block's declared ``lang``. + + Blanking is BYTE-preserving: a masked character becomes as many spaces as + its UTF-8 encoding takes. tree-sitter reports byte offsets, so one space + per character would shift every offset after any non-ASCII markup (an + accented word or an emoji in the template) and misreport the column of + everything in the script below it. """ def _blank(s: str) -> str: - return re.sub(r"[^\r\n]", " ", s) + if s.isascii(): + return re.sub(r"[^\r\n]", " ", s) + return "".join( + ch if ch in "\r\n" else " " * len(ch.encode("utf-8")) for ch in s + ) out: list[str] = [] pos = 0 diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index 704d8ccec..535364069 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -397,3 +397,37 @@ def test_repeated_dynamic_import_emits_one_edge(tmp_path): if e.get("target") == target and e.get("relation") == "dynamic_import" ] assert len(matching) == 1 + + +def test_mask_preserves_byte_offsets_through_non_ascii_markup(): + """tree-sitter reports BYTE offsets, so the mask must be byte-preserving. + + One space per character shifts every offset after any non-ASCII markup — + an accented word or an emoji in the template — misreporting the column of + everything in the script below it. + """ + src = ( + '
{x}
\n' + '\n" + ) + masked, _lang = _sfc_mask_non_script(src) + assert len(masked.encode("utf-8")) == len(src.encode("utf-8")) + assert masked.count("\n") == src.count("\n") + assert masked.encode("utf-8").index(b"import") == src.encode("utf-8").index(b"import") + + +def test_symbol_lines_are_right_under_non_ascii_markup(tmp_path): + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Emoji.svelte", + '
{x}
\n' + '\n", + ) + result = extract_svelte(component) + lines = {n["label"]: n.get("source_location") for n in result["nodes"]} + assert lines.get("handler()") == "L4" From ddccf899edcd5a70f5ffe03a6e50abaefde9b608 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:28:52 -0400 Subject: [PATCH 15/25] fix: Never label a node with destructuring pattern source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pattern that binds no identifier (`const {} =`, `const { a: {} } =`, `const { /* nothing */ } =`) fell back to the pattern SOURCE as the node label — the bug `_js_pattern_bound_names` exists to fix. The `normalize_id` guard only caught patterns that normalize to nothing, so `{ a: {} }` still shipped as a symbol. A pattern that binds nothing now nodes nothing. Also documents that `_is_require_initializer` accepts a call reached through member access, which is deliberate. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/engine.py | 15 +++++++++++- tests/test_js_exported_scalar_bindings.py | 29 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index f662a9059..bbe89976a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2020,6 +2020,11 @@ def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges _JS_FUNCTION_VALUE_TYPES = frozenset({"arrow_function", "function_expression", "function", "generator_function"}) +# Declarator `name` node types that are a destructuring PATTERN rather than a +# single bound identifier. Their `name` field is the pattern source, never a +# symbol, so it must not be used as a node label. +_JS_DESTRUCTURING_PATTERNS = frozenset({"object_pattern", "array_pattern"}) + def _scan_js_nested_function_declarations( container_node, parent_nid: str, *, source: bytes, config, @@ -2393,7 +2398,15 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, # stub instead of the callee's definition. const_names = [] elif not const_names: - const_names = [_read_text(name_node, source)] + if name_node.type in _JS_DESTRUCTURING_PATTERNS: + # A pattern that binds nothing (`const {} =`, + # `const { a: {} } =`) has no name to node. + # Falling back to the pattern SOURCE is the + # bug `_js_pattern_bound_names` exists to + # fix — it mints `{ a: {} }` as a symbol. + const_names = [] + else: + const_names = [_read_text(name_node, source)] for const_name in const_names: # A name that normalizes to nothing would collapse # the id to the absolute file-stem and leak the diff --git a/tests/test_js_exported_scalar_bindings.py b/tests/test_js_exported_scalar_bindings.py index 87ea3739b..27679e027 100644 --- a/tests/test_js_exported_scalar_bindings.py +++ b/tests/test_js_exported_scalar_bindings.py @@ -248,3 +248,32 @@ def test_member_access_require_is_still_an_import(tmp_path): ] assert len(calls) == 1 assert nodes[calls[0]["target"]]["source_file"].endswith("lib.js") + + +def test_pattern_binding_nothing_mints_no_node(tmp_path): + """A pattern that binds no identifier has no name to node. + + Falling back to the pattern SOURCE is the bug `_js_pattern_bound_names` + exists to fix; the `normalize_id` guard only caught patterns that + normalize to nothing, so `{ a: {} }` still shipped as a symbol. + """ + for name, body in ( + ("empty", "export const {} = getThing();\n"), + ("nested", "export const { a: {} } = getThing();\n"), + ("comment", "export const { /* nothing */ } = getThing();\n"), + ("arr", "export const [] = getThing();\n"), + ): + source = tmp_path / f"{name}.ts" + source.write_text(body, encoding="utf-8") + labels = {n["label"] for n in extract_js(source)["nodes"]} + assert labels == {f"{name}.ts"}, name + + +def test_exported_destructured_require_is_still_suppressed(tmp_path): + """Binding exported patterns must not undo the CJS-import exclusion.""" + source = tmp_path / "barrel.js" + source.write_text( + "export const { doWork } = require('./lib');\n", encoding="utf-8" + ) + labels = {n["label"] for n in extract_js(source)["nodes"]} + assert "doWork" not in labels From e566e6fef71a8e698fdbbc3fe48d6ece91283643 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:28:52 -0400 Subject: [PATCH 16/25] fix: Resolve items of a file-backed parent module A Rust module can be backed by a sibling file: `src/models.rs` owns `src/models/`. `_rust_module_root_file` looked only for `mod.rs`/`lib.rs`/`main.rs`, so `use super::Config` resolved only when the parent happened to be a `mod.rs` and missed every item defined in a file-backed parent. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 6 +++++- tests/test_rust_use_reexports.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 936fb1f12..fb91d1723 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -357,7 +357,11 @@ def _rust_module_root_file(directory: Path) -> Path | None: candidate = directory / name if candidate.is_file(): return candidate - return None + # A module can equally be backed by a sibling FILE: `src/models.rs` owns + # `src/models/`. Without this, `use super::Config` from a child resolved + # only when the parent happened to be a `mod.rs`. + sibling = directory.parent / f"{directory.name}.rs" + return sibling if sibling.is_file() else None def _walk_rust_segments( diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index 4bf79bc35..f78557828 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -928,3 +928,24 @@ def test_sweep_answers_are_pinned(tmp_path): ('src/shared.rs', 'shared::X'): ('src/shared.rs', 'X'), ('src/shared.rs', 'super::shared::X'): ('src/shared.rs', 'X'), } + + +def test_super_reaches_an_item_of_a_file_backed_parent_module(tmp_path): + """A module can be backed by a sibling FILE: `src/models.rs` owns `models/`. + + `use super::Config` resolved only when the parent happened to be a + `mod.rs`, so the whole file-backed half of Rust's module system missed + every item defined in a parent. + """ + (tmp_path / "src" / "models").mkdir(parents=True) + (tmp_path / "Cargo.toml").write_text('[package]\nname = "d"\n', encoding="utf-8") + (tmp_path / "src" / "lib.rs").write_text("pub mod models;\n", encoding="utf-8") + (tmp_path / "src" / "models.rs").write_text( + "pub mod risk;\npub struct Config;\n", encoding="utf-8" + ) + (tmp_path / "src" / "models" / "risk.rs").write_text( + "use super::Config;\npub fn run() -> u32 { 1 }\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + assert ("risk.rs", "models.rs") in _edges(result, nodes, "imports_from") + assert ("risk.rs", "Config") in _edges(result, nodes, "imports") From 47a6cbb69d42ce1acd52ceed2942df1269bbeb27 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:39:05 -0400 Subject: [PATCH 17/25] fix: Pick the grammar that parses every SFC script block The mask keeps every ` close tag pos = m.end() - if lang is None: - lang_m = _SFC_SCRIPT_LANG_RE.search(m.group(1)) - if lang_m: - lang = lang_m.group(1).lower() + lang_m = _SFC_SCRIPT_LANG_RE.search(m.group(1)) + if lang_m: + langs.append(lang_m.group(1).lower()) out.append(_blank(src[pos:])) - return "".join(out), lang + return "".join(out), _sfc_widest_lang(langs) + + +# Grammar precedence when a component's script blocks disagree. Every block is +# parsed as ONE unit, so the grammar has to accept all of them: TS is a +# superset of JS, and TSX of JSX, but not the reverse. A Svelte 5 `\n' + '\n' + ) + assert _sfc_mask_non_script(src)[1] == "ts" + + +def test_all_js_blocks_still_pick_js(): + src = '\n' + assert _sfc_mask_non_script(src)[1] == "js" + + +def test_js_then_ts_component_parses_the_ts_block(tmp_path): + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Mixed.svelte", + '\n" + '\n", + ) + result = extract_svelte(component) + assert not result.get("parse_errors") + assert _make_id(str(tmp_path / "format.ts")) in _targets( + result, relation="imports_from" + ) + assert {"PRESET", "handler()"} <= _labels(result) From 671ab195f79de3e0078b7c73a376dfd58fb846ef Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 14:39:05 -0400 Subject: [PATCH 18/25] fix: Resolve keyword-only Rust use paths `_rust_use_leaves` reduces a use-list `self` to its prefix, so `use super::{self, X};` yields a bare `('super',)` leaf. Nothing was left to walk after the anchor keyword was consumed, and the empty remainder returned None, dropping an import of the parent module. A keyword-only path names the anchor's own module, so that file is the target. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 11 ++++++++- tests/test_rust_use_reexports.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index fb91d1723..5288f2803 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -333,6 +333,9 @@ def _resolve_rust_use_path( # that item (`self::Status::Active`), so the item is attributed and # the rest dropped — the same rule `_walk_rust_segments` applies. return path, segments[1] + if first == "self": + # `use self::{self};` names this module, children or not. + return path, None return None if first == "self": anchor_file = path @@ -343,7 +346,13 @@ def _resolve_rust_use_path( anchor_file = path else: anchor_file = _rust_module_root_file(anchor) - return _walk_rust_segments(anchor, segments[index:], anchor_file=anchor_file) + remainder = segments[index:] + if not remainder: + # A keyword-only path: `use super::{self};` / `use crate::{self};` + # name the anchor's module itself, with nothing left to walk. Without + # this the leaf resolved to nothing and the import was dropped. + return (anchor_file, None) if anchor_file is not None else None + return _walk_rust_segments(anchor, remainder, anchor_file=anchor_file) def _rust_module_root_file(directory: Path) -> Path | None: diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index f78557828..aa4e93700 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -949,3 +949,43 @@ def test_super_reaches_an_item_of_a_file_backed_parent_module(tmp_path): result, nodes = _graph(tmp_path) assert ("risk.rs", "models.rs") in _edges(result, nodes, "imports_from") assert ("risk.rs", "Config") in _edges(result, nodes, "imports") + + +@pytest.mark.parametrize( + "segments,expected", + [ + (("super",), ("src/models/mod.rs", None)), + (("crate",), ("src/lib.rs", None)), + (("super", "super"), ("src/lib.rs", None)), + (("self",), ("src/models/risk.rs", None)), + ], +) +def test_keyword_only_use_path_names_the_anchor_module(tmp_path, segments, expected): + """`use super::{self};` leaves nothing to walk after the keyword. + + `_rust_use_leaves` reduces a use-list `self` to the prefix, so + `use super::{self, X}` yields a bare `('super',)` leaf. The walk was + handed an empty remainder and returned None, dropping the import. + """ + (tmp_path / "src" / "models").mkdir(parents=True) + (tmp_path / "Cargo.toml").write_text('[package]\nname = "d"\n', encoding="utf-8") + (tmp_path / "src" / "lib.rs").write_text("pub mod models;\n", encoding="utf-8") + (tmp_path / "src" / "models" / "mod.rs").write_text("pub mod risk;\n", encoding="utf-8") + (tmp_path / "src" / "models" / "risk.rs").write_text("pub struct X;\n", encoding="utf-8") + + resolved = _resolve_rust_use_path(segments, tmp_path / "src" / "models" / "risk.rs") + assert resolved is not None + module_file, symbol = resolved + assert (str(module_file.relative_to(tmp_path)), symbol) == expected + + +def test_use_list_self_alongside_a_name_edges_both(tmp_path): + """`use super::{self, X};` imports the module AND the name.""" + _crate(tmp_path) + (tmp_path / "src" / "models" / "_entities" / "prelude.rs").write_text( + "use super::{self, risk};\n", encoding="utf-8" + ) + result, nodes = _graph(tmp_path) + imports_from = _edges(result, nodes, "imports_from") + assert ("prelude.rs", "mod.rs") in imports_from + assert ("prelude.rs", "risk.rs") in imports_from From 0af6a2422dad70ed9ad68f3a65da5c40a4cb0523 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 15:03:46 -0400 Subject: [PATCH 19/25] fix: Use the TSX grammar for TSX single-file components Two gaps in SFC grammar selection. The widest-lang pick ordered TSX above JSX above TS, so a `lang="ts"` block beside a `lang="jsx"` one chose JSX and the TS block's annotations failed. TSX is the only grammar that accepts both, so the choice is now made on what the blocks need rather than on a fixed ranking. `_parse_js_tree` keyed the TSX grammar off the file suffix, which for an SFC is `.vue`/`.svelte`. A `lang="tsx"` script was parsed with the plain TS grammar, misparsing JSX and dropping the calls inside it. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/resolution.py | 40 +++++++++++++++++----------- tests/test_svelte_extraction.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 67bdbf1e2..ac45061aa 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -658,20 +658,26 @@ def _blank(s: str) -> str: return "".join(out), _sfc_widest_lang(langs) -# Grammar precedence when a component's script blocks disagree. Every block is -# parsed as ONE unit, so the grammar has to accept all of them: TS is a -# superset of JS, and TSX of JSX, but not the reverse. A Svelte 5 `\n' + '\n' + ) + assert _sfc_mask_non_script(src)[1] == "tsx" + + +def test_jsx_only_blocks_pick_jsx(): + src = '\n' + assert _sfc_mask_non_script(src)[1] == "jsx" + + +def test_tsx_lang_reaches_the_call_graph_pass(tmp_path): + """`_parse_js_tree` keyed the TSX grammar off the file SUFFIX. + + An SFC's suffix is `.svelte`, so a `lang="tsx"` script was parsed with the + plain TS grammar, which misparses JSX and drops the calls in it. + """ + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Tsx.svelte", + '\n", + ) + result = extract( + [component, tmp_path / "format.ts"], cache_root=tmp_path, + ) + nodes = {n["id"]: n for n in result["nodes"]} + calls = [ + e for e in result["edges"] + if e["relation"] == "calls" + and nodes.get(e["source"], {}).get("label") == "render()" + and nodes.get(e["target"], {}).get("label") == "fmt()" + ] + assert calls From ea44203d2d6433555df07087f5fa1bbd00294079 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 15:03:46 -0400 Subject: [PATCH 20/25] fix: Only treat a literal require specifier as a CJS import `_is_require_initializer` checked the callee name alone, so `require(name)` and `require('./' + x)` suppressed the local binding too. `_require_imports_js` needs a string literal to emit an edge at all, so nothing replaced the suppressed name and it left the graph entirely. A string-literal specifier is now required. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/engine.py | 13 ++++++++++++- tests/test_js_exported_scalar_bindings.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index bbe89976a..d6f560a93 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1920,6 +1920,12 @@ def _is_require_initializer(value_node, source: bytes) -> bool: both sides must agree on what counts or a destructured binding would be both imported and shadowed by a local stub. + A STRING-LITERAL specifier is required, because that is what + :func:`_require_imports_js` needs to emit an edge at all. A computed + specifier (``require(name)``, ``require('./' + x)``) names no module it + can resolve, so suppressing the local binding for one would delete the + name from the graph outright rather than repoint it. + ``_find_require_call`` matches the call *shape* only — any ``identifier(...)`` — and leaves the callee-name check to its callers, so it must not be used alone to recognise a CJS import. @@ -1928,7 +1934,12 @@ def _is_require_initializer(value_node, source: bytes) -> bool: if call is None: return False fn = call.child_by_field_name("function") - return fn is not None and _read_text(fn, source) == "require" + if fn is None or _read_text(fn, source) != "require": + return False + args = call.child_by_field_name("arguments") + if args is None: + return False + return any(arg.type == "string" for arg in args.children) def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges: list, str_path: str) -> bool: diff --git a/tests/test_js_exported_scalar_bindings.py b/tests/test_js_exported_scalar_bindings.py index 27679e027..5b7de3444 100644 --- a/tests/test_js_exported_scalar_bindings.py +++ b/tests/test_js_exported_scalar_bindings.py @@ -277,3 +277,25 @@ def test_exported_destructured_require_is_still_suppressed(tmp_path): ) labels = {n["label"] for n in extract_js(source)["nodes"]} assert "doWork" not in labels + + +def test_computed_require_specifier_still_binds_locally(tmp_path): + """`require(name)` names no module, so nothing replaces the local binding. + + Suppressing it would delete the name from the graph rather than repoint + it: `_require_imports_js` needs a string literal to emit an edge at all. + """ + for name, body in ( + ("var", "const m = './lib';\nexport const { doWork } = require(m);\n"), + ("concat", "export const { doWork } = require('./' + n);\n"), + ): + source = tmp_path / f"{name}.js" + source.write_text(body, encoding="utf-8") + labels = {n["label"] for n in extract_js(source)["nodes"]} + assert "doWork" in labels, name + + +def test_literal_require_specifier_is_still_suppressed(tmp_path): + source = tmp_path / "lit.js" + source.write_text("const { doWork } = require('./lib');\n", encoding="utf-8") + assert "doWork" not in {n["label"] for n in extract_js(source)["nodes"]} From 3d90c10110ae3d3f437146c66ad9d35627e8eb69 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 15:03:46 -0400 Subject: [PATCH 21/25] fix: Treat a bare Rust use path as a crate from edition 2018 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 2018 a bare `use foo::…` names a crate; a module of the current crate requires `crate::`/`self::`/`super::`. The crate-relative fallback ran regardless of edition, so a local module sharing a dependency's name shadowed it — `use anyhow::Result` landing on `src/anyhow.rs`, the false-hub failure this resolver exists to avoid. The fallback is now gated on the package edition, read from `Cargo.toml`, where an absent `edition` means 2015 as Cargo defines it. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 36 +++++++++++++++++++++++-- tests/test_rust_use_reexports.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 5288f2803..911623806 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -184,6 +184,31 @@ def _rust_crate_src_root(path: Path) -> Path | None: probe = probe.parent +_RUST_EDITION_RE = re.compile(r"^\s*edition\s*=\s*[\"'](\d{4})[\"']", re.MULTILINE) + + +def _rust_package_edition(path: Path) -> int: + """The Rust edition of the package owning ``path``. + + Cargo's default when `edition` is absent is 2015, which is the edition + whose path rules the bare-path fallback below models. + """ + probe = path.parent + while True: + manifest = probe / "Cargo.toml" + if manifest.is_file(): + try: + match = _RUST_EDITION_RE.search( + manifest.read_text(encoding="utf-8", errors="replace") + ) + except OSError: + return 2015 + return int(match.group(1)) if match else 2015 + if probe.parent == probe: + return 2015 + probe = probe.parent + + def _rust_is_auto_target_dir(directory: Path, src: Path, package: Path) -> bool: """True when ``directory`` is one of Cargo's auto-discovered target dirs. @@ -312,8 +337,15 @@ def _resolve_rust_use_path( if anchor is None or src_root not in (anchor, *anchor.parents): return None if anchor is None and first not in ("crate", "self", "super"): - # 2018-edition paths may be crate-relative without the `crate` prefix; - # try the crate root, then the current module. An external crate simply + if _rust_package_edition(path) >= 2018: + # From 2018 a bare `use foo::…` names a CRATE, never a module of + # this one — a local module requires `crate::`/`self::`/`super::`. + # Resolving it locally lets a same-named module shadow the + # dependency (`use anyhow::Result` landing on `src/anyhow.rs`), + # which is the false-hub failure this resolver exists to avoid. + return None + # 2015-edition paths are crate-relative without a `crate` prefix; try + # the crate root, then the current module. An external crate simply # resolves to nothing at either. for candidate_anchor in (src_root, search_dir): if candidate_anchor is None: diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index aa4e93700..86fb6dee9 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -989,3 +989,49 @@ def test_use_list_self_alongside_a_name_edges_both(tmp_path): imports_from = _edges(result, nodes, "imports_from") assert ("prelude.rs", "mod.rs") in imports_from assert ("prelude.rs", "risk.rs") in imports_from + + +def _edition_crate(tmp_path: Path, edition: str | None) -> Path: + (tmp_path / "src").mkdir(parents=True) + manifest = '[package]\nname = "d"\n' + if edition is not None: + manifest += f'edition = "{edition}"\n' + (tmp_path / "Cargo.toml").write_text(manifest, encoding="utf-8") + (tmp_path / "src" / "lib.rs").write_text("pub mod anyhow;\n", encoding="utf-8") + # A local module sharing an external dependency's name. + (tmp_path / "src" / "anyhow.rs").write_text("pub struct Result;\n", encoding="utf-8") + (tmp_path / "src" / "service.rs").write_text("pub fn run() {}\n", encoding="utf-8") + return tmp_path + + +@pytest.mark.parametrize("edition", ["2018", "2021", "2024"]) +def test_bare_path_is_a_crate_from_the_2018_edition(tmp_path, edition): + """`use anyhow::Result` names the CRATE, never a local module. + + From 2018 a local module needs `crate::`/`self::`/`super::`, so resolving + a bare path locally lets a same-named module shadow the dependency — the + false-hub failure this resolver exists to avoid. + """ + root = _edition_crate(tmp_path, edition) + assert _resolve_rust_use_path(("anyhow", "Result"), root / "src" / "service.rs") is None + + +@pytest.mark.parametrize("edition", ["2015", None]) +def test_bare_path_stays_crate_relative_in_the_2015_edition(tmp_path, edition): + """2015 paths ARE crate-relative, and an absent `edition` means 2015.""" + root = _edition_crate(tmp_path, edition) + resolved = _resolve_rust_use_path(("anyhow", "Result"), root / "src" / "service.rs") + assert resolved is not None + module_file, symbol = resolved + assert (str(module_file.relative_to(root)), symbol) == ("src/anyhow.rs", "Result") + + +def test_explicit_crate_prefix_still_resolves_under_2021(tmp_path): + """The edition rule constrains BARE paths only.""" + root = _edition_crate(tmp_path, "2021") + resolved = _resolve_rust_use_path( + ("crate", "anyhow", "Result"), root / "src" / "service.rs" + ) + assert resolved is not None + module_file, symbol = resolved + assert (str(module_file.relative_to(root)), symbol) == ("src/anyhow.rs", "Result") From 84e7943de22a94588e7814a13d520fa12603672a Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 15:48:11 -0400 Subject: [PATCH 22/25] refactor: Dedupe rescued edges via build.dedupe_edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Svelte rescue had its own `_dedupe_edges`, byte-for-byte the same collapse on `(source, target, relation)` that `build.dedupe_edges` already does. Worse, `_dedupe_edges` is the alias cli.py and watch.py bind to that function, so two names one underscore apart took different argument types — an edge list against a result dict. The rescue calls the shared helper. Imported inside the function, as cli.py and watch.py do. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 22 ++++------------------ tests/test_svelte_extraction.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 1bc121a29..ecefff7ca 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1679,29 +1679,15 @@ def extract_svelte(path: Path) -> dict: # Both rescues can repeat an edge: a recovered parse edges some # imports before the error node, and a specifier imported twice in one # file (`import('./X')` in two markup branches) is matched twice. - _dedupe_edges(result) + # Imported inside the function, as cli.py and watch.py do, to keep + # extract.py free of a module-level dependency on build.py. + from graphify.build import dedupe_edges + result["edges"] = dedupe_edges(result.get("edges", [])) except Exception: pass return result -def _dedupe_edges(result: dict) -> None: - """Drop repeat ``(source, target, relation)`` edges, keeping the first. - - The first occurrence is the AST-derived edge, which carries ``target_file`` - and richer context than a regex rescue's. - """ - seen: set[tuple[str, str, str]] = set() - kept = [] - for edge in result.get("edges", []): - key = (edge.get("source"), edge.get("target"), edge.get("relation")) - if key in seen: - continue - seen.add(key) - kept.append(edge) - result["edges"] = kept - - def extract_astro(path: Path) -> dict: """Extract imports from .astro files: frontmatter (TS) + template regex fallback. diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index beb1287f9..33a225bf8 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -514,3 +514,16 @@ def test_tsx_lang_reaches_the_call_graph_pass(tmp_path): and nodes.get(e["target"], {}).get("label") == "fmt()" ] assert calls + + +def test_rescue_dedupe_uses_the_shared_build_helper(): + """The rescue dedupes through `build.dedupe_edges`, not a private twin. + + A second `_dedupe_edges` in extract.py collided by name with the alias + cli.py and watch.py already bind to `build.dedupe_edges`, while taking a + result dict instead of an edge list — a trap for anyone importing the + wrong one. + """ + import graphify.extract as extract_module + + assert not hasattr(extract_module, "_dedupe_edges") From c083c1dbd40711b34f88a402ca71a65feb9c0156 Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Wed, 2 Sep 2026 17:41:35 -0400 Subject: [PATCH 23/25] fix: Rescue Svelte imports on a hard extractor failure too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_extract_generic` reports a recovered parse as `parse_errors` but a hard failure — grammar missing, source unreadable — as `error`, with no tree and no nodes. The rescue was gated on `parse_errors` alone, so the case where the AST contributed NOTHING was the one case it skipped, and every static import was lost; the pre-mask extractor ran its regex unconditionally and recovered them. The gate now covers both. On the `error` path the file node is minted too, in the shape `_extract_generic` would have produced, since a rescued edge with no source node is dropped at build time (#701). The `error` key is left in place for extract()'s own reporting. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 23 ++++++++++-- tests/test_svelte_extraction.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index ecefff7ca..2fc20a592 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1643,6 +1643,18 @@ def extract_svelte(path: Path) -> dict: # _make_id(str(path)) - single arg, no stem prefix. Otherwise the source # endpoint is a phantom node and build_from_json drops the edge (#701). file_node_id = _make_id(str(path)) + if result.get("error") and not result.get("nodes"): + # A hard failure (grammar missing, unreadable source) returns no + # nodes at all, so a rescued edge would have a dangling source and + # be dropped at build time. Mint the file node _extract_generic + # would have, in the same shape, so the rescue is worth running. + # `error` stays on the result for extract()'s own reporting. + result.setdefault("nodes", []).append({ + "id": file_node_id, "label": path.name, + "file_type": "code", "source_file": str(path), + "source_location": "L1", + }) + existing_ids.add(file_node_id) aliases = _load_tsconfig_aliases(path.parent) base_url = _load_tsconfig_base_url(path.parent) # Scanned over the raw source, not the masked one, so template-layer @@ -1658,10 +1670,13 @@ def extract_svelte(path: Path) -> dict: result, existing_ids, file_node_id, path, raw, "dynamic_import", aliases, base_url, ) - if result.get("parse_errors"): - # The masked script did not parse cleanly, so `import_statement` - # nodes may never have been reached and the AST pass edged nothing. - # Fall back to the regex rescue the pre-mask extractor relied on. + if result.get("parse_errors") or result.get("error"): + # The AST pass produced no usable tree — the masked script parsed + # WITH errors (`parse_errors`, so `import_statement` nodes may + # never have been reached), or it failed outright (`error`: the + # grammar is missing, the source unreadable). Both leave imports + # unedged, so fall back to the regex rescue the pre-mask extractor + # ran unconditionally. # Gated on the failure so a clean parse does not double-emit: the # AST already edges those specifiers. Scanned over the MASKED # source, whose only surviving text is the script regions. diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index 33a225bf8..24e29b431 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -527,3 +527,68 @@ def test_rescue_dedupe_uses_the_shared_build_helper(): import graphify.extract as extract_module assert not hasattr(extract_module, "_dedupe_edges") + + +def _generic_hard_error(path, config, source_override=None, **kwargs): + """What `_extract_generic` returns when it cannot parse at all.""" + return {"nodes": [], "edges": [], "error": "tree-sitter-typescript not installed"} + + +def test_static_rescue_runs_on_a_hard_extractor_error(tmp_path, monkeypatch): + """`_extract_generic` signals a HARD failure with `error`, not `parse_errors`. + + A missing grammar or unreadable source returns no tree and no nodes. The + rescue was gated on `parse_errors` alone, so it never ran and every static + import was lost — where the pre-mask extractor ran the regex + unconditionally and recovered them. + """ + import graphify.extract as extract_module + + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Broken.svelte", + '\n" + "\n", + ) + monkeypatch.setattr(extract_module, "_extract_generic", _generic_hard_error) + result = extract_module.extract_svelte(component) + + assert result.get("error"), "the error must stay for extract()'s reporting" + assert _make_id(str(tmp_path / "format.ts")) in _targets( + result, relation="imports_from" + ) + + +def test_hard_error_rescue_mints_the_source_file_node(tmp_path, monkeypatch): + """A rescued edge needs a real source node or build drops it (#701).""" + import graphify.extract as extract_module + + _write(tmp_path / "format.ts", "export const fmt = (s: string) => s\n") + component = _write( + tmp_path / "Broken.svelte", + '\n', + ) + monkeypatch.setattr(extract_module, "_extract_generic", _generic_hard_error) + result = extract_module.extract_svelte(component) + + file_node_id = _make_id(str(component)) + assert file_node_id in {n["id"] for n in result["nodes"]} + assert all(e["source"] == file_node_id for e in result["edges"]) + + +def test_dynamic_rescue_also_survives_a_hard_error(tmp_path, monkeypatch): + import graphify.extract as extract_module + + _write(tmp_path / "Lazy.svelte", "\n") + component = _write( + tmp_path / "Host.svelte", + "{#await import('./Lazy.svelte')}{/await}\n", + ) + monkeypatch.setattr(extract_module, "_extract_generic", _generic_hard_error) + result = extract_module.extract_svelte(component) + + assert _make_id(str(tmp_path / "Lazy.svelte")) in _targets( + result, relation="dynamic_import" + ) From 0f34d29abb9ccee3f79387dd0e8fc10738cbf7ed Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Thu, 3 Sep 2026 13:43:07 -0400 Subject: [PATCH 24/25] fix: Report unreadable SFC components instead of returning empty A read failure returned a bare empty result, so a permission or I/O error was indistinguishable from an empty component: the file contributed nothing and extract() reported success. Both `.svelte` and `.vue` now return `error`, which extract() already warns on. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extract.py | 14 ++++++++++---- tests/test_svelte_extraction.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 2fc20a592..25d259626 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1623,8 +1623,11 @@ def extract_svelte(path: Path) -> dict: """ try: src = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return {"nodes": [], "edges": []} + except OSError as e: + # Report the failure rather than returning an empty result: an + # unreadable component is indistinguishable from an empty one + # otherwise, and extract() warns on `error` (#2551). + return {"nodes": [], "edges": [], "error": f"cannot read {path}: {e}"} masked, lang = _sfc_mask_non_script(src) if lang == "tsx": @@ -1781,8 +1784,11 @@ def extract_vue(path: Path) -> dict: """ try: src = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return {"nodes": [], "edges": []} + except OSError as e: + # Report the failure rather than returning an empty result: an + # unreadable component is indistinguishable from an empty one + # otherwise, and extract() warns on `error` (#2551). + return {"nodes": [], "edges": [], "error": f"cannot read {path}: {e}"} masked, lang = _sfc_mask_non_script(src) if lang == "tsx": diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index 24e29b431..c4aa4b084 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -592,3 +592,36 @@ def test_dynamic_rescue_also_survives_a_hard_error(tmp_path, monkeypatch): assert _make_id(str(tmp_path / "Lazy.svelte")) in _targets( result, relation="dynamic_import" ) + + +def test_unreadable_component_reports_an_error(tmp_path, monkeypatch): + """An unreadable component must not look like an empty one. + + Returning a bare empty result hides a permission or I/O failure: the file + contributes nothing and extract() reports success. + """ + component = _write(tmp_path / "Locked.svelte", "\n") + + def _deny(*args, **kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "read_text", _deny) + result = extract_svelte(component) + + assert result["nodes"] == [] + assert "Permission denied" in result.get("error", "") + + +def test_unreadable_vue_component_reports_an_error(tmp_path, monkeypatch): + """`extract_vue` shares the shape and had the same silent return.""" + from graphify.extract import extract_vue + + component = _write(tmp_path / "Locked.vue", "\n") + + def _deny(*args, **kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "read_text", _deny) + result = extract_vue(component) + + assert "Permission denied" in result.get("error", "") From 4a1d901e99faa9ddf04c12894c646184a348c2dc Mon Sep 17 00:00:00 2001 From: Will Mitchell Date: Thu, 3 Sep 2026 13:43:07 -0400 Subject: [PATCH 25/25] fix: Read a workspace member's inherited Rust edition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `edition.workspace = true` is the normal shape for a crate in a monorepo, and the edition itself lives in an ancestor's `[workspace.package]`. Only a literal `edition = "…"` was read, so every workspace member looked like 2015 and the crate-relative fallback came back for exactly the crates the edition rule protects — a local module named after a dependency shadowed it again. Both the dotted and inline-table inherit forms are recognised, and an inherit with no ancestor workspace still falls back to 2015 as Cargo defines it. Co-Authored-By: Claude Opus 5 (1M context) --- graphify/extractors/rust.py | 63 ++++++++++++++++++++++++++------ tests/test_rust_use_reexports.py | 62 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 911623806..6aa276124 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -185,27 +185,66 @@ def _rust_crate_src_root(path: Path) -> Path | None: _RUST_EDITION_RE = re.compile(r"^\s*edition\s*=\s*[\"'](\d{4})[\"']", re.MULTILINE) +# `edition.workspace = true` / `edition = { workspace = true }`: the package +# inherits whatever `[workspace.package]` declares in an ancestor manifest. +_RUST_EDITION_INHERIT_RE = re.compile( + r"^\s*edition(?:\.workspace\s*=\s*true|\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\})", + re.MULTILINE, +) +_RUST_WORKSPACE_PACKAGE_RE = re.compile( + r"^\s*\[workspace\.package\]\s*$(.*?)(?=^\s*\[|\Z)", + re.MULTILINE | re.DOTALL, +) + +# Cargo's default when no `edition` is declared anywhere. +_RUST_DEFAULT_EDITION = 2015 + + +def _rust_read_manifest(manifest: Path) -> str | None: + try: + return manifest.read_text(encoding="utf-8", errors="replace") + except OSError: + return None def _rust_package_edition(path: Path) -> int: """The Rust edition of the package owning ``path``. - Cargo's default when `edition` is absent is 2015, which is the edition - whose path rules the bare-path fallback below models. + Cargo's default when ``edition`` is absent is 2015, which is the edition + whose path rules the bare-path fallback models. A workspace member may + instead write ``edition.workspace = true`` and inherit the edition from an + ancestor's ``[workspace.package]`` — the normal shape in a monorepo, and + reading it as 2015 would put the crate-relative fallback back in play for + exactly the crates this rule exists to protect. """ probe = path.parent while True: - manifest = probe / "Cargo.toml" - if manifest.is_file(): - try: - match = _RUST_EDITION_RE.search( - manifest.read_text(encoding="utf-8", errors="replace") - ) - except OSError: - return 2015 - return int(match.group(1)) if match else 2015 + text = _rust_read_manifest(probe / "Cargo.toml") + if text is not None: + match = _RUST_EDITION_RE.search(text) + if match: + return int(match.group(1)) + if _RUST_EDITION_INHERIT_RE.search(text): + return _rust_workspace_edition(probe.parent) + return _RUST_DEFAULT_EDITION + if probe.parent == probe: + return _RUST_DEFAULT_EDITION + probe = probe.parent + + +def _rust_workspace_edition(start: Path) -> int: + """The ``[workspace.package] edition`` of the nearest ancestor workspace.""" + probe = start + while True: + text = _rust_read_manifest(probe / "Cargo.toml") + if text is not None: + section = _RUST_WORKSPACE_PACKAGE_RE.search(text) + if section: + match = _RUST_EDITION_RE.search(section.group(1)) + if match: + return int(match.group(1)) if probe.parent == probe: - return 2015 + return _RUST_DEFAULT_EDITION probe = probe.parent diff --git a/tests/test_rust_use_reexports.py b/tests/test_rust_use_reexports.py index 86fb6dee9..7e98988e6 100644 --- a/tests/test_rust_use_reexports.py +++ b/tests/test_rust_use_reexports.py @@ -18,6 +18,7 @@ from graphify.extract import extract from graphify.extractors.rust import ( _resolve_rust_use_path, + _rust_package_edition, extract_rust, _rust_module_dirs, _rust_use_leaves, @@ -1035,3 +1036,64 @@ def test_explicit_crate_prefix_still_resolves_under_2021(tmp_path): assert resolved is not None module_file, symbol = resolved assert (str(module_file.relative_to(root)), symbol) == ("src/anyhow.rs", "Result") + + +def _workspace(tmp_path: Path, member_manifest: str, *, workspace: bool = True) -> Path: + """A workspace member with a local module named after a dependency.""" + if workspace: + (tmp_path / "Cargo.toml").write_text( + '[workspace]\nmembers = ["backend"]\n\n' + '[workspace.package]\nedition = "2021"\n', + encoding="utf-8", + ) + (tmp_path / "backend" / "src").mkdir(parents=True) + (tmp_path / "backend" / "Cargo.toml").write_text(member_manifest, encoding="utf-8") + (tmp_path / "backend" / "src" / "lib.rs").write_text( + "pub mod anyhow;\n", encoding="utf-8" + ) + (tmp_path / "backend" / "src" / "anyhow.rs").write_text( + "pub struct Result;\n", encoding="utf-8" + ) + (tmp_path / "backend" / "src" / "service.rs").write_text( + "pub fn run() {}\n", encoding="utf-8" + ) + return tmp_path / "backend" / "src" / "service.rs" + + +@pytest.mark.parametrize( + "manifest", + [ + '[package]\nname = "b"\nedition.workspace = true\n', + '[package]\nname = "b"\nedition = { workspace = true }\n', + ], +) +def test_workspace_member_inherits_the_edition(tmp_path, manifest): + """`edition.workspace = true` is the normal shape in a monorepo. + + Reading it as 2015 puts the crate-relative fallback back in play for + exactly the crates the edition rule exists to protect. + """ + service = _workspace(tmp_path, manifest) + assert _rust_package_edition(service) == 2021 + assert _resolve_rust_use_path(("anyhow", "Result"), service) is None + + +def test_member_edition_overrides_the_workspace(tmp_path): + service = _workspace(tmp_path, '[package]\nname = "b"\nedition = "2015"\n') + assert _rust_package_edition(service) == 2015 + assert _resolve_rust_use_path(("anyhow", "Result"), service) is not None + + +def test_inheriting_without_a_workspace_falls_back_to_2015(tmp_path): + """Cargo's default when no edition is declared anywhere.""" + service = _workspace( + tmp_path, '[package]\nname = "b"\nedition.workspace = true\n', workspace=False + ) + assert _rust_package_edition(service) == 2015 + + +def test_package_edition_always_returns_an_int(tmp_path): + """Every path returns an int, so the `>= 2018` comparison cannot raise.""" + service = _workspace(tmp_path, '[package]\nname = "b"\n') + for probe in (service, service.parent, tmp_path, Path("/nonexistent/x.rs")): + assert isinstance(_rust_package_edition(probe), int)