Files
codegraph/__tests__/sync-path-reference-retry.test.ts
T
Colby Mchenry 388b25279a fix(sync): a route links the module it lazily loads when the module appears later (#2422)
A router names the page or layout it loads lazily by the module's path:
React Router's `lazy-import:./pages/Team`, Vue Router's and Angular's
`import:./home/home.component#HomeComponent`, each also behind `layout:`.
Sync retries a parked failed ref by name_tail (#1240), and these were
parked under a fragment of the path ('/pages/Team',
'component#HomeComponent') that no file's keys match. So a route whose
module was added after the router was indexed, or whose module gained its
component in a later edit, stayed unlinked until the router file changed
or the project was indexed again. #2392 fixed the same gap for imports,
#2403 for Liquid's path references.

- referenceNameTail parks a module reference under the stem of its path
  behind 'module:' ('module:Team'), a key no symbol's name can be, so name
  lookups never retry it by a namesake.
- Sync adds the changed files' moduleReferenceKeys (importPathKeys behind
  'module:') to the symbol retry: an added file can be the module, and an
  edit can give the module the component the route renders.
- Schema v14 rewrites the tails parked before. Its select keeps SQLite off
  idx_unresolved_status with `+status`, reading idx_unresolved_name ranges
  instead of every failed row (vscode's index: about 1 s down to ~12 ms).
- The retry resolves in row order, as a full index does, so when a route's
  module reference and its layout reference make the same edge, sync keeps
  the one a fresh index keeps (ghostfolio's markets page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:54:37 +00:00

140 lines
6.8 KiB
TypeScript

/**
* A sync links a reference written as a path once the file it names appears.
*
* Sync retries parked failed refs by name tail (#1240): the tail is matched
* against the names of the nodes a sync adds, and a file's node is named for
* the file. A reference written as a path — Liquid's `{% render 'price' %}` is
* `snippets/price.liquid`, a JSON template's section `"type": "404"` is
* `sections/404.liquid` — was parked under its extension, `liquid`, which no
* node is named, so it linked only once the file naming it changed or on a
* full index. #2392 fixed the same gap for imports.
*/
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';
import { DatabaseConnection, getDatabasePath } from '../src/db';
import { CURRENT_SCHEMA_VERSION, getCurrentVersion, runMigrations } from '../src/db/migrations';
import { QueryBuilder } from '../src/db/queries';
import { referenceNameTail } from '../src/db/reference-tail';
let root: string | undefined;
let cg: CodeGraph | undefined;
let db: DatabaseConnection | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
db?.close();
db = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = undefined;
});
function write(files: Record<string, string>): 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);
}
}
describe('referenceNameTail', () => {
it('parks a reference written as a path under the name of the file it names', () => {
expect(referenceNameTail('snippets/price.liquid', 'references')).toBe('price.liquid');
expect(referenceNameTail('sections/404.liquid', 'references')).toBe('404.liquid');
expect(referenceNameTail('snippets/icon.logo.liquid', 'references')).toBe('icon.logo.liquid');
});
it('keeps the symbol tail of a reference that names no file', () => {
// No path, a path with no file name at its end, or an Erlang arity. (A
// route's module, `lazy-import:./routes/about.tsx` or
// `import:./home.component#HomeComponent`, is sync-route-module-retry's.)
expect(referenceNameTail('Foo.Bar', 'references')).toBe('Bar');
expect(referenceNameTail('snippets/price', 'references')).toBe('snippets/price');
expect(referenceNameTail('lists::map/2', 'references')).toBe('map');
// A call's slashes sit in its arguments, a comment or a division.
expect(referenceNameTail('assert.logfile("logs/error.log").has.line', 'calls')).toBe('line');
expect(referenceNameTail('(rss_kb / 1024.0).to_i', 'calls')).toBe('to_i');
// An import keeps the stem of its path (#2392).
expect(referenceNameTail('package:app/b.dart', 'imports')).toBe('b');
});
});
describe('sync links a path reference whose file appears later', () => {
it('in an index whose references an older version parked', async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-path-ref-'));
write({
'layout/theme.liquid': "{% render 'price' %}{{ content_for_layout }}\n",
'templates/404.json': JSON.stringify({ sections: { main: { type: '404' } }, order: ['main'] }),
});
cg = await CodeGraph.init(root, { index: true });
cg.close();
cg = undefined;
// What a version before 13 left: the extension as the tail.
db = DatabaseConnection.open(getDatabasePath(root));
db.getDb().exec(`UPDATE unresolved_refs SET name_tail = 'liquid' WHERE reference_kind = 'references';
DELETE FROM schema_versions WHERE version >= 13;
INSERT OR IGNORE INTO schema_versions(version, applied_at, description) VALUES (12, 0, 'legacy fixture');`);
db.close();
db = undefined;
cg = await CodeGraph.open(root);
write({ 'sections/404.liquid': '<h1>404</h1>\n', 'snippets/price.liquid': '{{ product.price }}\n' });
expect((await cg.sync()).filesAdded).toBe(2);
expect(cg.getFileDependencies('templates/404.json')).toEqual(['sections/404.liquid']);
expect(cg.getFileDependencies('layout/theme.liquid')).toEqual(['snippets/price.liquid']);
expect(cg.getPendingReferenceCount()).toBe(0);
}, 60_000);
});
describe('schema v13', () => {
function fixture(): QueryBuilder {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-path-ref-'));
db = DatabaseConnection.initialize(path.join(root, 'test.db'));
const queries = new QueryBuilder(db.getDb());
queries.insertNode({ id: 'a', kind: 'file', name: 'theme.liquid', qualifiedName: 'layout/theme.liquid', filePath: 'layout/theme.liquid',
language: 'liquid', startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0 });
for (const [referenceName, referenceKind, status, tail] of [
['snippets/price.liquid', 'references', 'failed', 'liquid'],
['sections/icon.logo.liquid', 'references', 'failed', 'liquid'],
['snippets/card.liquid', 'references', 'pending', ''],
['package:app/b.dart', 'imports', 'failed', 'b'],
['assert.logfile("logs/error.log").has.line', 'calls', 'failed', 'line'],
['lists::map/2', 'calls', 'failed', 'map'],
] as const) {
queries.insertUnresolvedRef({ fromNodeId: 'a', referenceName, referenceKind, line: 1, column: 0, filePath: 'layout/theme.liquid', language: 'liquid' });
db.getDb().prepare('UPDATE unresolved_refs SET status = ?, name_tail = ? WHERE reference_name = ?').run(status, 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 reference parked by an older version, and replays cleanly', () => {
fixture();
db!.getDb().exec(`DELETE FROM schema_versions WHERE version >= 13;
INSERT OR IGNORE INTO schema_versions(version, applied_at, description) VALUES (12, 0, 'legacy fixture');`);
db!.close();
db = DatabaseConnection.open(path.join(root!, 'test.db'));
expect(getCurrentVersion(db.getDb())).toBe(CURRENT_SCHEMA_VERSION);
const migrated = tails();
expect(migrated).toEqual([
{ name: 'snippets/price.liquid', tail: 'price.liquid' },
{ name: 'sections/icon.logo.liquid', tail: 'icon.logo.liquid' },
// Only a failed row is looked up by its tail.
{ name: 'snippets/card.liquid', tail: '' },
{ name: 'package:app/b.dart', tail: 'b' },
{ name: 'assert.logfile("logs/error.log").has.line', tail: 'line' },
{ name: 'lists::map/2', tail: 'map' },
]);
const queries = new QueryBuilder(db.getDb());
expect(queries.getRetryableFailedReferences(['price.liquid', 'liquid']).map((ref) => ref.referenceName)).toEqual(['snippets/price.liquid']);
db.getDb().exec('DELETE FROM schema_versions WHERE version >= 13');
runMigrations(db.getDb(), 12);
expect(tails()).toEqual(migrated);
});
});