From 54e3e3ee0d60cde7fc1a5fcaac0ecdb8b7186885 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 12 Oct 2026 14:08:26 -0500 Subject: [PATCH] fix(resolution): component and scoped-alias imports link the module file 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 --- CHANGELOG.md | 1 + __tests__/sfc-alias-module-imports.test.ts | 78 ++++++++++++++++++++++ __tests__/sync-module-importers.test.ts | 10 ++- src/db/queries.ts | 77 ++++++++++++++++++--- src/index.ts | 29 +++++++- src/resolution/import-resolver.ts | 19 +++--- src/resolution/index.ts | 5 ++ 7 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 __tests__/sfc-alias-module-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cf93e91..fe4ce4961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. - 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 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. Re-index C++ projects after upgrading. - In C++, a call on a variable, parameter or member declared with template arguments nested inside its template arguments, like `autovector>` or `Striped>`, now links to the method of the class it is declared as. Before, the nested arguments hid that class: in RocksDB, `files_marked_for_compaction_.clear()` on such an `autovector` linked to an unrelated `CompactionInputFiles::clear` that only shares words with the variable's name, and `mutex_.Get(key)` on a `Striped` linked to nothing. Re-index C++ projects after upgrading. diff --git a/__tests__/sfc-alias-module-imports.test.ts b/__tests__/sfc-alias-module-imports.test.ts new file mode 100644 index 000000000..600c0252e --- /dev/null +++ b/__tests__/sfc-alias-module-imports.test.ts @@ -0,0 +1,78 @@ +/** + * 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): Promise { + 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': + '\n\n', + 'src/components/Badge.svelte': + '\n{label}\n', + 'src/pages/index.astro': + '---\nimport { format } from \'../lib/util\';\nconst heading = format(\'z\');\n---\n

{heading}

