diff --git a/graphify/extract.py b/graphify/extract.py
index f68757a85..25d259626 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -137,6 +137,7 @@
_ts_collect_type_refs,
_ts_heritage_clause_entries,
_ts_walk_class_members,
+ _sfc_mask_non_script,
_vue_mask_non_script,
_walk_js_tree,
_walk_python_tree,
@@ -1599,49 +1600,93 @@ def _emit_rescued_import(
def extract_svelte(path: Path) -> dict:
- """Extract imports from .svelte files: script-block via JS AST + template regex fallback.
+ """Extract imports, symbols, and type refs from a ``.svelte`` component.
- Tree-sitter only sees the ", _re.IGNORECASE
- )
- static_import_re = _re.compile(
- r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
- )
- for script_match in script_re.finditer(src):
- script_body = script_match.group(1)
- for m in static_import_re.finditer(script_body):
+ 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.
+ 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
@@ -1649,6 +1694,13 @@ def extract_svelte(path: Path) -> dict:
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
+ # 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.
+ # 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
@@ -1732,10 +1784,13 @@ 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 = _vue_mask_non_script(src)
+ masked, lang = _sfc_mask_non_script(src)
if lang == "tsx":
config = _TSX_CONFIG
elif lang in ("js", "jsx"):
diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py
index 3c8a0238d..d6f560a93 100644
--- a/graphify/extractors/engine.py
+++ b/graphify/extractors/engine.py
@@ -1912,6 +1912,36 @@ 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 ``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.
+
+ 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.
+ """
+ call = _find_require_call(value_node)
+ if call is None:
+ return False
+ fn = call.child_by_field_name("function")
+ 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:
"""Detect CommonJS require imports inside lexical_declaration / variable_declaration.
@@ -2001,6 +2031,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,
@@ -2130,6 +2165,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 +2393,47 @@ 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:
+ 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
+ # 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/graphify/extractors/resolution.py b/graphify/extractors/resolution.py
index 5369637ae..ac45061aa 100644
--- a/graphify/extractors/resolution.py
+++ b/graphify/extractors/resolution.py
@@ -605,40 +605,85 @@ def _resolve_lua_import_target(raw_module: str, str_path: str) -> str:
probe = probe.parent
return _make_id(raw_module)
-_VUE_SCRIPT_RE = re.compile(
+_SFC_SCRIPT_RE = re.compile(
r"""()""",
re.IGNORECASE,
)
-_VUE_SCRIPT_LANG_RE = re.compile(
+_SFC_SCRIPT_LANG_RE = re.compile(
r"""\blang\s*=\s*['"]?([A-Za-z]+)['"]?""", re.IGNORECASE
)
-def _vue_mask_non_script(src: str) -> tuple[str, str | None]:
+# Back-compat aliases: these were named for Vue before Svelte shared the masker.
+_VUE_SCRIPT_RE = _SFC_SCRIPT_RE
+_VUE_SCRIPT_LANG_RE = _SFC_SCRIPT_LANG_RE
+
+def _sfc_mask_non_script(src: str) -> tuple[str, str | None]:
"""Blank everything outside `` close tag
pos = m.end()
- if lang is None:
- lang_m = _VUE_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)
+
+
+def _sfc_widest_lang(langs: list[str]) -> str | None:
+ """The grammar that can parse every declared block, or None if undeclared.
+
+ Every block is masked into ONE source and parsed together, so the grammar
+ has to accept all of them. TS is a superset of JS and TSX of JSX, but not
+ the reverse, and TSX is the only grammar that takes BOTH type annotations
+ and JSX — so a `lang="ts"` block beside a `lang="jsx"` one needs TSX, not
+ either of the two declared.
+ """
+ if not langs:
+ return None
+ wants_types = any(lang in ("ts", "tsx") for lang in langs)
+ wants_jsx = any(lang in ("jsx", "tsx") for lang in langs)
+ if wants_types and wants_jsx:
+ return "tsx"
+ if wants_types:
+ return "ts"
+ if wants_jsx:
+ return "jsx"
+ return "js"
+
+_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:
@@ -1104,22 +1149,25 @@ 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"
+ "
\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
+
+
+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"
+ )
+
+
+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
+
+
+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 = (
+ '