From f25bcb213c8c91ea8a0f2b120f1d82a5456a8ed7 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 17 Oct 2026 10:31:25 -0500 Subject: [PATCH] fix(vue): an import through `@/` or `~/` in a Vue project links the module's file In a project with a `vue` dependency or a `.vue` file, a script's module import through `@/` or `~/` went through the Vue resolver's own mapping to `src/`, which linked the first node of the file it found: a constant the module declares, or a component. A tsconfig alias that starts like a Nuxt virtual module (`#app/where`) linked the importing file itself. Without Vue the import resolver links the module's file node, following tsconfig paths. So the walks a sync makes from a module or barrel that appears or goes away to the files importing it (#2512, #2560) never found these importers. Which node the edge hit also depended on the order a file's nodes were stored, so a sync and a fresh index could disagree. The Vue resolver now leaves a module import to the import resolver whenever that finds the file, and only adds what a Vue app's bundler finds from a plain script: a component named without `.vue`, or a folder's `index.vue` (`import Layout from '@/layout'`), linked to the file. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/vue-module-imports.test.ts | 178 +++++++++++++++++++++++++++ src/resolution/frameworks/vue.ts | 76 ++++++------ 3 files changed, 216 insertions(+), 39 deletions(-) create mode 100644 __tests__/vue-module-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d50f1de81b..d921ed2ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - An import in a Vue, Svelte or Astro component now links the component to the file it imports, as an import in a script does, and so does an import of a scoped package that a `tsconfig` path or a workspace package maps to a project file, like `import { debounce } from '@excalidraw/common'`. Before, only the names such an import binds were linked, so a barrel or a module imported for its side effects showed no dependency from those files in `codegraph affected`, and `codegraph sync` could not update them when a barrel they import through was deleted or added. `codegraph sync` and the file watcher also link such an import again, and one written as `.` or `..`, when the file it names comes back. Re-index JavaScript, TypeScript, Vue, Svelte and Astro projects after upgrading. +- In a project that uses Vue, an import written through an `@/` or `~/` alias, like `import { Button } from '@/components'`, now links the file it imports, as it does in any other project, so `codegraph sync` and the file watcher update the files that import a module when that module, or a barrel they import through, is added or deleted. Before, Vue support linked such an import to the first declaration or the component in that file, which sync does not follow, and an import through a `tsconfig` alias that starts like one of Nuxt's built-in modules, like `#app/where`, linked the importing file to itself. Re-index Vue projects after upgrading. - `codegraph sync` and the file watcher now move an import written without an extension, like `import { sizeOf } from './button'`, to a file added beside the one it found before when the new file comes first, such as `button.ts` next to `button.vue`. Before, the import and the names it binds stayed linked to `button.vue` until the importing file changed or the project was re-indexed. JavaScript, TypeScript, Vue, Svelte and Astro. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - In C++, a call on a variable, parameter or member declared through a `typedef` or `using` alias now links to the method of the type the alias names, with the alias looked up the way C++ does it: in the calling function, its class and the classes that class inherits from, then the namespaces around it. In google/leveldb, `table_.Insert(buf)` on a `Table table_;` declared next to `typedef SkipList Table;` now reaches `SkipList::Insert` instead of the unrelated `HandleTable::Insert`, and `Table::Iterator iter(&table_);` reaches `SkipList::Iterator`'s constructor. A class that only shares the alias's name is no longer taken for it, and when the aliased type has no such method, as with an alias of `std::vector` or of a template parameter, no method is guessed from the receiver's name. An alias of a type written from the global scope names that global type, also inside a namespace of the same name: protocolbuffers/protobuf's `using Arena = ::upb::Arena;` in `hpb::internal::backend::upb`, so hpb's `arena_.Fuse(…)` on a `backend::Arena` now reaches `upb::Arena::Fuse` instead of the calling method itself. Re-index C++ projects after upgrading. diff --git a/__tests__/vue-module-imports.test.ts b/__tests__/vue-module-imports.test.ts new file mode 100644 index 0000000000..431ff5346a --- /dev/null +++ b/__tests__/vue-module-imports.test.ts @@ -0,0 +1,178 @@ +/** + * In a Vue project, an import's module path links the module's file. + * + * Every script's `imports` reference named by a path (`@/flags/registry`, + * `~/utils/helper`) went through the Vue resolver first, which mapped `@/` and + * `~/` to `src/` by hand and linked the FIRST node of the file it found: a + * constant the module declares, a component, or for a `#app/…` path alias the + * importing file itself (the Nuxt virtual-module rule). Without Vue the import + * resolver links the file node, following tsconfig paths. So in any project + * with a `vue` dependency or a `.vue` file, no importer's edge reached the + * module's file, and what follows those edges came up empty: sync's walk from + * a module that appears to the files importing it (#2512), and a barrel's + * importers (#2560). + * + * Now the import resolver links what it finds, and the Vue resolver only adds + * what a Vue app's bundler finds from a plain script: a component named + * without `.vue`, or a folder's `index.vue` (`import Layout from '@/layout'`). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +type Files = Record; + +const PROJECT: Files = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'], '~/*': ['src/*'], '#app/*': ['client/*'] } } }), + 'src/flags.ts': "import { REGISTERED_FLAGS } from '@/flags/registry';\nexport function flag(n: string) { return REGISTERED_FLAGS.get(n); }\n", + 'src/flags/registry.ts': 'export const REGISTERED_FLAGS = new Map();\n', + 'src/router.js': "import Layout from '@/layout';\nimport Foo from '@/components/Foo.vue';\nimport api from '@/api';\nexport const routes = [Layout, Foo, api];\n", + 'src/layout/index.vue': '\n\n', + 'src/components/Foo.vue': '\n', + 'src/api/index.js': 'export default { get() { return 1; } };\n', + 'src/tilde.ts': "import { helper } from '~/utils/helper';\nexport const h = helper();\n", + 'src/utils/helper.ts': 'export const flagged = 1;\nexport function helper() { return flagged; }\n', + 'src/components/Panel.vue': "\n\n", + 'src/table.ts': "export const COLUMNS = [{ name: 'a' }];\n", + // A tsconfig alias that starts like one of Nuxt's virtual modules. + 'src/elsewhere.ts': "import { where } from '#app/where';\nexport const w = where;\n", + 'client/where.ts': "export const where = 'client';\n", + // A Nuxt virtual module is no file: it keeps linking the importing file. + 'src/virtual.ts': "import { useHead } from '#imports';\nexport const head = useHead;\n", +}; + +let roots: string[] = []; +let graphs: CodeGraph[] = []; + +afterEach(() => { + for (const graph of graphs) graph.close(); + graphs = []; + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + roots = []; +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-imports-')); + roots.push(root); + return root; +} + +function write(root: string, files: Files): void { + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } +} + +async function index(root: string): Promise { + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** Each `imports` edge whose reference names a module by path: ` -> `. */ +function moduleImportsOf(graph: CodeGraph): string[] { + const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id); + const links: string[] = []; + for (const edge of graph.getOutgoingEdgesFrom(ids, ['imports'])) { + const ref = edge.metadata?.refName; + if (typeof ref !== 'string' || (!ref.includes('/') && !ref.startsWith('#'))) continue; + const source = graph.getNode(edge.source)!; + const target = graph.getNode(edge.target)!; + links.push(`${source.filePath} ${ref} -> ${target.kind} ${target.filePath}`); + } + return links.sort(); +} + +/** Every edge, by its ends' natural keys, with where it is written and how it resolved. */ +function edgesOf(graph: CodeGraph): string[] { + const keys = new Map( + graph.getFiles() + .flatMap((file) => graph.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + return graph.getOutgoingEdgesFrom([...keys.keys()]) + .map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`) + .sort(); +} + +describe('a module import in a Vue project', () => { + it('links the module\'s file, as without Vue', async () => { + const root = tempRoot(); + write(root, PROJECT); + const graph = await index(root); + expect(moduleImportsOf(graph)).toEqual([ + 'src/components/Panel.vue @/table -> file src/table.ts', + 'src/elsewhere.ts #app/where -> file client/where.ts', + 'src/flags.ts @/flags/registry -> file src/flags/registry.ts', + 'src/router.js @/api -> file src/api/index.js', + 'src/router.js @/components/Foo.vue -> file src/components/Foo.vue', + 'src/router.js @/layout -> file src/layout/index.vue', + 'src/tilde.ts ~/utils/helper -> file src/utils/helper.ts', + 'src/virtual.ts #imports -> file src/virtual.ts', + ]); + }, 60_000); + + it('links the module the way the import resolver does, wherever it can', async () => { + const root = tempRoot(); + write(root, PROJECT); + const graph = await index(root); + const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id); + const how = new Map(graph.getOutgoingEdgesFrom(ids, ['imports']) + .map((e) => [String(e.metadata?.refName), String(e.metadata?.resolvedBy)] as const)); + expect(how.get('@/flags/registry')).toBe('import'); + expect(how.get('@/table')).toBe('import'); + expect(how.get('#app/where')).toBe('import'); + // A script's `@/layout` is a folder's `index.vue` only to a Vue bundler. + expect(how.get('@/layout')).toBe('framework'); + }, 60_000); + +}); + +describe('a sync in a Vue project follows an import to its barrel', () => { + // `src/components/index.ts` passes `Button` on from `./Button`. Its first + // node is the constant it declares, which the import of `@/components` + // used to link; sync finds a barrel's importers through the edge into the + // barrel's file (#2512). + const BASE: Files = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }), + 'src/components/Button.ts': 'export function Button() { return 1; }\n', + 'src/views/page.ts': "import { Button } from '@/components';\nexport function page() { return Button(); }\n", + }; + const BARREL: Files = { 'src/components/index.ts': "export const VERSION = 1;\nexport * from './Button';\n" }; + + /** The final tree, indexed from scratch in a folder of its own. */ + async function fullIndexOf(files: Files): Promise { + const root = tempRoot(); + write(root, files); + return edgesOf(await index(root)); + } + + it('when the barrel is added after its importers', async () => { + // Meanwhile `Button` links its one namesake by name; through the barrel + // once it is there. + const root = tempRoot(); + write(root, BASE); + const graph = await index(root); + + write(root, BARREL); + expect((await graph.sync()).filesAdded).toBe(1); + const fresh = await fullIndexOf({ ...BASE, ...BARREL }); + expect(fresh.filter((e) => e.startsWith('calls ') && e.includes('"refName":"Button"') && e.includes('"resolvedBy":"import"'))).toHaveLength(1); + expect(edgesOf(graph)).toEqual(fresh); + }, 120_000); + + it('when the barrel is deleted', async () => { + const root = tempRoot(); + write(root, { ...BASE, ...BARREL }); + const graph = await index(root); + + fs.rmSync(path.join(root, 'src/components/index.ts')); + expect((await graph.sync()).filesRemoved).toBe(1); + expect(edgesOf(graph)).toEqual(await fullIndexOf(BASE)); + }, 120_000); +}); diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index 044221afc6..b8a5a3c2ee 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { isJsPathImportRef, resolveImportPath } from '../import-resolver'; import { dependsOn } from './package-deps'; import { pageComponentRef, resolvePageComponent } from './page-component'; @@ -82,6 +83,9 @@ const NUXT_VIRTUAL_MODULES = [ '#head', ]; +/** The alias prefixes a Vue app's imports name `src/` by, as Vue CLI and Nuxt set them up. */ +const VUE_ALIAS_PREFIXES = ['@/', '~/']; + export const vueResolver: FrameworkResolver = { name: 'vue', @@ -133,7 +137,34 @@ export const vueResolver: FrameworkResolver = { }; } - // Pattern 3: Nuxt virtual module imports (#imports, #components, etc.) + // A module an import names by path is the file the path names: the import + // resolver's to find, through tsconfig paths, workspace packages and the + // `@/` and `~/` conventions, and to link. Answering here took that from + // it: this linked the first node of the file (a constant it declares, a + // component), and `#app/…` the importing file itself, so no importer's + // edge reached the module's file node for sync to follow. + if (isJsPathImportRef(ref)) { + if (moduleFileNode(resolveImportPath(ref.referenceName, ref.filePath, ref.language, context), ref, context)) return null; + // What only a Vue app's bundler finds from a plain script: a component + // named without `.vue`, or a folder's `index.vue` (`import Layout from + // '@/layout'` is `src/layout/index.vue`; the path alone names the + // folder). An SFC's own imports already look for those. Linked to the + // file, as the import resolver links one. + if (ref.language !== 'vue' && VUE_ALIAS_PREFIXES.some((prefix) => ref.referenceName.startsWith(prefix))) { + const fileNode = moduleFileNode(resolveImportPath(ref.referenceName, ref.filePath, 'vue', context), ref, context); + if (fileNode) { + return { + original: ref, + targetNodeId: fileNode.id, + confidence: 0.9, + resolvedBy: 'framework', + }; + } + } + } + + // Pattern 3: Nuxt virtual module imports (#imports, #components, etc.), + // which no file of the project is. if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('#')) { if (NUXT_VIRTUAL_MODULES.some((prefix) => ref.referenceName.startsWith(prefix))) { return { @@ -145,44 +176,6 @@ export const vueResolver: FrameworkResolver = { } } - // Pattern 4: @ alias imports (@/components/Foo -> src/components/Foo) - if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('@/')) { - const aliasPath = ref.referenceName.replace('@/', 'src/'); - for (const ext of ['', '.ts', '.js', '.vue', '/index.ts', '/index.js', '/index.vue']) { - const fullPath = aliasPath + ext; - if (context.fileExists(fullPath)) { - const nodes = context.getNodesInFile(fullPath); - if (nodes.length > 0) { - return { - original: ref, - targetNodeId: nodes[0]!.id, - confidence: 0.9, - resolvedBy: 'framework', - }; - } - } - } - } - - // Pattern 5: ~ alias imports (~/components/Foo -> src/components/Foo, Nuxt convention) - if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('~/')) { - const aliasPath = ref.referenceName.replace('~/', 'src/'); - for (const ext of ['', '.ts', '.js', '.vue', '/index.ts', '/index.js', '/index.vue']) { - const fullPath = aliasPath + ext; - if (context.fileExists(fullPath)) { - const nodes = context.getNodesInFile(fullPath); - if (nodes.length > 0) { - return { - original: ref, - targetNodeId: nodes[0]!.id, - confidence: 0.9, - resolvedBy: 'framework', - }; - } - } - } - } - // Pattern 6: Component references (PascalCase) — resolve to .vue files if (isPascalCase(ref.referenceName) && ref.referenceKind === 'calls') { const result = resolveComponent(ref.referenceName, ref.filePath, context); @@ -303,6 +296,11 @@ export const nuxtResolver: FrameworkResolver = { }, }; +/** The file node of `file`, a path an import resolved to — none for a folder, an unindexed file or the importing file itself. */ +function moduleFileNode(file: string | null, ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + return file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined; +} + /** Does the reference's own file declare a function (or a const holding one) by that name? */ function declaresOwnFunction(ref: UnresolvedRef, context: ResolutionContext): boolean { return context