feat(svelte): real Svelte 5 extractor (props, runes, functions, snippets, component usage) - #2275
feat(svelte): real Svelte 5 extractor (props, runes, functions, snippets, component usage)#2275zendoj wants to merge 3 commits into
Conversation
…ets, 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. <script> and
<style> are raw-text elements so their content really does end at the first
closing tag, but finding the OPENING tag needs care: "<!-- <script> -->" and
a '>' inside a quoted attribute both defeat the naive pattern.
- Script blocks are parsed with tree-sitter-typescript at their true byte
offset, so reported line numbers point into the original file.
- Nodes: the component, $props() props (public name, with the local binding
recorded when renamed), $state/$derived bindings, every function including
arrow consts, {#snippet} snippets, exported constants, local types.
- Component usage in the template is emitted as relation "uses" with context
"renders". Deliberately not "renders": DEFAULT_AFFECTED_RELATIONS does not
contain it, so a "renders" edge would be invisible to `graphify affected`.
- Cross-file calls bind to the SYMBOL rather than the file. A bare callee name
cannot be resolved corpus-wide (the app measured here has eleven distinct
formatOre definitions), but the import statement names the exact module and
symbol ids are reconstructible as make_id(<file stem>, <name>).
- Aliased imports map alias -> module, so `import { Foo as Bar }` still
resolves; $lib/tsconfig aliases and workspace packages go through the
existing resolution helpers.
- Dynamic imports are still recovered from raw source, including markup-only
forms like {#await import('./X.svelte')} that no JS parser can see.
Deliberately NOT nodes, because node count is not the goal and a node per
`const x = 1` makes the graph noisier rather than more useful: scalar locals,
loop variables, closures nested inside other functions, and anonymous $effect
callbacks (their bodies are still swept for calls, attributed to the
component).
Also fixes a latent id collision that is not Svelte-specific: make_id
casefolds, so `type State` and `let state` in one file produce the same id and
the second declaration was silently swallowed by the seen-ids guard. Colliding
declarations are now disambiguated by kind.
Adds tests/test_svelte_extraction.py (13 cases) covering region scanning,
props/runes/functions/snippets, renamed props, rune variants sharing one kind,
the less-than-operator false positive, barrel siblings, the id collision,
symbol-level call binding, markup dynamic imports, and the noise exclusions.
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR extracts the previously inline extract_svelte function out of graphify/extract.py and replaces it with a new dedicated module at graphify/extractors/svelte.py, wired in via an import in extract.py. The new module reimplements Svelte extraction with a region scanner for script/style/comment blocks, parses script bodies with tree-sitter-typescript, and emits nodes/edges for named declarations, runes, and component usages. Accompanying test file changes cover the new extraction behavior. The surface area spans the svelte extractor logic (region scanning, rune handling, template call/component resolution, import resolution) plus a large set of touched helper/rationale symbols and svelte extraction tests.
Worth a look
- Module script detection matches 'module' anywhere in tag text —
graphify/extractors/svelte.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
graphify review
Impact — 1218 function(s) in the blast radius of 232 changed node(s).
Health — regressions introduced by this change:
- new offender:
extract_svelte()(Ca=12 Ce=6) — fans out to 6 callees (efferent coupling); 12 callers depend on it (afferent coupling) - new offender:
handle_declarator()(Ca=1 Ce=6) — fans out to 6 callees (efferent coupling) - new offender:
walk_top()(Ca=0 Ce=6) — fans out to 6 callees (efferent coupling)
Verification — 1218 function(s) need re-proving (callee-first):
graphify_extract_xaml_inferred_viewmodel_names_add → graphify_extract_safe_extract → graphify_extract_safe_extract_with_xaml_root → graphify_extract_node_label_key → graphify_extract_lang_is_case_insensitive → graphify_extract_lang_family → graphify_extract_is_top_level_function_definition → graphify_extract_rewire_unique_stub_nodes → graphify_extract_file_node_id → graphify_extract_repoint_python_package_imports …
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not enforced this run (re-run with --prove to verify the blast radius)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1098 function(s) in blast radius — re-run with
--proveto formally verify them
· 3 grounded finding(s) anchored inline below.
| return out | ||
|
|
||
|
|
||
| def extract_svelte(path: Path) -> dict: |
There was a problem hiding this comment.
extract_svelte()
fans out to 6 callees (efferent coupling); 12 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| _read_text(name_node, source), | ||
| ) | ||
|
|
||
| def handle_declarator(dec, exported: bool, kw: str) -> None: |
There was a problem hiding this comment.
handle_declarator()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| for pname in _pattern_names(name_node, source): | ||
| declare(pname, L(dec), rune_kind, scope=scope, rune=callee) | ||
|
|
||
| def walk_top(node, exported: bool = False) -> None: |
There was a problem hiding this comment.
walk_top()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
… prop keys
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 <Self /> 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.
|
Follow-up pushed ( The one worth calling out: I had made the template brace-depth tracker string- and comment-aware, on the theory that it would be more correct. It measured worse, and the failure mode is nasty — a single miscount pins the depth above zero, so every remaining component in the file looks like it lives inside an expression and is silently dropped. Real templates break it constantly:
That last one cost a 737-line component all 30 of its component-usage edges. Counting braces plainly can only be fooled by a brace inside a string, which costs one local misjudgement instead of a whole file. Over those 455 components, plain counting balanced in every single file; the lexer did not. So it now counts plainly, with a self-check that drops the depth filter entirely when braces do not balance — an unparseable file should lose a little precision, never all of its components. The other two: a self-import is a recursive component ( Result on both corpora, full sets:
App B was never inspected while developing the extractor — it is what surfaced the regex-literal bug. Test suite is unchanged against |
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR moves the Svelte extraction logic out of graphify/extract.py and into a new dedicated module at graphify/extractors/svelte.py, replacing the previous whole-file regex-plus-JS approach with a region scanner that isolates <script> blocks and parses them with tree-sitter-typescript. The new extractor emits nodes for named declarations, runes, and component usage edges, and includes accompanying tests in tests/test_svelte_extraction.py. The change also touches a number of related extraction/resolution helpers (C#, C++, Python, Swift, Objective-C, Scala, Kotlin, Vue, XAML, etc.), the surface area of which is broad.
No blocking issues surfaced. 3 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
graphify review
Impact — 1225 function(s) in the blast radius of 239 changed node(s).
Health — regressions introduced by this change:
- new offender:
extract_svelte()(Ca=16 Ce=6) — fans out to 6 callees (efferent coupling); 16 callers depend on it (afferent coupling) - new offender:
handle_declarator()(Ca=1 Ce=6) — fans out to 6 callees (efferent coupling) - new offender:
walk_top()(Ca=0 Ce=6) — fans out to 6 callees (efferent coupling)
Verification — 1225 function(s) need re-proving (callee-first):
graphify_extract_xaml_inferred_viewmodel_names_add → graphify_extract_safe_extract → graphify_extract_safe_extract_with_xaml_root → graphify_extract_node_label_key → graphify_extract_lang_is_case_insensitive → graphify_extract_lang_family → graphify_extract_is_top_level_function_definition → graphify_extract_rewire_unique_stub_nodes → graphify_extract_file_node_id → graphify_extract_repoint_python_package_imports …
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not enforced this run (re-run with --prove to verify the blast radius)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1105 function(s) in blast radius — re-run with
--proveto formally verify them
· 3 grounded finding(s) anchored inline below.
| return out | ||
|
|
||
|
|
||
| def extract_svelte(path: Path) -> dict: |
There was a problem hiding this comment.
extract_svelte()
fans out to 6 callees (efferent coupling); 16 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| _read_text(name_node, source), | ||
| ) | ||
|
|
||
| def handle_declarator(dec, exported: bool, kw: str) -> None: |
There was a problem hiding this comment.
handle_declarator()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| for pname in _pattern_names(name_node, source): | ||
| declare(pname, L(dec), rune_kind, scope=scope, rune=callee) | ||
|
|
||
| def walk_top(node, exported: bool = False) -> None: |
There was a problem hiding this comment.
walk_top()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
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 <script> is preceded by a `<script module>` block reported every call site several lines too high. Found by a second verifier that checks each `calls` edge against the source: the cited line must actually mention the callee, and a cross-file target must really declare the symbol. Over 2314 call edges in two apps that check now passes at 100%; before the fix, the call sites in files with a module block did not line up. Wrong line numbers are worse than missing ones in a navigation tool, since they send a reader to code that has nothing to do with the edge.
|
One more fix pushed (
It immediately found that call sites were reported at script-block lines rather than file lines. The declaration walk already corrected for the block offset; the call sweep did not. So in any component whose instance Verification surface now, across both apps:
The 5 inferred edges are 1.7‰ of component edges — Test suite still matches |
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This pull request extracts the extract_svelte function out of graphify/extract.py and replaces it with a new, substantially larger dedicated module at graphify/extractors/svelte.py, wiring in the new implementation via an import. The new extractor moves from a regex-over-whole-file approach to a region scanner plus tree-sitter-typescript parsing of script blocks, and adds handling for runes, component usage edges, template calls, and snippets. Accompanying test files under tests/test_svelte_extraction are added or updated to cover the new extraction behavior across various kinds and scenarios.
Worth a look
- module script detection over-matches attributes containing 'module' —
graphify/extractors/svelte.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
graphify review
Impact — 1227 function(s) in the blast radius of 241 changed node(s).
Health — regressions introduced by this change:
- new offender:
extract_svelte()(Ca=17 Ce=6) — fans out to 6 callees (efferent coupling); 17 callers depend on it (afferent coupling) - new offender:
handle_declarator()(Ca=1 Ce=6) — fans out to 6 callees (efferent coupling) - new offender:
walk_top()(Ca=0 Ce=6) — fans out to 6 callees (efferent coupling)
Verification — 1227 function(s) need re-proving (callee-first):
graphify_extract_xaml_inferred_viewmodel_names_add → graphify_extract_safe_extract → graphify_extract_safe_extract_with_xaml_root → graphify_extract_node_label_key → graphify_extract_lang_is_case_insensitive → graphify_extract_lang_family → graphify_extract_is_top_level_function_definition → graphify_extract_rewire_unique_stub_nodes → graphify_extract_file_node_id → graphify_extract_repoint_python_package_imports …
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not enforced this run (re-run with --prove to verify the blast radius)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1107 function(s) in blast radius — re-run with
--proveto formally verify them
· 3 grounded finding(s) anchored inline below.
| return out | ||
|
|
||
|
|
||
| def extract_svelte(path: Path) -> dict: |
There was a problem hiding this comment.
extract_svelte()
fans out to 6 callees (efferent coupling); 17 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| _read_text(name_node, source), | ||
| ) | ||
|
|
||
| def handle_declarator(dec, exported: bool, kw: str) -> None: |
There was a problem hiding this comment.
handle_declarator()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| for pname in _pattern_names(name_node, source): | ||
| declare(pname, L(dec), rune_kind, scope=scope, rune=callee) | ||
|
|
||
| def walk_top(node, exported: bool = False) -> None: |
There was a problem hiding this comment.
walk_top()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Problem
extract_svelte()calls_extract_generic(path, _JS_CONFIG), which feeds the whole.sveltefile to the tree-sitter-javascript parser. Svelte markup is not valid JavaScript, so the parse returns a top-levelERRORnode, the walk never reaches animport_statementor a declaration, and the only thing recovered is what the regex rescue pass finds — the imports.The consequence is that a Svelte component contributes essentially nothing to the graph. On a 233-component SvelteKit app I measured 2.79 nodes per
.sveltefile, with a median of 1 — the typical component produced only its own file node, and the mean was lifted by import stubs rather than by symbols.Concretely, a 280-line component with 12 props, 2
$derived, a function and 2 snippets became 1 node. And because no symbol nodes existed,graphify affectedon a function that four components call answered:which reads exactly like dead code.
This mirrors what
extract_vuealready does for.vue(mask the non-script regions, parse the script with the TypeScript grammar) —.sveltejust never got the same treatment.Approach
<script>/<style>are raw-text elements, so their content genuinely ends at the first closing tag; the hard part is finding the opening tag without being fooled by<!-- <script> -->or a>inside a quoted attribute value.$props()props,$state/$derivedbindings, every function includingconst f = () => {},{#snippet}snippets, exported constants, local types.useswith contextrenders. Deliberately notrenders:DEFAULT_AFFECTED_RELATIONSdoes not contain it, so arendersedge would be invisible tographify affected.formatOredefinitions — but the import statement names the exact module, and symbol ids are reconstructible asmake_id(<file stem>, <name>). A wrong guess dangles and is dropped at build time; theimports_fromedge still records the dependency.$lib/tsconfig aliases and workspace packages go through the existing resolution helpers.{#await import('./X.svelte')}that no JS parser can see.Deliberately not nodes
Node count is not the goal, and a node per
const x = 1makes the graph noisier rather than more navigable. Excluded: scalar locals, loop variables, closures nested inside other functions, and anonymous$effectcallbacks — though an effect's body is still swept for calls, attributed to the component, so what it does still reaches the graph.Rune variants are an attribute, not a node kind:
$stateand$state.raware one kind with the exact spelling kept inrune, so the graph doesn't fragment across spellings.Results
Same repo, same command, 233
.sveltefiles:.sveltenodeThe
affectedquery that previously returned nothing now returns four exact call sites with correct line numbers, each verified against the source.Quality
Because node count is gameable, the extractor is scored against a second, independent oracle (regex + brace matching, sharing no code with the tree-sitter path). 100 randomly sampled files:
Every remaining disagreement was checked by hand against source: all are limitations of the oracle (multi-declarator statements, arrow functions with return-type annotations,
async function) or the extractor's deliberate choice to skip nested closures. None were extractor errors.Incidental fix
make_idcasefolds, sotype Stateandlet statein the same file produce the same id, and the second declaration was silently swallowed by the seen-ids guard — a lost symbol, not a merge. This isn't Svelte-specific; it's reachable from any extractor that declares both a type and a value with names differing only in case. Colliding declarations are now disambiguated by kind.Tests
Adds
tests/test_svelte_extraction.py— 13 cases covering region scanning (including the commented-out-<script>trap), props/runes/functions/snippets, renamed props (class: classNameexposesclass), rune variants sharing one kind, the{#if i < items.length}less-than false positive, barrel siblings (Alert/AlertTitleresolving to one module but being two usages), the id collision, symbol-level call binding, markup-only dynamic imports, and the noise exclusions.Full suite, same machine:
v8baseline: 43 failed, 3599 passed, 166 skippedIdentical failure sets — verified by diffing the two
FAILEDlists, not just the totals. The 43 are pre-existing and unrelated (terraform grammar not installed,serve_http,skillgen, ollama). The +13 are the new tests.Known limits
<svelte:component this={X}>,<Foo />whereFoois a variable) are emitted withconfidence: INFERREDand contextrenders_dynamic.svelte/compileris a deliberate trade: it keeps extraction dependency-free and in-process, at the cost of a hand-written template pass. The measured template-edge precision (100%) is what justified staying with it.