Skip to content
Merged
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
40 changes: 40 additions & 0 deletions src/core/auto-refresh.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
13 changes: 12 additions & 1 deletion src/core/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 5 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -252,7 +253,7 @@ const indexState: IndexState = {
status: 'idle'
};

let autoRefreshQueued = false;
const autoRefresh = createAutoRefreshController();

const server: Server = new Server(
{
Expand Down Expand Up @@ -573,8 +574,7 @@ async function performIndexing(incrementalOnly?: boolean): Promise<void> {
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) {
Expand Down Expand Up @@ -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');
}
Expand Down
9 changes: 6 additions & 3 deletions src/storage/lancedb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 0 additions & 5 deletions src/tools/search-codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,15 +364,10 @@ export async function handle(
}

const targets = resultPaths.map((rp) => normalizeGraphPath(rp));
const targetSet = new Set(targets);

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

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;
Expand Down
31 changes: 31 additions & 0 deletions tests/auto-refresh-controller.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calling consumeQueuedRefresh clears the queue regardless of status, so line 14 will return false (not true as expected). Remove this line - you should only call consumeQueuedRefresh once after indexing completes.

Suggested change
expect(controller.consumeQueuedRefresh('indexing')).toBe(false);
expect(controller.consumeQueuedRefresh('ready')).toBe(true);

expect(controller.consumeQueuedRefresh('ready')).toBe(true);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove unreachable ready expectation after queue consumption

This new test case is internally inconsistent and will fail every run: createAutoRefreshController.consumeQueuedRefresh clears the queued flag on every call (see src/core/auto-refresh.ts), so after calling it with 'indexing' on the previous line, the follow-up expectation that 'ready' returns true cannot be satisfied. In CI environments where tests execute, this blocks the commit despite no product-code path changing this behavior.

Useful? React with 👍 / 👎.

});

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

119 changes: 119 additions & 0 deletions tests/impact-2hop.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> };
};
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)}`
);
}
});
});
28 changes: 28 additions & 0 deletions tests/internal-file-graph-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

22 changes: 22 additions & 0 deletions tests/relationship-sidecar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, { line?: number; importedSymbols?: string[] }>>
| 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();
Expand Down
Loading