Skip to content
Closed
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
4 changes: 2 additions & 2 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ coverage:
status:
project:
default:
target: 70%
target: 85%
threshold: 2%
patch:
default:
target: 70%
target: 85%
threshold: 2%
166 changes: 166 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,4 +1085,170 @@ describe('cli', () => {
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(0));
});
});

// ── Analyze subcommand ──────────────────────────────────────────────────────

describe('analyze subcommand', () => {
/** Create a minimal DB with files/symbols/symbol_refs for analysis. */
async function seedAnalysisDb(dbPath: string): Promise<void> {
const { openDb } = await import('../../src/indexer/db.js');
const db = openDb(dbPath);
db.exec(`
INSERT INTO files (path, language, branch, size_bytes) VALUES ('a.ts', 'typescript', 'HEAD', 100);
INSERT INTO files (path, language, branch, size_bytes) VALUES ('b.ts', 'typescript', 'HEAD', 200);
INSERT INTO symbols (file_id, name, kind, start_line, end_line, signature) VALUES (1, 'foo', 'function', 0, 5, 'function foo()');
INSERT INTO symbols (file_id, name, kind, start_line, end_line, signature) VALUES (2, 'bar', 'function', 0, 10, 'function bar()');
INSERT INTO symbol_refs (caller_id, file_id, callee_id, callee_name, call_line, call_kind) VALUES (1, 1, 2, 'bar', 3, 'direct');
`);
db.close();
}

it('should print an error and exit when --db is missing', async () => {
await loadCli(['analyze']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should print an error for invalid --mode', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
await loadCli(['analyze', '--db', dbPath, '--mode', 'invalid']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should print an error for invalid --edge-kinds', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
await loadCli(['analyze', '--db', dbPath, '--edge-kinds', 'invalid']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should print an error for invalid --max-lines', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
await loadCli(['analyze', '--db', dbPath, '--max-lines', '-5']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should run cycles mode successfully', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath, '--mode', 'cycles']);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});

it('should run components mode successfully', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath, '--mode', 'components']);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});

it('should run clusters mode successfully', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath, '--mode', 'clusters']);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});

it('should default to summary mode', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath]);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});

it('should accept --max-lines for summary mode', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath, '--mode', 'summary', '--max-lines', '100']);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});

it('should accept --edge-kinds and --branch flags', async () => {
const dbPath = freshDb();
await seedAnalysisDb(dbPath);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await loadCli(['analyze', '--db', dbPath, '--mode', 'cycles', '--edge-kinds', 'call', '--branch', 'HEAD']);
await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalled(), { timeout: 5000 });
consoleSpy.mockRestore();
});
});

// ── Index subcommand — additional branches ──────────────────────────────────

describe('index subcommand — history-depth validation', () => {
it('should print an error for invalid --history-depth', async () => {
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--history-depth', '-5']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should print an error for non-numeric --history-depth', async () => {
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--history-depth', 'abc']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should accept a valid --history-depth', async () => {
let capturedOptions: unknown;
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--history-depth', '50'], () => {
mockIndexBuilderWithOptionsCapture((opts) => { capturedOptions = opts; });
});
await vi.waitFor(() => expect(capturedOptions).toBeDefined());
});

it('should accept --history-all flag', async () => {
let capturedOptions: unknown;
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--history-all'], () => {
mockIndexBuilderWithOptionsCapture((opts) => { capturedOptions = opts; });
});
await vi.waitFor(() => expect(capturedOptions).toBeDefined());
});
});

