Skip to content

Commit 86a1728

Browse files
authored
test(phase2): rigorous coverage for importDetails + 2-hop + watcher queue (#53)
* test(phase2): cover watcher queue + impact + importDetails * fix(watcher): keep queued refresh during indexing * fix(impact): don't hide 2-hop candidates * test(impact): stabilize 2-hop preflight assertion * test(impact): assert relationships sidecar before impact * test(impact): avoid fuse edge-case token * fix(lancedb): keyword-only when table missing
1 parent 59e3686 commit 86a1728

File tree

9 files changed

+263
-14
lines changed

9 files changed

+263
-14
lines changed

src/core/auto-refresh.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface AutoRefreshController {
2+
/**
3+
* Called when a file watcher detects a change.
4+
* Returns true when an incremental refresh should run immediately.
5+
*/
6+
onFileChange: (isIndexing: boolean) => boolean;
7+
/**
8+
* Called after an indexing run completes.
9+
* Returns true when a queued incremental refresh should run next.
10+
*/
11+
consumeQueuedRefresh: (indexStatus: 'ready' | 'error' | 'idle' | 'indexing') => boolean;
12+
/** Clears any queued refresh. */
13+
reset: () => void;
14+
}
15+
16+
export function createAutoRefreshController(): AutoRefreshController {
17+
let queued = false;
18+
19+
return {
20+
onFileChange: (isIndexing: boolean) => {
21+
if (isIndexing) {
22+
queued = true;
23+
return false;
24+
}
25+
return true;
26+
},
27+
consumeQueuedRefresh: (indexStatus) => {
28+
if (indexStatus === 'indexing') {
29+
// Defensive: if called while indexing, do not clear the queue.
30+
return false;
31+
}
32+
const shouldRun = queued && indexStatus === 'ready';
33+
queued = false;
34+
return shouldRun;
35+
},
36+
reset: () => {
37+
queued = false;
38+
}
39+
};
40+
}

src/core/file-watcher.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void {
2525
};
2626

2727
const watcher = chokidar.watch(rootPath, {
28-
ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'],
28+
ignored: [
29+
'**/node_modules/**',
30+
'**/.codebase-context/**',
31+
'**/.git/**',
32+
'**/dist/**',
33+
'**/.nx/**',
34+
'**/.planning/**',
35+
'**/coverage/**',
36+
'**/.turbo/**',
37+
'**/.next/**',
38+
'**/.cache/**'
39+
],
2940
persistent: true,
3041
ignoreInitial: true,
3142
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 }

src/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { appendMemoryFile } from './memory/store.js';
4040
import { handleCliCommand } from './cli.js';
4141
import { startFileWatcher } from './core/file-watcher.js';
42+
import { createAutoRefreshController } from './core/auto-refresh.js';
4243
import { parseGitLogLineToMemory } from './memory/git-memory.js';
4344
import {
4445
isComplementaryPatternCategory,
@@ -252,7 +253,7 @@ const indexState: IndexState = {
252253
status: 'idle'
253254
};
254255

255-
let autoRefreshQueued = false;
256+
const autoRefresh = createAutoRefreshController();
256257

257258
const server: Server = new Server(
258259
{
@@ -573,8 +574,7 @@ async function performIndexing(incrementalOnly?: boolean): Promise<void> {
573574
for (;;) {
574575
await performIndexingOnce(nextMode);
575576

576-
const shouldRunQueuedRefresh = autoRefreshQueued && indexState.status === 'ready';
577-
autoRefreshQueued = false;
577+
const shouldRunQueuedRefresh = autoRefresh.consumeQueuedRefresh(indexState.status);
578578
if (!shouldRunQueuedRefresh) return;
579579

580580
if (process.env.CODEBASE_CONTEXT_DEBUG) {
@@ -753,8 +753,8 @@ async function main() {
753753
rootPath: ROOT_PATH,
754754
debounceMs,
755755
onChanged: () => {
756-
if (indexState.status === 'indexing') {
757-
autoRefreshQueued = true;
756+
const shouldRunNow = autoRefresh.onFileChange(indexState.status === 'indexing');
757+
if (!shouldRunNow) {
758758
if (process.env.CODEBASE_CONTEXT_DEBUG) {
759759
console.error('[file-watcher] Index in progress — queueing auto-refresh');
760760
}

src/storage/lancedb.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,12 @@ export class LanceDBStorageProvider implements VectorStorageProvider {
143143
);
144144
}
145145
if (!this.table) {
146-
throw new IndexCorruptedError(
147-
'LanceDB index corrupted: no table available for search (rebuild required)'
148-
);
146+
// No semantic index was built (e.g. skipEmbedding) or it hasn't been created yet.
147+
// Degrade gracefully to keyword-only search instead of forcing an auto-heal rebuild.
148+
if (process.env.CODEBASE_CONTEXT_DEBUG) {
149+
console.error('[LanceDB] No table available for semantic search (keyword-only mode).');
150+
}
151+
return [];
149152
}
150153

151154
try {

src/tools/search-codebase.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,10 @@ export async function handle(
364364
}
365365

366366
const targets = resultPaths.map((rp) => normalizeGraphPath(rp));
367-
const targetSet = new Set(targets);
368367

369368
const candidates = new Map<string, ImpactCandidate>();
370369

371370
const addCandidate = (file: string, hop: 1 | 2, line?: number): void => {
372-
for (const t of targetSet) {
373-
if (pathsMatch(t, file)) return;
374-
}
375-
376371
const existing = candidates.get(file);
377372
if (existing) {
378373
if (existing.hop <= hop) return;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { createAutoRefreshController } from '../src/core/auto-refresh.js';
3+
4+
describe('AutoRefreshController', () => {
5+
it('runs immediately when not indexing', () => {
6+
const controller = createAutoRefreshController();
7+
expect(controller.onFileChange(false)).toBe(true);
8+
});
9+
10+
it('queues when indexing and runs after ready', () => {
11+
const controller = createAutoRefreshController();
12+
expect(controller.onFileChange(true)).toBe(false);
13+
expect(controller.consumeQueuedRefresh('indexing')).toBe(false);
14+
expect(controller.consumeQueuedRefresh('ready')).toBe(true);
15+
});
16+
17+
it('does not run queued refresh if indexing failed', () => {
18+
const controller = createAutoRefreshController();
19+
expect(controller.onFileChange(true)).toBe(false);
20+
expect(controller.consumeQueuedRefresh('error')).toBe(false);
21+
});
22+
23+
it('coalesces multiple changes into one queued refresh', () => {
24+
const controller = createAutoRefreshController();
25+
expect(controller.onFileChange(true)).toBe(false);
26+
expect(controller.onFileChange(true)).toBe(false);
27+
expect(controller.consumeQueuedRefresh('ready')).toBe(true);
28+
expect(controller.consumeQueuedRefresh('ready')).toBe(false);
29+
});
30+
});
31+

tests/impact-2hop.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import path from 'path';
4+
import { CodebaseIndexer } from '../src/core/indexer.js';
5+
import { dispatchTool } from '../src/tools/index.js';
6+
import type { ToolContext } from '../src/tools/types.js';
7+
import {
8+
CODEBASE_CONTEXT_DIRNAME,
9+
INTELLIGENCE_FILENAME,
10+
KEYWORD_INDEX_FILENAME,
11+
VECTOR_DB_DIRNAME,
12+
MEMORY_FILENAME,
13+
RELATIONSHIPS_FILENAME
14+
} from '../src/constants/codebase-context.js';
15+
16+
describe('Impact candidates (2-hop)', () => {
17+
let tempRoot: string | null = null;
18+
const token = 'UNIQUETOKEN123';
19+
20+
beforeEach(async () => {
21+
// Keep test artifacts under CWD (mirrors other indexer tests and avoids OS tmp quirks)
22+
tempRoot = await fs.mkdtemp(path.join(process.cwd(), '.tmp-impact-2hop-'));
23+
const srcDir = path.join(tempRoot, 'src');
24+
await fs.mkdir(srcDir, { recursive: true });
25+
await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' }));
26+
27+
await fs.writeFile(
28+
path.join(srcDir, 'c.ts'),
29+
`export function cFn() { return '${token}'; }\n`
30+
);
31+
await fs.writeFile(path.join(srcDir, 'b.ts'), `import { cFn } from './c';\nexport const b = cFn();\n`);
32+
await fs.writeFile(path.join(srcDir, 'a.ts'), `import { b } from './b';\nexport const a = b;\n`);
33+
34+
const indexer = new CodebaseIndexer({
35+
rootPath: tempRoot,
36+
config: { skipEmbedding: true }
37+
});
38+
await indexer.index();
39+
});
40+
41+
afterEach(async () => {
42+
if (tempRoot) {
43+
await fs.rm(tempRoot, { recursive: true, force: true });
44+
tempRoot = null;
45+
}
46+
});
47+
48+
it('includes hop 1 and hop 2 candidates in preflight impact.details', async () => {
49+
if (!tempRoot) throw new Error('tempRoot not initialized');
50+
51+
const rootPath = tempRoot;
52+
const paths = {
53+
baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME),
54+
memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME),
55+
intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME),
56+
keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME),
57+
vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME)
58+
};
59+
60+
const ctx: ToolContext = {
61+
indexState: { status: 'ready' },
62+
paths,
63+
rootPath,
64+
performIndexing: () => {}
65+
};
66+
67+
const relationshipsPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, RELATIONSHIPS_FILENAME);
68+
const relationshipsRaw = await fs.readFile(relationshipsPath, 'utf-8');
69+
const relationships = JSON.parse(relationshipsRaw) as {
70+
graph?: { imports?: Record<string, string[]> };
71+
};
72+
const imports = relationships.graph?.imports ?? {};
73+
const hasInternalEdge =
74+
(imports['src/b.ts'] ?? []).some((d) => d.endsWith('src/c.ts') || d === 'src/c.ts') &&
75+
(imports['src/a.ts'] ?? []).some((d) => d.endsWith('src/b.ts') || d === 'src/b.ts');
76+
if (!hasInternalEdge) {
77+
throw new Error(
78+
`Expected relationships graph to include src/a.ts -> src/b.ts and src/b.ts -> src/c.ts, got imports keys=${JSON.stringify(
79+
Object.keys(imports)
80+
)}`
81+
);
82+
}
83+
84+
const resp = await dispatchTool(
85+
'search_codebase',
86+
{ query: token, intent: 'edit', includeSnippets: false, limit: 1 },
87+
ctx
88+
);
89+
90+
const text = resp.content?.[0]?.text ?? '';
91+
const parsed = JSON.parse(text) as {
92+
status?: string;
93+
results?: Array<{ file?: string }>;
94+
preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } };
95+
};
96+
const results = parsed.results ?? [];
97+
if (!Array.isArray(results) || results.length === 0) {
98+
throw new Error(
99+
`Expected at least one search result for token, got status=${String(parsed.status)} results=${JSON.stringify(
100+
results
101+
)}`
102+
);
103+
}
104+
const details = parsed.preflight?.impact?.details ?? [];
105+
106+
const hasHop1 = details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1);
107+
if (!hasHop1) {
108+
throw new Error(
109+
`Expected hop 1 candidate src/b.ts, got impact.details=${JSON.stringify(details)}`
110+
);
111+
}
112+
const hasHop2 = details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2);
113+
if (!hasHop2) {
114+
throw new Error(
115+
`Expected hop 2 candidate src/a.ts, got impact.details=${JSON.stringify(details)}`
116+
);
117+
}
118+
});
119+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, it, expect } from 'vitest';
2+
import path from 'path';
3+
import os from 'os';
4+
import { InternalFileGraph } from '../src/utils/usage-tracker.js';
5+
6+
describe('InternalFileGraph serialization', () => {
7+
it('round-trips importDetails and importedSymbols behavior', () => {
8+
const rootPath = path.join(os.tmpdir(), `ifg-${Date.now()}`);
9+
const graph = new InternalFileGraph(rootPath);
10+
11+
const exportedFile = path.join(rootPath, 'src', 'exported.ts');
12+
const importingFile = path.join(rootPath, 'src', 'importer.ts');
13+
14+
graph.trackExports(exportedFile, [{ name: 'Foo', type: 'function' }]);
15+
graph.trackImport(importingFile, exportedFile, 12, ['Foo']);
16+
17+
const json = graph.toJSON();
18+
expect(json.importDetails).toBeDefined();
19+
20+
const restored = InternalFileGraph.fromJSON(json, rootPath);
21+
const restoredJson = restored.toJSON();
22+
expect(restoredJson.importDetails).toEqual(json.importDetails);
23+
24+
const unused = restored.findUnusedExports();
25+
expect(unused.length).toBe(0);
26+
});
27+
});
28+

tests/relationship-sidecar.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,28 @@ describe('Relationship Sidecar', () => {
8080
expect(typeof relationships.graph.imports).toBe('object');
8181
expect(typeof relationships.graph.importedBy).toBe('object');
8282
expect(typeof relationships.graph.exports).toBe('object');
83+
84+
// Rich edge details should be persisted when available
85+
const importDetails = relationships.graph.importDetails as
86+
| Record<string, Record<string, { line?: number; importedSymbols?: string[] }>>
87+
| undefined;
88+
expect(importDetails).toBeDefined();
89+
expect(typeof importDetails).toBe('object');
90+
91+
const fromFile = Object.keys(importDetails ?? {}).find((k) => k.endsWith('src/b.ts'));
92+
expect(fromFile).toBeDefined();
93+
const edges = fromFile ? importDetails?.[fromFile] : undefined;
94+
95+
const toFile = Object.keys(edges ?? {}).find((k) => k.endsWith('src/a.ts'));
96+
expect(toFile).toBeDefined();
97+
const detail = toFile ? edges?.[toFile] : undefined;
98+
99+
expect(detail).toBeDefined();
100+
if (detail) {
101+
expect(detail.line).toBe(1);
102+
expect(Array.isArray(detail.importedSymbols)).toBe(true);
103+
expect(detail.importedSymbols ?? []).toContain('greet');
104+
}
83105
expect(relationships.symbols).toBeDefined();
84106
expect(typeof relationships.symbols.exportedBy).toBe('object');
85107
expect(relationships.stats).toBeDefined();

0 commit comments

Comments
 (0)