diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 67b34387a..e76144525 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -207,6 +207,7 @@ program .option('-g, --goal ', 'What you want to find') .option('-l, --limit ', 'Max processes to return (default: 5)') .option('--content', 'Include full symbol source code') + .option('--include-tests', 'Include test files in results (default: false, matching impact)') .action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand')); program diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 0e70c3970..5aefc8967 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -66,6 +66,7 @@ export async function queryCommand( goal?: string; limit?: string; content?: boolean; + includeTests?: boolean; }, ): Promise { if (!queryText?.trim()) { @@ -80,6 +81,7 @@ export async function queryCommand( goal: options?.goal, limit: options?.limit ? parseInt(options.limit) : undefined, include_content: options?.content ?? false, + includeTests: options?.includeTests ?? false, repo: options?.repo, }); output(result); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cf9ab483b..20105ba70 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -924,6 +924,7 @@ export class LocalBackend { limit?: number; max_symbols?: number; include_content?: boolean; + includeTests?: boolean; }, ): Promise { if (!params.query?.trim()) { @@ -935,6 +936,7 @@ export class LocalBackend { const processLimit = params.limit || 5; const maxSymbolsPerProcess = params.max_symbols || 10; const includeContent = params.include_content ?? false; + const includeTests = params.includeTests ?? false; const searchQuery = params.query.trim(); // Per-phase timing instrumentation (#553). Records wall time for each @@ -989,8 +991,13 @@ export class LocalBackend { } } + // Exclude test files by default so production paths outrank tests. Mirrors + // the include_tests opt-in convention already used by impact. Filter at the + // merge step so excluded items never incur the per-symbol STEP_IN_PROCESS + // round-trip. const merged = Array.from(scoreMap.entries()) .sort((a, b) => b[1].score - a[1].score) + .filter(([, item]) => includeTests || !isTestFilePath(item.data.filePath || '')) .slice(0, searchLimit); timer.stop(); // merge diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 15b7dc7d4..efee529b1 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -121,6 +121,11 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W description: 'Include full symbol source code (default: false)', default: false, }, + includeTests: { + type: 'boolean', + description: 'Include test files in results (default: false, matching impact)', + default: false, + }, repo: { type: 'string', description: diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 9596d6b7a..4017ceb54 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -357,6 +357,80 @@ describe('LocalBackend.callTool', () => { expect(result.error).toContain('query parameter is required'); }); + it('query excludes test-file results by default (matches impact precedent)', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + (searchFTSFromLbug as any).mockResolvedValue({ + results: [ + { filePath: 'src/auth.ts', score: 1.0 }, + { filePath: 'src/auth.test.ts', score: 1.0 }, + { filePath: 'src/__tests__/login.ts', score: 1.0 }, + ], + ftsAvailable: true, + }); + (executeParameterized as any).mockImplementation( + (_repoId: string, cypher: string, params: any) => { + if (cypher.includes('n.filePath = $filePath')) { + return Promise.resolve([ + { + id: `${params.filePath}#sym`, + name: 'foo', + type: 'Function', + filePath: params.filePath, + startLine: 1, + endLine: 10, + }, + ]); + } + return Promise.resolve([]); + }, + ); + + const result = await backend.callTool('query', { query: 'auth' }); + const allPaths = [ + ...(result.process_symbols ?? []).map((s: any) => s.filePath), + ...(result.definitions ?? []).map((d: any) => d.filePath), + ]; + expect(allPaths).toContain('src/auth.ts'); + expect(allPaths.some((p: string) => p.endsWith('.test.ts'))).toBe(false); + expect(allPaths.some((p: string) => p.includes('__tests__'))).toBe(false); + }); + + it('query includes test-file results when includeTests is true', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + (searchFTSFromLbug as any).mockResolvedValue({ + results: [ + { filePath: 'src/auth.ts', score: 1.0 }, + { filePath: 'src/auth.test.ts', score: 1.0 }, + ], + ftsAvailable: true, + }); + (executeParameterized as any).mockImplementation( + (_repoId: string, cypher: string, params: any) => { + if (cypher.includes('n.filePath = $filePath')) { + return Promise.resolve([ + { + id: `${params.filePath}#sym`, + name: 'foo', + type: 'Function', + filePath: params.filePath, + startLine: 1, + endLine: 10, + }, + ]); + } + return Promise.resolve([]); + }, + ); + + const result = await backend.callTool('query', { query: 'auth', includeTests: true }); + const allPaths = [ + ...(result.process_symbols ?? []).map((s: any) => s.filePath), + ...(result.definitions ?? []).map((d: any) => d.filePath), + ]; + expect(allPaths).toContain('src/auth.ts'); + expect(allPaths.some((p: string) => p.endsWith('.test.ts'))).toBe(true); + }); + it('dispatches cypher tool and blocks write queries', async () => { (executeParameterized as any).mockRejectedValueOnce(new Error('read-only database')); const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' });