Fix Svelte, JS destructuring, and Rust use-declaration extraction - #3168
Fix Svelte, JS destructuring, and Rust use-declaration extraction#3168wn-mitch wants to merge 3 commits into
Conversation
Feeding a whole .svelte component to the JS grammar makes the markup a
top-level ERROR node, so import_statement and declaration nodes are never
reached. Every component extracted as a stub: a regex pass rescued import
specifiers and nothing else, no symbols and no participation in the call
graph. On a SvelteKit codebase this covered 613 files.
Apply the treatment .vue already gets. The Vue script masker is renamed
_sfc_mask_non_script and shared (the old name stays as an alias); both
extractors blank the non-script regions, keeping newlines so line numbers
stay accurate, and parse the script with the grammar its lang implies.
Every script block survives the mask, so a Svelte 5 <script module> block
is parsed alongside the instance script, as is Svelte 4's context=module.
_parse_js_tree needed the same masking or the call-graph pass kept handing
it raw component bytes, leaving components out of calls edges.
The static-import regex rescue is dropped, since the AST now edges those
and the pass would double-emit. The dynamic-import rescue stays and still
scans the raw source: {#await import('./X.svelte')} lives in the markup
layer the mask blanks out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A destructuring declarator binds identifiers, but its name field is the
pattern source. Reading that field verbatim minted one node labelled
{ a, b: renamed, c = 1, ...rest } -- text that names no symbol and can
never be a reference target. Svelte 5 makes the shape universal, since
every component destructures $props(), but the declarator is ordinary
JS/TS and the junk node appeared for both.
Walk the binding side instead: a pair_pattern's value (b: renamed binds
renamed, not the key b), an assignment pattern's left operand
(c = $bindable() binds c, not $bindable), recursing through nested
patterns and rest elements.
A destructured require is excluded. const { doWork } = require('./lib')
binds an import, not a local definition, and noding it shadowed the real
cross-file target so the call resolved to a local stub. The exclusion
tests the callee name: _find_require_call matches the call shape only --
any identifier(...) -- and leaves the name check to its callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The use_declaration branch read a declaration by string-splitting its text: everything before the first brace, then the last :: segment. A braced list collapsed to its shared prefix and every name inside it was lost; an as clause was never parsed, leaving the alias glued to the symbol (Entity as Risk); and the resulting id was a bare name no node carried, so the edge dangled and was dropped at build time. That last part made a crate prelude a false hub rather than a dead end. Every external prelude import in a codebase -- use sea_orm::entity:: prelude::*, use loco_rs::prelude::* -- stripped to the segment prelude, whose id collided with a local prelude.rs file node. One SeaORM model tree showed 466 inbound edges that were all spurious, 0 outbound, and 75 generated entity modules orphaned from the code using them. Walk the use tree instead. _rust_use_leaves flattens a declaration into one leaf per bound name, handling use_as_clause, use_list, scoped_use_list, use_wildcard and arbitrary nesting. _resolve_rust_use_path resolves crate/super/self-anchored paths through the module tree to a file on disk, encoding that mod.rs, lib.rs and main.rs are their module while foo.rs keeps its children in a sibling foo/. A use path's trailing segment is usually a symbol, so the tail is retried as a symbol inside the module the rest resolves to. Each leaf emits a file-level imports_from plus a symbol-level edge, both stamped with target_file so the shared canonicalization repoints them. pub use emits re_exports, so the corpus-level barrel collapse follows a consumer through a prelude to the defining symbol the way it already does for a JS barrel. Paths that resolve to nothing -- external crates -- get a sourceless stub rather than a phantom target. The extractor's own edge filter allowed dangling targets for imports and imports_from but not re_exports, dropping the new edges before the corpus pass saw them; it now matches the shared engine filter. A renamed re-export still does not chain symbol-level: the collapse keys on a name derived from the target id, with nowhere to record the alias. The file-level edges resolve either way. JS has the same limitation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Reworks Svelte extraction to parse the <script> region with a shared _sfc_mask_non_script mask (picking TSX/JS/TS by the lang attribute) instead of feeding the whole component to the JS grammar, so static imports, symbols, and type refs are now edged rather than dropped as ERROR-node noise, while a regex pass still recovers template-layer import('...') dynamic imports from the raw source; <script module> blocks survive alongside the instance script. Adds _js_pattern_bound_names so destructuring declarators node each identifier they actually bind ({ a, b: renamed, ...rest }) rather than one bogus node labelled with the pattern text, and skips names that normalize to empty to avoid leaking the scan path. Uses _is_require_initializer to recognise const { x } = require(...) as a CJS import and suppress the local stub so calls resolve to the real cross-file callee.
Worth a look
- Static Svelte imports are no longer rescued when parsing fails —
graphify/extract.py:1632· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Member access on require is misclassified as a direct require initializer —
graphify/extractors/engine.py:1918· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Private
pub(self) useis emitted as a re-export —graphify/extractors/rust.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
- use-list self is treated as a symbol named self —
graphify/extractors/rust.py:126· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- External pub use loses re-export relation —
graphify/extractors/rust.py:357· 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 — 2195 functions depend on the 640 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 505 callers, 42 callees - new:
_rebuild_code()— 98 callers, 50 callees - new:
_extract_generic()— 19 callers, 24 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
extract_js()— 89 callers, 3 callees - new:
dispatch_command()— 2 callers, 122 callees - new:
extract_objc()— 27 callers, 9 callees - new:
_resolve_js_module_path()— 27 callers, 6 callees - …and 46 more — each is listed as a finding
Verification — 2195 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2038 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify extract\_svelte.
The verifier did not have enough to check extract\_svelte, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify extract\_vue.
The verifier did not have enough to check extract\_vue, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_js\_extra\_walk.
The verifier did not have enough to check \_js\_extra\_walk, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_parse\_js\_tree.
The verifier did not have enough to check \_parse\_js\_tree, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify extract\_rust.
The verifier did not have enough to check extract\_rust, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
· 6 grounded finding(s) anchored inline below; 48 more finding(s) on lines outside this diff (see the check run).
| @@ -1599,56 +1600,60 @@ def _emit_rescued_import( | |||
|
|
|||
|
|
|||
| def extract_svelte(path: Path) -> dict: | |||
There was a problem hiding this comment.
extract_svelte()
fans out to 6 callees (efferent coupling); 13 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return fn is not None and _read_text(fn, source) == "require" | ||
|
|
||
|
|
||
| def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges: list, str_path: str) -> bool: |
There was a problem hiding this comment.
_require_imports_js()
high coupling complexity (Ca·Ce = 15).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return names | ||
|
|
||
|
|
||
| def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, |
There was a problem hiding this comment.
_js_extra_walk()
fans out to 10 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return (sibling if sibling.is_dir() else path.parent), path.parent | ||
|
|
||
|
|
||
| def _resolve_rust_use_path( |
There was a problem hiding this comment.
_resolve_rust_use_path()
6 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return (resolved, None) if resolved is not None else None | ||
|
|
||
|
|
||
| def extract_rust(path: Path) -> dict: |
There was a problem hiding this comment.
extract_rust()
fans out to 6 callees (efferent coupling); 16 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| "target_file": str(module_file), | ||
| }) | ||
|
|
||
| def walk(node, parent_impl_nid: str | None = None) -> None: |
There was a problem hiding this comment.
walk()
fans out to 10 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Three independent extraction fixes, one commit each. All three surfaced from running graphify over a Rust + SvelteKit monorepo, where the frontend extracted as stubs and the generated SeaORM entity modules were orphaned from the code using them.
Each commit passes the suite on its own. Full suite at the tip: 5043 passed. The 4 failures in
tests/test_ollama_retry_cap.pyare present onv8before these commits and are untouched here.1. Parse Svelte script blocks with the SFC masker
Feeding a whole
.sveltecomponent to the JS grammar makes the markup a top-levelERRORnode, soimport_statementand declaration nodes are never reached. Every component extracted as a stub — the regex pass rescued import specifiers and nothing else, no symbols, no participation in the call graph. On the codebase that prompted this, 613 files..vuealready solves this, so Svelte now shares it. The Vue masker is renamed_sfc_mask_non_script(old name kept as an alias); it blanks non-script regions, keeps newlines so line numbers stay accurate, and the script is parsed with the grammar itslangimplies. Every script block survives the mask, so a Svelte 5<script module>block is parsed alongside the instance script, as is Svelte 4'scontext="module"._parse_js_treeneeded the same masking, or the call-graph pass kept handing it raw component bytes.The static-import regex rescue is dropped, since the AST now edges those and it would double-emit. The dynamic-import rescue stays and still scans the raw source, because
{#await import('./X.svelte')}lives in the markup layer the mask blanks out.2. Node destructured bindings, not the pattern text
A destructuring declarator binds identifiers, but its
namefield is the pattern source. Reading it verbatim minted one node labelled{ a, b: renamed, c = 1, ...rest }— text that names no symbol and can never be a reference target.Now the binding side is walked: a
pair_pattern's value (b: renamedbindsrenamed, not the keyb), an assignment pattern's left operand (c = $bindable()bindsc, not$bindable), recursing through nested patterns and rest elements.Destructured
requireis excluded:const { doWork } = require('./lib')binds an import, and noding it as a local shadowed the real cross-file target. The exclusion checks the callee name, since_find_require_callmatches the call shape only and leaves the name check to its callers.This is ordinary JS/TS, but Svelte 5 makes it universal — every component destructures
$props()— so it was roughly one junk node per component.3. Resolve Rust
usedeclarations through the ASTuse_declarationwas read by string-splitting its text: everything before the first{, then the last::segment. Braced lists collapsed to their shared prefix and every name inside was lost;asclauses were never parsed, leaving the alias glued to the symbol (Entity as Risk); and the resulting id was a bare name no node carried, so the edge dangled and was dropped at build time.That last part makes a crate prelude a false hub, not just a dead end. Every external prelude import —
use sea_orm::entity::prelude::*,use loco_rs::prelude::*— strips to the segmentprelude, whose id collides with a localprelude.rsfile node. One SeaORM model tree showed 466 inbound edges that were all spurious, 0 outbound, and 75 generated entity modules orphaned from their consumers.The use tree is now walked.
_rust_use_leavesflattens a declaration into one leaf per bound name (use_as_clause,use_list,scoped_use_list,use_wildcard, arbitrary nesting)._resolve_rust_use_pathresolvescrate/super/self-anchored paths through the module tree to a file on disk, encoding thatmod.rs/lib.rs/main.rsare their module whilefoo.rskeeps its children in a siblingfoo/; ausepath's trailing segment is usually a symbol, so the tail is retried as a symbol inside the module the rest resolves to.Each leaf emits a file-level
imports_fromplus a symbol-level edge, both stamped withtarget_file.pub useemitsre_exports, so the corpus-level barrel collapse follows a consumer through a prelude to the defining symbol the way it already does for a JS barrel. Unresolvable paths (external crates) get a sourceless stub rather than a phantom target.rust.py's own edge filter allowed dangling targets forimports/imports_frombut notre_exports, dropping the new edges before the corpus pass saw them. It now matches the shared engine filter.Known limitation, called out in the commit: a renamed re-export does not chain symbol-level, because the collapse keys on a name derived from the target id and there is nowhere to record the alias. File-level edges resolve either way, and JS has the same limitation.
Verification
38 new tests across three files:
tests/test_svelte_extraction.py(14) — masking and line-number stability, static/dynamic imports, symbols, Svelte 5<script module>and Svelte 4context="module", runes, plain-JS blocks, markup-only files, a cross-filecallsedge through the masked_parse_js_tree, and a guard asserting the unmasked path still loses everything.tests/test_rust_use_reexports.py(17) — use-tree parsing (braced, nested, aliased, glob), module resolution (crate/super/self,mod.rsvs plain file, external crates), and the emitted edges, including a consumer resolving through a prelude to the definition and a regression test that nouseedge points at a phantom node.tests/test_js_exported_scalar_bindings.py(+7) — bound names vs pattern text, keys and defaults excluded, destructuredrequirestill resolving cross-file, and closures in a destructured initializer still tracked.Effect on the monorepo that prompted this: 22,707 nodes / 47,025 edges to 31,404 / 63,594. Files with syntax errors 613 to 4 (the remainder are a tree-sitter-typescript limitation with TS
import('…')types, which hits plain.tsequally). Entity-module median degree 3 to 7.I am happy to split this into three PRs if you would rather review them separately.