Skip to content

Commit 7a9af7a

Browse files
committed
test(phase2): cover watcher queue + impact + importDetails
1 parent 59e3686 commit 7a9af7a

File tree

7 files changed

+213
-6
lines changed

7 files changed

+213
-6
lines changed

src/core/auto-refresh.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
const shouldRun = queued && indexStatus === 'ready';
29+
queued = false;
30+
return shouldRun;
31+
},
32+
reset: () => {
33+
queued = false;
34+
}
35+
};
36+
}

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
}
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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import path from 'path';
4+
import os from 'os';
5+
import { CodebaseIndexer } from '../src/core/indexer.js';
6+
import { dispatchTool } from '../src/tools/index.js';
7+
import type { ToolContext } from '../src/tools/types.js';
8+
import {
9+
CODEBASE_CONTEXT_DIRNAME,
10+
INTELLIGENCE_FILENAME,
11+
KEYWORD_INDEX_FILENAME,
12+
VECTOR_DB_DIRNAME,
13+
MEMORY_FILENAME
14+
} from '../src/constants/codebase-context.js';
15+
16+
describe('Impact candidates (2-hop)', () => {
17+
let tempRoot: string | null = null;
18+
19+
beforeEach(async () => {
20+
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'impact-2hop-'));
21+
const srcDir = path.join(tempRoot, 'src');
22+
await fs.mkdir(srcDir, { recursive: true });
23+
await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' }));
24+
25+
await fs.writeFile(
26+
path.join(srcDir, 'c.ts'),
27+
`export function cFn() { return 'UNIQUE_TOKEN_123'; }\n`
28+
);
29+
await fs.writeFile(path.join(srcDir, 'b.ts'), `import { cFn } from './c';\nexport const b = cFn();\n`);
30+
await fs.writeFile(path.join(srcDir, 'a.ts'), `import { b } from './b';\nexport const a = b;\n`);
31+
32+
const indexer = new CodebaseIndexer({
33+
rootPath: tempRoot,
34+
config: { skipEmbedding: true }
35+
});
36+
await indexer.index();
37+
});
38+
39+
afterEach(async () => {
40+
if (tempRoot) {
41+
await fs.rm(tempRoot, { recursive: true, force: true });
42+
tempRoot = null;
43+
}
44+
});
45+
46+
it('includes hop 1 and hop 2 candidates in preflight impact.details', async () => {
47+
if (!tempRoot) throw new Error('tempRoot not initialized');
48+
49+
const rootPath = tempRoot;
50+
const paths = {
51+
baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME),
52+
memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME),
53+
intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME),
54+
keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME),
55+
vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME)
56+
};
57+
58+
const ctx: ToolContext = {
59+
indexState: { status: 'ready' },
60+
paths,
61+
rootPath,
62+
performIndexing: () => {}
63+
};
64+
65+
const resp = await dispatchTool(
66+
'search_codebase',
67+
{ query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false },
68+
ctx
69+
);
70+
71+
const text = resp.content?.[0]?.text ?? '';
72+
const parsed = JSON.parse(text) as { preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } } };
73+
const details = parsed.preflight?.impact?.details ?? [];
74+
75+
expect(details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1)).toBe(true);
76+
expect(details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2)).toBe(true);
77+
});
78+
});
79+
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)