Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- Claude Code prompts that get CodeGraph context start faster on large projects, because project-wide summaries are now kept between prompts and refreshed when the index changes.

### Fixes

- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
Expand Down
7 changes: 6 additions & 1 deletion __tests__/dominant-file-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ describe('dominant file — computed once per index state (#1864)', () => {
}

it('reuses the answer across explores while the database is unchanged', async () => {
const cg = await setup();
// indexAll warmed the memo in memory and in the sidecar; start from neither.
(await setup()).close();
open.splice(0);
fs.rmSync(getDatabasePath(dir) + '.memo.json', { force: true });
const cg = await CodeGraph.open(dir);
open.push(cg);
const compute = spyCompute(cg);

for (const q of ['engine step', 'plugin step', 'how does the engine run']) {
Expand Down
121 changes: 121 additions & 0 deletions __tests__/persisted-memo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* The dominant-file and stats aggregates are shared across processes through a
* sidecar next to the database, valid only under the current `graph_epoch`.
* A stale or unreadable sidecar must recompute; every graph writer must make a
* fresh process see its changes; and a sync warms the sidecar after a delay.
*/

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 { getDatabasePath } from '../src/db';

const WARM_DELAY_FLOOR_MS = 10_000;

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;
}

describe('persisted aggregate memo', () => {
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-memo-'));
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;
}

async function freshProcess(): Promise<CodeGraph> {
const cg = await CodeGraph.open(dir);
open.push(cg);
return cg;
}

const sidecar = () => getDatabasePath(dir) + '.memo.json';

it.each([
['a stale stamp', () => JSON.stringify({ stamp: 'old', entries: { dominantFile: { filePath: 'bogus.ts', edgeCount: 99, nextEdgeCount: 0 } } })],
['a corrupt sidecar', () => '{not json'],
])('recomputes and repairs %s', async (_name, contents) => {
const writer = await setup();
writer.close();
open.splice(0);
fs.writeFileSync(sidecar(), contents());

const reader = await freshProcess();
expect(reader.getStats().fileCount).toBe(2);
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts');

const repaired = JSON.parse(fs.readFileSync(sidecar(), 'utf8')) as { entries: { dominantFile: { filePath: string } } };
expect(repaired.entries.dominantFile.filePath).toBe('core/engine.ts');
});

it('shows a fresh process the graph after indexFiles', async () => {
const writer = await setup();
expect(queriesOf(await freshProcess()).getDominantFile()?.filePath).toBe('core/engine.ts');

fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120));
await writer.indexFiles(['ext/plugin.ts']);

const reader = await freshProcess();
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts');
});

it('warms the sidecar after a sync once the delay elapses', async () => {
const writer = await setup();
fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120));

const realSetTimeout = global.setTimeout;
const warmTimers: Array<() => void> = [];
vi.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void, ms?: number, ...args: unknown[]) => {
if (typeof ms === 'number' && ms >= WARM_DELAY_FLOOR_MS) {
warmTimers.push(fn);
return { unref() {} } as unknown as NodeJS.Timeout;
}
return realSetTimeout(fn, ms, ...args);
}) as typeof setTimeout);
await writer.sync();
vi.restoreAllMocks();

expect(warmTimers).toHaveLength(1);
warmTimers[0]!();

const reader = await freshProcess();
const compute = vi.spyOn(queriesOf(reader) as unknown as { computeDominantFile: () => unknown }, 'computeDominantFile');
expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts');
expect(compute).not.toHaveBeenCalled();
});

it('shows a fresh process an empty graph after clear', async () => {
const writer = await setup();
expect(writer.getStats().nodeCount).toBeGreaterThan(0);

writer.clear();

expect((await freshProcess()).getStats().nodeCount).toBe(0);
});
});
60 changes: 60 additions & 0 deletions src/db/persisted-memo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Cross-process memo for query-independent whole-graph aggregates.
*
* `getDominantFile()` and `getStats()` scan every edge/node, and a fresh
* process (the prompt hook) cannot reuse the in-memory memo. Values are kept
* in a sidecar file next to the database, tagged with `project_metadata.graph_epoch`,
* which `QueryBuilder.bumpGraphEpoch` replaces whenever an index or sync run
* changes the graph. File mtimes are deliberately not used: WAL checkpoints
* and no-op opens touch them without changing content. The sidecar is a
* separate file so storing a value never changes the epoch and a read-only
* connection can still use it.
*/

import * as fs from 'fs';
import * as path from 'path';

const SIDECAR_SUFFIX = '.memo.json';

type MemoEntries = Record<string, unknown>;

function readEntries(dbPath: string, stamp: string): MemoEntries {
try {
const parsed = JSON.parse(fs.readFileSync(dbPath + SIDECAR_SUFFIX, 'utf8')) as {
stamp?: unknown;
entries?: MemoEntries;
};
if (parsed.stamp !== stamp || !parsed.entries) return {};
return parsed.entries;
} catch {
return {};
}
}

