fix(astro,svelte): a self-closing <script /> no longer swallows the next script

A self-closing `<script ... />`, such as Astro's
`<script is:inline set:html={code} />` or a third-party
`<script src="/analytics.js" />` in Svelte's `<svelte:head>`, matched the
extractors' script regex as an opening tag, so the match ran on to the next
`</script>`. The real script that followed was parsed with the markup in
between, failed with "parse produced no symbols", and contributed nothing.
The covered-range regexes had the same shape, so the template scans also
skipped the markup between the two tags, and a self-closing `<style />` hid
the markup after it.

The opening-tag pattern now matches a `/>` ending on its own as an empty
tag: in the Astro extractor's script blocks and covered ranges, the Svelte
extractor's script blocks and both of its covered-range scans, and the
viewer's highlight regions.

With a fix of ours: a self-closing tag has no body, so the covered-range
scans skip it, and a call in its attributes, like
`set:html={serializeJsonLd(schema)}`, is read as on main. Matched as a
range, such a tag hid those calls even where no script followed it.

Validation on main bc9291af (Windows 11):
- Red/green, 6 tests: 4 from the PRs, plus 2 of ours for an attribute call
  on a self-closing script in Astro and in Svelte. With main's
  astro-extractor.ts, svelte-extractor.ts and syntax-tokens.ts all 6 fail;
  with the PRs' code as written the 2 of ours fail; with this commit all 6
  pass.
- Svelte 5.56.10's own compiler reads the Svelte fixture as an empty head
  script plus an instance script, as the extractor now does.
- A/B against main on 7 Astro repos, with flask and express as controls.
  starlight, diploi.com and both controls are byte-identical, and
  devfest-website keeps every edge. Gains: the genkit docsite 2 import
  links, nodejsdesignpatterns.com 2 nodes and 2 import links, a portfolio
  25 component references, and wm-project-astro-components 3
  `serializeJsonLd` calls from JSON-LD scripts plus 1 call in a script main
  swallowed. That repo also drops 31 false calls, CSS like
  `border-radius: var(...)` that main read as calls after parsing a
  swallowed `<style>` block as TypeScript. No other edge changes.
- Index time unchanged: median of 3 interleaved runs on
  wm-project-astro-components, 4.67 s on main and 4.52 s with the fix.

Vue's extractor has the same pattern and the same bug; a follow-up shares
one pattern across the three extractors.

