From 7a9af7a4728521da0be063c721654b9f286dc2cd Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 19:42:10 +0100 Subject: [PATCH 1/7] test(phase2): cover watcher queue + impact + importDetails --- src/core/auto-refresh.ts | 36 +++++++++ src/core/file-watcher.ts | 13 ++- src/index.ts | 10 +-- tests/auto-refresh-controller.test.ts | 31 ++++++++ tests/impact-2hop.test.ts | 79 +++++++++++++++++++ .../internal-file-graph-serialization.test.ts | 28 +++++++ tests/relationship-sidecar.test.ts | 22 ++++++ 7 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/core/auto-refresh.ts create mode 100644 tests/auto-refresh-controller.test.ts create mode 100644 tests/impact-2hop.test.ts create mode 100644 tests/internal-file-graph-serialization.test.ts diff --git a/src/core/auto-refresh.ts b/src/core/auto-refresh.ts new file mode 100644 index 0000000..7f3032d --- /dev/null +++ b/src/core/auto-refresh.ts @@ -0,0 +1,36 @@ +export interface AutoRefreshController { + /** + * Called when a file watcher detects a change. + * Returns true when an incremental refresh should run immediately. + */ + onFileChange: (isIndexing: boolean) => boolean; + /** + * Called after an indexing run completes. + * Returns true when a queued incremental refresh should run next. + */ + consumeQueuedRefresh: (indexStatus: 'ready' | 'error' | 'idle' | 'indexing') => boolean; + /** Clears any queued refresh. */ + reset: () => void; +} + +export function createAutoRefreshController(): AutoRefreshController { + let queued = false; + + return { + onFileChange: (isIndexing: boolean) => { + if (isIndexing) { + queued = true; + return false; + } + return true; + }, + consumeQueuedRefresh: (indexStatus) => { + const shouldRun = queued && indexStatus === 'ready'; + queued = false; + return shouldRun; + }, + reset: () => { + queued = false; + } + }; +} diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index 6144990..6abc3ba 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -25,7 +25,18 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void { }; const watcher = chokidar.watch(rootPath, { - ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'], + ignored: [ + '**/node_modules/**', + '**/.codebase-context/**', + '**/.git/**', + '**/dist/**', + '**/.nx/**', + '**/.planning/**', + '**/coverage/**', + '**/.turbo/**', + '**/.next/**', + '**/.cache/**' + ], persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 } diff --git a/src/index.ts b/src/index.ts index e653834..dc43f01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import { import { appendMemoryFile } from './memory/store.js'; import { handleCliCommand } from './cli.js'; import { startFileWatcher } from './core/file-watcher.js'; +import { createAutoRefreshController } from './core/auto-refresh.js'; import { parseGitLogLineToMemory } from './memory/git-memory.js'; import { isComplementaryPatternCategory, @@ -252,7 +253,7 @@ const indexState: IndexState = { status: 'idle' }; -let autoRefreshQueued = false; +const autoRefresh = createAutoRefreshController(); const server: Server = new Server( { @@ -573,8 +574,7 @@ async function performIndexing(incrementalOnly?: boolean): Promise { for (;;) { await performIndexingOnce(nextMode); - const shouldRunQueuedRefresh = autoRefreshQueued && indexState.status === 'ready'; - autoRefreshQueued = false; + const shouldRunQueuedRefresh = autoRefresh.consumeQueuedRefresh(indexState.status); if (!shouldRunQueuedRefresh) return; if (process.env.CODEBASE_CONTEXT_DEBUG) { @@ -753,8 +753,8 @@ async function main() { rootPath: ROOT_PATH, debounceMs, onChanged: () => { - if (indexState.status === 'indexing') { - autoRefreshQueued = true; + const shouldRunNow = autoRefresh.onFileChange(indexState.status === 'indexing'); + if (!shouldRunNow) { if (process.env.CODEBASE_CONTEXT_DEBUG) { console.error('[file-watcher] Index in progress — queueing auto-refresh'); } diff --git a/tests/auto-refresh-controller.test.ts b/tests/auto-refresh-controller.test.ts new file mode 100644 index 0000000..5851769 --- /dev/null +++ b/tests/auto-refresh-controller.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { createAutoRefreshController } from '../src/core/auto-refresh.js'; + +describe('AutoRefreshController', () => { + it('runs immediately when not indexing', () => { + const controller = createAutoRefreshController(); + expect(controller.onFileChange(false)).toBe(true); + }); + + it('queues when indexing and runs after ready', () => { + const controller = createAutoRefreshController(); + expect(controller.onFileChange(true)).toBe(false); + expect(controller.consumeQueuedRefresh('indexing')).toBe(false); + expect(controller.consumeQueuedRefresh('ready')).toBe(true); + }); + + it('does not run queued refresh if indexing failed', () => { + const controller = createAutoRefreshController(); + expect(controller.onFileChange(true)).toBe(false); + expect(controller.consumeQueuedRefresh('error')).toBe(false); + }); + + it('coalesces multiple changes into one queued refresh', () => { + const controller = createAutoRefreshController(); + expect(controller.onFileChange(true)).toBe(false); + expect(controller.onFileChange(true)).toBe(false); + expect(controller.consumeQueuedRefresh('ready')).toBe(true); + expect(controller.consumeQueuedRefresh('ready')).toBe(false); + }); +}); + diff --git a/tests/impact-2hop.test.ts b/tests/impact-2hop.test.ts new file mode 100644 index 0000000..dcd131c --- /dev/null +++ b/tests/impact-2hop.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { CodebaseIndexer } from '../src/core/indexer.js'; +import { dispatchTool } from '../src/tools/index.js'; +import type { ToolContext } from '../src/tools/types.js'; +import { + CODEBASE_CONTEXT_DIRNAME, + INTELLIGENCE_FILENAME, + KEYWORD_INDEX_FILENAME, + VECTOR_DB_DIRNAME, + MEMORY_FILENAME +} from '../src/constants/codebase-context.js'; + +describe('Impact candidates (2-hop)', () => { + let tempRoot: string | null = null; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'impact-2hop-')); + const srcDir = path.join(tempRoot, 'src'); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' })); + + await fs.writeFile( + path.join(srcDir, 'c.ts'), + `export function cFn() { return 'UNIQUE_TOKEN_123'; }\n` + ); + await fs.writeFile(path.join(srcDir, 'b.ts'), `import { cFn } from './c';\nexport const b = cFn();\n`); + await fs.writeFile(path.join(srcDir, 'a.ts'), `import { b } from './b';\nexport const a = b;\n`); + + const indexer = new CodebaseIndexer({ + rootPath: tempRoot, + config: { skipEmbedding: true } + }); + await indexer.index(); + }); + + afterEach(async () => { + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it('includes hop 1 and hop 2 candidates in preflight impact.details', async () => { + if (!tempRoot) throw new Error('tempRoot not initialized'); + + const rootPath = tempRoot; + const paths = { + baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), + memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME), + intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME), + keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), + vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) + }; + + const ctx: ToolContext = { + indexState: { status: 'ready' }, + paths, + rootPath, + performIndexing: () => {} + }; + + const resp = await dispatchTool( + 'search_codebase', + { query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false }, + ctx + ); + + const text = resp.content?.[0]?.text ?? ''; + const parsed = JSON.parse(text) as { preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } } }; + const details = parsed.preflight?.impact?.details ?? []; + + expect(details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1)).toBe(true); + expect(details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2)).toBe(true); + }); +}); + diff --git a/tests/internal-file-graph-serialization.test.ts b/tests/internal-file-graph-serialization.test.ts new file mode 100644 index 0000000..1f773a6 --- /dev/null +++ b/tests/internal-file-graph-serialization.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import os from 'os'; +import { InternalFileGraph } from '../src/utils/usage-tracker.js'; + +describe('InternalFileGraph serialization', () => { + it('round-trips importDetails and importedSymbols behavior', () => { + const rootPath = path.join(os.tmpdir(), `ifg-${Date.now()}`); + const graph = new InternalFileGraph(rootPath); + + const exportedFile = path.join(rootPath, 'src', 'exported.ts'); + const importingFile = path.join(rootPath, 'src', 'importer.ts'); + + graph.trackExports(exportedFile, [{ name: 'Foo', type: 'function' }]); + graph.trackImport(importingFile, exportedFile, 12, ['Foo']); + + const json = graph.toJSON(); + expect(json.importDetails).toBeDefined(); + + const restored = InternalFileGraph.fromJSON(json, rootPath); + const restoredJson = restored.toJSON(); + expect(restoredJson.importDetails).toEqual(json.importDetails); + + const unused = restored.findUnusedExports(); + expect(unused.length).toBe(0); + }); +}); + diff --git a/tests/relationship-sidecar.test.ts b/tests/relationship-sidecar.test.ts index 0a644ee..d4ac6da 100644 --- a/tests/relationship-sidecar.test.ts +++ b/tests/relationship-sidecar.test.ts @@ -80,6 +80,28 @@ describe('Relationship Sidecar', () => { expect(typeof relationships.graph.imports).toBe('object'); expect(typeof relationships.graph.importedBy).toBe('object'); expect(typeof relationships.graph.exports).toBe('object'); + + // Rich edge details should be persisted when available + const importDetails = relationships.graph.importDetails as + | Record> + | undefined; + expect(importDetails).toBeDefined(); + expect(typeof importDetails).toBe('object'); + + const fromFile = Object.keys(importDetails ?? {}).find((k) => k.endsWith('src/b.ts')); + expect(fromFile).toBeDefined(); + const edges = fromFile ? importDetails?.[fromFile] : undefined; + + const toFile = Object.keys(edges ?? {}).find((k) => k.endsWith('src/a.ts')); + expect(toFile).toBeDefined(); + const detail = toFile ? edges?.[toFile] : undefined; + + expect(detail).toBeDefined(); + if (detail) { + expect(detail.line).toBe(1); + expect(Array.isArray(detail.importedSymbols)).toBe(true); + expect(detail.importedSymbols ?? []).toContain('greet'); + } expect(relationships.symbols).toBeDefined(); expect(typeof relationships.symbols.exportedBy).toBe('object'); expect(relationships.stats).toBeDefined(); From 2f0b3196e833c2cd3e4d076864ef444bfc97c834 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 19:50:14 +0100 Subject: [PATCH 2/7] fix(watcher): keep queued refresh during indexing --- src/core/auto-refresh.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/auto-refresh.ts b/src/core/auto-refresh.ts index 7f3032d..0f49144 100644 --- a/src/core/auto-refresh.ts +++ b/src/core/auto-refresh.ts @@ -25,6 +25,10 @@ export function createAutoRefreshController(): AutoRefreshController { return true; }, consumeQueuedRefresh: (indexStatus) => { + if (indexStatus === 'indexing') { + // Defensive: if called while indexing, do not clear the queue. + return false; + } const shouldRun = queued && indexStatus === 'ready'; queued = false; return shouldRun; From d42e173d4e296cc74b4dbd723058bace4eac173a Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 20:24:33 +0100 Subject: [PATCH 3/7] fix(impact): don't hide 2-hop candidates --- src/tools/search-codebase.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index 6a69e70..f2c7526 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -364,15 +364,10 @@ export async function handle( } const targets = resultPaths.map((rp) => normalizeGraphPath(rp)); - const targetSet = new Set(targets); const candidates = new Map(); const addCandidate = (file: string, hop: 1 | 2, line?: number): void => { - for (const t of targetSet) { - if (pathsMatch(t, file)) return; - } - const existing = candidates.get(file); if (existing) { if (existing.hop <= hop) return; From 18b1f011d75f931ed984dd59724beed83b6239d6 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 21:25:33 +0100 Subject: [PATCH 4/7] test(impact): stabilize 2-hop preflight assertion --- tests/impact-2hop.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/impact-2hop.test.ts b/tests/impact-2hop.test.ts index dcd131c..05d05d4 100644 --- a/tests/impact-2hop.test.ts +++ b/tests/impact-2hop.test.ts @@ -64,7 +64,7 @@ describe('Impact candidates (2-hop)', () => { const resp = await dispatchTool( 'search_codebase', - { query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false }, + { query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false, limit: 1 }, ctx ); @@ -72,8 +72,17 @@ describe('Impact candidates (2-hop)', () => { const parsed = JSON.parse(text) as { preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } } }; const details = parsed.preflight?.impact?.details ?? []; - expect(details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1)).toBe(true); - expect(details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2)).toBe(true); + const hasHop1 = details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1); + if (!hasHop1) { + throw new Error( + `Expected hop 1 candidate src/b.ts, got impact.details=${JSON.stringify(details)}` + ); + } + const hasHop2 = details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2); + if (!hasHop2) { + throw new Error( + `Expected hop 2 candidate src/a.ts, got impact.details=${JSON.stringify(details)}` + ); + } }); }); - From 305bb74201520b9fc3fe01f8c557d753b70e34b4 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 21:34:59 +0100 Subject: [PATCH 5/7] test(impact): assert relationships sidecar before impact --- tests/impact-2hop.test.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/impact-2hop.test.ts b/tests/impact-2hop.test.ts index 05d05d4..a0813d5 100644 --- a/tests/impact-2hop.test.ts +++ b/tests/impact-2hop.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import os from 'os'; import { CodebaseIndexer } from '../src/core/indexer.js'; import { dispatchTool } from '../src/tools/index.js'; import type { ToolContext } from '../src/tools/types.js'; @@ -10,14 +9,16 @@ import { INTELLIGENCE_FILENAME, KEYWORD_INDEX_FILENAME, VECTOR_DB_DIRNAME, - MEMORY_FILENAME + MEMORY_FILENAME, + RELATIONSHIPS_FILENAME } from '../src/constants/codebase-context.js'; describe('Impact candidates (2-hop)', () => { let tempRoot: string | null = null; beforeEach(async () => { - tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'impact-2hop-')); + // Keep test artifacts under CWD (mirrors other indexer tests and avoids OS tmp quirks) + tempRoot = await fs.mkdtemp(path.join(process.cwd(), '.tmp-impact-2hop-')); const srcDir = path.join(tempRoot, 'src'); await fs.mkdir(srcDir, { recursive: true }); await fs.writeFile(path.join(tempRoot, 'package.json'), JSON.stringify({ name: 'impact-2hop' })); @@ -62,6 +63,23 @@ describe('Impact candidates (2-hop)', () => { performIndexing: () => {} }; + const relationshipsPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, RELATIONSHIPS_FILENAME); + const relationshipsRaw = await fs.readFile(relationshipsPath, 'utf-8'); + const relationships = JSON.parse(relationshipsRaw) as { + graph?: { imports?: Record }; + }; + const imports = relationships.graph?.imports ?? {}; + const hasInternalEdge = + (imports['src/b.ts'] ?? []).some((d) => d.endsWith('src/c.ts') || d === 'src/c.ts') && + (imports['src/a.ts'] ?? []).some((d) => d.endsWith('src/b.ts') || d === 'src/b.ts'); + if (!hasInternalEdge) { + throw new Error( + `Expected relationships graph to include src/a.ts -> src/b.ts and src/b.ts -> src/c.ts, got imports keys=${JSON.stringify( + Object.keys(imports) + )}` + ); + } + const resp = await dispatchTool( 'search_codebase', { query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false, limit: 1 }, From 32ab51b898f59c72e483fa20945e39ac70b41fd5 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 22:12:12 +0100 Subject: [PATCH 6/7] test(impact): avoid fuse edge-case token --- tests/impact-2hop.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/impact-2hop.test.ts b/tests/impact-2hop.test.ts index a0813d5..40e22d9 100644 --- a/tests/impact-2hop.test.ts +++ b/tests/impact-2hop.test.ts @@ -15,6 +15,7 @@ import { describe('Impact candidates (2-hop)', () => { let tempRoot: string | null = null; + const token = 'UNIQUETOKEN123'; beforeEach(async () => { // Keep test artifacts under CWD (mirrors other indexer tests and avoids OS tmp quirks) @@ -25,7 +26,7 @@ describe('Impact candidates (2-hop)', () => { await fs.writeFile( path.join(srcDir, 'c.ts'), - `export function cFn() { return 'UNIQUE_TOKEN_123'; }\n` + `export function cFn() { return '${token}'; }\n` ); await fs.writeFile(path.join(srcDir, 'b.ts'), `import { cFn } from './c';\nexport const b = cFn();\n`); await fs.writeFile(path.join(srcDir, 'a.ts'), `import { b } from './b';\nexport const a = b;\n`); @@ -82,12 +83,24 @@ describe('Impact candidates (2-hop)', () => { const resp = await dispatchTool( 'search_codebase', - { query: 'UNIQUE_TOKEN_123', intent: 'edit', includeSnippets: false, limit: 1 }, + { query: token, intent: 'edit', includeSnippets: false, limit: 1 }, ctx ); const text = resp.content?.[0]?.text ?? ''; - const parsed = JSON.parse(text) as { preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } } }; + const parsed = JSON.parse(text) as { + status?: string; + results?: Array<{ file?: string }>; + preflight?: { impact?: { details?: Array<{ file: string; hop: 1 | 2 }> } }; + }; + const results = parsed.results ?? []; + if (!Array.isArray(results) || results.length === 0) { + throw new Error( + `Expected at least one search result for token, got status=${String(parsed.status)} results=${JSON.stringify( + results + )}` + ); + } const details = parsed.preflight?.impact?.details ?? []; const hasHop1 = details.some((d) => d.file.endsWith('src/b.ts') && d.hop === 1); From b899dcc281e436fa706a39de870e0bc6c8e3909a Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 22:41:23 +0100 Subject: [PATCH 7/7] fix(lancedb): keyword-only when table missing --- src/storage/lancedb.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/storage/lancedb.ts b/src/storage/lancedb.ts index 8d54f2c..de6f413 100644 --- a/src/storage/lancedb.ts +++ b/src/storage/lancedb.ts @@ -143,9 +143,12 @@ export class LanceDBStorageProvider implements VectorStorageProvider { ); } if (!this.table) { - throw new IndexCorruptedError( - 'LanceDB index corrupted: no table available for search (rebuild required)' - ); + // No semantic index was built (e.g. skipEmbedding) or it hasn't been created yet. + // Degrade gracefully to keyword-only search instead of forcing an auto-heal rebuild. + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error('[LanceDB] No table available for semantic search (keyword-only mode).'); + } + return []; } try {