Skip to content

Commit 00ed205

Browse files
feat: add --json to search, glob --file, --exclude to prune, exclude worktrees from vitest
Four CLI improvements from dogfooding feedback: - search command: add --json flag for machine-readable output (single + multi-query) - search --file: support glob patterns (e.g. src/*.js) alongside existing substring matching - registry prune: add --exclude flag to protect specific repos from pruning - vitest.config.js: exclude .claude/** to prevent worktree test files from interfering Impact: 6 functions changed, 10 affected
1 parent 6eef6b3 commit 00ed205

6 files changed

Lines changed: 151 additions & 3 deletions

File tree

src/cli.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,15 @@ registry
395395
.command('prune')
396396
.description('Remove stale registry entries (missing directories or idle beyond TTL)')
397397
.option('--ttl <days>', 'Days of inactivity before pruning (default: 30)', '30')
398+
.option('--exclude <names>', 'Comma-separated repo names to preserve from pruning')
398399
.action((opts) => {
399-
const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10));
400+
const excludeNames = opts.exclude
401+
? opts.exclude
402+
.split(',')
403+
.map((s) => s.trim())
404+
.filter((s) => s.length > 0)
405+
: [];
406+
const pruned = pruneRegistry(undefined, parseInt(opts.ttl, 10), excludeNames);
400407
if (pruned.length === 0) {
401408
console.log('No stale entries found.');
402409
} else {
@@ -464,6 +471,7 @@ program
464471
.option('-k, --kind <kind>', 'Filter by kind: function, method, class')
465472
.option('--file <pattern>', 'Filter by file path pattern')
466473
.option('--rrf-k <number>', 'RRF k parameter for multi-query ranking', '60')
474+
.option('-j, --json', 'Output as JSON')
467475
.action(async (query, opts) => {
468476
await search(query, opts.db, {
469477
limit: parseInt(opts.limit, 10),
@@ -473,6 +481,7 @@ program
473481
kind: opts.kind,
474482
filePattern: opts.file,
475483
rrfK: parseInt(opts.rrfK, 10),
484+
json: opts.json,
476485
});
477486
});
478487

src/embedder.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ function splitIdentifier(name) {
1616
.trim();
1717
}
1818

19+
/**
20+
* Match a file path against a glob pattern.
21+
* Supports *, **, and ? wildcards. Zero dependencies.
22+
*/
23+
function globMatch(filePath, pattern) {
24+
// Normalize separators to forward slashes
25+
const normalized = filePath.replace(/\\/g, '/');
26+
// Escape regex specials except glob chars
27+
let regex = pattern.replace(/\\/g, '/').replace(/[.+^${}()|[\]]/g, '\\$&');
28+
// Replace ** first (matches any path segment), then * and ?
29+
regex = regex.replace(/\*\*/g, '\0');
30+
regex = regex.replace(/\*/g, '[^/]*');
31+
regex = regex.replace(/\0/g, '.*');
32+
regex = regex.replace(/\?/g, '[^/]');
33+
return new RegExp(`^${regex}$`).test(normalized);
34+
}
35+
1936
// Lazy-load transformers (heavy, optional module)
2037
let pipeline = null;
2138
let _cos_sim = null;
@@ -496,7 +513,8 @@ function _prepareSearch(customDbPath, opts = {}) {
496513
conditions.push('n.kind = ?');
497514
params.push(opts.kind);
498515
}
499-
if (opts.filePattern) {
516+
const isGlob = opts.filePattern && /[*?{[\]]/.test(opts.filePattern);
517+
if (opts.filePattern && !isGlob) {
500518
conditions.push('n.file LIKE ?');
501519
params.push(`%${opts.filePattern}%`);
502520
}
@@ -505,6 +523,9 @@ function _prepareSearch(customDbPath, opts = {}) {
505523
}
506524

507525
let rows = db.prepare(sql).all(...params);
526+
if (isGlob) {
527+
rows = rows.filter((row) => globMatch(row.file, opts.filePattern));
528+
}
508529
if (noTests) {
509530
rows = rows.filter((row) => !TEST_PATTERN.test(row.file));
510531
}
@@ -668,6 +689,11 @@ export async function search(query, customDbPath, opts = {}) {
668689
const data = await searchData(singleQuery, customDbPath, opts);
669690
if (!data) return;
670691

692+
if (opts.json) {
693+
console.log(JSON.stringify(data, null, 2));
694+
return;
695+
}
696+
671697
console.log(`\nSemantic search: "${singleQuery}"\n`);
672698

673699
if (data.results.length === 0) {
@@ -687,6 +713,11 @@ export async function search(query, customDbPath, opts = {}) {
687713
const data = await multiSearchData(queries, customDbPath, opts);
688714
if (!data) return;
689715

716+
if (opts.json) {
717+
console.log(JSON.stringify(data, null, 2));
718+
return;
719+
}
720+
690721
console.log(`\nMulti-query semantic search (RRF, k=${opts.rrfK || 60}):`);
691722
queries.forEach((q, i) => {
692723
console.log(` [${i + 1}] "${q}"`);

src/registry.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,18 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
136136
* or that haven't been accessed within `ttlDays` days.
137137
* Returns an array of `{ name, path, reason }` for each pruned entry.
138138
*/
139-
export function pruneRegistry(registryPath = REGISTRY_PATH, ttlDays = DEFAULT_TTL_DAYS) {
139+
export function pruneRegistry(
140+
registryPath = REGISTRY_PATH,
141+
ttlDays = DEFAULT_TTL_DAYS,
142+
excludeNames = [],
143+
) {
140144
const registry = loadRegistry(registryPath);
141145
const pruned = [];
142146
const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
147+
const excludeSet = new Set(excludeNames);
143148

144149
for (const [name, entry] of Object.entries(registry.repos)) {
150+
if (excludeSet.has(name)) continue;
145151
if (!fs.existsSync(entry.path)) {
146152
pruned.push({ name, path: entry.path, reason: 'missing' });
147153
delete registry.repos[name];

tests/search/embedder-search.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ describe('multiSearchData', () => {
220220
});
221221
});
222222

223+
describe('searchData file pattern', () => {
224+
test('glob src/*.js matches only direct children of src/', async () => {
225+
const data = await searchData('auth', dbPath, { minScore: 0.01, filePattern: 'src/*.js' });
226+
expect(data).not.toBeNull();
227+
for (const r of data.results) {
228+
expect(r.file).toMatch(/^src\/[^/]+\.js$/);
229+
}
230+
});
231+
232+
test('plain substring auth still works (backward compat)', async () => {
233+
const data = await searchData('auth', dbPath, { minScore: 0.01, filePattern: 'auth' });
234+
expect(data).not.toBeNull();
235+
for (const r of data.results) {
236+
expect(r.file).toContain('auth');
237+
}
238+
});
239+
});
240+
223241
describe('search (CLI wrapper)', () => {
224242
/** Capture console.log calls and return joined output. */
225243
function captureLog(fn) {
@@ -253,4 +271,22 @@ describe('search (CLI wrapper)', () => {
253271
expect(out).toContain('Semantic search: "auth"');
254272
expect(out).not.toContain('Multi-query');
255273
});
274+
275+
test('single query with json: true outputs valid JSON with results array', async () => {
276+
const out = await captureLog(() => search('auth', dbPath, { minScore: 0.2, json: true }));
277+
const parsed = JSON.parse(out);
278+
expect(parsed.results).toBeInstanceOf(Array);
279+
expect(parsed.results.length).toBeGreaterThan(0);
280+
expect(parsed.results[0]).toHaveProperty('similarity');
281+
expect(parsed.results[0]).toHaveProperty('name');
282+
});
283+
284+
test('multi query with json: true outputs valid JSON with rrf and queryScores', async () => {
285+
const out = await captureLog(() => search('auth ; jwt', dbPath, { minScore: 0.2, json: true }));
286+
const parsed = JSON.parse(out);
287+
expect(parsed.results).toBeInstanceOf(Array);
288+
expect(parsed.results.length).toBeGreaterThan(0);
289+
expect(parsed.results[0]).toHaveProperty('rrf');
290+
expect(parsed.results[0]).toHaveProperty('queryScores');
291+
});
256292
});

tests/unit/registry.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,71 @@ describe('pruneRegistry', () => {
454454
const pruned = pruneRegistry(registryPath);
455455
expect(pruned).toEqual([]);
456456
});
457+
458+
it('excluded entry survives missing-dir prune', () => {
459+
const dir1 = path.join(tmpDir, 'keep');
460+
const dir2 = path.join(tmpDir, 'gone-excluded');
461+
fs.mkdirSync(dir1, { recursive: true });
462+
fs.mkdirSync(dir2, { recursive: true });
463+
464+
registerRepo(dir1, 'keep', registryPath);
465+
registerRepo(dir2, 'gone-excluded', registryPath);
466+
467+
// Remove the directory
468+
fs.rmSync(dir2, { recursive: true, force: true });
469+
470+
const pruned = pruneRegistry(registryPath, 30, ['gone-excluded']);
471+
expect(pruned).toHaveLength(0);
472+
473+
const reg = loadRegistry(registryPath);
474+
expect(reg.repos['gone-excluded']).toBeDefined();
475+
});
476+
477+
it('excluded entry survives TTL prune', () => {
478+
const dir = path.join(tmpDir, 'protected');
479+
fs.mkdirSync(dir, { recursive: true });
480+
481+
const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
482+
const registry = {
483+
repos: {
484+
protected: {
485+
path: dir,
486+
dbPath: path.join(dir, '.codegraph', 'graph.db'),
487+
addedAt: oldDate,
488+
lastAccessedAt: oldDate,
489+
},
490+
},
491+
};
492+
saveRegistry(registry, registryPath);
493+
494+
const pruned = pruneRegistry(registryPath, 30, ['protected']);
495+
expect(pruned).toHaveLength(0);
496+
497+
const reg = loadRegistry(registryPath);
498+
expect(reg.repos.protected).toBeDefined();
499+
});
500+
501+
it('empty exclude array prunes normally (backward compat)', () => {
502+
const dir = path.join(tmpDir, 'stale');
503+
fs.mkdirSync(dir, { recursive: true });
504+
505+
const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
506+
const registry = {
507+
repos: {
508+
stale: {
509+
path: dir,
510+
dbPath: path.join(dir, '.codegraph', 'graph.db'),
511+
addedAt: oldDate,
512+
lastAccessedAt: oldDate,
513+
},
514+
},
515+
};
516+
saveRegistry(registry, registryPath);
517+
518+
const pruned = pruneRegistry(registryPath, 30, []);
519+
expect(pruned).toHaveLength(1);
520+
expect(pruned[0].name).toBe('stale');
521+
});
457522
});
458523

459524
// ─── DEFAULT_TTL_DAYS ──────────────────────────────────────────────

vitest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export default defineConfig({
44
test: {
55
globals: true,
66
testTimeout: 30000,
7+
exclude: ['**/node_modules/**', '**/.git/**', '.claude/**'],
78
},
89
});

0 commit comments

Comments
 (0)