Original PRs #2995 and #2997 by @maxmilian.
Refs #2989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Max Hsu
2026-10-30 12:24:40 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 4b0f72b7a7
commit 617769279d
7 changed files with 152 additions and 8 deletions
+1
View File
@@ -103,6 +103,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 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)
+60
View File
@@ -10081,6 +10081,66 @@ const value = 42;
);
expect(containEdges.length).toBeGreaterThan(0);
});
it('should not let a self-closing <script /> swallow the next script (#2989)', () => {
const code = `---
import { formatDate } from '../utils/format';
const inlineScript = 'window.example = true';
---
<script is:inline set:html={inlineScript} />
<time>{formatDate(new Date())}</time>
<script>
const ready = true;
console.log(ready);
</script>
`;
const result = extractFromSource('Page.astro', code);
expect(result.errors).toEqual([]);
const ready = result.nodes.find((n) => n.name === 'ready');
expect(ready).toBeDefined();
expect(ready?.startLine).toBe(8);
// The markup between the two scripts is still scanned.
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 6
);
expect(call).toBeDefined();
});
it('should not let a self-closing <style /> hide the markup after it (#2989)', () => {
const code = `<style is:global />
<p>{formatDate(date)}</p>
<style>.a { color: red; }</style>
`;
const result = extractFromSource('Styled.astro', code);
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 2
);
expect(call).toBeDefined();
});
it("still reads a call in a self-closing <script />'s attributes (#2989)", () => {
// A self-closing tag has no body for the template scan to skip:
// `set:html={…}` runs when the page renders, like any attribute expression.
const code = `---
import { serializeJsonLd } from '../lib/jsonld';
const schema = {};
---
<script type="application/ld+json" set:html={serializeJsonLd(schema)} />
<script>
const ready = true;
</script>
`;
const result = extractFromSource('Schema.astro', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7);
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'serializeJsonLd' && ref.line === 5
);
expect(call).toBeDefined();
});
});
describe('Instantiates + Decorates edge extraction', () => {
@@ -0,0 +1,60 @@
/**
* A self-closing `<script ... />` in a Svelte component, such as a
* third-party script in `<svelte:head>`, has no body. It used to be read as
* an opening tag running on to the next `</script>`: the component's real
* script was parsed with the markup in between, failed, and contributed no
* symbols, and the template scan skipped that markup. The same fix as #2989
* for Astro.
*/
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();
});
describe('Svelte self-closing <script />', () => {
it('does not swallow the component script that follows it', () => {
const code = `<svelte:head>
<script src="/analytics.js" />
</svelte:head>
<p>{formatDate(date)}</p>
<Header />
<script>
const ready = true;
</script>
`;
const result = extractFromSource('src/routes/+page.svelte', code);
expect(result.errors).toEqual([]);
const ready = result.nodes.find((n) => n.name === 'ready');
expect(ready).toBeDefined();
expect(ready?.startLine).toBe(7);
// The markup between the two tags is still scanned.
const call = result.unresolvedReferences.find(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 4
);
expect(call).toBeDefined();
expect(result.unresolvedReferences.some((ref) => ref.referenceName === 'Header' && ref.line === 5)).toBe(true);
});
it("still reads a call in a self-closing <script />'s attributes", () => {
// A self-closing tag has no body for the template scan to skip.
const code = `<svelte:head>
<script src={assetUrl('/analytics.js')} />
</svelte:head>
<script>
const ready = true;
</script>
`;
const result = extractFromSource('src/routes/+layout.svelte', code);
expect(result.errors).toEqual([]);
expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(5);
expect(result.unresolvedReferences.some(
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'assetUrl' && ref.line === 2
)).toBe(true);
});
});
+7
View File
@@ -95,6 +95,13 @@ describe('which languages classify', () => {
expect(regions?.[0]?.language).toBe('typescript');
});
it('skips a self-closing <script /> instead of reading on to the next script (#2989)', () => {
const source = '<script is:inline set:html={x} />\n<p>hi</p>\n<script>\nconst ready = true;\n</script>\n';
const regions = syntaxRegionsFor(source, 'astro');
expect(regions).toHaveLength(1);
expect(source.slice(regions![0]!.start, regions![0]!.end)).toBe('\nconst ready = true;\n');
});
it('has no grammar for the formats that only have file-level extraction', () => {
for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) {
expect(grammarFor(language)).toBeNull();
+9 -3
View File
@@ -159,11 +159,14 @@ export class AstroExtractor {
private extractScriptBlocks(): Array<{ content: string; startLine: number }> {
const blocks: Array<{ content: string; startLine: number }> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
// 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;
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
const content = match.groups?.content || match[2] || '';
if (match.groups?.content === undefined) continue;
const content = match.groups.content;
// Calculate the 0-indexed line where the content begins. The content
// starts right after the opening tag's `>` — its leading `\n` is part
@@ -225,9 +228,12 @@ export class AstroExtractor {
coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]);
}
const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
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]);
+13 -4
View File
@@ -125,12 +125,16 @@ export class SvelteExtractor {
isTypeScript: boolean;
}> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
// 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;
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;
// Detect TypeScript from lang attribute
const isTypeScript = sfcScriptLanguage(attrs, 'svelte') === 'typescript';
@@ -204,9 +208,12 @@ export class SvelteExtractor {
const coveredRanges: Array<[number, number]> = [];
// Find all <script>...</script> and <style>...</style> ranges
const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
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]);
@@ -260,9 +267,11 @@ 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;
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]);
+2 -1
View File
@@ -362,7 +362,8 @@ export interface SyntaxRegion {
language: Language;
}
const SCRIPT_BLOCK = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi;
// 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;
/** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;