From ff8a324985d83180e7b423d0b9355545a8e451a2 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:24:33 -0700 Subject: [PATCH] typescript, javascript: index Vue single-file components A .vue file was never read, so a helper called only from a template had no caller and a component rendered only by a template had no use. Each .vue component is now read as a virtual script that keeps every line: its inline + + diff --git a/graph/test/javascript/cases/69-vue-sfc/src/TemplateOnly.vue b/graph/test/javascript/cases/69-vue-sfc/src/TemplateOnly.vue new file mode 100644 index 000000000..25d1e389b --- /dev/null +++ b/graph/test/javascript/cases/69-vue-sfc/src/TemplateOnly.vue @@ -0,0 +1,3 @@ + diff --git a/graph/test/javascript/cases/69-vue-sfc/src/Typed.vue b/graph/test/javascript/cases/69-vue-sfc/src/Typed.vue new file mode 100644 index 000000000..ad39656cd --- /dev/null +++ b/graph/test/javascript/cases/69-vue-sfc/src/Typed.vue @@ -0,0 +1,4 @@ + + diff --git a/graph/test/javascript/cases/69-vue-sfc/src/child.js b/graph/test/javascript/cases/69-vue-sfc/src/child.js new file mode 100644 index 000000000..83c1bf8e6 --- /dev/null +++ b/graph/test/javascript/cases/69-vue-sfc/src/child.js @@ -0,0 +1,7 @@ +export function Child(props) { + return props.msg; +} + +export function MyBadge() { + return 'badge'; +} diff --git a/graph/test/javascript/cases/69-vue-sfc/src/main.js b/graph/test/javascript/cases/69-vue-sfc/src/main.js new file mode 100644 index 000000000..6d2103e88 --- /dev/null +++ b/graph/test/javascript/cases/69-vue-sfc/src/main.js @@ -0,0 +1,5 @@ +import Comp from './Comp.vue'; + +export function mount() { + return Comp; +} diff --git a/graph/test/javascript/expected/69-vue-sfc.diag b/graph/test/javascript/expected/69-vue-sfc.diag new file mode 100644 index 000000000..3ef2afad0 --- /dev/null +++ b/graph/test/javascript/expected/69-vue-sfc.diag @@ -0,0 +1 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - diff --git a/graph/test/javascript/expected/69-vue-sfc.edges b/graph/test/javascript/expected/69-vue-sfc.edges new file mode 100644 index 000000000..d7efd225a --- /dev/null +++ b/graph/test/javascript/expected/69-vue-sfc.edges @@ -0,0 +1,5 @@ +Comp.vue:10:34 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper +Comp.vue:10:6 FUNCTION_CALL Child -> known_edge child.js:1:1 Child +Comp.vue:11:6 FUNCTION_CALL MyBadge -> known_edge child.js:5:1 MyBadge +Comp.vue:12:21 FUNCTION_CALL onSave -> known_edge Comp.vue:4:1 onSave +Comp.vue:12:40 FUNCTION_CALL sfcHelper -> known_edge Comp.vue:3:1 sfcHelper diff --git a/graph/test/javascript/expected/69-vue-sfc.oracle b/graph/test/javascript/expected/69-vue-sfc.oracle new file mode 100644 index 000000000..8675b52d3 --- /dev/null +++ b/graph/test/javascript/expected/69-vue-sfc.oracle @@ -0,0 +1 @@ +# defects: 0 diff --git a/graph/test/typescript/cases/80-vue-sfc/src/Comp.vue b/graph/test/typescript/cases/80-vue-sfc/src/Comp.vue new file mode 100644 index 000000000..b4f89c5b6 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/Comp.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/graph/test/typescript/cases/80-vue-sfc/src/Plain.vue b/graph/test/typescript/cases/80-vue-sfc/src/Plain.vue new file mode 100644 index 000000000..a8b318cce --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/Plain.vue @@ -0,0 +1,4 @@ + + diff --git a/graph/test/typescript/cases/80-vue-sfc/src/TemplateFirst.vue b/graph/test/typescript/cases/80-vue-sfc/src/TemplateFirst.vue new file mode 100644 index 000000000..d4ab960c2 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/TemplateFirst.vue @@ -0,0 +1,14 @@ + + + diff --git a/graph/test/typescript/cases/80-vue-sfc/src/TemplateOnly.vue b/graph/test/typescript/cases/80-vue-sfc/src/TemplateOnly.vue new file mode 100644 index 000000000..25d1e389b --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/TemplateOnly.vue @@ -0,0 +1,3 @@ + diff --git a/graph/test/typescript/cases/80-vue-sfc/src/child.ts b/graph/test/typescript/cases/80-vue-sfc/src/child.ts new file mode 100644 index 000000000..157bb63e7 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/child.ts @@ -0,0 +1,11 @@ +export function Child(props: { msg: string }): string { + return props.msg; +} + +export function MyBadge(): string { + return 'badge'; +} + +export function format(n: number): string { + return n.toFixed(2); +} diff --git a/graph/test/typescript/cases/80-vue-sfc/src/main.ts b/graph/test/typescript/cases/80-vue-sfc/src/main.ts new file mode 100644 index 000000000..c804793d3 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/main.ts @@ -0,0 +1,5 @@ +import Comp from './Comp.vue'; + +export function mount(): unknown { + return Comp; +} diff --git a/graph/test/typescript/cases/80-vue-sfc/src/shims-vue.d.ts b/graph/test/typescript/cases/80-vue-sfc/src/shims-vue.d.ts new file mode 100644 index 000000000..821cca508 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/shims-vue.d.ts @@ -0,0 +1,4 @@ +declare module '*.vue' { + const component: unknown; + export default component; +} diff --git a/graph/test/typescript/cases/80-vue-sfc/src/tsconfig.json b/graph/test/typescript/cases/80-vue-sfc/src/tsconfig.json new file mode 100644 index 000000000..98ff2e003 --- /dev/null +++ b/graph/test/typescript/cases/80-vue-sfc/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.vue"] +} diff --git a/graph/test/typescript/expected/80-vue-sfc.edges b/graph/test/typescript/expected/80-vue-sfc.edges new file mode 100644 index 000000000..716281dd0 --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.edges @@ -0,0 +1,8 @@ +ambiguous_unknown METHOD_CALL child#format(number) @L10 -> - +known_edge FUNCTION_CALL Comp.vue#() @L10 -> Comp.vue#sfcHelper() +known_edge FUNCTION_CALL Comp.vue#() @L10 -> child#Child({ msg: string }) +known_edge FUNCTION_CALL Comp.vue#() @L11 -> Comp.vue#onSave(number) +known_edge FUNCTION_CALL Comp.vue#() @L11 -> child#format(number) +known_edge FUNCTION_CALL TemplateFirst.vue#() @L3 -> TemplateFirst.vue#shown() +known_edge FUNCTION_CALL TemplateFirst.vue#() @L3 -> child#MyBadge() +known_edge FUNCTION_CALL TemplateFirst.vue#() @L5 -> TemplateFirst.vue#onInput() diff --git a/graph/test/typescript/expected/80-vue-sfc.entries b/graph/test/typescript/expected/80-vue-sfc.entries new file mode 100644 index 000000000..3d43a8448 --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.entries @@ -0,0 +1,5 @@ +── entry_point (4) ── + exported_from_entry_module main#mount main.ts:3 + unimported_module TemplateFirst.vue# TemplateFirst.vue:1 + unimported_module main# main.ts:1 + unimported_module shims-vue# shims-vue.d.ts:1 diff --git a/graph/test/typescript/expected/80-vue-sfc.fields b/graph/test/typescript/expected/80-vue-sfc.fields new file mode 100644 index 000000000..2c586263a --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.fields @@ -0,0 +1 @@ +known_edge read child#Child({ msg: string }) -> { msg: string }#msg diff --git a/graph/test/typescript/expected/80-vue-sfc.fields-oracle b/graph/test/typescript/expected/80-vue-sfc.fields-oracle new file mode 100644 index 000000000..706b5d611 --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.fields-oracle @@ -0,0 +1,9 @@ +80-vue-sfc [fields] + precision 0.0000 (0 correct, 1 wrong) + recall 0.0000 (0 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 0 rows whose target is not a client declaration + WRONG child#Child({ msg: string }) READ { msg: string }#msg + MISSING child#Child({ msg: string }) READ child#msg diff --git a/graph/test/typescript/expected/80-vue-sfc.oracle b/graph/test/typescript/expected/80-vue-sfc.oracle new file mode 100644 index 000000000..8f740ea84 --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.oracle @@ -0,0 +1,8 @@ +oracle=0 engine=7 agree=0 missing=0 (known 0, NEW 0) extra=7 + extra Comp.vue#() -> Comp.vue#onSave(number) + extra Comp.vue#() -> Comp.vue#sfcHelper() + extra Comp.vue#() -> child#Child({ msg: string }) + extra Comp.vue#() -> child#format(number) + extra TemplateFirst.vue#() -> TemplateFirst.vue#onInput() + extra TemplateFirst.vue#() -> TemplateFirst.vue#shown() + extra TemplateFirst.vue#() -> child#MyBadge() diff --git a/graph/test/typescript/expected/80-vue-sfc.type-use b/graph/test/typescript/expected/80-vue-sfc.type-use new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/80-vue-sfc.types-oracle b/graph/test/typescript/expected/80-vue-sfc.types-oracle new file mode 100644 index 000000000..01d62a428 --- /dev/null +++ b/graph/test/typescript/expected/80-vue-sfc.types-oracle @@ -0,0 +1,7 @@ +80-vue-sfc [types] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + contexts + not scored: 0 rows whose target is not a client declaration diff --git a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts index cea8168dc..639334369 100644 --- a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts @@ -111,6 +111,11 @@ export interface JsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** + * How `sourceText` parses, when the file's extension cannot say: a `.vue` + * component's virtual script is JS or JSX by its `lang`, not by its name. + */ + readonly scriptKind?: ts.ScriptKind; } export interface JsFileFacts { @@ -160,7 +165,7 @@ export function extractJavaScriptFile(options: JsFileExtractionOptions): JsFileF options.sourceText, ts.ScriptTarget.Latest, true, - scriptKindFor(options.absoluteFilePath) + options.scriptKind ?? scriptKindFor(options.absoluteFilePath) ); const moduleResult = extractModule({ diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index d6e2e89eb..2766c34b6 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -28,6 +28,7 @@ import { isRequireCall, pointOf, } from '@/utils/javascript'; +import { resolveVueSpecifier } from '@/utils/vue-sfc'; /** * `js_import` and `js_export`, minted in a **second pass from expression rows**. @@ -1199,7 +1200,10 @@ class JsModuleEdgeExtractor { undefined, m ).resolvedModule?.resolvedFileName; - const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined); + // tsc resolves no `.vue` import itself (its extensions are fixed), so a + // component in this program is looked up by path. + const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) + ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath); if (resolved === undefined) { return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; } diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts index 6c07f5461..da67a6b4b 100644 --- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -43,6 +43,7 @@ import { ResolutionStats, TsLocalResolver, } from '@/parsers/typescript/extractors/ts-resolution-linker'; +import { resolveVueSpecifier } from '@/utils/vue-sfc'; /** * Extracts the whole fact spine for ONE TypeScript file. @@ -91,6 +92,11 @@ export interface TsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** + * How `sourceText` parses, when the file's extension cannot say: a `.vue` + * component's virtual script is TS or TSX by its `lang`, not by its name. + */ + readonly scriptKind?: ts.ScriptKind; } export interface TsFileFacts { @@ -142,7 +148,7 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF options.sourceText, ts.ScriptTarget.Latest, true, - scriptKindFor(options.absoluteFilePath) + options.scriptKind ?? scriptKindFor(options.absoluteFilePath) ); const modules = extractModules({ @@ -173,7 +179,8 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF options.compilerOptions, ts.sys ); - const fileName = resolved.resolvedModule?.resolvedFileName; + const fileName = resolved.resolvedModule?.resolvedFileName + ?? resolveVueSpecifier(specifier, options.absoluteFilePath); if (!fileName) { return undefined; } diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts index 27c96967b..8c7e89525 100644 --- a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts @@ -5,6 +5,7 @@ import * as ts from 'typescript'; import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; import { TsImportKind, TsImportResolutionKind } from '@/enums/typescript/imports'; import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; +import { resolveVueSpecifier, VUE_EXTENSION } from '@/utils/vue-sfc'; /** * Emits `ts_import` rows — schema §4.12. @@ -442,6 +443,18 @@ export class TsImportExtractor { ts.sys ); const module = resolved.resolvedModule; + const vueFile = module ? undefined : resolveVueSpecifier(specifier, this.options.absoluteFilePath); + if (vueFile !== undefined && this.options.projectModuleHashes.has(vueFile)) { + // A component in this program; tsc resolves no `.vue` import itself. + return { + absolutePath: vueFile, + relativePath: this.options.toProjectRelative(vueFile), + moduleHash: this.options.projectModuleHashes.get(vueFile)!, + kind: TsImportResolutionKind.RELATIVE_FILE, + extension: VUE_EXTENSION, + packageName: '', + }; + } if (!module) { // `undefined` is an honest answer, not a failure to try. It is also the // right answer for a wildcard ambient specifier like `"*.svg"`, which diff --git a/parser/src/parsers/typescript/tsconfig-resolver.ts b/parser/src/parsers/typescript/tsconfig-resolver.ts index 0363f3c8f..f36937469 100644 --- a/parser/src/parsers/typescript/tsconfig-resolver.ts +++ b/parser/src/parsers/typescript/tsconfig-resolver.ts @@ -243,7 +243,11 @@ export class TsConfigResolver { ts.sys, path.dirname(configPath), undefined, - configPath + configPath, + undefined, + // `.vue` components are the program's files too, as vue-tsc reads it: + // without this `include: ["src/**/*.vue"]` claims nothing. + [{ extension: '.vue', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }] ); result = { configPath, diff --git a/parser/src/utils/vue-sfc.ts b/parser/src/utils/vue-sfc.ts new file mode 100644 index 000000000..151e09f35 --- /dev/null +++ b/parser/src/utils/vue-sfc.ts @@ -0,0 +1,454 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +/** + * A Vue single-file component read as the script it compiles to. + * + * ## Why a virtual script, and why it keeps every line + * + * A `.vue` file is three languages in one file: `