Skip to content

Fix Svelte, JS destructuring, and Rust use-declaration extraction - #3168

Open
wn-mitch wants to merge 3 commits into
Graphify-Labs:v8from
wn-mitch:wnmitch/extraction-fidelity
Open

Fix Svelte, JS destructuring, and Rust use-declaration extraction#3168
wn-mitch wants to merge 3 commits into
Graphify-Labs:v8from
wn-mitch:wnmitch/extraction-fidelity

Conversation

@wn-mitch

Copy link
Copy Markdown

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.py are present on v8 before these commits and are untouched here.

1. Parse Svelte script blocks with the SFC masker

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

.vue already 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 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.

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 name field 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: 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.

Destructured require is 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_call matches 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 use declarations through the AST

use_declaration was 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; as clauses 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 segment prelude, whose id collides 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 their consumers.

The use tree is now walked. _rust_use_leaves flattens a declaration into one leaf per bound name (use_as_clause, use_list, scoped_use_list, use_wildcard, 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/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. 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. Unresolvable paths (external crates) get a sourceless stub rather than a phantom target.

rust.py's own edge filter allowed dangling targets for imports/imports_from but not re_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 4 context="module", runes, plain-JS blocks, markup-only files, a cross-file calls edge 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.rs vs plain file, external crates), and the emitted edges, including a consumer resolving through a prelude to the definition and a regression test that no use edge points at a phantom node.
  • tests/test_js_exported_scalar_bindings.py (+7) — bound names vs pattern text, keys and defaults excluded, destructured require still 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 .ts equally). Entity-module median degree 3 to 7.

I am happy to split this into three PRs if you would rather review them separately.

wn-mitch and others added 3 commits August 28, 2026 10:11
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>

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

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 failsgraphify/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 initializergraphify/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) use is emitted as a re-exportgraphify/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 selfgraphify/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 relationgraphify/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).

Comment thread graphify/extract.py
@@ -1599,56 +1600,60 @@ def _emit_rescued_import(


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); 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:

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 regression_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,

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 regression_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(

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 regression_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:

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_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:

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()

fans out to 10 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