diff --git a/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Legacy.vue b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Legacy.vue
new file mode 100644
index 000000000..a5280d51b
--- /dev/null
+++ b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Legacy.vue
@@ -0,0 +1,10 @@
+{{ slug }}
+
+
diff --git a/graph/test/javascript/cases/70-single-file-component-scripts/src/components/List.svelte b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/List.svelte
new file mode 100644
index 000000000..7d6739141
--- /dev/null
+++ b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/List.svelte
@@ -0,0 +1,8 @@
+
+{#each items as p}
{show(p)} {onlyInMarkup(p)}{/each}
diff --git a/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Price.vue b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Price.vue
new file mode 100644
index 000000000..28d5800cb
--- /dev/null
+++ b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Price.vue
@@ -0,0 +1,9 @@
+{{ label }} {{ onlyInMarkup(label) }}
+
+
diff --git a/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Typed.vue b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Typed.vue
new file mode 100644
index 000000000..e695a8ce2
--- /dev/null
+++ b/graph/test/javascript/cases/70-single-file-component-scripts/src/components/Typed.vue
@@ -0,0 +1,5 @@
+{{ n }}
+
diff --git a/graph/test/javascript/cases/70-single-file-component-scripts/src/lib/format.js b/graph/test/javascript/cases/70-single-file-component-scripts/src/lib/format.js
new file mode 100644
index 000000000..46a90a21d
--- /dev/null
+++ b/graph/test/javascript/cases/70-single-file-component-scripts/src/lib/format.js
@@ -0,0 +1,8 @@
+// Helpers called only from single-file components: every call to them lives in a
+//
diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.diag b/graph/test/javascript/expected/70-single-file-component-scripts.diag
new file mode 100644
index 000000000..f5093dfa5
--- /dev/null
+++ b/graph/test/javascript/expected/70-single-file-component-scripts.diag
@@ -0,0 +1,13 @@
+import_cause components/List.svelte:2:12 svelte not_staged
+import_cause components/Price.vue:4:10 vue not_staged
+import_cause main.js:2:10 vue not_staged
+package_entry single-file-component-scripts . [] DEFAULT_INDEX index.js MISSING_FILE -> -
+unresolved components/Legacy.vue:7:26 METHOD_CALL $emit member_absent
+unresolved components/List.svelte:5:3 FUNCTION_CALL onMount callee_untyped
+unresolved components/Price.vue:6:15 FUNCTION_CALL defineProps callee_untyped
+unresolved components/Price.vue:7:15 FUNCTION_CALL computed callee_untyped
+unresolved components/Price.vue:8:22 METHOD_CALL log no_target
+unresolved lib/format.js:3:47 METHOD_CALL toFixed no_target
+unresolved lib/format.js:4:37 METHOD_CALL toLowerCase no_target
+unresolved main.js:5:1 FUNCTION_CALL createApp callee_untyped
+unresolved main.js:5:1 METHOD_CALL mount receiver_untyped
diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.edges b/graph/test/javascript/expected/70-single-file-component-scripts.edges
new file mode 100644
index 000000000..10564fe80
--- /dev/null
+++ b/graph/test/javascript/expected/70-single-file-component-scripts.edges
@@ -0,0 +1,20 @@
+components/Legacy.vue:6:31 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
+components/Legacy.vue:7:26 METHOD_CALL this.$emit -> ambiguous_unknown -
+components/Legacy.vue:7:48 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
+components/List.svelte:5:3 FUNCTION_CALL onMount -> ambiguous_unknown -
+components/List.svelte:5:3 FUNCTION_CALL onMount -> callback_registered components/List.svelte:5:11
+components/List.svelte:5:39 FUNCTION_CALL fetchProducts -> known_edge lib/format.js:5:1 fetchProducts
+components/List.svelte:6:29 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
+components/Price.vue:6:15 FUNCTION_CALL defineProps -> ambiguous_unknown -
+components/Price.vue:7:15 FUNCTION_CALL computed -> ambiguous_unknown -
+components/Price.vue:7:15 FUNCTION_CALL computed -> callback_registered components/Price.vue:7:24
+components/Price.vue:7:30 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
+components/Price.vue:8:22 METHOD_CALL console.log -> ambient_terminal -
+lib/format.js:3:47 METHOD_CALL n.toFixed -> ambient_terminal -
+lib/format.js:4:37 METHOD_CALL s.toLowerCase -> ambient_terminal -
+main.js:5:1 FUNCTION_CALL createApp -> ambiguous_unknown -
+main.js:5:1 METHOD_CALL createApp(Price).mount -> ambiguous_unknown -
+main.js:5:30 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
+pages/index.astro:3:18 FUNCTION_CALL fetchProducts -> known_edge lib/format.js:5:1 fetchProducts
+pages/index.astro:4:14 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
+pages/index.astro:9:20 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.oracle b/graph/test/javascript/expected/70-single-file-component-scripts.oracle
new file mode 100644
index 000000000..72b4acd06
--- /dev/null
+++ b/graph/test/javascript/expected/70-single-file-component-scripts.oracle
@@ -0,0 +1,2 @@
+main.js:5:30 FUNCTION_CALL slugify EXACT lib/format.js:4:1
+# defects: 0
diff --git a/parser/src/constants/javascript-constants.ts b/parser/src/constants/javascript-constants.ts
index 74c41034a..0463c83e9 100644
--- a/parser/src/constants/javascript-constants.ts
+++ b/parser/src/constants/javascript-constants.ts
@@ -98,6 +98,19 @@ export const JS_DEFAULT_EXPORT_NAME = 'default';
*/
export const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const;
+/**
+ * Single-file components: markup files whose JavaScript lives in `\n'],
+ ['a.vue', '\n\n'],
+ ['a.svelte', '{g()}'],
+ ['a.astro', '---\n', 'h()\n', '---\n{h()}
\n'],
+ ];
+ for (const [name, ...parts] of COMPONENTS) {
+ const text = parts.join('');
+ const want = parts.map((part, i) => (i % 2 === 1 ? part : part.replace(/[^\n]/g, ' '))).join('');
+ const got = sfcScriptText(name, text).text;
+ if (got !== want) {
+ failures += fail(`sfcScriptText(${name}, ${JSON.stringify(text)}) is ${JSON.stringify(got)}, `
+ + `expected ${JSON.stringify(want)}`);
+ }
+ }
+
// ---- 2. source-level: nobody rolls their own.
const KNOWN = ['.js', '.jsx', '.mjs', '.cjs', '.flow'];
const files: string[] = [];
diff --git a/parser/src/utils/javascript/javascript-module-utils.ts b/parser/src/utils/javascript/javascript-module-utils.ts
index f301664b2..fad52cfd2 100644
--- a/parser/src/utils/javascript/javascript-module-utils.ts
+++ b/parser/src/utils/javascript/javascript-module-utils.ts
@@ -1,4 +1,4 @@
-import { JS_SOURCE_EXTENSIONS } from '@/constants/javascript-constants';
+import { JS_COMPONENT_EXTENSIONS, JS_SOURCE_EXTENSIONS } from '@/constants/javascript-constants';
/**
* What counts as a platform module, in one place.
@@ -129,7 +129,96 @@ export function jsExtensionOf(fileName: string): string {
// table, which is the argument for having written one: the function was in use
// at eight call sites and every one of them happened to pass a `.js` family
// name, so nothing else would ever have shown it.
- return (JS_SOURCE_EXTENSIONS as readonly string[]).includes(extension) ? extension : '';
+ if ((JS_SOURCE_EXTENSIONS as readonly string[]).includes(extension)) {
+ return extension;
+ }
+ // A component has no `.flow` spelling: `a.vue.flow` is not a declaration file.
+ return base === name && (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(extension)
+ ? extension
+ : '';
+}
+
+/** Is this a single-file component (`.vue`, `.svelte`, `.astro`), by name? */
+export function isJsComponentFile(fileName: string): boolean {
+ return (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(jsExtensionOf(fileName));
+}
+
+/** One `