\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); +}); diff --git a/__tests__/sync-module-importers.test.ts b/__tests__/sync-module-importers.test.ts index 68268fdb9..57f6f311c 100644 --- a/__tests__/sync-module-importers.test.ts +++ b/__tests__/sync-module-importers.test.ts @@ -57,10 +57,18 @@ const PYTHON_BARRELS = ['app/shop/__init__.py', 'lib/api.py', 'app/orm/__init__. * ES modules: an `index.ts` re-exporting under the same name and under * another, and modules re-exporting it in turn — an outer `index.ts`, all of * it and one name renamed, and a module passing on one name, read through a - * namespace import — imported in its stead. + * namespace import — imported in its stead. It is imported from Vue + * components too (one beside it, as `.`), and through a scoped package a + * `tsconfig` path maps to it. */ const TS: Files = { 'package.json': '{"name":"app"}', + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@app/widgets': ['./src/components/index.ts'] } } }), + 'src/View.vue': + "\n\n", + 'src/aliased.ts': "import { Button } from '@app/widgets';\nexport function aliased() { return Button(); }\n", + 'src/components/Panel.vue': + "\n\n", 'src/components/Button.ts': 'export function Button() { return 1; }\n', 'src/components/Icon.ts': 'export function BaseIcon() { return 2; }\n', 'src/components/index.ts': "export { Button } from './Button';\nexport { BaseIcon as Icon } from './Icon';\n", diff --git a/src/db/queries.ts b/src/db/queries.ts index 9700e3d63..010d32fd3 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4042,26 +4042,40 @@ export class QueryBuilder { /** * Failed `imports` refs a sync should retry once files appear. An import * names a file, a folder or a namespace, which the symbol lookup above - * cannot match, so it is found two ways: + * cannot match, so it is found three ways: * - by `pathKeys` (`importPathKeys` of each added file), as its tail or its * whole name: `package:app/b.dart` waits for a file named `b.dart`, a * bare `#include "b.h"` or `require 'db.php'` is the file's whole name; * - by `names` (the changed files' namespaces and modules), as its whole * name when its tail is something else: a C# `using Foo.Bar` waits for * the namespace node `Foo.Bar`. An import whose tail is the name is the - * symbol lookup's, under that lookup's ceiling. - * Same per-name ceiling as {@link getRetryableFailedReferences}, counted - * per key. + * symbol lookup's, under that lookup's ceiling; + * - by `resolvedModules`, module specifiers that now resolve to an added + * file, as its whole name: `@excalidraw/common`, which a `tsconfig` path + * maps to `packages/common/src/index.ts`, waits under the tail `common`, + * which the file's own keys (`src`, `index`) never give. + * The other lookups share the per-name ceiling of + * {@link getRetryableFailedReferences}, counted per key; this one has the + * module ceiling, since a specifier that resolves to the file is no noise. */ - getRetryableFailedImports(pathKeys: string[], names: string[] = [], perNameCeiling: number = 500): UnresolvedReference[] { + getRetryableFailedImports( + pathKeys: string[], + names: string[] = [], + perNameCeiling: number = 500, + resolvedModules: string[] = [], + perModuleCeiling: number = 5000, + ): UnresolvedReference[] { const failedImports = "status = 'failed' AND reference_kind = 'imports'"; const lookups = [ - { keys: pathKeys, column: 'name_tail', where: failedImports }, - { keys: pathKeys, column: 'reference_name', where: failedImports }, - { keys: names, column: 'reference_name', where: `${failedImports} AND name_tail != reference_name` }, + { keys: pathKeys, column: 'name_tail', where: failedImports, ceiling: perNameCeiling }, + { keys: pathKeys, column: 'reference_name', where: failedImports, ceiling: perNameCeiling }, + { keys: names, column: 'reference_name', where: `${failedImports} AND name_tail != reference_name`, ceiling: perNameCeiling }, + // A module specifier that now resolves to an added file: no noise, so + // the module ceiling, as for a module key. + { keys: resolvedModules, column: 'reference_name', where: failedImports, ceiling: perModuleCeiling }, ]; const rows = new Map(); - for (const { keys, column, where } of lookups) { + for (const { keys, column, where, ceiling } of lookups) { const unique = [...new Set(keys)].filter((key) => key.length > 0); // Pass 1: per-key counts, chunked under the SQLite parameter limit. const retryKeys: string[] = []; @@ -4072,7 +4086,7 @@ export class QueryBuilder { .prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM unresolved_refs WHERE ${where} AND ${column} IN (${placeholders}) GROUP BY ${column}`) .all(...chunk) as Array<{ key: string; count: number }>; for (const row of counts) { - if (row.count <= perNameCeiling) retryKeys.push(row.key); + if (row.count <= ceiling) retryKeys.push(row.key); } } // Pass 2: load the surviving rows; a row two lookups find is kept once. @@ -4099,6 +4113,49 @@ export class QueryBuilder { })); } + /** + * The scoped package specifiers (`@excalidraw/common`) of failed imports, + * each with one file that wrote it — what a `tsconfig` path or a workspace + * package can map to a project file that a sync adds. + */ + getFailedScopedImports(): Array<{ name: string; filePath: string; language: Language }> { + return (this.db + .prepare( + `SELECT reference_name AS name, MIN(file_path) AS file_path, language + FROM unresolved_refs + WHERE status = 'failed' AND reference_kind = 'imports' + AND reference_name GLOB '@?*/?*' AND reference_name NOT GLOB '@*/*/*' + GROUP BY reference_name` + ) + .all() as Array<{ name: string; file_path: string; language: Language }>) + .map((row) => ({ name: row.name, filePath: row.file_path, language: row.language })); + } + + /** + * Failed imports of the folder the importing file is in or under (`.`, + * `..`). Their tail is empty, since they name a folder only through where + * they are written, so no added file's keys find them. + */ + getFailedFolderImports(): UnresolvedReference[] { + return (this.db + .prepare( + `SELECT * FROM unresolved_refs + WHERE status = 'failed' AND reference_kind = 'imports' AND reference_name IN ('.', './', '..', '../')` + ) + .all() as UnresolvedRefRow[]) + .map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + rowId: row.id, + })); + } + /** * Failed `calls` refs whose name tail is one of `tails` — the navigation * calls (`history.push`, `navigate`) a sync looks through after a route diff --git a/src/index.ts b/src/index.ts index 2e777dbf5..d98fac584 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { FindRelevantContextOptions, UnresolvedReference, IndexHealth, + Language, } from './types'; import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db'; import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve'; @@ -1056,13 +1057,35 @@ export class CodeGraph { // Look those up by what the ADDED files can be imported as (a // modified file was already there for any import of it), and by // the namespaces and modules the changed files declare. + // A scoped package that a `tsconfig` path or a workspace package maps + // to an added file (`@excalidraw/common` to `packages/common/src/ + // index.ts`) waits under its last segment, which that file's keys + // never give: look its failed imports up by the specifier itself. + // An import of the importer's own folder or the one above (`.`, + // `..`) waits under no key at all, so each is tried where written. + const addedFiles = new Set(result.addedFilePaths ?? []); + const resolvesToAdded = (specifier: string, fromFile: string, language: Language): boolean => { + const file = this.resolver.resolveModuleFile(specifier, fromFile, language); + return file !== null && addedFiles.has(file); + }; + const resolvedModules = addedFiles.size === 0 ? [] : this.queries.getFailedScopedImports() + .filter((s) => resolvesToAdded(s.name, s.filePath, s.language)) + .map((s) => s.name); + const folderImports = addedFiles.size === 0 ? [] : this.queries.getFailedFolderImports() + .filter((ref) => ref.filePath !== undefined && ref.language !== undefined && + resolvesToAdded(ref.referenceName, ref.filePath, ref.language)); const retryRows = new Set(retryable.map((ref) => ref.rowId)); const importRetry = this.queries.getRetryableFailedImports( (result.addedFilePaths ?? []).flatMap(importPathKeys), - this.queries.getNodeNamesByFiles(result.changedFilePaths, ['namespace', 'module']) + this.queries.getNodeNamesByFiles(result.changedFilePaths, ['namespace', 'module']), + undefined, + resolvedModules ); - for (const ref of importRetry) { - if (!retryRows.has(ref.rowId)) retryable.push(ref); + for (const ref of [...importRetry, ...folderImports]) { + if (!retryRows.has(ref.rowId)) { + retryable.push(ref); + retryRows.add(ref.rowId); + } } // In row order, as a full index resolves them: when two refs make // the same edge, such as a route's lazily loaded class that its diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index c0a64b554..9423eaebf 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1717,25 +1717,26 @@ export function resolvePhpImportedStaticCall( return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' }; } -const JS_MODULE_LANGUAGES: ReadonlySet = new Set(['javascript', 'jsx', 'typescript', 'tsx', 'arkts']); - /** * A module specifier written as a path — relative (`./x`, `../x`), rooted, or - * an alias with a folder in it (`@/lib/x`, `~/x`, `$lib/x`) — rather than an - * imported binding's name or a bare package (`react`, `lodash`). + * an alias with a folder in it (`@/lib/x`, `~/x`, `$lib/x`) — or a scoped + * package (`@excalidraw/common`), which no binding can be named and which a + * `tsconfig` path or a workspace member can map to a project file; rather + * than an imported binding's name or a bare package (`react`, `lodash`). */ function isJsPathSpecifier(name: string): boolean { return name.startsWith('./') || name.startsWith('../') || name === '.' || name === '..' || - (name.includes('/') && !/\s/.test(name) && !/^@[\w.-]+\/[\w.-]+$/.test(name)); + (name.includes('/') && !/\s/.test(name)); } /** - * A JS/TS `imports` reference that names a module by path. It names a FILE, not - * a symbol, so it skips the resolver's name-exists pre-filter — a CommonJS - * `require('./x')` has no import node of that name to pass it. + * A JS/TS `imports` reference that names a module by path — from a script, or + * from a Vue, Svelte or Astro component's. It names a FILE, not a symbol, so + * it skips the resolver's name-exists pre-filter — a CommonJS `require('./x')` + * has no import node of that name to pass it. */ export function isJsPathImportRef(ref: UnresolvedRef): boolean { - return ref.referenceKind === 'imports' && JS_MODULE_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName); + return ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName); } /** PHP reference kinds whose name is a class name, written as in the source. */ diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 24a5ad5ec..cac05d6ce 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2641,6 +2641,11 @@ export class ReferenceResolver { return this.frameworks.map((f) => f.name); } + /** The project file a module specifier written in `fromFile` names, as its import resolves it; null when none. */ + resolveModuleFile(specifier: string, fromFile: string, language: Language): string | null { + return resolveImportPath(specifier, fromFile, language, this.context); + } + /** True when a detected router binds navigation calls to its routes (see {@link reopenNavigationsFor}). */ hasNavigationRouters(): boolean { return this.frameworks.some((f) => f.navigation !== undefined);