describe('index subcommand — language filter', () => {
it('should reject an unknown language name', async () => {
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--language', 'brainfuck']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should accept a known language name', async () => {
let capturedOptions: unknown;
await loadCli(['index', '--root', tmpDir, '--db', freshDb(), '--language', 'typescript'], () => {
mockIndexBuilderWithOptionsCapture((opts) => { capturedOptions = opts; });
});
await vi.waitFor(() => expect(capturedOptions).toBeDefined());
});
});

// ── Refresh subcommand — history-depth validation ───────────────────────────

describe('refresh subcommand — history-depth validation', () => {
it('should print an error for invalid --history-depth in refresh', async () => {
await loadCli(['refresh', '--db', freshDb(), '--root', tmpDir, '--history-depth', '-1']);
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('should print an error for non-numeric --history-depth in refresh', async () => {
await loadCli(['refresh', '--db', freshDb(), '--root', tmpDir, '--history-depth', 'abc']);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

// ── Unknown subcommand ──────────────────────────────────────────────────────

describe('unknown subcommand', () => {
it('should print an error for an unrecognized subcommand', async () => {
await loadCli(['foobar']);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
24 changes: 24 additions & 0 deletions tests/indexer/complexity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,28 @@ describe('computeSymbolMetrics', () => {
expect(metrics.cyclomatic).toBe(1);
expect(metrics.max_nesting).toBe(0);
});

it('hits parameter fallback walk when parameters field is absent', () => {
// Create a symbol with an AST node that lacks a 'parameters' field
// We simulate this by using a class-level arrow expression that the extractor
// captures as a symbol with astNode.childForFieldName('parameters') === null
const source = [
'class Svc {',
' handler = (x: number, y: string) => {',
' if (x > 0) return y;',
' return "";',
' };',
'}',
].join('\n');

const tree = new ParserPool().parse('typescript', source);
expect(tree).not.toBeNull();
const extracted = new TypeScriptExtractor().extract(tree!, source, '/tmp/svc.ts');
// Find the arrow function symbol
const arrowSym = extracted.symbols.find(s => s.name === 'handler');
if (arrowSym?.astNode) {
const metrics = computeSymbolMetrics(arrowSym, 'typescript');
expect(metrics.param_count).toBeGreaterThanOrEqual(0);
}
});
});
46 changes: 46 additions & 0 deletions tests/indexer/config-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,50 @@ describe('parseConfigFile', () => {
it('throws for malformed TOML input', () => {
expect(() => parseConfigFile('broken.toml', 'invalid line')).toThrow(/Invalid TOML config/u);
});

it('parses JSON config with array-like values', () => {
const json = JSON.stringify({
hosts: ['foo', 'bar', 'baz'],
emptyArr: [],
});
const result = parseConfigFile('config.json', json);
expect(result.length).toBeGreaterThan(0);
const hostsEntry = result.find(e => e.key === 'hosts');
expect(hostsEntry).toBeDefined();
expect(hostsEntry!.inferredType).toBe('array');
const emptyArr = result.find(e => e.key === 'emptyArr');
expect(emptyArr).toBeDefined();
expect(emptyArr!.inferredType).toBe('array');
});

it('parses .env with unquoted plain text values', () => {
const result = parseConfigFile('.env', 'HOST=localhost\nMODE=production\n');
expect(result.length).toBe(2);
const host = result.find(e => e.key === 'HOST');
expect(host!.value).toBe('localhost');
expect(host!.inferredType).toBe('string');
});

it('parses JSON config with nested objects as values', () => {
const json = JSON.stringify({
database: {
host: 'localhost',
port: 5432,
options: { ssl: true, timeout: 30 },
},
});
const result = parseConfigFile('config.json', json);
expect(result.length).toBeGreaterThan(0);
// The nested 'options' object should produce entries
const optionsEntry = result.find(e => e.key.includes('options'));
expect(optionsEntry).toBeDefined();
});

it('parses YAML config with boolean and null values', () => {
const yaml = 'debug: true\nverbose: false\ncache: null\nname: myapp\n';
const result = parseConfigFile('config.yaml', yaml);
expect(result.length).toBeGreaterThan(0);
const debug = result.find(e => e.key === 'debug');
expect(debug!.inferredType).toBe('boolean');
});
});
19 changes: 19 additions & 0 deletions tests/indexer/embedder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,23 @@ describe('LazyEmbeddingProvider', () => {
const provider = new LazyEmbeddingProvider('some-model');
await expect(provider.dispose()).resolves.toBeUndefined();
});

it('should throw from dims before init', () => {
const provider = new LazyEmbeddingProvider('some-model');
expect(() => provider.dims).toThrow();
});

it('should handle dispose after failed init gracefully', async () => {
const provider = new LazyEmbeddingProvider('nonexistent-model-that-will-fail');
// Trigger init (it will fail because the model doesn't exist)
const initPromise = provider.init().catch(() => {});
await initPromise;
// dispose should not throw even after failed init
await expect(provider.dispose()).resolves.toBeUndefined();
});

it('should accept dtype parameter', () => {
const provider = new LazyEmbeddingProvider('some-model', 'q8');
expect(provider.modelName).toBe('some-model');
});
});
109 changes: 109 additions & 0 deletions tests/indexer/graph-analysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,112 @@ describe('resolution method filtering', () => {
expect(new Set(sccsAll[0])).toEqual(new Set([a, b, c]));
});
});

// ─── Branch option filtering ──────────────────────────────────────────────────

describe('branch option filtering', () => {
it('should filter edges by branch in detectSymbolCycles', () => {
const db = createDb();
// Insert files for two branches
const f1 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('a.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const f2 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('b.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const a = insertSymbol(db, f1, 'funcA');
const b = insertSymbol(db, f2, 'funcB');

// Cycle: a → b → a
insertResolvedCallRef(db, a, b, f1);
insertResolvedCallRef(db, b, a, f2);

// Filter by 'main' — should find the cycle
const sccsMain = detectSymbolCycles(db, { branch: 'main' });
expect(sccsMain).toHaveLength(1);

// Filter by 'other' — files don't match, so no edges → no cycles
const sccsOther = detectSymbolCycles(db, { branch: 'other' });
expect(sccsOther).toEqual([]);
});

it('should filter file components by branch', () => {
const db = createDb();
const f1 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('a.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const f2 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('b.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const f3 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('c.ts', 'feat', 'typescript', 0)")
.run().lastInsertRowid,
);

insertFileImport(db, f1, f2);
insertFileImport(db, f3, f1); // cross-branch import

const mainComponents = findConnectedComponents(db, { scope: 'file', branch: 'main' });
// Should only see f1 and f2 connected
expect(mainComponents.length).toBeGreaterThanOrEqual(1);
// f3 should not be in any main-branch component
const allIds = mainComponents.flat();
expect(allIds).not.toContain(f3);
});

it('should filter summary by branch', () => {
const db = createDb();
const f1 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('a.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const f2 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('b.ts', 'develop', 'typescript', 0)")
.run().lastInsertRowid,
);
const a = insertSymbol(db, f1, 'funcA');
const b = insertSymbol(db, f2, 'funcB');

insertResolvedCallRef(db, a, b, f1);

const summary = buildCodebaseSummary(db, { branch: 'main' });
// The branch filter should limit files/symbols/edges
expect(summary.totalFiles).toBe(1);
});

it('should return empty for empty methods array', () => {
const db = createDb();
const f = insertFile(db, 'src/a.ts');
const a = insertSymbol(db, f, 'a');
const b = insertSymbol(db, f, 'b');

insertResolvedCallRef(db, a, b, f);
insertResolvedCallRef(db, b, a, f);

// Empty methods array → no edges → no cycles
const sccs = detectSymbolCycles(db, { methods: [] });
expect(sccs).toEqual([]);
});

it('should filter clusters by branch', () => {
const db = createDb();
const f1 = Number(
db.prepare("INSERT INTO files (path, branch, language, size_bytes) VALUES ('a.ts', 'main', 'typescript', 0)")
.run().lastInsertRowid,
);
const a = insertSymbol(db, f1, 'funcA', 'function', 1, 50);
const b = insertSymbol(db, f1, 'funcB', 'function', 51, 100);

insertResolvedCallRef(db, a, b, f1);

const clusters = clusterSymbols(db, { branch: 'main' });
expect(clusters.length).toBeGreaterThanOrEqual(1);

const clustersOther = clusterSymbols(db, { branch: 'nonexistent' });
expect(clustersOther).toEqual([]);
});
});
Loading
Loading