fix(sfc): a Vue/Svelte/Astro component owns its script (#2268)

The single-file-component extractors kept the delegated script's file node
and made the component contain it and every node, so the hierarchy ran
file-inside-component, members had two parents, and a file with no script
had no file node. And a per-instance script's top-level calls stayed on the
file, so a walk from the component found nothing. Now: one file node (the
whole file) holds the component; the component holds what the script holds
at top level, nested symbols keep their parent; <script setup>, a Svelte
instance script and Astro frontmatter give their top-level calls — a
top-level constant's initializer's included — to the component, while
imports and module-level scripts stay with the file. Svelte 5's
`<script module>` is a module script too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 21:24:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c4babfce6a
commit d6a2bdb0d6
7 changed files with 253 additions and 124 deletions
+11 -40
View File
@@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } fr
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
/**
* Astro built-in components — compiler-provided (`<Fragment>`) or shipped by
@@ -45,8 +46,10 @@ export class AstroExtractor {
const startTime = Date.now();
try {
// Create component node for the .astro file itself
// The file, holding the component the .astro file is
this.nodes.push(sfcFileNode(this.filePath, this.source, 'astro'));
const componentNode = this.createComponentNode();
this.edges.push({ source: `file:${this.filePath}`, target: componentNode.id, kind: 'contains' });
// Extract and process the frontmatter block (--- fenced, TypeScript)
const frontmatter = this.extractFrontmatter();
@@ -199,45 +202,13 @@ export class AstroExtractor {
const extractor = new TreeSitterExtractor(this.filePath, block.content, 'typescript');
const result = extractor.extract();
// Offset line numbers from the block back to .astro file positions
for (const node of result.nodes) {
node.startLine += block.startLine;
node.endLine += block.startLine;
node.language = 'astro'; // Mark as astro, not TS
this.nodes.push(node);
// Add containment edge from component to this node
this.edges.push({
source: componentNodeId,
target: node.id,
kind: 'contains',
});
}
// Offset edges (they reference line numbers)
for (const edge of result.edges) {
if (edge.line) {
edge.line += block.startLine;
}
this.edges.push(edge);
}
// Offset unresolved references
for (const ref of result.unresolvedReferences) {
ref.line += block.startLine;
ref.filePath = this.filePath;
ref.language = 'astro';
this.unresolvedReferences.push(ref);
}
// Carry over errors
for (const error of result.errors) {
if (error.line) {
error.line += block.startLine;
}
this.errors.push(error);
}
// Frontmatter runs on every render of the component, and a <script> is
// the page's own client code: both are the component's doing.
foldScriptResult(
result,
{ filePath: this.filePath, componentNodeId, lineOffset: block.startLine, language: 'astro', perInstance: true },
{ nodes: this.nodes, edges: this.edges, unresolvedReferences: this.unresolvedReferences, errors: this.errors }
);
}
/**
+116
View File
@@ -0,0 +1,116 @@
/**
* How a single-file component's script joins the component — shared by the
* Vue, Svelte and Astro extractors, which hand each `<script>` block (or
* Astro's frontmatter) to the TypeScript extractor and fold its result back.
*
* The fold used to keep the block's own file node and make the component
* contain it AND every node in the block, so the hierarchy ran file-inside-
* component and every member had two parents: the Symbol view listed a
* component's script twice, with the component and its file among its own
* members. And everything the block did at its top level stayed on that file
* node — but `<script setup>`, a Svelte instance script and Astro's
* frontmatter run once per component instance, so their top-level calls ARE
* the component's doing: a Nuxt page's `useAsyncData(…)`, a Svelte page's
* `onMount(…)`. With them on the file, a walk from the component found
* nothing, and the viewer's Steps tab drew a screen alone.
*
* Now: the SFC has one file node, the whole file, holding the component; what
* the block holds at its top level the component holds, and a nested symbol
* keeps its own parent; a per-instance block's top-level calls and references
* — a top-level constant's initializer's included — are the component's,
* while its imports, and everything a module-level block does, stay with the
* file.
*/
import type { Edge, ExtractionError, ExtractionResult, Language, Node, UnresolvedReference } from '../types';
import * as path from 'path';
/** The SFC's own file node — the whole file, as the tree-sitter extractor makes one. */
export function sfcFileNode(filePath: string, source: string, language: Language): Node {
return {
id: `file:${filePath}`,
kind: 'file',
name: path.basename(filePath),
qualifiedName: filePath,
filePath,
language,
startLine: 1,
endLine: source.split('\n').length,
startColumn: 0,
endColumn: 0,
isExported: false,
updatedAt: Date.now(),
};
}
export interface ScriptFold {
filePath: string;
componentNodeId: string;
/** Lines before the block in the SFC file: the block's line 1 is `lineOffset + 1`. */
lineOffset: number;
language: Language;
/** The block runs once per component instance — Vue `<script setup>`, a Svelte instance script, Astro frontmatter. */
perInstance: boolean;
}
export interface ScriptSink {
nodes: Node[];
edges: Edge[];
unresolvedReferences: UnresolvedReference[];
errors: ExtractionError[];
}
/** What a per-instance block does at its top level, as opposed to what it imports. */
const INSTANCE_REF_KINDS: ReadonlySet<string> = new Set(['calls', 'references', 'instantiates', 'function_ref']);
export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sink: ScriptSink): void {
const blockFile = `file:${fold.filePath}`;
// What already has a parent inside the block — a method's class, a nested
// function's function. The block's file holding something is not a parent:
// the component is, now.
const parented = new Set<string>();
for (const edge of result.edges) {
if (edge.kind === 'contains' && edge.source !== blockFile) parented.add(edge.target);
}
// A per-instance block's top-level `const data = useFetch(…)` is component
// state: its initializer runs on every instance, as the component's doing.
const instanceValues = new Set<string>();
if (fold.perInstance) {
for (const node of result.nodes) {
if ((node.kind === 'constant' || node.kind === 'variable') && !parented.has(node.id)) instanceValues.add(node.id);
}
}
const runsAsComponent = (id: string) => id === blockFile || instanceValues.has(id);
for (const node of result.nodes) {
if (node.kind === 'file') continue;
node.startLine += fold.lineOffset;
node.endLine += fold.lineOffset;
node.language = fold.language;
sink.nodes.push(node);
if (!parented.has(node.id)) sink.edges.push({ source: fold.componentNodeId, target: node.id, kind: 'contains' });
}
for (const edge of result.edges) {
if (edge.kind === 'contains' && edge.source === blockFile) continue;
if (edge.line) edge.line += fold.lineOffset;
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports') edge.source = fold.componentNodeId;
sink.edges.push(edge);
}
for (const ref of result.unresolvedReferences) {
ref.line += fold.lineOffset;
ref.filePath = fold.filePath;
ref.language = fold.language;
if (fold.perInstance && runsAsComponent(ref.fromNodeId) && INSTANCE_REF_KINDS.has(ref.referenceKind)) {
ref.fromNodeId = fold.componentNodeId;
}
sink.unresolvedReferences.push(ref);
}
for (const error of result.errors) {
if (error.line) error.line += fold.lineOffset;
sink.errors.push(error);
}
}
+11 -42
View File
@@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
/** Svelte 5 rune names — compiler builtins, not real functions */
const SVELTE_RUNES = new Set([
@@ -41,8 +42,10 @@ export class SvelteExtractor {
const startTime = Date.now();
try {
// Create component node for the .svelte file itself
// The file, holding the component the .svelte file is
this.nodes.push(sfcFileNode(this.filePath, this.source, 'svelte'));
const componentNode = this.createComponentNode();
this.edges.push({ source: `file:${this.filePath}`, target: componentNode.id, kind: 'contains' });
// Extract and process script blocks
const scriptBlocks = this.extractScriptBlocks();
@@ -132,8 +135,8 @@ export class SvelteExtractor {
// Detect TypeScript from lang attribute
const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
// Detect module script
const isModule = /context\s*=\s*["']module["']/.test(attrs);
// Detect module script: Svelte 4's `context="module"`, Svelte 5's `module`
const isModule = /context\s*=\s*["']module["']|(?:^|\s)module(?=[\s=]|$)/.test(attrs);
// Calculate the 0-indexed line where the content begins. The content
// starts right after the opening tag's `>` — its leading `\n` is part
@@ -179,45 +182,11 @@ export class SvelteExtractor {
const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
const result = extractor.extract();
// Offset line numbers from script block back to .svelte file positions
for (const node of result.nodes) {
node.startLine += block.startLine;
node.endLine += block.startLine;
node.language = 'svelte'; // Mark as svelte, not TS/JS
this.nodes.push(node);
// Add containment edge from component to this node
this.edges.push({
source: componentNodeId,
target: node.id,
kind: 'contains',
});
}
// Offset edges (they reference line numbers)
for (const edge of result.edges) {
if (edge.line) {
edge.line += block.startLine;
}
this.edges.push(edge);
}
// Offset unresolved references
for (const ref of result.unresolvedReferences) {
ref.line += block.startLine;
ref.filePath = this.filePath;
ref.language = 'svelte';
this.unresolvedReferences.push(ref);
}
// Carry over errors
for (const error of result.errors) {
if (error.line) {
error.line += block.startLine;
}
this.errors.push(error);
}
foldScriptResult(
result,
{ filePath: this.filePath, componentNodeId, lineOffset: block.startLine, language: 'svelte', perInstance: !block.isModule },
{ nodes: this.nodes, edges: this.edges, unresolvedReferences: this.unresolvedReferences, errors: this.errors }
);
}
/**
+9 -40
View File
@@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
import { vueOptionsMembers } from './vue-options-api';
/**
@@ -56,8 +57,10 @@ export class VueExtractor {
const startTime = Date.now();
try {
// Create component node for the .vue file itself
// The file, holding the component the .vue file is
this.nodes.push(sfcFileNode(this.filePath, this.source, 'vue'));
const componentNode = this.createComponentNode();
this.edges.push({ source: `file:${this.filePath}`, target: componentNode.id, kind: 'contains' });
// Extract and process script blocks
const scriptBlocks = this.extractScriptBlocks();
@@ -254,45 +257,11 @@ export class VueExtractor {
// part of the file. Name each one, and hand it the calls written inside it.
if (!block.isSetup) this.addOptionsMembers(block, result, componentNodeId);
// Offset line numbers from script block back to .vue file positions
for (const node of result.nodes) {
node.startLine += block.startLine;
node.endLine += block.startLine;
node.language = 'vue'; // Mark as vue, not TS/JS
this.nodes.push(node);
// Add containment edge from component to this node
this.edges.push({
source: componentNodeId,
target: node.id,
kind: 'contains',
});
}
// Offset edges (they reference line numbers)
for (const edge of result.edges) {
if (edge.line) {
edge.line += block.startLine;
}
this.edges.push(edge);
}
// Offset unresolved references
for (const ref of result.unresolvedReferences) {
ref.line += block.startLine;
ref.filePath = this.filePath;
ref.language = 'vue';
this.unresolvedReferences.push(ref);
}
// Carry over errors
for (const error of result.errors) {
if (error.line) {
error.line += block.startLine;
}
this.errors.push(error);
}
foldScriptResult(
result,
{ filePath: this.filePath, componentNodeId, lineOffset: block.startLine, language: 'vue', perInstance: block.isSetup },
{ nodes: this.nodes, edges: this.edges, unresolvedReferences: this.unresolvedReferences, errors: this.errors }
);
}
/**