Skip to content

Vue SFC <script setup> blocks are never parsed — silent empty graph for .vue files, causing false-positive dead code #1410

Closed

Description

@oscar6echo

Version

codebase-memory-mcp 0.9.0 (Linux x86_64, standalone binary)

Platform

Linux (x64)

Install channel

GitHub release archive / install.sh / install.ps1

Binary variant

standard

What happened, and what did you expect?

Summary

.vue files are discovered and registered as File and Module nodes, and
get_architecture reports them under languages. But their <script setup>
contents are never parsed: no Function/Method nodes are created for
functions defined in a .vue file, and no CALLS edges are created for calls
originating from one.

The index therefore looks healthy while being structurally empty for every
.vue file in the repo.

Impact

This makes search_graph(max_degree=0, exclude_entry_points=true) unsafe on any
Vue project. Every utility called only from components reports in_degree: 0
and is presented as dead code.

On a 9.2k-line Vue/TS codebase the dead-code query returned 29 functions.
6 of the 11 I verified were false positives — all called from .vue files:

Reported dead Actual call sites
showModal 5
hideModal 6
copyToClipboard 2
showToast 1
hideToast 1
now 1

Acting on that list deletes working code. fan_in in get_architecture
hotspots is understated for the same reason (reported clone at fan_in: 3;
actual is 6).

Reproduction

Minimal reproduction

Four files. The .ts caller and the .vue caller do the same thing.

repro/
  package.json
  tsconfig.json
  src/util.ts
  src/caller.ts
  src/components/Caller.vue

src/util.ts

export function fnRelTs(x: number): number { return x + 1; }
export function fnAliasTs(x: number): number { return x + 2; }
export function fnRelVue(x: number): number { return x + 3; }
export function fnAliasVue(x: number): number { return x + 4; }
export default { fnRelTs, fnAliasTs, fnRelVue, fnAliasVue };

src/caller.ts

import uRel from './util';
import uAlias from '~/util';

export function runTs(): number {
  return uRel.fnRelTs(1) + uAlias.fnAliasTs(2);
}

src/components/Caller.vue

<script setup lang="ts">
import uRel from '../util';
import uAlias from '~/util';

const runVue = (): number => {
  return uRel.fnRelVue(1) + uAlias.fnAliasVue(2);
};
</script>

<template>
  <div>{{ runVue() }}</div>
</template>

tsconfig.json

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "baseUrl": ".", "paths": { "~/*": ["src/*"] } } }

package.json

{ "name": "repro", "version": "1.0.0", "type": "module" }

Then:

index_repository(repo_path="…/repro", mode="full", name="repro")
query_graph(project="repro",
  query="MATCH (f) WHERE (f:Function OR f:Method) RETURN f.name, f.file_path, f.in_degree ORDER BY f.name")

Actual

fnAliasTs   src/util.ts   1
fnAliasVue  src/util.ts   0     ← called from Caller.vue
fnRelTs     src/util.ts   1
fnRelVue    src/util.ts   0     ← called from Caller.vue
runTs       src/caller.ts 0

runVue is absent from the graph entirely — no node is created for it.

MATCH (a)-[r:CALLS]->(b) RETURN a.name, a.file_path, b.name
→ runTs, src/caller.ts, fnRelTs
  runTs, src/caller.ts, fnAliasTs

Only two edges. Nothing originates from Caller.vue.

Expected

fnRelVue and fnAliasVue at in_degree: 1, a Function node for runVue
at src/components/Caller.vue, and two additional CALLS edges.

What this is not

I first suspected namespace default imports (import u from './util' then
u.fn()) or path-alias resolution (~/util). Both work correctly — the
2×2 above isolates it: caller.ts resolves relative and aliased namespace
calls fine. The only failing variable is the caller being a .vue file.

Why it is easy to miss

get_architecture reports the files as present, which reads as success:

"languages": [ { "language": "Vue", "file_count": 34 }, … ]

Aggregate edge density is the tell — on the same machine:

Repo Nodes Edges Edges/node
Python, 3.7k LOC 409 1665 4.07
Vue+TS, 9.2k LOC 480 924 1.93

