From 924b55d969389ecd1e1f280d2358babf6d0c95c2 Mon Sep 17 00:00:00 2001 From: Jonas Helwig Date: Wed, 29 Jul 2026 00:46:45 +0200 Subject: [PATCH 1/3] feat(svelte): real Svelte 5 extractor (props, runes, functions, snippets, component usage) extract_svelte() fed the whole .svelte file to the tree-sitter-javascript parser. Svelte markup is not valid JS, so the parse returns a top-level ERROR node, no declaration is ever reached, and only the regex rescue pass recovers imports. A 280-line component with 12 props, 2 $derived, a function and 2 snippets became a single file node. Measured on a 233-component SvelteKit app: 2.79 -> 16.51 nodes per .svelte file, median 1 -> 12. Scored against an independent regex/brace-matching oracle over 100 randomly sampled files: 99.6% precision, 100% recall across 2771 symbols; component-usage edges 100% precision, 99.9% recall. What it does now: - Region SCANNER (not a regex) splits script/style/comment. \n" + '\n" + "

{count}

\n" + ) + scripts, template = _scan_regions(src) + assert len(scripts) == 2 + assert scripts[0]["module"] is True + assert scripts[1]["module"] is False + # Masking keeps length and newlines, so offsets into the template are still + # valid offsets into the source. + assert len(template) == len(src) + assert template.count("\n") == src.count("\n") + assert "$state" not in template + assert "

{count}

