Files
codegraph/__tests__/sync-import-retry.test.ts
T
Colby MchenryandClaude Opus 5.5 3695818fa2 fix(sync): an import of a folder further up links its index file when it appears (#2669)
An import that names a folder only through where it is written (`.`, `..`,
`../..`) is parked under no key, so sync tries each where it is written when
files are added (#2528). That lookup listed `.`, `./`, `..` and `../` only, so
`import { Excalidraw } from "../.."` in excalidraw's tests, or any import of a
folder two or more levels up, stayed unlinked after its folder's `index` file
was added or restored, though a fresh index links it. It now lists the folder
up to twelve levels above, with or without the trailing slash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:37:06 +00:00

460 lines
20 KiB
TypeScript

/**
* A sync links an import whose file appears after the importer was indexed.
*
* Sync retries parked failed refs by name tail (#1240), but an import names a
* file, a folder or a namespace, not a symbol, so the symbol-name lookup never
* found it: the tail of `package:app/b.dart` was `dart`, of `inc/db.php`
* `php`, of `./foo` `/foo`. Most languages never even parked theirs: a module
* path that named no file reached the importing file's own `import` node by
* its qualified name, and that edge was never revisited. A Lua `require` took
* the local it is assigned to. Either way the import linked only once the
* importer itself changed, or on a full index.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { DatabaseConnection } from '../src/db';
import { CURRENT_SCHEMA_VERSION, getCurrentVersion, runMigrations } from '../src/db/migrations';
import { QueryBuilder } from '../src/db/queries';
import { importPathKeys } from '../src/db/reference-tail';
import type { Node } from '../src/types';
interface Case {
name: string;
initial: Record<string, string>;
added: Record<string, string>;
/** The importing file. */
from: string;
/** What its imports reach once `added` exists: `<kind> <file>`. */
expected: string[];
}
const CASES: Case[] = [
{
name: 'Dart',
initial: {
'pubspec.yaml': 'name: app\n',
'lib/a.dart': "import 'package:app/b.dart';\nimport 'package:kit/kit.dart';\nimport 'c.dart';\nimport 'src/d.dart';\nexport 'src/e.dart';\npart 'a.g.dart';\n\nvoid main() {}\n",
},
added: {
'lib/b.dart': 'class B {}\n',
'packages/kit/pubspec.yaml': 'name: kit\n',
'packages/kit/lib/kit.dart': 'class Kit {}\n',
'lib/c.dart': 'class C {}\n',
'lib/src/d.dart': 'class D {}\n',
'lib/src/e.dart': 'class E {}\n',
// A generated part, written by build_runner after the library was indexed.
'lib/a.g.dart': "part of 'a.dart';\n\nclass _Generated {}\n",
},
from: 'lib/a.dart',
expected: ['file lib/b.dart', 'file packages/kit/lib/kit.dart', 'file lib/c.dart', 'file lib/src/d.dart', 'file lib/src/e.dart', 'file lib/a.g.dart'],
},
{
name: 'TypeScript',
initial: {
'src/a.ts': "import { X } from './x';\nimport './side';\nimport * as NS from './ns';\nimport { Y } from './dir';\nimport { Z } from './z.js';\nimport type { T } from './types';\nexport function run() { return [X, NS, Y, Z]; }\nexport type U = T;\n",
},
added: {
'src/x.ts': 'export const X = 1;\n',
'src/side.ts': 'console.log(1);\n',
'src/ns.ts': 'export const member = 1;\n',
'src/dir/index.ts': 'export const Y = 2;\n',
'src/z.ts': 'export const Z = 3;\n',
'src/types.d.ts': 'export interface T { a: number }\n',
},
from: 'src/a.ts',
expected: ['file src/x.ts', 'file src/side.ts', 'file src/ns.ts', 'file src/dir/index.ts', 'file src/z.ts', 'file src/types.d.ts'],
},
{
name: 'JavaScript',
initial: {
'a.js': "import './side';\nimport def from './def.js';\nconst req = require('./req');\nconst lib = require('./lib');\nmodule.exports = { def, req, lib };\n",
},
added: {
'side.js': 'console.log(1);\n',
'def.js': 'export default function def() {}\n',
'req.js': 'module.exports = 1;\n',
'lib/index.js': 'module.exports = 2;\n',
},
from: 'a.js',
expected: ['file side.js', 'file def.js', 'file req.js', 'file lib/index.js'],
},
{
// A folder further up than the one above names it only through where it
// is written, so it waits under no key of the file that appears.
name: 'TypeScript (a folder further up)',
initial: {
'src/a/b/c/m.ts': "import { x } from '../..';\nimport { y } from '../../';\nimport { z } from '../../..';\nexport const v = [x, y, z];\n",
},
added: { 'src/a/index.ts': 'export const x = 1;\nexport const y = 2;\n', 'src/index.ts': 'export const z = 3;\n' },
from: 'src/a/b/c/m.ts',
expected: ['file src/a/index.ts', 'file src/index.ts'],
},
{
name: 'C',
initial: { 'src/main.c': '#include "a/b.h"\n#include "c.h"\n#include <stdio.h>\nint main(void) { return 0; }\n' },
added: { 'src/a/b.h': 'int b(void);\n', 'src/c.h': 'int c(void);\n' },
from: 'src/main.c',
expected: ['file src/a/b.h', 'file src/c.h'],
},
{
name: 'C++',
initial: { 'src/main.cpp': '#include "util/u.hpp"\n#include "w.h"\nint main() { return 0; }\n' },
added: { 'src/util/u.hpp': 'namespace util { int u(); }\n', 'src/w.h': 'int w();\n' },
from: 'src/main.cpp',
expected: ['file src/util/u.hpp', 'file src/w.h'],
},
{
name: 'Objective-C',
initial: { 'Foo.m': '#import "Bar.h"\n#import "sub/Baz.h"\n@implementation Foo\n@end\n' },
added: { 'Bar.h': '@interface Bar\n@end\n', 'sub/Baz.h': '@interface Baz\n@end\n' },
from: 'Foo.m',
expected: ['file Bar.h', 'file sub/Baz.h'],
},
{
name: 'PHP',
initial: { 'index.php': "<?php\nrequire 'inc/db.php';\ninclude_once 'helpers.php';\n" },
added: { 'inc/db.php': '<?php\nfunction db() {}\n', 'helpers.php': '<?php\nfunction helper() {}\n' },
from: 'index.php',
expected: ['file inc/db.php', 'file helpers.php'],
},
{
name: 'Ruby',
initial: { 'app.rb': "require_relative 'lib/foo'\nrequire 'lib/bar'\nclass App; end\n" },
added: { 'lib/foo.rb': 'class Foo; end\n', 'lib/bar.rb': 'class Bar; end\n' },
from: 'app.rb',
expected: ['file lib/foo.rb', 'file lib/bar.rb'],
},
{
name: 'Python',
initial: { 'main.py': 'import pkg.mod\nfrom pkg import other\nfrom pkg.sub import thing\nimport single\n\ndef run():\n return pkg.mod, other, thing, single\n' },
added: {
'pkg/__init__.py': '',
'pkg/mod.py': 'def f():\n return 1\n',
'pkg/other.py': 'def g():\n return 2\n',
'pkg/sub/__init__.py': 'thing = 3\n',
'single.py': 'value = 4\n',
},
from: 'main.py',
expected: ['file pkg/mod.py', 'file pkg/other.py', 'file pkg/sub/__init__.py', 'file single.py', 'variable pkg/sub/__init__.py'],
},
{
name: 'Lua',
initial: { 'main.lua': 'local m = require("lib.mod")\nlocal p = require("pkg")\nreturn { m, p }\n' },
added: { 'lib/mod.lua': 'local M = {}\nreturn M\n', 'pkg/init.lua': 'local P = {}\nreturn P\n' },
from: 'main.lua',
expected: ['file lib/mod.lua', 'file pkg/init.lua'],
},
{
name: 'Luau',
initial: { 'src/main.luau': 'local Signal = require(script.Parent.Signal)\nreturn Signal\n' },
added: { 'src/Signal.luau': 'local S = {}\nreturn S\n' },
from: 'src/main.luau',
expected: ['file src/Signal.luau'],
},
{
name: 'Nix',
initial: { 'default.nix': '{ pkgs }:\nlet\n foo = import ./foo.nix;\n bar = import ./bar;\nin { inherit foo bar; }\n' },
added: { 'foo.nix': '{ a = 1; }\n', 'bar/default.nix': '{ b = 2; }\n' },
from: 'default.nix',
expected: ['file foo.nix', 'file bar/default.nix'],
},
{
name: 'R',
initial: { 'main.R': 'source("utils.R")\nsource("R/helpers.R")\nrun <- function() 1\n' },
added: { 'utils.R': 'u <- function() 1\n', 'R/helpers.R': 'h <- function() 2\n' },
from: 'main.R',
expected: ['file utils.R', 'file R/helpers.R'],
},
{
name: 'Solidity',
initial: { 'A.sol': 'pragma solidity ^0.8.0;\nimport "./B.sol";\nimport "./lib/C.sol";\ncontract A {}\n' },
added: { 'B.sol': 'pragma solidity ^0.8.0;\ncontract B {}\n', 'lib/C.sol': 'pragma solidity ^0.8.0;\ncontract C {}\n' },
from: 'A.sol',
expected: ['file B.sol', 'file lib/C.sol'],
},
{
name: 'Erlang',
initial: { 'src/a.erl': '-module(a).\n-include("b.hrl").\n-include_lib("app/include/c.hrl").\n-export([f/0]).\nf() -> ok.\n' },
added: { 'src/b.hrl': '-define(B, 1).\n', 'include/c.hrl': '-define(C, 1).\n' },
from: 'src/a.erl',
expected: ['file src/b.hrl', 'file include/c.hrl'],
},
{
name: 'COBOL',
initial: {
'MAIN.cbl': ' IDENTIFICATION DIVISION.\n PROGRAM-ID. MAIN.\n DATA DIVISION.\n WORKING-STORAGE SECTION.\n COPY CUSTREC.\n PROCEDURE DIVISION.\n STOP RUN.\n',
},
added: { 'CUSTREC.cpy': ' 01 CUST-REC.\n 05 CUST-ID PIC 9(5).\n' },
from: 'MAIN.cbl',
expected: ['file CUSTREC.cpy'],
},
{
name: 'Java (a package wildcard)',
initial: { 'src/com/x/A.java': 'package com.x;\nimport com.z.*;\npublic class A {}\n' },
added: { 'src/com/z/Z.java': 'package com.z;\npublic class Z {}\n' },
from: 'src/com/x/A.java',
expected: ['namespace src/com/z/Z.java'],
},
{
name: 'C# (a using of a namespace declared later)',
initial: { 'A.cs': 'using Foo.Bar;\nnamespace App { class A {} }\n' },
added: { 'Baz.cs': 'namespace Foo.Bar { class Baz {} }\n' },
from: 'A.cs',
expected: ['namespace Baz.cs'],
},
{
name: 'Svelte',
initial: { 'src/App.svelte': "<script>\n import Child from './Child.svelte';\n</script>\n<Child />\n" },
added: { 'src/Child.svelte': '<p>child</p>\n' },
from: 'src/App.svelte',
expected: ['file src/Child.svelte'],
},
{
name: 'Vue',
initial: { 'src/App.vue': "<script setup>\nimport Child from './Child.vue';\n</script>\n<template><Child /></template>\n" },
added: { 'src/Child.vue': '<template><p>child</p></template>\n' },
from: 'src/App.vue',
expected: ['file src/Child.vue'],
},
];
let root = '';
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.destroy();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
});
function write(files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
}
/** `<kind> <file>` for every `imports` edge out of `file`, plus how many land on one of its own import statements. */
function importsOf(file: string): { targets: string[]; ownStatements: number } {
const graph = cg!;
const ids = graph.getNodesInFile(file).map((n) => n.id);
const targets = new Set<string>();
let ownStatements = 0;
for (const edge of graph.getOutgoingEdgesFrom(ids, ['imports'])) {
const target = graph.getNode(edge.target);
if (!target) continue;
if (target.filePath === file) {
if (target.kind === 'import') ownStatements++;
continue;
}
targets.add(`${target.kind} ${target.filePath}`);
}
return { targets: [...targets].sort(), ownStatements };
}
describe('sync links an import of a file that appears after the importer', () => {
it.each(CASES)('$name', async (c) => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-import-'));
write(c.initial);
cg = await CodeGraph.init(root, { index: true });
// Unresolved, the import parks as failed — not as an edge to itself.
const before = importsOf(c.from);
expect(before.ownStatements).toBe(0);
for (const target of c.expected) expect(before.targets).not.toContain(target);
write(c.added);
const result = await cg.sync();
expect(result.filesAdded).toBe(Object.keys(c.added).length);
// The importer did not change: only the retry of its parked import links it.
const synced = importsOf(c.from);
expect(synced.targets).toEqual(expect.arrayContaining(c.expected));
expect(synced.ownStatements).toBe(0);
expect(cg.getPendingReferenceCount()).toBe(0);
// A full re-index links the same. It has to start from an empty database,
// as `codegraph index` does: indexAll over this one skips the unchanged
// importer and keeps the edges the sync wrote. Close first: recreate
// unlinks the database file, which a held handle makes EBUSY on Windows.
cg.close();
cg = await CodeGraph.recreate(root);
await cg.indexAll();
expect(importsOf(c.from)).toEqual(synced);
}, 60_000);
});
describe('a removed file', () => {
it('relinks its unchanged importers when it is restored', async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-import-'));
write({
'pubspec.yaml': 'name: app\n',
'lib/a.dart': "import 'package:app/b.dart';\n\nvoid main() {}\n",
'lib/b.dart': 'class B {}\n',
});
cg = await CodeGraph.init(root, { index: true });
expect(importsOf('lib/a.dart').targets).toEqual(['file lib/b.dart']);
fs.rmSync(path.join(root, 'lib/b.dart'));
expect((await cg.sync()).filesRemoved).toBe(1);
expect(importsOf('lib/a.dart')).toEqual({ targets: [], ownStatements: 0 });
expect(cg.getPendingReferenceCount()).toBe(0);
write({ 'lib/b.dart': 'class B {}\n' });
expect((await cg.sync()).filesAdded).toBe(1);
expect(importsOf('lib/a.dart').targets).toEqual(['file lib/b.dart']);
}, 60_000);
});
describe('importPathKeys', () => {
it('names a file and its folder, whole and up to the first dot', () => {
expect(importPathKeys('lib/src/d.dart')).toEqual(['src', 'd.dart', 'd']);
expect(importPathKeys('src/types.d.ts')).toEqual(['src', 'types.d.ts', 'types']);
expect(importPathKeys('pkg/sub/__init__.py')).toEqual(['sub', '__init__.py', '__init__']);
expect(importPathKeys('vendor/gopkg.in/yaml.v3/yaml.go')).toEqual(['yaml.v3', 'yaml', 'yaml.go']);
expect(importPathKeys('main.c')).toEqual(['main.c', 'main']);
expect(importPathKeys('.github/.eslintrc.js')).toEqual(['.github', '.eslintrc.js', '.eslintrc']);
});
});
describe('getRetryableFailedImports', () => {
let db: DatabaseConnection | undefined;
afterEach(() => {
db?.close();
db = undefined;
});
function setup(): QueryBuilder {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-import-'));
db = DatabaseConnection.initialize(path.join(root, 'test.db'));
const queries = new QueryBuilder(db.getDb());
const failed = (id: string, referenceName: string, referenceKind: 'imports' | 'calls' = 'imports') => {
const node: Node = { id, kind: 'file', name: id, qualifiedName: id, filePath: id, language: 'dart',
startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: Date.now() };
queries.insertNode(node);
queries.insertUnresolvedRef({ fromNodeId: id, referenceName, referenceKind, line: 1, column: 0, filePath: id, language: 'dart' });
queries.markReferencesFailed([{ fromNodeId: id, referenceName, referenceKind }]);
};
failed('a.dart', 'package:app/b.dart');
failed('c.dart', '../b.dart');
failed('d.dart', 'src/b.dart');
failed('e.dart', 'b.dart');
failed('f.dart', 'x.b', 'calls');
failed('g.cs', 'Foo.Bar');
failed('h.ts', 'useState');
return queries;
}
const names = (refs: Array<{ referenceName: string }>) => refs.map((r) => r.referenceName).sort();
it('finds an import by the stem of its path or by its whole name, and nothing else', () => {
const queries = setup();
expect(names(queries.getRetryableFailedImports(['b']))).toEqual(['../b.dart', 'package:app/b.dart', 'src/b.dart']);
expect(names(queries.getRetryableFailedImports(['b.dart']))).toEqual(['b.dart']);
expect(queries.getRetryableFailedImports(['b', 'b.dart', 'b'])).toHaveLength(4);
});
it('finds an import by a node name only when the name is not also its tail', () => {
const queries = setup();
// `using Foo.Bar` waits for the namespace node of that name.
expect(names(queries.getRetryableFailedImports([], ['Foo.Bar', 'useState', 'b']))).toEqual(['Foo.Bar']);
// A path key still reaches a plain name.
expect(names(queries.getRetryableFailedImports(['useState']))).toEqual(['useState']);
});
it('skips a key that more failed imports share than the ceiling', () => {
const queries = setup();
expect(queries.getRetryableFailedImports(['b'], [], 2)).toEqual([]);
expect(names(queries.getRetryableFailedImports(['b', 'b.dart'], [], 2))).toEqual(['b.dart']);
expect(queries.getRetryableFailedImports(['b'], [], 3)).toHaveLength(3);
});
it('looks a key past the ceiling through for the imports that could name an added file', () => {
const queries = setup();
const only = (...accepted: string[]) => (ref: { referenceName: string }) => accepted.includes(ref.referenceName);
// Three imports wait under `b`, one more than the ceiling.
expect(names(queries.getRetryableFailedImports(['b'], [], 2, [], undefined, only('../b.dart')))).toEqual(['../b.dart']);
// What the test accepts is no noise: the module ceiling holds it.
expect(names(queries.getRetryableFailedImports(['b'], [], 1, [], 2, only('../b.dart', 'src/b.dart')))).toEqual(['../b.dart', 'src/b.dart']);
expect(queries.getRetryableFailedImports(['b'], [], 1, [], 1, only('../b.dart', 'src/b.dart'))).toEqual([]);
// A key within the ceiling is retried whole, whatever the test says.
expect(queries.getRetryableFailedImports(['b'], [], 3, [], undefined, only())).toHaveLength(3);
// A namespace waits for its node, not a file: the test is not asked.
expect(names(queries.getRetryableFailedImports([], ['Foo.Bar'], 0, [], undefined, only('Foo.Bar')))).toEqual([]);
});
});
describe('schema v12', () => {
let db: DatabaseConnection | undefined;
afterEach(() => {
vi.restoreAllMocks();
db?.close();
db = undefined;
});
function fixture(): QueryBuilder {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-import-'));
db = DatabaseConnection.initialize(path.join(root, 'test.db'));
const queries = new QueryBuilder(db.getDb());
queries.insertNode({ id: 'a', kind: 'file', name: 'a.dart', qualifiedName: 'lib/a.dart', filePath: 'lib/a.dart',
language: 'dart', startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0 });
for (const [referenceName, referenceKind, tail] of [
['package:app/b.dart', 'imports', 'dart'],
['c.h', 'imports', 'h'],
['lists::map/2', 'calls', 'map'],
] as const) {
queries.insertUnresolvedRef({ fromNodeId: 'a', referenceName, referenceKind, line: 1, column: 0, filePath: 'lib/a.dart', language: 'dart' });
db.getDb().prepare("UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE reference_name = ?").run(tail, referenceName);
}
return queries;
}
const tails = () => db!.getDb().prepare('SELECT reference_name AS name, name_tail AS tail FROM unresolved_refs ORDER BY id').all();
it('rewrites the tail of a path import parked by an older version, and replays cleanly', () => {
fixture();
db!.getDb().exec(`DROP INDEX idx_unresolved_failed_import_tail;
DROP INDEX idx_unresolved_failed_import_name;
DELETE FROM schema_versions WHERE version >= 12;
INSERT OR IGNORE INTO schema_versions(version, applied_at, description) VALUES (11, 0, 'legacy fixture');`);
db!.close();
db = DatabaseConnection.open(path.join(root, 'test.db'));
expect(getCurrentVersion(db.getDb())).toBe(CURRENT_SCHEMA_VERSION);
const indexes = db.getDb().prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_unresolved_failed_import_%' ORDER BY name").all();
expect(indexes).toEqual([{ name: 'idx_unresolved_failed_import_name' }, { name: 'idx_unresolved_failed_import_tail' }]);
const migrated = tails();
expect(migrated).toEqual([
{ name: 'package:app/b.dart', tail: 'b' },
{ name: 'c.h', tail: 'h' },
{ name: 'lists::map/2', tail: 'map' },
]);
db.getDb().exec('DELETE FROM schema_versions WHERE version >= 12');
runMigrations(db.getDb(), 11);
expect(tails()).toEqual(migrated);
});
it('looks failed imports up through their own indexes', () => {
const queries = fixture();
const prepare = vi.spyOn(db!.getDb(), 'prepare');
// The fixture's tails are old ones: `dart` finds a row by tail, `c.h` by name.
expect(queries.getRetryableFailedImports(['dart', 'c.h'], ['Foo.Bar'])).toHaveLength(2);
// A key past the ceiling is read whole for `couldName` to look through: at
// a ceiling of 0 both keys are, and what it accepts is retried.
expect(queries.getRetryableFailedImports(['dart', 'c.h'], [], 0, [], undefined, () => true)).toHaveLength(2);
const lookups = prepare.mock.calls.map(([sql]) => sql as string).filter((sql) => sql.includes("reference_kind = 'imports'"));
prepare.mockRestore();
expect(lookups.length).toBeGreaterThan(0);
for (const sql of lookups) {
const plan = db!.getDb().prepare(`EXPLAIN QUERY PLAN ${sql}`).all('k').map((row) => (row as { detail: string }).detail).join('; ');
expect(plan).toMatch(/USING (COVERING )?INDEX idx_unresolved_failed_import_(tail|name)/);
}
});
});