Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## 0.9.42 (unreleased)

- Fix: `.svelte` and `.astro` files now get a real AST pass. Both extractors fed the raw file to the JS grammar, which errors on the first tag (Svelte) or the opening `---` (Astro), abandoning the AST and leaving only the regex import rescue — so every function, const, interface and type in those files was invisible to the graph. Both now mask the non-code regions and parse the script with the TypeScript grammar, mirroring `extract_vue` (#850 family). Measured on a Svelte 5 + Astro monorepo: `.svelte` 931 → 4,307 nodes and 1,047 → 6,225 edges across 260 files; `.astro` 2,946 → 6,607 nodes and 9,963 → 15,337 edges across 1,107 files.
- Fix: a JS/TS `for...of` / `for...in` loop binding is now shadowed, so passing it as a call argument no longer fabricates an `indirect_call` edge to an unrelated same-named callable (#2685, thanks @ousamabenyounes); completes the loop/closure/catch shadow family (#2568/#2569/#2517).
- Fix: graph provenance (`built_at_commit`) is stamped from the analysed repository rather than the shell's working directory, so `graphify extract` run from elsewhere records the target's commit, not the caller's (#2534 family; #2699, thanks @C0KERNEL).
- Fix: `affected` resolves a seed passed as a `./`-relative path (or an absolute path when run from the repo root) instead of silently returning nothing (#2707, thanks @phudayyy). Note: an absolute-path seed still requires the working directory to be the analysed repo root.
Expand Down
33 changes: 31 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
_ts_heritage_clause_entries,
_ts_walk_class_members,
_vue_mask_non_script,
_astro_best_mask,
_walk_js_tree,
_walk_python_tree,
_workspace_globs,
Expand Down Expand Up @@ -1577,7 +1578,23 @@ def extract_svelte(path: Path) -> dict:
{#await import('./X.svelte')} lives in the markup layer and is invisible
to the JS parser, so a regex pass covers those dynamic imports.
"""
result = _extract_generic(path, _JS_CONFIG)
# Mask the markup so the <script> block reaches the AST
# pass. Without this the raw .svelte file makes the JS grammar error at the
# first tag, and only the regex rescue below contributes anything.
try:
_svelte_src = path.read_text(encoding="utf-8", errors="replace")
_masked, _svelte_lang = _vue_mask_non_script(_svelte_src)
if _svelte_lang == "tsx":
_svelte_config = _TSX_CONFIG
elif _svelte_lang in ("js", "jsx"):
_svelte_config = _JS_CONFIG
else: # "ts" or unspecified — TS is a superset of JS, safe default
_svelte_config = _TS_CONFIG
result = _extract_generic(
path, _svelte_config, source_override=_masked.encode("utf-8")
)
except Exception:
result = _extract_generic(path, _JS_CONFIG)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
Expand Down Expand Up @@ -1639,7 +1656,19 @@ def extract_astro(path: Path) -> dict:
approach, scanning the frontmatter block and any client-side ``<script>`` blocks
for static and dynamic imports.
"""
result = _extract_generic(path, _JS_CONFIG)
# Mask the template so frontmatter (and client scripts)
# reach the AST pass. Without this the raw .astro file makes the JS grammar
# error on the opening `---`, and only the regex rescue below contributes.
try:
result = _extract_generic(
path,
_TS_CONFIG,
source_override=_astro_best_mask(
path.read_text(encoding="utf-8", errors="replace")
).encode("utf-8"),
)
except Exception:
result = _extract_generic(path, _JS_CONFIG)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
Expand Down
69 changes: 69 additions & 0 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,66 @@ def _blank(s: str) -> str:
out.append(_blank(src[pos:]))
return "".join(out), lang


# Astro frontmatter is a `---` fenced block that must open the file; a `---`
# appearing later is markup, not code.
_ASTRO_FRONTMATTER_RE = re.compile(r"\A(\s*---[^\n]*\r?\n)([\s\S]*?)(\r?\n---)")


def _astro_mask(src: str, *, include_scripts: bool = True) -> str:
"""Blank everything in a ``.astro`` file except executable TS/JS regions.

Astro files are ``---`` fenced TypeScript frontmatter followed by an
HTML-with-expressions template that may carry client-side ``<script>``
blocks. Neither the template nor the fences parse as JS, so feeding the raw
file to the JS grammar yields a top-level ERROR node and the AST pass is
abandoned (#850). Blanking the non-code regions — preserving ``\r``/``\n``
so line numbers stay accurate — lets the TS grammar see the real code.

``include_scripts=False`` keeps only the frontmatter. Used as a fallback for
files whose client script does not concatenate cleanly onto the frontmatter.
"""
def _blank_region(s: str) -> str:
return re.sub(r"[^\r\n]", " ", s)

m = _ASTRO_FRONTMATTER_RE.match(src)
if m:
out = [_blank_region(m.group(1)), m.group(2)]
pos = m.end(2)
else:
out, pos = [], 0
if include_scripts:
for sm in _VUE_SCRIPT_RE.finditer(src, pos):
out.append(_blank_region(src[pos:sm.start()]))
out.append(_blank_region(sm.group(1)))
out.append(sm.group(2))
out.append(_blank_region(sm.group(3)))
pos = sm.end()
out.append(_blank_region(src[pos:]))
return "".join(out)


def _astro_best_mask(src: str) -> str:
"""Mask an ``.astro`` file, preferring the variant that parses cleanly.

Frontmatter + client ``<script>`` yields the most symbols, but a client
script occasionally will not concatenate onto the frontmatter as valid TS.
Falling back to frontmatter-only recovers those without losing the rest.
"""
with_scripts = _astro_mask(src, include_scripts=True)
try:
from tree_sitter import Language, Parser
import tree_sitter_typescript as _tsts
parser = Parser(Language(_tsts.language_typescript()))
if not parser.parse(with_scripts.encode("utf-8")).root_node.has_error:
return with_scripts
frontmatter_only = _astro_mask(src, include_scripts=False)
if not parser.parse(frontmatter_only.encode("utf-8")).root_node.has_error:
return frontmatter_only
except Exception:
pass
return with_scripts

def _source_key(source_file: str, root: Path) -> str:
if not source_file:
return ""
Expand Down Expand Up @@ -1109,6 +1169,15 @@ def _parse_js_tree(path: Path):
path.read_text(encoding="utf-8", errors="replace")
)
source = masked.encode("utf-8")
elif path.suffix == ".svelte":
masked, vue_lang = _vue_mask_non_script(
path.read_text(encoding="utf-8", errors="replace")
)
source = masked.encode("utf-8")
elif path.suffix == ".astro":
source = _astro_best_mask(
path.read_text(encoding="utf-8", errors="replace")
).encode("utf-8")
else:
source = path.read_bytes()
use_ts = path.suffix in (".ts", ".mts", ".cts") or (
Expand Down
158 changes: 158 additions & 0 deletions tests/test_svelte_astro_masking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Tests for ``.svelte`` and ``.astro`` script masking.

Feeding a whole ``.svelte`` or ``.astro`` file to the JS grammar produces a
top-level ERROR node, so the AST pass is abandoned and only the regex rescue
contributes — imports survive, every declaration is lost.
:func:`extract_svelte` and :func:`extract_astro` now mask the non-code regions
and parse the real script with the TypeScript grammar, mirroring
:func:`extract_vue`.
"""
from __future__ import annotations

from pathlib import Path

from graphify.detect import CODE_EXTENSIONS
from graphify.extract import extract_astro, extract_svelte
from graphify.extractors.resolution import _astro_mask, _vue_mask_non_script


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) -> set[str]:
return {str(n.get("label") or "") for n in result.get("nodes", [])}


def _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
}


SVELTE_SRC = """\
<script lang="ts">
import { onMount } from 'svelte';
import Child from './Child.svelte';

interface Props { title: string }

const greeting = 'hello';

export function formatTitle(t: string): string {
return t.toUpperCase();
}

function handleClick() {
formatTitle(greeting);
}
</script>

<div on:click={handleClick}>
<Child />
{#if greeting}<span>{greeting}</span>{/if}
</div>

<style>
div { color: red; }
</style>
"""

ASTRO_SRC = """\
---
import Layout from '../layouts/Layout.astro';
import { getItems } from '../lib/items';

interface PageProps { slug: string }

const items = await getItems();

function renderCount(n: number): string {
return `${n} items`;
}
---

<Layout>
<p>{renderCount(items.length)}</p>
</Layout>

<script>
const clientOnly = 'browser';
console.log(clientOnly);
</script>
"""


def test_extensions_registered():
assert ".svelte" in CODE_EXTENSIONS
assert ".astro" in CODE_EXTENSIONS


def test_svelte_mask_preserves_line_numbers_and_blanks_markup():
masked, lang = _vue_mask_non_script(SVELTE_SRC)
assert lang == "ts"
assert len(masked.splitlines()) == len(SVELTE_SRC.splitlines())
# Script body survives; markup and style do not.
assert "formatTitle" in masked
assert "color: red" not in masked
assert "on:click" not in masked


def test_astro_mask_preserves_line_numbers_and_blanks_template():
masked = _astro_mask(ASTRO_SRC)
assert len(masked.splitlines()) == len(ASTRO_SRC.splitlines())
assert "renderCount" in masked
assert "clientOnly" in masked # client <script> kept
assert "<Layout>" not in masked


def test_astro_mask_frontmatter_only_drops_client_script():
masked = _astro_mask(ASTRO_SRC, include_scripts=False)
assert "renderCount" in masked
assert "clientOnly" not in masked


def test_astro_mask_without_frontmatter_is_safe():
src = "<h1>no frontmatter here</h1>\n"
masked = _astro_mask(src)
assert masked.strip() == ""
assert len(masked.splitlines()) == len(src.splitlines())


def test_svelte_extraction_recovers_declarations(tmp_path: Path):
path = _write(tmp_path / "Widget.svelte", SVELTE_SRC)
result = extract_svelte(path)
assert not result.get("error")
labels = _labels(result)
# Declarations that the pre-masking extractor could never see. Function
# labels carry a "()" suffix; interfaces do not.
assert "formatTitle()" in labels
assert "handleClick()" in labels
assert "Props" in labels
# Imports still resolve, as before.
assert any("svelte" in t or "Child" in t for t in _targets(result))


def test_astro_extraction_recovers_declarations(tmp_path: Path):
path = _write(tmp_path / "page.astro", ASTRO_SRC)
result = extract_astro(path)
assert not result.get("error")
labels = _labels(result)
assert "renderCount()" in labels
assert "PageProps" in labels
assert any("items" in t or "Layout" in t for t in _targets(result))


def test_svelte_extraction_beats_unmasked_baseline(tmp_path: Path):
"""The masked pass must strictly add nodes, never lose them."""
from graphify.extract import _JS_CONFIG
from graphify.extractors.engine import _extract_generic

path = _write(tmp_path / "Widget.svelte", SVELTE_SRC)
unmasked = _extract_generic(path, _JS_CONFIG)
masked = extract_svelte(path)
assert len(masked.get("nodes", [])) > len(unmasked.get("nodes", []))