And on the Vue repo:

MATCH (f) WHERE (f:Function OR f:Method) AND f.file_path ENDS WITH '.vue'
RETURN count(f)                                            → 0

MATCH (a)-[r:CALLS]->(b) WHERE a.file_path ENDS WITH '.vue'
RETURN count(r)                                            → 0

MATCH (f:File) WHERE f.path ENDS WITH '.vue' RETURN count(f) → 34

34 file nodes, zero structural content.

Suggested direction

Extract the <script> / <script setup> block from the SFC before handing it
to the TS parser, preserving a line offset so file_path/line attribution stays
correct. Template-block references (@click="handler", {{ fn() }}) are a
larger job and could be a follow-up — script-block extraction alone would
recover the bulk of the missing edges.

Two smaller asks, if useful:

  1. Until this lands, consider having index_repository warn when a language is
    detected but yields zero function nodes — that single signal would have made
    this obvious immediately.
  2. exclude_entry_points=true in search_graph(max_degree=0, …) implies a
    confidence the index cannot support for partially-parsed languages. A
    per-language "structural coverage" figure in index_status would let callers
    know when not to trust a dead-code result.

Environment

  • codebase-memory-mcp 0.9.0, standalone binary
  • Linux 7.0.0-28-generic, x86_64
  • Repos: Vue 3 + TypeScript (Vite, ~/* path alias), and Python 3 for the
    comparison baseline
  • mode="full" in all runs; skipped_count: 0, expected_nodes matched
    nodes in every index (no reported extraction failures)

Logs


Diagnostics trajectory (memory / performance / leak issues)


Project scale (if relevant)

No response

Confirmations

  • I searched existing issues and this is not a duplicate.
  • My reproduction uses shareable code (a dummy snippet or a public OSS repository), not proprietary code.

Activity

  1. added
    parsing/qualityGraph extraction bugs, false positives, missing edges
    windowsWindows-specific issues
    on Aug 1, 2026
  2. AmirF194 commented on Aug 2, 2026

    @AmirF194
    Contributor

    Reproduced this on current main (d6be58ef, 2026-07-31), in a clean container, with your exact 4-file example (util.ts / caller.ts / Caller.vue / tsconfig.json). Same result: runVue is entirely absent from the graph, and fnRelVue/fnAliasVue both show in_degree: 0 despite the calls in Caller.vue. runTs (the .ts caller) correctly shows up with its two outgoing edges.

    Root cause is exactly what the "what this is not" section rules toward: internal/cbm/lang_specs.c:1978-1982, the CBM_LANG_VUE spec row, sets func_types/class_types/method_types/call_types all to empty_types:

    [CBM_LANG_VUE] = {CBM_LANG_VUE, empty_types, empty_types, empty_types, vue_module_types,
                      empty_types, empty_types, empty_types, empty_types, empty_types, empty_types,
                      empty_types, NULL, empty_types, NULL, NULL, tree_sitter_vue,
                      vue_embedded_imports},

    The only special-case handling for <script> content is vue_embedded_imports (same file, dispatched via parse_embedded_imports() in extract_imports.c), and its own comment says what it's for: "re-parse that slice with the JS grammar so the existing ES import extractor sees real import_statement nodes." It exists purely to make imports work through raw_text nodes; nothing re-parses the same slice for functions or calls. So .vue is not a partially-broken extractor, it's an extractor that was only ever built for one edge type. CBM_LANG_SVELTE's row (right below, same file) has the identical all-empty_types shape and the same svelte_embedded_imports-only mechanism, so this is probably not Vue-specific, though I only reproduced the .vue case above.

    Competing-PR sweep on internal/cbm/lang_specs.c + extract_imports.c (87/87 open PRs, uncapped): 10 candidates touch one of the two files, none add function/call extraction for the embedded-script languages. Closest is #1245, which touches the CBM_LANG_VUE row, but only to append one new struct field (an unrelated incremental-indexing change) and leaves every empty_types in place.

    Agree with the suggested direction (extract the <script>/<script setup> block and hand it to the JS/TS extractor with a line offset, mirroring what vue_embedded_imports already does for the import case); happy to take a pass at it if that'd be useful.

  3. added
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    and removed
    windowsWindows-specific issues
    on Aug 3, 2026
  4. added this to the 0.9.1-rc milestone on Aug 3, 2026
  5. DeusData commented on Aug 3, 2026

    @DeusData
    Owner

    Thank you for the clear Vue SFC reproduction. Current language metadata routes .vue, but its definition/class/function extraction sets are empty; only module and embedded-import handling is configured. That explains a successful index with an effectively empty symbol graph for <script setup>. I have removed the unrelated Windows label and routed this as a high-priority parsing bug in 0.9.1-rc.

  6. bmcnaboe commented on Aug 20, 2026

    @bmcnaboe
    Contributor

    Agreeing with @AmirF194's direction and the maintainer's framing — proposing a concrete generalization shape and asking for design feedback before sending a PR.

    Approach: generalize the existing embedded-script seam (vue_embedded_imports / svelte_embedded_imports in internal/cbm/extract_imports.c) from imports-only to the full walker set (defs, classes, methods, calls — everything func_types/class_types/method_types/call_types currently leave as empty_types for CBM_LANG_VUE/CBM_LANG_SVELTE). Concretely:

    • Extract the <script>/<script setup> block's byte range from the SFC, same as the import seam already does.
    • Parse that slice with ts_parser_set_included_ranges against the JS/TS grammar rather than a raw re-slice-and-reparse, so node coordinates come back in the original file's coordinate space — this is what lets defs/calls/edges land on the right line numbers without a separate offset-translation pass, and keeps a single walker pass over the whole file rather than a second synthetic file.
    • Sniff lang="ts" on the script_element node and route that block through CBM_LANG_TYPESCRIPT's walker configuration instead of plain JS, since <script setup lang="ts"> is idiomatic in current Vue.
    • Since CBM_LANG_SVELTE's spec row has the identical all-empty_types shape and the same single-purpose embedded-import mechanism, the same generalization should cover Svelte at near-zero marginal cost — same seam, same included-ranges technique, different grammar/spec row.

    This keeps .vue/.svelte files off any second-pass reparse and reuses the walker set that already exists for JS/TS, rather than teaching the graph builder a third extraction path.

    We're carrying a fork patch along these lines in the meantime and will have it further validated (212-SFC repo, fan-in/dead-code checks against <script setup>-only exports) before proposing it as a PR — would appreciate a read on whether this shape (included-ranges reparse of the existing seam, lang sniffing, Svelte sharing the same code path) matches the direction you'd want to merge, per CONTRIBUTING's ask for a design discussion before code on cross-cutting changes.

  7. DeusData commented on Aug 27, 2026

    @DeusData
    Owner

    @bmcnaboe — thank you for this, and I'm sorry you waited a week for a reply. You asked for a design read before spending effort, which is exactly the right way round, and leaving that unanswered was my mistake.

    The read you asked for: yes, that shape is right — on all three points.

    ts_parser_set_included_ranges against the original buffer is the correct call, and for a reason worth stating explicitly since it's the part that's easy to get wrong: it isn't only about avoiding an offset-translation pass. Parsing the full original buffer through an absolute included range is what keeps byte and point coordinates correct across CRLF line endings and a UTF-8 BOM — a re-slice-and-reparse gets both subtly wrong in exactly the files where nobody looks. Sniffing lang="ts" and routing that block through the TypeScript configuration is right too; <script setup lang="ts"> is the common case in current Vue, not an edge case. And your read that Svelte's spec row has the identical all-empty_types shape and the same single-purpose embedded-import mechanism is correct.

    Where things stand, so you don't duplicate work. The Vue half is up as #1852 and finishing CI now. It converged independently on the shape you described here — included-ranges over the original buffer, plus a lang=/src= resolver on the script_element — which I take as good evidence the design is right rather than a coincidence. Your analysis in this thread stands on its own, and @AmirF194's root-cause narrowing to the empty_types spec row is what made the whole thing tractable; thank you both.

    The generalization is yours if you'd like it, and I'd rather it were, because you have a 212-SFC corpus and fan-in/dead-code checks that I don't. Concretely what's left after #1852:

    • The gate is one line in internal/cbm/extract_imports.c: bool extract_structure = ctx->language == CBM_LANG_VUE;. Widening it to Svelte, HTML and Astro is the core of the change.
    • vue_embedded_language() is already host-agnostic despite the name — it walks the script_element's attributes for src= (bail) and lang= (js/ts). Renaming it to something like script_embedded_language() and reusing it is the intended path.
    • lang_specs.c already declares the embedded specs for all four hosts, and Vue/Svelte/HTML are byte-identical ({"script_element", "raw_text", CBM_LANG_JAVASCRIPT}), so block discovery and re-parse already work — only structural extraction is gated off.
    • The one genuinely new piece is HTML type=. Plain HTML routinely carries <script type="application/json">, type="importmap", type="text/x-template". Today those are re-parsed for imports only, which is harmless because a JSON blob yields no import_statement. Turning on structural extraction makes it harmful — garbage defs and calls in the graph. Accept absent / module / text/javascript / application/javascript, bail on the rest.
    • Astro's frontmatter block has no attributes at all, so the resolver falls through to the JS default; worth deciding TS vs JS deliberately there rather than by fallthrough.
    • Svelte's <script context="module"> is a second block in the same file. MAX_EMBEDDED_BLOCKS is 16 so both are collected — worth a test that both contribute.

    #1852 ships a control test that asserts Svelte/HTML/Astro still produce zero functions and zero calls. Flipping that test from zero to the real symbols is the cleanest possible proof of your change, so please do flip it rather than work around it. Please also add a control that must stay at zero: a <script type="application/json"> block, and a <script src="./x.js"> in each host.

    One more thing worth having on your radar: #1807 is the Svelte-side report of the same bug, with an A/B table. Landing this closes it, and I'd like to credit you there when it does.

    No deadline from me, and no rush — take the time to validate against your corpus. If you'd rather not carry it after all, just say so and I'll pick it up; either answer is genuinely fine, I'd just like to know which so #1807's reporter gets an honest ETA. Thanks again for the care you put into the proposal.

  8. DeusData commented on Aug 27, 2026

    @DeusData
    Owner

    Fixed on main in e678722 (#1852).

    .vue files now extract functions, classes and calls from <script> / <script setup>, not just imports. Your original repro is the acceptance case: runVue appears in the graph, and fnRelVue / fnAliasVue get real in-edges from Caller.vue.

    Mechanically, the embedded region is parsed with ts_parser_set_included_ranges against the original buffer rather than re-sliced into a synthetic source, which is what keeps byte and point coordinates correct across CRLF and a UTF-8 BOM; lang="ts" routes the block through the TypeScript configuration, and a <script src="..."> is left alone. Everything lands on existing node and edge types — no new vocabulary.

    Credit where it belongs: @AmirF194 narrowed this to the empty_types spec row at lang_specs.c with a clean container reproduction, which is what made it tractable, and @bmcnaboe proposed the generalization shape — included-ranges plus lang= sniffing — that the fix independently converged on. Thank you both, and thank you @oscar6echo for a report that was precise enough to act on directly.

    Closing this one as the Vue case is resolved. #1807 stays open for the same underlying gap in Svelte, and HTML/Astro ride along with it — @bmcnaboe is carrying that generalization, with the remaining detail on that thread.

  9. bmcnaboe commented on Sep 3, 2026

    @bmcnaboe
    Contributor

    Yes — I'll pick this up. Thanks for the design read and for laying out what's left.

    Plan, following your list: widen the extract_structure gate to Svelte/HTML/Astro; rename vue_embedded_language() to script_embedded_language() and reuse it; gate HTML on type= (absent / module / text/javascript / application/javascript, bail on the rest); decide TS vs JS for Astro frontmatter explicitly; cover both Svelte blocks (context="module" + instance) contributing. Flip #1852's zero-symbol control to the real symbols, and add the stay-at-zero controls (type="application/json", <script src> in each host).

    First step is sizing it against current main and validating on the SFC corpus. I'll post an ETA on #1807 once that's done.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions