fix(vue): a self-closing <script /> no longer swallows the next script (#3070)

The Vue extractor read a self-closing `<script src="./legacy.js" />` block
as an opening tag running on to the next `</script>`, as the Astro and
Svelte extractors did until PR #3063 (PRs #2995 and #2997) landed. The
`<script setup>` after it was parsed with whatever lay in between and
contributed nothing, and a `<template>` in between lost its calls and its
components too. One copy of the same pattern sat in each of 9 places in 5
files, and that fix reached the 6 copies in Astro, Svelte and the viewer.

sfc-script.ts, which the Vue, Svelte and Astro extractors already share,
now exports the script pattern and the script-or-style pattern, built from
@maxmilian's regexes, plus sfcBlockLineRanges, the line ranges a template
scan skips. A self-closing tag has no body, so it covers nothing and isn't
blanked, and calls in its attributes are read. All 9 sites use them: each
extractor's script blocks, Astro's covered ranges, both Svelte template
scans, Vue's template-components scan, the Vue template-calls blanking
and the viewer's highlight regions. The pair also accepts a closing tag
written with a space, `</script >`, as the template-calls blanking always
did; the viewer finds a block's body from its opening tag for that reason.

Validation on main 61776927 (Windows 11):
- Red/green: 4 Vue tests (a `<script src ... />` block before
  `<script setup>`, one before `<template>`, an attribute call on a
  self-closing script in the template, and `</script >`) fail with the
  previous sources and pass; the 6 Astro and Svelte tests still pass.
- A/B: 5 Vue repos (vuejs/core, vuejs/docs, NekoBox, justgage,
  element-plus-playground; none has a self-closing script) are
  byte-identical to main. 7 Astro repos, 2 Svelte subtrees (shadcn-svelte
  docs routes, SvelteKit's basics test app) and the flask and express
  controls are byte-identical to the Astro and Svelte fix before this one.

Fixes #2989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 14:02:53 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cb7fb61e76
commit de0a4eb362
8 changed files with 180 additions and 57 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
- In Vue and Nuxt components, calls written in the template (`{{ formatDate(post) }}`, `:to="getAccountRoute(account)"`, `@click="save(item)"`) and calls in a destructuring declaration at the top of `<script setup>` or of a TypeScript or JavaScript file (`const { t } = useI18n()`) now link to the function they call, so `callers` and impact for a composable or helper used this way are no longer mostly empty. A `v-for` item or slot prop is never taken for a project function, a handler like `@click="open = false"` no longer links to an unrelated function of the same name, and a component's own function named like a Nuxt helper, such as `clearError`, is now the one its calls reach. Thanks @deniskern for the report, and @drakeo338 and @L0garithmic. (#2340)
- In Vue, a `<script lang="tsx">` block is now read as TSX. Before, it was read as JavaScript, which can't read its TypeScript: a script holding only `defineProps` or `withDefaults` was reported as producing no symbols, and in a script with other code an `interface` or type it declared was missing from the graph without any warning. Re-index Vue projects that use `lang="tsx"` after upgrading. Thanks @leadingGo for the report, and @maxmilian and @yanhongwang1017. (#2570)
- In Astro and Svelte, a self-closing script like Astro's `<script is:inline set:html={code} />` or `<script src="/analytics.js" />` in `<svelte:head>` no longer swallows the `<script>` that follows it. Before, that script's symbols and calls were dropped with a "parse produced no symbols" warning, and calls and components in the markup between the two were missed too, as they were after a self-closing `<style />`. Re-index Astro and Svelte projects after upgrading. Thanks @rezaularif for the report and @maxmilian for the fix. (#2989)
- In Astro, Svelte and Vue, a self-closing script like Astro's `<script is:inline set:html={code} />`, `<script src="/analytics.js" />` in `<svelte:head>` or a Vue `<script src="./legacy.js" />` block no longer swallows the `<script>` that follows it. Before, that script's symbols and calls were dropped with a "parse produced no symbols" warning, and calls and components in the markup between the two were missed too, as they were after a self-closing `<style />`. Re-index Astro, Svelte and Vue projects after upgrading. Thanks @rezaularif for the report and @maxmilian for the fix. (#2989)
- In COBOL, asking `codegraph_explore` (or `codegraph explore` and `codegraph context`) about a copybook by name, such as `CVACT01Y` or a member brought in with `EXEC SQL INCLUDE`, now answers with the copybook's own source when it is indexed and every `COPY` or `EXEC SQL INCLUDE` statement that includes it, and says so when the copybook's source isn't part of the project. Before, it answered "No relevant code found", or showed an unrelated program whose name was a letter or two away. Thanks @popolusiak for the report. (#2342)
- In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338)
- In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future<Report?>.value(null)` or Riverpod's `final reportProvider = Family<Report?, String>()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327)
+102
View File
@@ -0,0 +1,102 @@
/**
* A self-closing `<script ... />` in a Vue single-file component, such as a
* `<script src="./legacy.js" />` block, has no body. Vue, Svelte and Astro
* shared one script pattern that read it as an opening tag running on to the
* next `</script>`: the `<script setup>` after it was parsed with whatever lay
* in between, failed, and contributed no symbols, and a template in between
* lost its calls and components too (#2989, #2995 and #2997 for Astro and
* Svelte). The three extractors now share one pattern pair, and a
* self-closing tag covers nothing a template scan should skip.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const calls = (result: ReturnType<typeof extractFromSource>, name: string, line: number) =>
result.unresolvedReferences.some((ref) => ref.referenceKind === 'calls' && ref.referenceName === name && ref.line === line);
const renders = (result: ReturnType<typeof extractFromSource>, name: string, line: number) =>
result.unresolvedReferences.some((ref) => ref.referenceKind === 'references' && ref.referenceName === name && ref.line === line);
describe('Vue self-closing <script />', () => {
it('does not swallow the <script setup> that follows it', () => {
const code = `<template>
<p>{{ formatDate(date) }}</p>
<Header />
</template>
<script src="./legacy.js" />
<script setup>
const ready = true;
function go() { return helper(); }
</script>
`;
const result = extractFromSource('src/components/Page.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7);
expect(result.nodes.find((n) => n.name === 'go')?.startLine).toBe(8);
expect(calls(result, 'helper', 8)).toBe(true);
expect(calls(result, 'formatDate', 2)).toBe(true);
expect(renders(result, 'Header', 3)).toBe(true);
});
it('keeps the template it comes before', () => {
const code = `<script src="./legacy.js" />
<template>
<p>{{ formatDate(date) }}</p>
<Header />
</template>
<script setup>
const ready = true;
</script>
`;
const result = extractFromSource('src/components/First.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7);
expect(calls(result, 'formatDate', 3)).toBe(true);
expect(renders(result, 'Header', 4)).toBe(true);
});
it("still reads a call in a self-closing <script />'s attributes", () => {
const code = `<template>
<article>
<script type="application/ld+json" :innerHTML="toJsonLd(post)" />
<p>{{ formatDate(post.date) }}</p>
<Header />
</article>
</template>
<script setup>
const ready = true;
</script>
`;
const result = extractFromSource('src/components/Post.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(9);
expect(calls(result, 'toJsonLd', 3)).toBe(true);
expect(calls(result, 'formatDate', 4)).toBe(true);
expect(renders(result, 'Header', 5)).toBe(true);
});
// The template-call scan always took `</script >` as a closing tag; with one
// pattern for every site, the script reader does too.
it('ends a block at a closing tag written with a space, `</script >`', () => {
const code = `<template>
<p>{{ formatDate(date) }}</p>
</template>
<script setup>
const ready = true;
</script >
`;
const result = extractFromSource('src/components/Spaced.vue', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(5);
expect(calls(result, 'formatDate', 2)).toBe(true);
});
});
+5 -12
View File
@@ -2,7 +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';
import { foldScriptResult, sfcFileNode, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/**
* Astro built-in components — compiler-provided (`<Fragment>`) or shipped by
@@ -161,7 +161,7 @@ export class AstroExtractor {
// A self-closing `<script ... />` (e.g. `is:inline set:html={...}`) has no
// body; match it on its own so it cannot run on to the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g;
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -228,16 +228,9 @@ export class AstroExtractor {
coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]);
}
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip, and its attributes
// (`set:html={serializeJsonLd(schema)}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// A self-closing tag covers nothing: its attributes
// (`set:html={serializeJsonLd(schema)}`) are template expressions.
coveredRanges.push(...sfcBlockLineRanges(this.source));
return coveredRanges;
}
+45
View File
@@ -46,6 +46,51 @@ export function sfcScriptLanguage(attrs: string, host: Language): Language {
return 'javascript';
}
/**
* A `<script>` element of a Vue, Svelte or Astro file: group 1 its opening
* tag's attributes, `content` its body. A self-closing tag — Astro's
* `<script is:inline set:html={code} />`, a third-party
* `<script src="/analytics.js" />` — is matched on its own, with no
* `content`: read as an opening tag it ran on to the next script's
* `</script>`, and that script was parsed with the markup in between and
* contributed nothing (#2989).
*
* Shared by the three extractors and the viewer's syntax regions. It carries
* the `g` flag, so each loop runs on its own copy (`new RegExp(...)`).
*/
export const SFC_SCRIPT_ELEMENT = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script\s*>)/g;
/**
* A `<script>` or `<style>` element, which the template scans skip: what it
* holds is code or CSS, not markup. A self-closing one (see
* {@link SFC_SCRIPT_ELEMENT}) has no body to skip, and its attributes, like
* `set:html={serializeJsonLd(schema)}`, are template expressions like any
* other's: {@link sfcElementHasBody} says which a match is.
*/
export const SFC_SCRIPT_OR_STYLE_ELEMENT = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
/** Whether a match of {@link SFC_SCRIPT_OR_STYLE_ELEMENT} has a body: false for a self-closing tag. */
export function sfcElementHasBody(element: string): boolean {
return !element.endsWith('/>');
}
/**
* The lines (0-indexed, inclusive) of each `<script>` and `<style>` element
* with a body in `source`, which a template scan skips. A self-closing tag
* covers nothing.
*/
export function sfcBlockLineRanges(source: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const elements = new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT);
let match: RegExpExecArray | null;
while ((match = elements.exec(source)) !== null) {
if (!sfcElementHasBody(match[0])) continue;
const startLine = (source.substring(0, match.index).match(/\n/g) || []).length;
ranges.push([startLine, startLine + (match[0].match(/\n/g) || []).length]);
}
return ranges;
}
/** 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 {
+7 -26
View File
@@ -2,7 +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, sfcScriptLanguage } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/** Svelte 5 rune names — compiler builtins, not real functions */
const SVELTE_RUNES = new Set([
@@ -128,7 +128,7 @@ export class SvelteExtractor {
// A self-closing `<script ... />` (e.g. a third-party script in
// `<svelte:head>`) has no body; match it on its own so it cannot run on to
// the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g;
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -204,20 +204,10 @@ export class SvelteExtractor {
componentNodeId: string,
_scriptBlocks: Array<{ content: string; startLine: number }>
): void {
// Build a set of line ranges covered by <script> and <style> blocks so we skip them
const coveredRanges: Array<[number, number]> = [];
// Find all <script>...</script> and <style>...</style> ranges
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip, and its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// Line ranges covered by <script> and <style> blocks, so we skip them. A
// self-closing tag covers nothing: its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
const coveredRanges = sfcBlockLineRanges(this.source);
// Find template expressions: {...} outside of script/style blocks
// Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render})
@@ -266,16 +256,7 @@ export class SvelteExtractor {
*/
private extractTemplateComponents(componentNodeId: string): void {
// Build ranges covered by <script> and <style> blocks to skip them
const coveredRanges: Array<[number, number]> = [];
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
const coveredRanges = sfcBlockLineRanges(this.source);
const lines = this.source.split('\n');
// Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match)
+6 -4
View File
@@ -43,7 +43,7 @@ import { Language } from '../types';
import { EXTRACTORS } from './languages';
import { getParser, loadGrammarsForLanguages } from './grammars';
import { parseWithinBudget } from './parse-budget';
import { sfcScriptLanguage } from './sfc-script';
import { sfcScriptLanguage, SFC_SCRIPT_ELEMENT } from './sfc-script';
import type { LanguageExtractor } from './tree-sitter-types';
/* ------------------------------------------------------------- the classes -- */
@@ -362,8 +362,9 @@ export interface SyntaxRegion {
language: Language;
}
// A self-closing `<script ... />` has no body and must not run on to the next `</script>`.
const SCRIPT_BLOCK = /<script(\s[^>]*?)?(?:\/>|>([\s\S]*?)<\/script>)/gi;
// The extractors' own script element, read case-insensitively here. A
// self-closing `<script ... />` has no body and must not run on to the next `</script>`.
const SCRIPT_BLOCK = new RegExp(SFC_SCRIPT_ELEMENT.source, 'gi');
/** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;
@@ -390,7 +391,8 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi
while ((match = SCRIPT_BLOCK.exec(source)) !== null) {
const body = match[2] ?? '';
if (body.trim() === '') continue;
const start = match.index + match[0].length - body.length - '</script>'.length;
// The body starts right after the opening tag, whose attributes hold no `>`.
const start = match.index + match[0].indexOf('>') + 1;
regions.push({
start,
end: start + body.length,
+9 -12
View File
@@ -2,7 +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, sfcScriptLanguage } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
import { vueOptionsMembers } from './vue-options-api';
import { vueTemplateCalls } from './vue-template-calls';
@@ -201,12 +201,15 @@ export class VueExtractor {
language: Language;
}> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
// A self-closing `<script src="./legacy.js" />` has no body; match it on
// its own so it cannot run on to the next block's `</script>`.
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
if (match.groups?.content === undefined) continue;
const attrs = match[1] || '';
const content = match.groups?.content || match[2] || '';
const content = match.groups.content;
// TypeScript, TSX or JavaScript, from the lang attribute
const language = sfcScriptLanguage(attrs, 'vue');
@@ -288,15 +291,9 @@ export class VueExtractor {
// Ranges covered by <script> / <style> blocks — skip them so script
// identifiers and CSS selectors aren't mistaken for template tags. This
// also correctly handles nested <template> tags (v-if / slots), which a
// single non-greedy <template>…</template> match would mis-bound.
const coveredRanges: Array<[number, number]> = [];
const blockRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
let blockMatch;
while ((blockMatch = blockRegex.exec(this.source)) !== null) {
const startLine = (this.source.substring(0, blockMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (blockMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// single non-greedy <template>…</template> match would mis-bound. A
// self-closing `<script ... />` covers nothing.
const coveredRanges = sfcBlockLineRanges(this.source);
const lines = this.source.split('\n');
// Opening / self-closing tags (closing `</Foo>` starts with `</`, so the
+5 -2
View File
@@ -27,6 +27,8 @@
* Approach after the template-binding scan in #2073 by @L0garithmic.
*/
import { SFC_SCRIPT_OR_STYLE_ELEMENT, sfcElementHasBody } from './sfc-script';
export interface VueTemplateCall {
/** The callee as a script-side call names it: `useBar`, `store.fetchUsers`. */
name: string;
@@ -74,10 +76,11 @@ const FOR_ALIAS = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;
/** Every call written in the root `<template>` of a Vue SFC, in source order. */
export function vueTemplateCalls(source: string): VueTemplateCall[] {
// Blank <script>/<style> blocks and HTML comments, keeping every offset.
// Blank <script>/<style> blocks and HTML comments, keeping every offset. A
// self-closing `<script ... />` has no body to blank: its attributes are markup.
const blank = (m: string) => m.replace(/[^\n]/g, ' ');
const masked = source
.replace(/<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1\s*>/g, blank)
.replace(new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT), (m) => (sfcElementHasBody(m) ? blank(m) : m))
.replace(/<!--[\s\S]*?-->/g, blank);
const root = rootTemplate(masked);
if (!root) return [];