Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char*, KeyComparator> 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<std::pair<int, FileMetaData*>>` or `Striped<CacheAlignedWrapper<port::Mutex>>`, 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.
Expand Down
78 changes: 78 additions & 0 deletions __tests__/sfc-alias-module-imports.test.ts
Original file line number Diff line number Diff line change
@@ -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<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);
});
10 changes: 9 additions & 1 deletion __tests__/sync-module-importers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
"<script setup lang=\"ts\">\nimport { Button } from './components';\nconst label = Button();\n</script>\n<template><div>{{ label }}</div></template>\n",
'src/aliased.ts': "import { Button } from '@app/widgets';\nexport function aliased() { return Button(); }\n",
'src/components/Panel.vue':
"<script setup lang=\"ts\">\nimport { Button } from '.';\nconst inner = Button();\n</script>\n<template><div>{{ inner }}</div></template>\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",
Expand Down
77 changes: 67 additions & 10 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, UnresolvedRefRow>();
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[] = [];
Expand All @@ -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.
Expand All @@ -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
Expand Down
29 changes: 26 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
FindRelevantContextOptions,
UnresolvedReference,
IndexHealth,
Language,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
Expand Down Expand Up @@ -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
Expand Down
Loading