/** Look up `key` under `stamp`; undefined when absent, stale, or unreadable. */
export function readPersistedMemo<T>(dbPath: string, stamp: string, key: string): T | undefined {
return readEntries(dbPath, stamp)[key] as T | undefined;
}

/** Store `value` under `stamp`; failures (read-only dir, races) are ignored. */
export function writePersistedMemo(dbPath: string, stamp: string, key: string, value: unknown): void {
const target = dbPath + SIDECAR_SUFFIX;
const tmp = `${target}.${process.pid}.tmp`;
try {
const entries = { ...readEntries(dbPath, stamp), [key]: value };
fs.writeFileSync(tmp, JSON.stringify({ stamp, entries }));
fs.renameSync(tmp, target);
} catch {
try {
fs.rmSync(tmp, { force: true });
} catch {
// best effort
}
}
}

export function memoDbPath(db: { prepare(sql: string): { all(): unknown[] } }): string | null {
const rows = db.prepare('PRAGMA database_list').all() as Array<{ name: string; file: string }>;
const main = rows.find(r => r.name === 'main');
return main?.file ? path.resolve(main.file) : null;
}
48 changes: 47 additions & 1 deletion src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ import {
SearchResult,
} from '../types';
import { safeJsonParse } from '../utils';
import { memoDbPath, readPersistedMemo, writePersistedMemo } from './persisted-memo';
import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils';
import { parseQuery, boundedEditDistance } from '../search/query-parser';
import { isGeneratedFile } from '../extraction/generated-detection';
import { splitIdentifierSegments } from '../search/identifier-segments';

const GRAPH_EPOCH_KEY = 'graph_epoch';

/**
* Files that should not be candidates for "dominant file" detection: test/spec
* files and tool-generated files. Generated files (`*.pb.go`, `*.pulsar.go`,
Expand Down Expand Up @@ -1129,11 +1132,48 @@ export class QueryBuilder {
}
const stamp = this.getChangeStamp();
if (this.dominantFileMemo?.stamp === stamp) return this.dominantFileMemo.value;
const value = this.computeDominantFile();
const value = this.persistedMemo('dominantFile', () => this.computeDominantFile());
this.dominantFileMemo = { stamp, value };
return value;
}

hasGraphEpoch(): boolean {
return this.getMetadata(GRAPH_EPOCH_KEY) !== null;
}

/** Replace `graph_epoch`: every persisted memo computed before this is stale. */
bumpGraphEpoch(): void {
this.setMetadata(GRAPH_EPOCH_KEY, `${Date.now()}.${Math.random().toString(36).slice(2)}`);
}

/** Recompute and store the persisted aggregates under the current epoch. */
warmPersistedMemo(): void {
this.dominantFileMemo = undefined;
this.getDominantFile();
this.getStats();
}

/**
* Cross-process memo for a query-independent aggregate, so a fresh process
* (the prompt hook) reuses what another process computed. It lives in a
* sidecar file, not a `project_metadata` row: a reader must never need the
* database write lock the daemon's writer holds, and storing a row would
* make the hook's own connection a writer. The stamp is read before
* computing, so a write racing the compute leaves a stale stamp that the
* next call recomputes. Skipped inside a transaction (uncommitted state)
* and when the index has no `graph_epoch` yet.
*/
private persistedMemo<T>(key: string, compute: () => T): T {
const dbPath = this.db.inTransaction === false ? memoDbPath(this.db) : null;
const stamp = dbPath ? this.getMetadata(GRAPH_EPOCH_KEY) : null;
if (!dbPath || !stamp) return compute();
const hit = readPersistedMemo<T>(dbPath, stamp, key);
if (hit !== undefined) return hit;
const value = compute();
writePersistedMemo(dbPath, stamp, key, value);
return value;
}

/**
* A value that differs whenever the database content may have changed
* since the last call, whoever changed it: `total_changes()` counts the
Expand Down Expand Up @@ -3949,6 +3989,11 @@ export class QueryBuilder {
* Get graph statistics
*/
getStats(): GraphStats {
const stats = this.persistedMemo('stats', () => this.computeStats());
return { ...stats, lastUpdated: Date.now() };
}

private computeStats(): GraphStats {
// Single query for all three aggregate counts
const counts = this.db.prepare(`
SELECT
Expand Down Expand Up @@ -4037,6 +4082,7 @@ export class QueryBuilder {
this.db.exec('DELETE FROM edges');
this.db.exec('DELETE FROM nodes');
this.db.exec('DELETE FROM files');
this.bumpGraphEpoch();
})();
}
}
Expand Down
Loading