Skip to content

feat(svelte): real Svelte 5 extractor (props, runes, functions, snippets, component usage) - #2275

Open
zendoj wants to merge 3 commits into
Graphify-Labs:v8from
zendoj:feat/svelte5-extractor
Open

feat(svelte): real Svelte 5 extractor (props, runes, functions, snippets, component usage)#2275
zendoj wants to merge 3 commits into
Graphify-Labs:v8from
zendoj:feat/svelte5-extractor

Conversation

@zendoj

@zendoj zendoj commented Jul 28, 2026

Copy link
Copy Markdown

Problem

extract_svelte() calls _extract_generic(path, _JS_CONFIG), which feeds the whole .svelte file to the tree-sitter-javascript parser. Svelte markup is not valid JavaScript, so the parse returns a top-level ERROR node, the walk never reaches an import_statement or 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 .svelte file, 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 affected on a function that four components call answered:

No affected nodes found.

which reads exactly like dead code.

This mirrors what extract_vue already does for .vue (mask the non-script regions, parse the script with the TypeScript grammar) — .svelte just never got the same treatment.

Approach

  • A region scanner, not a regex. <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.
  • 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, $state/$derived bindings, every function including const f = () => {}, {#snippet} snippets, exported constants, local types.
  • Component usage in the template becomes 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, not just the file. A bare callee name can't be resolved corpus-wide — the app I measured has eleven distinct formatOre definitions — but the import statement names the exact module, and symbol ids are reconstructible as make_id(<file stem>, <name>). A wrong guess dangles and is dropped at build time; the imports_from edge still records the dependency.
  • Aliased imports map alias → module; $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

Node count is not the goal, and a node per const x = 1 makes the graph noisier rather than more navigable. Excluded: scalar locals, loop variables, closures nested inside other functions, and anonymous $effect callbacks — 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: $state and $state.raw are one kind with the exact spelling kept in rune, so the graph doesn't fragment across spellings.

Results

Same repo, same command, 233 .svelte files:

before after
nodes per file (mean) 2.79 16.51 5.9×
nodes per file (median) 1 12 12×
edges touching a .svelte node 2 698 11 206 4.2×
the 280-line component above 1 18

The affected query 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:

category agreed extractor-only oracle-only precision recall
prop 220 2 0 99.1% 100%
rune 954 3 0 99.7% 100%
function 398 5 0 98.8% 100%
snippet 94 0 0 100% 100%
component usage 1 105 0 1 100% 99.9%
total 2 771 10 1 99.6% 100%

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_id casefolds, so type State and let state in 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: className exposes class), rune variants sharing one kind, the {#if i < items.length} less-than false positive, barrel siblings (Alert / AlertTitle resolving 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:

  • v8 baseline: 43 failed, 3599 passed, 166 skipped
  • this branch: 43 failed, 3612 passed, 166 skipped

Identical failure sets — verified by diffing the two FAILED lists, 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

  • Dynamic components (<svelte:component this={X}>, <Foo /> where Foo is a variable) are emitted with confidence: INFERRED and context renders_dynamic.
  • Template call detection resolves an identifier only when it is declared or imported in the same file; unknown names are dropped rather than guessed.
  • Parsing the script with tree-sitter rather than svelte/compiler is 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.

…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.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 textgraphify/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 --prove to formally verify them

· 3 grounded finding(s) anchored inline below.

return out


def extract_svelte(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionhandle_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk_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.
@zendoj

zendoj commented Jul 28, 2026

Copy link
Copy Markdown
Author

Follow-up pushed (2c6741b). I re-scored the extractor against the independent oracle on the full corpus of both apps — 455 components, not a sample — and that turned up three defects the 100-file sample had missed. All are fixed, with regression tests.

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:

  • an apostrophe in markup text — Don't
  • an apostrophe inside a comment within an expression — class={cn('x', // the CRM's own scope
  • a regex literal whose escaped slashes read as a line comment — replace(/^https?:\/\//, '')

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 (import Self from './ChainTree.svelte' used as <Self />) and now emits the self-edge rather than being discarded; and quoted destructure keys keep only the name, so "data-slot": slot yields a prop called data-slot.

Result on both corpora, full sets:

corpus components symbols precision recall
app A 233 6 079 100.0% 100.0%
app B (held out) 222 1 492 100.0% 100.0%

App B was never inspected while developing the extractor — it is what surfaced the regex-literal bug.

Test suite is unchanged against v8: I diffed the two FAILED lists rather than comparing totals, and they are identical (42 pre-existing failures — terraform grammar not installed, serve_http, skillgen, ollama). 17 new Svelte tests.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --prove to formally verify them

· 3 grounded finding(s) anchored inline below.

return out


def extract_svelte(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionhandle_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk_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.
@zendoj

zendoj commented Jul 28, 2026

Copy link
Copy Markdown
Author

One more fix pushed (efb509e), found by a second verifier rather than by the oracle.

precision.py scores declarations and component-usage edges, which left the call edges unmeasured — so "100% precision" would have been quietly narrower than it sounded. I wrote a different kind of check for those: for every calls edge, the cited line must actually mention the callee, and a cross-file target must really declare the symbol.

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 <script> sits below a <script module> block, every call site pointed several lines too high — at code with nothing to do with the edge. Wrong line numbers are worse than missing ones in a navigation tool.

Verification surface now, across both apps:

surface count how result
props, runes, functions, snippets, component usage 7 571 independent oracle 100%
calls edges 2 314 cited line mentions callee; cross-file target declares it 100%
renders_dynamic (INFERRED) 5 read by hand, all five all genuinely dynamic

The 5 inferred edges are 1.7‰ of component edges — $derived(role.icon), $derived(ICONS[...]), and a lazily-loaded $state<Component>. Runtime values that cannot be resolved statically, which is why they carry INFERRED.

Test suite still matches v8 exactly: I diff the two FAILED lists rather than compare totals — 42 pre-existing failures, no regressions, 18 new Svelte tests.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --prove to formally verify them

· 3 grounded finding(s) anchored inline below.

return out


def extract_svelte(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionhandle_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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk_top()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant