Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 36 additions & 0 deletions src/core/auto-refresh.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
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
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
Copy Markdown
Contributor

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

Check failure on line 14 in tests/auto-refresh-controller.test.ts

View workflow job for this annotation

GitHub Actions / Functional Tests

tests/auto-refresh-controller.test.ts > AutoRefreshController > queues when indexing and runs after ready

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ tests/auto-refresh-controller.test.ts:14:54
Copy link
Copy Markdown

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

79 changes: 79 additions & 0 deletions tests/impact-2hop.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 75 in tests/impact-2hop.test.ts

View workflow job for this annotation

GitHub Actions / Functional Tests

tests/impact-2hop.test.ts > Impact candidates (2-hop) > includes hop 1 and hop 2 candidates in preflight impact.details

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ tests/impact-2hop.test.ts:75:77
expect(details.some((d) => d.file.endsWith('src/a.ts') && d.hop === 2)).toBe(true);
});
});

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