fix(extract): parse only Svelte script blocks in the AST pass

extract_svelte fed the whole .svelte file to the JS grammar. The template
is not JS, so every component reported a syntax error at line 1 and no
symbol declared inside <script> was ever reached — only imports survived,
via the regex rescue pass.

Blank everything outside the <script> bodies (keeping newlines so
locations still match) and parse with the grammar the first block's lang
implies: js/jsx -> JS, ts or unset -> TS, a superset of JS and Svelte's
own default. <script module> and <script context="module"> are parsed
alongside the instance block. Scripts with a non-JS type
(application/ld+json, ...) are blanked too, and a script region is
terminated in place of the following `<` so two blocks on one line do not
run together. The existing regex import rescue is unchanged; an import
both passes resolve yields one edge after build.dedupe_edges, as on the
Astro path.

The masker mirrors _astro_mask_non_script and shares the script-tag and
non-JS-type patterns with it instead of duplicating them.

Fixes #3928. Covers the Svelte half of #3942 (markup is blanked, so a
template {#if} no longer mints a spurious if() node).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b64806924e)
This commit is contained in:
Agnik47 authored and safishamsi committed 2026-10-04 20:40:34 +01:00
1 parent 8629f02ff0
commit f9b42e8d23
2 files changed
+250 -20

No files matched your search

+77 -20
View File
@@ -154,6 +154,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
_ts_collect_type_refs,
_ts_heritage_clause_entries,
_ts_walk_class_members,
_VUE_SCRIPT_LANG_RE as _SCRIPT_LANG_RE,
_vue_mask_non_script,
_walk_js_tree,
_walk_python_tree,
@@ -2380,17 +2381,79 @@ def _emit_rescued_import(
existing_ids.add(node_id)
def extract_svelte(path: Path) -> dict:
"""Extract imports from .svelte files: script-block via JS AST + template regex fallback.
_HTML_SCRIPT_TAG_RE = re.compile(
r"<script\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>([\s\S]*?)</script\s*>",
re.IGNORECASE,
)
_NON_JS_SCRIPT_TYPE_RE = re.compile(
r"""\btype\s*=\s*["']?(?!module\b|text/javascript\b|application/javascript\b)""",
re.IGNORECASE,
)
Tree-sitter only sees the <script> block. Svelte template syntax like
{#await import('./X.svelte')} lives in the markup layer and is invisible
to the JS parser, so a regex pass covers those dynamic imports.
def _svelte_mask_non_script(src: str) -> tuple[str, str | None]:
"""Blank everything in a ``.svelte`` file except JS ``<script>`` bodies.
Every character outside those bodies becomes a space (``\\r``/``\\n`` are
kept), so AST locations match the original file. Scripts with a non-JS
``type`` (``application/ld+json`` and the like) are blanked too: their
bodies are not statements and would only add parse errors. Returns
``(masked_source, lang)``; ``lang`` is the first JS block's declared
``lang``. Mirrors :func:`_astro_mask_non_script`.
"""
result = _extract_generic(path, _JS_CONFIG)
chars = [c if c in "\r\n" else " " for c in src]
lang: str | None = None
for m in _HTML_SCRIPT_TAG_RE.finditer(src):
if _NON_JS_SCRIPT_TYPE_RE.search(m.group(1)):
continue
start, end = m.start(2), m.end(2)
chars[start:end] = src[start:end]
# Terminate the region in place of the following `<`, so two scripts on
# one line don't run together into a single statement.
if end < len(chars) and chars[end] == " ":
chars[end] = ";"
if lang is None:
lang_m = _SCRIPT_LANG_RE.search(m.group(1))
if lang_m:
lang = lang_m.group(1).lower()
return "".join(chars), lang
def extract_svelte(path: Path) -> dict:
"""Extract imports and symbols from .svelte files: script-block AST + template regex fallback.
The AST pass parses only the ``<script>`` bodies, blanking the markup and
style regions so line numbers still line up — the same masking
:func:`extract_vue` and :func:`extract_astro` use. Feeding the whole file to
the JS grammar made the template a top-level ERROR at line 1, so every
``.svelte`` file was flagged as a syntax error and no symbol inside
``<script>`` (functions, classes, runes) was ever reached; only imports
survived, via the regex rescue below (#3928).
The grammar follows the first block's declared ``lang`` (``js``/``jsx``→JS,
``ts`` or unset→TS, a superset of JS), matching Svelte's own default. Both
``<script>`` and ``<script module>`` / ``<script context="module">`` blocks
are parsed. A ``<script>`` carrying a non-JS ``type`` (``application/ld+json``
and the like) is masked out rather than parsed as code.
Svelte template syntax like {#await import('./X.svelte')} lives in the markup
layer and is invisible to the JS parser, so a regex pass covers those
dynamic imports.
"""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
masked, lang = _svelte_mask_non_script(src)
config = _JS_CONFIG if lang in ("js", "jsx") else _TS_CONFIG
masked_bytes = masked.encode("utf-8")
if config is _TS_CONFIG:
masked_bytes = _normalize_ts_import_types(masked_bytes) or masked_bytes
result = _extract_generic(path, config, source_override=masked_bytes)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
existing_ids = {n["id"] for n in result.get("nodes", [])}
# Source file node ID must match the one _extract_generic creates:
# _make_id(str(path)) - single arg, no stem prefix. Otherwise the source
@@ -2410,11 +2473,11 @@ def extract_svelte(path: Path) -> dict:
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports inside <script> blocks. The JS tree-sitter parser fed
# the full .svelte file produces a top-level ERROR node (HTML markup
# is not valid JS), so import_statement nodes are never reached and
# static imports are silently dropped (#713). Regex over each script
# body recovers them.
# Static imports inside <script> blocks (#713). The masked AST pass now
# reaches these import_statement nodes itself, so this is a fallback for
# a script body the grammar still cannot parse. An import both passes
# resolve yields the same (source, target, relation) edge twice, which
# build.dedupe_edges collapses — same as the Astro path (#3902).
script_re = _re.compile(
r"<script\b[^>]*>([\s\S]*?)</script\s*>", _re.IGNORECASE
)
@@ -2437,14 +2500,8 @@ def extract_svelte(path: Path) -> dict:
_ASTRO_FRONTMATTER_RE = re.compile(r"\A\s*---[^\S\r\n]*\r?\n([\s\S]*?)\r?\n---")
_ASTRO_SCRIPT_RE = re.compile(
r"<script\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>([\s\S]*?)</script\s*>",
re.IGNORECASE,
)
_ASTRO_NON_JS_TYPE_RE = re.compile(
r"""\btype\s*=\s*["']?(?!module\b|text/javascript\b|application/javascript\b)""",
re.IGNORECASE,
)
_ASTRO_SCRIPT_RE = _HTML_SCRIPT_TAG_RE
_ASTRO_NON_JS_TYPE_RE = _NON_JS_SCRIPT_TYPE_RE
def _astro_mask_non_script(src: str) -> str:
+173
View File
@@ -0,0 +1,173 @@
"""Tests for `.svelte` extraction (#3928).
A `.svelte` file is markup with one or two `<script>` blocks. Tree-sitter fed
the whole file produces a top-level ERROR node at line 1 because the template is
not JS, so the AST pass never reached the `function_declaration` nodes inside
`<script>` — every component was reported as a syntax error and only its
imports survived, via the regex rescue pass. :func:`extract_svelte` masks the
markup and style regions and parses just the script bodies, the same strategy
:func:`extract_vue` and :func:`extract_astro` use.
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import _make_id, extract_svelte
def _write(path: Path, body: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path
def _labels(result: dict) -> dict[str, str]:
"""Label -> source_location for every node except the file node."""
file_id = result.get("nodes", [{}])[0].get("id")
return {
str(n.get("label")): str(n.get("source_location"))
for n in result.get("nodes", [])
if n.get("id") != file_id
}
def _import_targets(result: dict, *, relation: str | None = None) -> set[str]:
return {
str(e.get("target") or "")
for e in result.get("edges", [])
if relation is None or e.get("relation") == relation
}
def test_extract_svelte_template_is_not_a_parse_error(tmp_path):
"""The template is not JS; only `<script>` bodies are parsed (#3928).
Parsing the whole file reported every component as a syntax error at line 1
and dropped every symbol declared inside `<script>`.
"""
component = _write(
tmp_path / "src/A.svelte",
"""<script>
import { foo } from "./x";
let n = 1;
function bump() { n++; }
</script>
<button onclick={bump}>{n}</button>
""",
)
result = extract_svelte(component)
assert result.get("parse_errors") is None
labels = _labels(result)
assert "bump()" in labels
# Masking keeps offsets, so locations still point at the original lines.
assert labels["bump()"] == "L4"
def test_extract_svelte_parses_module_and_instance_scripts(tmp_path):
"""Svelte 5 `<script module>` and the instance block are both code."""
component = _write(
tmp_path / "src/Both.svelte",
"""<script module>
export function shared() { return 1; }
</script>
<script>
function local() { return 2; }
</script>
<p>hi</p>
""",
)
result = extract_svelte(component)
assert result.get("parse_errors") is None
labels = _labels(result)
assert "shared()" in labels and "local()" in labels
assert labels["shared()"] == "L2"
assert labels["local()"] == "L5"
def test_extract_svelte_lang_ts_parses_type_syntax(tmp_path):
"""`<script lang="ts">` must be parsed with the TS grammar, not JS."""
component = _write(
tmp_path / "src/Typed.svelte",
"""<script lang="ts">
interface Props { year: number }
let n = $state(1);
function bump(): void { n++; }
</script>
<button onclick={bump}>{n}</button>
""",
)
result = extract_svelte(component)
assert result.get("parse_errors") is None
labels = _labels(result)
assert "Props" in labels
assert "bump()" in labels
def test_extract_svelte_non_js_script_type_is_not_parsed_as_code(tmp_path):
"""`<script type="application/ld+json">` is data, not statements."""
component = _write(
tmp_path / "src/Ld.svelte",
"""<script type="application/ld+json">{ "@type": "Event", "name": "x" }</script>
<script>
function go() { return 1; }
</script>
<p>hi</p>
""",
)
result = extract_svelte(component)
assert result.get("parse_errors") is None
assert "go()" in _labels(result)
def test_extract_svelte_static_import_still_resolves(tmp_path):
"""Masking must not cost the imports the regex rescue already recovered."""
component = _write(
tmp_path / "src/Imports.svelte",
"""<script>
import { helper } from "./helper";
</script>
<p>hi</p>
""",
)
helper = _write(tmp_path / "src/helper.ts", "export function helper(){}\n")
result = extract_svelte(component)
assert _make_id(str(helper)) in _import_targets(result, relation="imports_from")
def test_extract_svelte_dynamic_import_in_template(tmp_path):
"""`{#await import('./X.svelte')}` lives in markup, so the regex pass owns it."""
component = _write(
tmp_path / "src/Lazy.svelte",
"""<script>
let show = true;
</script>
{#await import('./Other.svelte') then Mod}
<Mod.default />
{/await}
""",
)
other = _write(tmp_path / "src/Other.svelte", "<p>o</p>\n")
result = extract_svelte(component)
assert _make_id(str(other)) in _import_targets(result, relation="dynamic_import")
def test_extract_svelte_scripts_on_one_line_do_not_merge(tmp_path):
"""Two script bodies on one line must not parse as a single statement."""
component = _write(
tmp_path / "src/Inline.svelte",
"<script>const a = 1</script><script>function b() {}</script>\n",
)
result = extract_svelte(component)
assert result.get("parse_errors") is None
assert "b()" in _labels(result)
def test_extract_svelte_markup_only_component_does_not_crash(tmp_path):
"""A `.svelte` file need not have a `<script>` block at all."""
component = _write(tmp_path / "src/Plain.svelte", "<h1>no script here</h1>\n")
result = extract_svelte(component)
assert isinstance(result, dict)
assert result.get("parse_errors") is None
assert _import_targets(result, relation="imports_from") == set()