" in template + + +def test_scan_regions_is_not_fooled_by_commented_out_script(tmp_path): + src = "\n
hi
\n" + scripts, _template = _scan_regions(src) + assert scripts == [] + + +def test_props_runes_functions_and_snippets_become_nodes(tmp_path): + path = _write( + tmp_path / "Widget.svelte", + '\n" + "\n" + "{#snippet row(x)}
  • {x}
  • {/snippet}\n" + "\n", + ) + result = extract_svelte(path) + + assert _by_kind(result, "prop") == {"title", "onSave"} + assert _by_kind(result, "state") == {"open"} + assert _by_kind(result, "derived") == {"label"} + assert _by_kind(result, "function") == {"toggle()", "reset()"} + assert _by_kind(result, "snippet") == {"row()"} + + +def test_renamed_prop_uses_the_public_name(tmp_path): + """`class: className` exposes `class`; the script refers to `className`.""" + path = _write( + tmp_path / "Styled.svelte", + "\n" + "
    \n", + ) + result = extract_svelte(path) + assert _by_kind(result, "prop") == {"class"} + prop = next(n for n in result["nodes"] if n.get("kind") == "prop") + assert prop["local_name"] == "className" + + +def test_rune_variants_share_one_kind(tmp_path): + path = _write( + tmp_path / "Raw.svelte", + "\n", + ) + result = extract_svelte(path) + kinds = {n["label"]: (n["kind"], n.get("rune")) for n in result["nodes"] + if n.get("kind") in ("state", "derived")} + assert kinds == {"a": ("state", "$state.raw"), "b": ("derived", "$derived.by")} + + +def test_component_usage_emits_uses_edge_with_renders_context(tmp_path): + _write(tmp_path / "Child.svelte", "

    child

    \n") + path = _write( + tmp_path / "Parent.svelte", + "\n" + "\n", + ) + result = extract_svelte(path) + # `uses`, not `renders`: DEFAULT_AFFECTED_RELATIONS does not include + # `renders`, so such an edge would be invisible to `graphify affected`. + uses = _edges(result, "uses", "renders") + assert len(uses) == 1 + assert uses[0]["symbol"] == "Child" + assert uses[0]["target_file"].endswith("Child.svelte") + + +def test_less_than_operator_is_not_a_component_tag(tmp_path): + """`{#if i < items.length}` must not read as a component named items.length.""" + path = _write( + tmp_path / "Cmp.svelte", + "\n" + "{#if i < items.length}more{/if}\n", + ) + result = extract_svelte(path) + assert _edges(result, "uses") == [] + + +def test_barrel_siblings_each_get_their_own_usage_edge(tmp_path): + """Alert/AlertTitle resolve to one module but are two distinct usages.""" + _write(tmp_path / "ui/index.js", "export const Alert = 1\nexport const AlertTitle = 2\n") + path = _write( + tmp_path / "Uses.svelte", + "\n" + "hi\n", + ) + result = extract_svelte(path) + assert {e["symbol"] for e in _edges(result, "uses", "renders")} == {"Alert", "AlertTitle"} + + +def test_case_colliding_type_and_variable_both_survive(tmp_path): + """make_id casefolds, so `type State` and `let state` hash alike (#id-collision).""" + path = _write( + tmp_path / "Health.svelte", + '\n", + ) + result = extract_svelte(path) + assert _by_kind(result, "type") == {"State"} + assert _by_kind(result, "state") == {"state"} + ids = [n["id"] for n in result["nodes"]] + assert len(ids) == len(set(ids)) + + +def test_imported_call_binds_to_the_symbol_not_just_the_file(tmp_path): + _write(tmp_path / "fmt.ts", "export function formatOre(x: number) { return x }\n") + path = _write( + tmp_path / "Money.svelte", + "\n" + "{formatOre(1)}\n", + ) + result = extract_svelte(path) + calls = _edges(result, "calls") + assert len(calls) == 1 + # The corpus can hold many `formatOre`; the import says which one this is. + assert calls[0]["target"] == _make_id(str((tmp_path / "fmt").as_posix()), "formatOre") + + +def test_dynamic_import_in_markup_is_recovered(tmp_path): + """`{#await import('./X.svelte')}` lives in markup; no JS parser sees it.""" + _write(tmp_path / "Lazy.svelte", "

    lazy

    \n") + path = _write( + tmp_path / "Host.svelte", + "{#await import('./Lazy.svelte') then M}{/await}\n", + ) + result = extract_svelte(path) + dyn = _edges(result, "dynamic_import") + assert len(dyn) == 1 + assert dyn[0]["target_file"].endswith("Lazy.svelte") + + +def test_scalar_locals_and_anonymous_effects_are_not_nodes(tmp_path): + """Node count is not the goal — a node per `const x = 1` makes the graph worse.""" + path = _write( + tmp_path / "Quiet.svelte", + "\n", + ) + result = extract_svelte(path) + labels = {n["label"] for n in result["nodes"]} + assert "LIMIT" not in labels + assert _by_kind(result, "state") == {"n"} + # One component node plus the single rune binding. + assert len(result["nodes"]) == 2 From 2c6741b8264b129492c23fb49eaedd9a9f3b992d Mon Sep 17 00:00:00 2001 From: Jonas Helwig Date: Wed, 29 Jul 2026 01:06:04 +0200 Subject: [PATCH 2/3] fix(svelte): correct template brace tracking, self-imports and quoted prop keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoring the extractor against the independent oracle on the FULL corpus of both apps (455 components, not a sample) turned up three defects. All three are now 100% precision and 100% recall on both corpora, 7571 symbols, zero disagreements. 1. Template brace depth is counted plainly again, not with a string- and comment-aware lexer. The lexer looked more correct and measured worse. Its mistakes are not local: one miscount pins the depth above zero, so every remaining component in the file is treated as living inside an expression and silently dropped. Real templates break it constantly — an apostrophe in markup text ("Don't"), an apostrophe inside a comment within an expression (class={cn('x', // the CRM's own scope), or a regex literal whose escaped slashes read as a line comment (replace(/^https?:\/\//, '')). The last one cost a 737-line component all 30 of its component edges. Counting braces alone can only be fooled by a brace inside a string, which costs one local misjudgement rather than a whole file. Measured over 455 real components: plain counting balanced in every single file, the lexer did not. A self-check drops the depth filter entirely if the braces do not balance, so an unparseable file loses precision rather than losing every component. 2. A self-import is a recursive component, not a self-reference to discard. `import Self from './ChainTree.svelte'` rendered as now emits the self-edge that records the recursion. Locals that merely resolve back to the component node are still dropped. 3. Quoted destructure keys keep only the prop name. A hyphenated prop has to be written as a quoted key ("data-slot": slot); the quotes are syntax, so the node is now named data-slot instead of "data-slot". Adds four regression tests: recursion, quoted keys, and one each for the regex literal and the markup apostrophe that used to swallow the rest of the file. --- graphify/extractors/svelte.py | 37 ++++++++++++++---- tests/test_svelte_extraction.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/graphify/extractors/svelte.py b/graphify/extractors/svelte.py index a8c5638dc..672e95207 100644 --- a/graphify/extractors/svelte.py +++ b/graphify/extractors/svelte.py @@ -241,6 +241,11 @@ def _prop_entries(node, source: bytes) -> list[tuple[str, str]]: key = c.child_by_field_name("key") value = c.child_by_field_name("value") public = _read_text(key, source) if key is not None else None + if public: + # A hyphenated prop must be written as a quoted key + # (`"data-slot": slot`). The prop is named data-slot; the quotes + # are syntax, not part of the name. + public = public.strip("\"'`") locals_ = _pattern_names(value, source) if public: out.append((public, locals_[0] if locals_ else public)) @@ -537,7 +542,11 @@ def emit_use(root_name: str, pos: int, context: str | None = None) -> None: if hit is None: return target_nid, ctx, conf = hit - if target_nid == file_nid: + if target_nid == file_nid and root_name not in imported: + # A local that is not a component resolved back to the component + # node — no information, drop it. A genuine self-import is different: + # `import Self from './ChainTree.svelte'` used as `` is a + # RECURSIVE component, and the self-edge is exactly what records that. return # Dedupe per (target, context, IDENTIFIER). Keying on the target alone # collapses distinct components that share a barrel module — `Alert`, @@ -562,20 +571,32 @@ def emit_use(root_name: str, pos: int, context: str | None = None) -> None: # OPERATOR, not a tag: `{#if i < railSteps.length}` otherwise reads as a # component named `railSteps.length`. Svelte block tags open and close their # own brace, so real markup always sits at depth 0. + # Plain brace counting, deliberately NOT string- or comment-aware. + # + # A lexer that skips strings and comments inside expressions sounds more + # correct and measures worse. Its mistakes are not local: one miscount pins + # the depth above zero and silently drops every remaining component in the + # file. Real templates break it constantly — an apostrophe in markup text + # (`Don't`), an apostrophe inside a comment within an expression + # (`class={cn('x', // the CRM's own scope`), or a regex literal whose escaped + # slashes read as a comment (`replace(/^https?:\/\//, '')`). + # + # Counting braces alone can only be fooled by a brace inside a string, whose + # cost is one local misjudgement. Measured over 455 real components across + # two apps: naive counting balanced in every single file; the lexer did not. depth_at: list[int] = [] depth = 0 - quote = "" for ch in template: depth_at.append(depth) - if quote: - if ch == quote: - quote = "" - elif ch in "\"'": - quote = ch - elif ch == "{": + if ch == "{": depth += 1 elif ch == "}": depth = max(0, depth - 1) + # Self-check: if the braces do not balance, this file contains something the + # counter cannot account for. Skipping tags on a wrong depth would drop real + # components, so drop the filter instead and accept the rarer false positive. + if depth != 0: + depth_at = [0] * len(template) # Component tags: PascalCase or dotted namespaces (``), plus the # deprecated ``. diff --git a/tests/test_svelte_extraction.py b/tests/test_svelte_extraction.py index 0a3576fac..4448b2391 100644 --- a/tests/test_svelte_extraction.py +++ b/tests/test_svelte_extraction.py @@ -228,3 +228,69 @@ def test_scalar_locals_and_anonymous_effects_are_not_nodes(tmp_path): assert _by_kind(result, "state") == {"n"} # One component node plus the single rune binding. assert len(result["nodes"]) == 2 + + +def test_recursive_component_records_a_self_edge(tmp_path): + """`import Self from './ChainTree.svelte'` used as is recursion.""" + path = _write( + tmp_path / "ChainTree.svelte", + "\n" + "{#each nodes as n}{/each}\n", + ) + result = extract_svelte(path) + uses = _edges(result, "uses", "renders") + assert [e["symbol"] for e in uses] == ["Self"] + # Self-import: source and target are the same component node. + assert uses[0]["source"] == uses[0]["target"] + + +def test_quoted_prop_key_keeps_only_the_prop_name(tmp_path): + """A hyphenated prop must be quoted; the quotes are syntax, not the name.""" + path = _write( + tmp_path / "Slot.svelte", + "\n", + ) + result = extract_svelte(path) + assert _by_kind(result, "prop") == {"data-slot"} + prop = next(n for n in result["nodes"] if n.get("kind") == "prop") + assert prop["local_name"] == "dataSlot" + + +def test_regex_literal_in_an_expression_does_not_swallow_later_components(tmp_path): + """A miscounted brace is not a local error — it drops the rest of the file. + + `replace(/^https?:\\/\\//, '')` puts two adjacent slashes inside an + expression; reading them as a comment used to pin the depth above zero so + every following component silently vanished. + """ + _write(tmp_path / "Icon.svelte", "\n") + path = _write( + tmp_path / "Host.svelte", + "\n" + "{site.replace(/^https?:\\/\\//, '')}\n" + "\n", + ) + result = extract_svelte(path) + assert [e["symbol"] for e in _edges(result, "uses", "renders")] == ["Icon"] + + +def test_apostrophe_in_markup_text_does_not_swallow_later_components(tmp_path): + _write(tmp_path / "Icon.svelte", "\n") + path = _write( + tmp_path / "Text.svelte", + "\n" + "

    Don't panic

    \n" + "\n", + ) + result = extract_svelte(path) + assert [e["symbol"] for e in _edges(result, "uses", "renders")] == ["Icon"] From efb509ea37a0eba8140a766ebae737642f97f1d7 Mon Sep 17 00:00:00 2001 From: Jonas Helwig Date: Wed, 29 Jul 2026 01:16:46 +0200 Subject: [PATCH 3/3] fix(svelte): report call sites at file lines, not script-block lines tree-sitter line numbers are relative to the block that was parsed. The declaration walk already corrected for that, but the call sweep did not, so a component whose instance \n" # 3 + "\n" # 4 + '\n", + ) + result = extract_svelte(path) + call = next(e for e in _edges(result, "calls") if e.get("context") == "call") + assert call["source_location"] == "L8"