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
parent c4babfce6a
commit d6a2bdb0d6
7 changed files with 253 additions and 124 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In Vue, Svelte and Astro files, a component now owns its script, and what a `<script setup>`, a Svelte instance script or Astro frontmatter does at its top level is the component's doing. That covers a Nuxt page's `useAsyncData(…)`, a Svelte page's `onMount(…)` and Astro's `await getCollection(…)`. Imports and a module-level script stay with the file. Before, those calls belonged to the file, so callers and flows from a component found nothing. The component was also recorded as containing its own file, and a file with no script had no file node at all.
- In JavaScript and TypeScript, a CommonJS `require('./application')` now counts as an import of that file, the way an ESM `import` does. A module path also resolves the way Node and bundlers find it: extensions, `index` files and path aliases. Before, a CommonJS file depended on nothing at file level, so Express's own `lib/express.js` showed no imports and impact analysis missed the files it loads. An extensionless side-effect import like `import './polyfills'` also stopped at its own import statement instead of reaching the file.
- An Express route whose handler is written inline as `function (req, res) {…}`, the form Express's own examples use, now links to what that handler calls, just like an arrow handler does. The handler is the route's last argument, so an inline middleware before a named handler no longer stands in for it, and a wrapper like `asyncHandler(async function (req, res) {…})` and a trailing comma both work. Before, a route such as `POST /login` linked to nothing.
- A Python module member used through the imported module, like `generic.ObjectEditView` after `from netbox.views import generic`, `serializers.ValidationError` or `utils.assert_deserialize(…)`, now also follows the package's re-exports to its definition. Before, netbox's view classes lost their `generic.*` base classes, and mealie's test helpers called through `utils.` went unresolved.
+4 -2
View File
@@ -9259,8 +9259,10 @@ function greet() {
expect(componentNode?.name).toBe('Static');
expect(componentNode?.language).toBe('vue');
// Only the component node should exist (no script nodes)
expect(result.nodes.length).toBe(1);
// The file and the component it is — no script nodes. (A file with no
// script used to have no file node at all.)
expect(result.nodes.map((n) => n.kind).sort()).toEqual(['component', 'file']);
expect(result.edges).toContainEqual(expect.objectContaining({ source: 'file:Static.vue', target: componentNode!.id, kind: 'contains' }));
});
it('should create containment edges from component to script nodes', () => {
+101
View File
@@ -0,0 +1,101 @@
/**
* A Vue / Svelte / Astro file is one file node holding the component it is,
* and the component holds its script: each symbol has one parent, and a
* component never contains its own file. What a per-instance script does at
* its top level — `<script setup>`, a Svelte instance script, Astro's
* frontmatter — is the component's doing (a Nuxt page's `useAsyncData(…)`, a
* Svelte page's `onMount(…)`); a module-level script's, and every import,
* stays with the file.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import type { ExtractionResult } from '../src/types';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const componentOf = (r: ExtractionResult) => r.nodes.find((n) => n.kind === 'component')!;
const from = (r: ExtractionResult, name: string) =>
r.unresolvedReferences.filter((ref) => ref.referenceName === name && ref.referenceKind === 'calls').map((ref) => ref.fromNodeId);
const parents = (r: ExtractionResult) => {
const out = new Map<string, string[]>();
for (const e of r.edges) if (e.kind === 'contains') out.set(e.target, [...(out.get(e.target) ?? []), e.source]);
return out;
};
describe('a single-file component’s structure', () => {
it('is a file holding the component holding the script, one parent each', () => {
const r = extractFromSource('pages/index.vue', `<template><button @click="load">Load</button></template>
<script setup lang="ts">
import { useFetch } from '#app';
const data = useFetch('/api/items');
function load() {
return data;
}
</script>
`);
const file = r.nodes.find((n) => n.kind === 'file')!;
const component = componentOf(r);
expect(file).toMatchObject({ id: 'file:pages/index.vue', startLine: 1, endLine: 9 });
const p = parents(r);
expect(p.get(component.id)).toEqual([file.id]);
expect(p.get(file.id)).toBeUndefined();
const load = r.nodes.find((n) => n.name === 'load')!;
expect(p.get(load.id)).toEqual([component.id]);
for (const [, list] of p) expect(list).toHaveLength(1);
});
it('gives a <script setup>’s top-level calls to the component, and its imports to the file', () => {
const r = extractFromSource('pages/index.vue', `<template><div /></template>
<script setup lang="ts">
import { useFetch } from '#app';
const data = useFetch('/api/items');
</script>
`);
expect(from(r, 'useFetch').filter((id) => id.startsWith('component:'))).toHaveLength(1);
expect(r.unresolvedReferences.filter((ref) => ref.referenceKind === 'imports').every((ref) => ref.fromNodeId === 'file:pages/index.vue')).toBe(true);
});
it('leaves a plain <script>’s module-level calls with the file', () => {
const r = extractFromSource('src/Widget.vue', `<template><div /></template>
<script>
registerWidget('widget');
export default { name: 'Widget' };
</script>
`);
expect(from(r, 'registerWidget')).toEqual(['file:src/Widget.vue']);
});
it('gives a Svelte instance script to the component, and a module script to the file', () => {
const r = extractFromSource('src/routes/+page.svelte', `<script context="module">
preload();
</script>
<script module>
register();
</script>
<script>
import { onMount } from 'svelte';
onMount(() => fetchItems());
</script>
<p>items</p>
`);
const component = componentOf(r).id;
expect(from(r, 'onMount')).toEqual([component]);
expect(from(r, 'preload')).toEqual(['file:src/routes/+page.svelte']);
expect(from(r, 'register')).toEqual(['file:src/routes/+page.svelte']);
});
it('gives Astro frontmatter to the component, and makes a file node for a file without one', () => {
const r = extractFromSource('src/pages/blog.astro', `---
const posts = await getCollection('blog');
---
<ul>{posts.map((p) => <li>{p.title}</li>)}</ul>
`);
expect(from(r, 'getCollection')).toEqual([componentOf(r).id]);
const bare = extractFromSource('src/pages/about.astro', `<p>about</p>\n`);
expect(bare.nodes.map((n) => n.kind).sort()).toEqual(['component', 'file']);
});
});
+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 }
);
}
/**