mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
09b9646b7e
* perf(context): compute the dominant file once per index state (#1864) getDominantFile() backs context ranking's core-directory boost. Its answer depends only on the graph, but the whole-graph aggregation behind it (edges joined to both endpoints' nodes, grouped by file) ran on every generic codegraph_explore - seconds per call on a large index. Memoize it in QueryBuilder against a database change stamp: total_changes() (rows this connection wrote) plus PRAGMA data_version (commits by any other connection or process). Both are O(1), so no write path has to remember to invalidate, and a sync made by another process is seen on the next call. Any write forces a recompute; the heuristic's result is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(context): never keep a dominant file read inside a transaction (#1864) total_changes() counts writes that are later rolled back, so a result computed inside a transaction could survive the ROLLBACK under an unchanged stamp and describe edges that no longer exist. getDominantFile now skips the memo while a transaction is open (and on a runtime without isTransaction). Ported from the maintainer's hardening of this fix in #1919. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): drop the dominant-file memo when a QueryBuilder is rebound (#1864) The memo's change stamp (total_changes + data_version) is per connection, and fresh read-only connections to two different databases report the same one ("0:2"). A pool worker rebound onto a rebuilt index kept serving the old database's dominant file until something wrote through the new connection. A test rebinds onto a second project and fails without the reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): credit @danusha2345 for #1864 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
/**
|
|
* getDominantFile() is computed once per database state (#1864).
|
|
*
|
|
* The dominant-file heuristic (context ranking's core-directory boost) is a
|
|
* whole-graph aggregation whose answer does not depend on the query, yet it
|
|
* ran on every generic explore — seconds per call on a large index. It is now
|
|
* memoized against a database change stamp. These tests pin both halves:
|
|
* repeated explores reuse the answer, and any write — by this connection or
|
|
* by another process's sync — makes the next call see the new graph.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import CodeGraph from '../src';
|
|
import { QueryBuilder } from '../src/db/queries';
|
|
import { DatabaseConnection, getDatabasePath } from '../src/db';
|
|
|
|
/** A file whose functions call each other in a chain: `n` in-file call edges. */
|
|
function chain(prefix: string, n: number): string {
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const next = i + 1 < n ? `${prefix}${i + 1}();` : '';
|
|
lines.push(`export function ${prefix}${i}(): void { ${next} }`);
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function queriesOf(cg: CodeGraph): QueryBuilder {
|
|
return (cg as unknown as { queries: QueryBuilder }).queries;
|
|
}
|
|
|
|
function spyCompute(cg: CodeGraph) {
|
|
return vi.spyOn(queriesOf(cg) as unknown as { computeDominantFile: () => unknown }, 'computeDominantFile');
|
|
}
|
|
|
|
describe('dominant file — computed once per index state (#1864)', () => {
|
|
let dir: string;
|
|
const open: CodeGraph[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const cg of open.splice(0)) cg.close();
|
|
vi.restoreAllMocks();
|
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function setup(): Promise<CodeGraph> {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dominant-'));
|
|
fs.mkdirSync(path.join(dir, 'core'));
|
|
fs.mkdirSync(path.join(dir, 'ext'));
|
|
fs.writeFileSync(path.join(dir, 'core', 'engine.ts'), chain('engineStep', 40));
|
|
fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 5));
|
|
const cg = await CodeGraph.init(dir, { index: true });
|
|
open.push(cg);
|
|
return cg;
|
|
}
|
|
|
|
it('reuses the answer across explores while the database is unchanged', async () => {
|
|
const cg = await setup();
|
|
const compute = spyCompute(cg);
|
|
|
|
for (const q of ['engine step', 'plugin step', 'how does the engine run']) {
|
|
await cg.findRelevantContext(q);
|
|
}
|
|
expect(compute).toHaveBeenCalledTimes(1);
|
|
expect(queriesOf(cg).getDominantFile()?.filePath).toBe('core/engine.ts');
|
|
expect(compute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('sees a new dominant file after a sync changes the graph', async () => {
|
|
const cg = await setup();
|
|
expect(queriesOf(cg).getDominantFile()?.filePath).toBe('core/engine.ts');
|
|
|
|
fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120));
|
|
await cg.sync();
|
|
|
|
const compute = spyCompute(cg);
|
|
expect(queriesOf(cg).getDominantFile()?.filePath).toBe('ext/plugin.ts');
|
|
expect(compute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('sees a sync made through another connection (another process)', async () => {
|
|
const writer = await setup();
|
|
const reader = await CodeGraph.open(dir);
|
|
open.push(reader);
|
|
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts');
|
|
|
|
fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120));
|
|
await writer.sync();
|
|
|
|
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts');
|
|
});
|
|
|
|
it('does not keep a result read inside a transaction that is rolled back', async () => {
|
|
const cg = await setup();
|
|
const q = queriesOf(cg);
|
|
const before = q.getDominantFile();
|
|
const db = (cg as any).db.getDb();
|
|
db.exec('BEGIN');
|
|
try {
|
|
db.exec('DELETE FROM edges');
|
|
expect(q.getDominantFile()).toBeNull();
|
|
} finally {
|
|
db.exec('ROLLBACK');
|
|
}
|
|
expect(q.getDominantFile()).toEqual(before);
|
|
});
|
|
|
|
it('forgets the answer when rebound to another connection (a pool worker following a rebuilt index)', async () => {
|
|
await setup();
|
|
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dominant-other-'));
|
|
const conns: DatabaseConnection[] = [];
|
|
try {
|
|
fs.mkdirSync(path.join(other, 'ext'));
|
|
fs.writeFileSync(path.join(other, 'ext', 'plugin.ts'), chain('pluginStep', 120));
|
|
(await CodeGraph.init(other, { index: true })).close();
|
|
conns.push(DatabaseConnection.open(getDatabasePath(dir)), DatabaseConnection.open(getDatabasePath(other)));
|
|
const q = new QueryBuilder(conns[0]!.getDb());
|
|
expect(q.getDominantFile()?.filePath).toBe('core/engine.ts');
|
|
// Two fresh connections to different databases can report the same
|
|
// change stamp, so only dropping the memo on rebind keeps this honest.
|
|
q.rebind(conns[1]!.getDb());
|
|
expect(q.getDominantFile()?.filePath).toBe('ext/plugin.ts');
|
|
} finally {
|
|
for (const c of conns) c.close();
|
|
fs.rmSync(other, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== 'win32')('sees a database rebuilt and reopened under it', async () => {
|
|
const reader = await setup();
|
|
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts');
|
|
fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120));
|
|
const rebuilt = await CodeGraph.recreate(dir);
|
|
try { await rebuilt.indexAll(); } finally { rebuilt.close(); }
|
|
expect(reader.reopenIfReplaced()).toBe(true);
|
|
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts');
|
|
});
|
|
});
|