diff --git a/src/core/auto-refresh.ts b/src/core/auto-refresh.ts new file mode 100644 index 0000000..0f49144 --- /dev/null +++ b/src/core/auto-refresh.ts @@ -0,0 +1,40 @@ +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) => { + if (indexStatus === 'indexing') { + // Defensive: if called while indexing, do not clear the queue. + return false; + } + 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/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 { 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; 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..40e22d9 --- /dev/null +++ b/tests/impact-2hop.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +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, + RELATIONSHIPS_FILENAME +} from '../src/constants/codebase-context.js'; + +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) + 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' })); + + await fs.writeFile( + path.join(srcDir, 'c.ts'), + `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`); + + 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 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: token, intent: 'edit', includeSnippets: false, limit: 1 }, + ctx + ); + + const text = resp.content?.[0]?.text ?? ''; + 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); + 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)}` + ); + } + }); +}); 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();