Files
codegraph/__tests__/sfc-alias-module-imports.test.ts
Colby Mchenry 4f3eaf33ae fix(resolution): component and scoped-alias imports link the module file (#2528)
An import statement's own module reference linked nothing in two cases,
even when its bindings resolved through the file: a Vue, Svelte or Astro
component's import (`isJsPathImportRef` covered only the script languages),
and a bare scoped package that a `tsconfig` path or a workspace package maps
to a project file (`isJsPathSpecifier` excluded `@scope/pkg`). So a barrel or
a side-effect module showed no dependency from those files, and sync's
importer discovery, which follows the module edge into a changed file,
never found them when a barrel they import through was deleted or added.

Both now resolve through `resolveImportPath`, as a script's path import
does; an outside scoped package still names no file. Component imports
written with an extension, which a basename match linked before, now link
through the resolver too. When a sync adds a file, it also retries the
failed imports that now resolve to it but wait under no key its path gives:
a scoped package parked under its last segment (`common` for
`@excalidraw/common`, mapped to `packages/common/src/index.ts`, whose keys
are `src` and `index`), under the module ceiling, and an import of the
importer's own folder or the one above (`.`, `..`), parked under no tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:23:41 +00:00

79 lines
3.8 KiB
TypeScript

/**
* An import statement's module reference links the importing file to the file
* it names: from a Vue, Svelte or Astro component as from a script, and for a
* scoped package a `tsconfig` path maps to a project file. Before, a
* component's `import { x } from '../lib/util'` and a script's `import { x }
* from '@acme/common'` bound `x` through that file while the statement itself
* linked nothing, so the module never counted the importer as a dependent.
*/
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';
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 = [];
});
/** The files a file's import statements link it to (its module edges, not its calls). */
function moduleImports(graph: CodeGraph, file: string): string[] {
const ids = graph.getNodesInFile(file).map((n) => n.id);
return [...new Set(graph.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'imports' && e.target.startsWith('file:'))
.map((e) => e.target.slice('file:'.length)))].sort();
}
async function index(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sfc-alias-'));
roots.push(root);
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);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}
describe('an import links the importing file to the module file', () => {
it('from a Vue, Svelte or Astro component, by relative path', async () => {
const graph = await index({
'package.json': '{"name":"app"}',
'src/lib/util.ts': 'export function format(s: string) { return s; }\n',
'src/components/Card.vue':
'<script setup lang="ts">\nimport { format } from \'../lib/util\';\nconst title = format(\'x\');\n</script>\n<template><div>{{ title }}</div></template>\n',
'src/components/Badge.svelte':
'<script lang="ts">\n import { format } from \'../lib/util\';\n const label = format(\'y\');\n</script>\n<span>{label}</span>\n',
'src/pages/index.astro':
'---\nimport { format } from \'../lib/util\';\nconst heading = format(\'z\');\n---\n<h1>{heading}</h1>\n',
});
for (const component of ['src/components/Card.vue', 'src/components/Badge.svelte', 'src/pages/index.astro']) {
expect(moduleImports(graph, component), component).toEqual(['src/lib/util.ts']);
}
}, 120_000);
it('for a scoped package a tsconfig path maps to a project file', async () => {
const graph = await index({
'package.json': '{"name":"app"}',
'tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { '@acme/common': ['./packages/common/src/index.ts'] } },
}),
'packages/common/src/index.ts': "export * from './debounce';\n",
'packages/common/src/debounce.ts': 'export function debounce(fn: () => void) { return fn; }\n',
// `useTesting` keeps the outside package's import in the file, unused.
'apps/web/src/search.ts':
"import { debounce } from '@acme/common';\nimport { render } from '@testing-library/react';\nexport const useTesting = render;\nexport const run = debounce(() => {});\n",
});
// The outside package names no project file: the file links to nothing for it.
expect(moduleImports(graph, 'apps/web/src/search.ts')).toEqual(['packages/common/src/index.ts']);
expect(graph.getFileDependents('packages/common/src/index.ts')).toEqual(['apps/web/src/search.ts']);
}, 120_000);
});