From 862b9bc0c78530c35228b49a815a624e23bc77fb Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Tue, 14 Apr 2026 15:54:01 +0200 Subject: [PATCH 1/2] fix(search): wire SearchResult imports/exports and stabilize map hub selection --- src/core/codebase-map.ts | 16 ++++--- src/core/search.ts | 4 ++ src/tools/search-codebase.ts | 4 +- src/types/index.ts | 2 + tests/codebase-map.test.ts | 57 +++++++++++++++++++++++++ tests/search-compact-mode.test.ts | 69 +++++++++++++++++++++++++++++-- 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/core/codebase-map.ts b/src/core/codebase-map.ts index 5b69f7a..90a8487 100644 --- a/src/core/codebase-map.ts +++ b/src/core/codebase-map.ts @@ -304,15 +304,13 @@ function enrichLayers( graphExports: Record> ): CodebaseMapLayer[] { return layers.map((layer) => { - let bestFile: string | undefined; - let bestCount = 0; - for (const [file, importers] of Object.entries(graphImportedBy)) { - if (file.split('/')[0] !== layer.name) continue; - if (importers.length > bestCount) { - bestCount = importers.length; - bestFile = file; - } - } + const bestFile = sortByCountThenAlpha( + Object.entries(graphImportedBy) + .filter(([file]) => file.split('/')[0] === layer.name) + .map(([file, importers]) => ({ file, count: importers.length })), + (entry) => entry.count, + (entry) => entry.file + )[0]?.file; if (!bestFile) return layer; const exps = graphExports[bestFile]; const hubExports = exps diff --git a/src/core/search.ts b/src/core/search.ts index 7280456..4a9be1b 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -699,6 +699,8 @@ export class CodebaseSearcher { framework: chunk.framework, componentType: chunk.componentType, layer: chunk.layer, + imports: chunk.imports, + exports: chunk.exports, metadata: chunk.metadata, trend, patternWarning: warning @@ -794,6 +796,8 @@ export class CodebaseSearcher { framework: bestTestChunk.chunk.framework, componentType: bestTestChunk.chunk.componentType, layer: bestTestChunk.chunk.layer, + imports: bestTestChunk.chunk.imports, + exports: bestTestChunk.chunk.exports, metadata: bestTestChunk.chunk.metadata, trend, patternWarning: warning diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index 84e8711..7128e45 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -1144,8 +1144,8 @@ export async function handle( : undefined; const scope = buildScopeHeader(r.metadata); // Chunk-level imports/exports (top 5 each) + complexity - const chunkImports = (r as unknown as { imports?: string[] }).imports?.slice(0, 5); - const chunkExports = (r as unknown as { exports?: string[] }).exports?.slice(0, 5); + const chunkImports = r.imports?.slice(0, 5); + const chunkExports = r.exports?.slice(0, 5); return { file: `${r.filePath}:${r.startLine}-${r.endLine}`, diff --git a/src/types/index.ts b/src/types/index.ts index 7ca5865..0576996 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -360,6 +360,8 @@ export interface SearchResult { trend?: 'Rising' | 'Stable' | 'Declining'; patternWarning?: string; + imports?: string[]; + exports?: string[]; relationships?: RelationshipData; diff --git a/tests/codebase-map.test.ts b/tests/codebase-map.test.ts index debc20d..6e1465d 100644 --- a/tests/codebase-map.test.ts +++ b/tests/codebase-map.test.ts @@ -1,9 +1,17 @@ import { describe, it, expect } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { createProjectState } from '../src/project-state.js'; import { buildCodebaseMap, renderMapMarkdown, renderMapPretty } from '../src/core/codebase-map.js'; import { generateCodebaseIntelligence } from '../src/resources/codebase-intelligence.js'; +import { + CODEBASE_CONTEXT_DIRNAME, + INTELLIGENCE_FILENAME, + KEYWORD_INDEX_FILENAME, + RELATIONSHIPS_FILENAME +} from '../src/constants/codebase-context.js'; // Resolve fixture path relative to this test file — portable across CWD setups. const __filename = fileURLToPath(import.meta.url); @@ -200,6 +208,55 @@ describe('buildCodebaseMap', () => { // search.ts has no exports in fixture → hubExports should be absent expect(srcLayer.hubExports).toBeUndefined(); }); + + it('breaks equal layer hub-file ties alphabetically', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'map-layer-tie-break-')); + + try { + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + await fs.mkdir(ctxDir, { recursive: true }); + + await fs.writeFile( + path.join(ctxDir, INTELLIGENCE_FILENAME), + JSON.stringify({}, null, 2), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify({ chunks: [] }, null, 2), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, RELATIONSHIPS_FILENAME), + JSON.stringify( + { + graph: { + importedBy: { + 'src/a.ts': ['src/app.ts', 'src/root.ts'], + 'src/b.ts': ['src/app.ts', 'src/root.ts'] + }, + exports: { + 'src/a.ts': [{ name: 'alpha', type: 'function' }], + 'src/b.ts': [{ name: 'beta', type: 'function' }] + } + } + }, + null, + 2 + ), + 'utf-8' + ); + + const project = createProjectState(tempRoot); + const map = await buildCodebaseMap(project); + const srcLayer = map.architecture.layers.find((layer) => layer.name === 'src'); + + expect(srcLayer?.hubFile).toBe('src/a.ts'); + expect(srcLayer?.hubExports).toEqual(['alpha']); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); }); // --------------------------------------------------------------------------- diff --git a/tests/search-compact-mode.test.ts b/tests/search-compact-mode.test.ts index 104fe42..8160f5e 100644 --- a/tests/search-compact-mode.test.ts +++ b/tests/search-compact-mode.test.ts @@ -359,7 +359,9 @@ describe('search_codebase compact/full mode', () => { [key: string]: unknown; }; - expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4)); + expect(payload.searchQuality.tokenEstimate).toBe( + Math.ceil(response.content[0].text.length / 4) + ); expect(payload.searchQuality.warning).toBeUndefined(); }); @@ -396,7 +398,9 @@ describe('search_codebase compact/full mode', () => { }; }; - expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4)); + expect(payload.searchQuality.tokenEstimate).toBe( + Math.ceil(response.content[0].text.length / 4) + ); expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000); expect(payload.searchQuality.warning).toBe( `Large search payload: estimated ${payload.searchQuality.tokenEstimate} tokens. Try tighter filters (e.g. layer=, language=) to reduce payload size.` @@ -439,6 +443,63 @@ describe('search_codebase compact/full mode', () => { expect(Array.isArray(hints.callers)).toBe(true); }); + it('full mode serializes chunk-level imports and exports', async () => { + searchMocks.search.mockResolvedValueOnce([ + makeResult({ + imports: [ + 'src/auth/token-store.ts', + 'src/auth/session.ts', + 'src/shared/logger.ts', + 'src/config/env.ts', + 'src/http/client.ts', + 'src/extra/ignored.ts' + ], + exports: ['AuthService', 'createAuthService', 'AUTH_TOKEN', 'defaultIgnored'] + }) + ]); + + const { server } = await import('../src/index.js'); + const handler = ( + server as { + _requestHandlers?: Map< + string, + (r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }> + >; + } + )._requestHandlers?.get('tools/call'); + if (!handler) throw new Error('Expected tools/call handler'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'auth service', mode: 'full' } + } + }); + + const payload = JSON.parse(response.content[0].text) as { + budget: { mode: string }; + results: Array<{ imports?: string[]; exports?: string[] }>; + }; + + expect(payload.budget.mode).toBe('full'); + expect(payload.results[0].imports).toEqual([ + 'src/auth/token-store.ts', + 'src/auth/session.ts', + 'src/shared/logger.ts', + 'src/config/env.ts', + 'src/http/client.ts' + ]); + expect(payload.results[0].exports).toEqual([ + 'AuthService', + 'createAuthService', + 'AUTH_TOKEN', + 'defaultIgnored' + ]); + }); + it('adds a warning only when the final full payload exceeds the compact budget threshold', async () => { const oversizedSummary = 'Token-heavy summary '.repeat(1200); const oversizedSnippet = 'const token = authService.getToken();\n'.repeat(600); @@ -482,7 +543,9 @@ describe('search_codebase compact/full mode', () => { [key: string]: unknown; }; - expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4)); + expect(payload.searchQuality.tokenEstimate).toBe( + Math.ceil(response.content[0].text.length / 4) + ); expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000); expect(payload.searchQuality.warning).toBe( `Large search payload: estimated ${payload.searchQuality.tokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.` From 5ba2b900c454b16ab608c91b261e20d9d7dbbc0a Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Tue, 14 Apr 2026 16:34:42 +0200 Subject: [PATCH 2/2] fix(search): align typed metadata with full-mode payload --- src/tools/types.ts | 2 ++ tests/search-compact-mode.test.ts | 60 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/tools/types.ts b/src/tools/types.ts index 00fa6c0..9b3eeae 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -84,6 +84,8 @@ export interface SearchResultItem { callers?: string[]; tests?: string[]; }; + imports?: string[]; + exports?: string[]; snippet?: string; } diff --git a/tests/search-compact-mode.test.ts b/tests/search-compact-mode.test.ts index 8160f5e..65ea866 100644 --- a/tests/search-compact-mode.test.ts +++ b/tests/search-compact-mode.test.ts @@ -500,6 +500,66 @@ describe('search_codebase compact/full mode', () => { ]); }); + it('real CodebaseSearcher preserves chunk imports and exports', async () => { + if (!tempRoot) throw new Error('tempRoot not initialized'); + + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + const actualChunk = { + id: 'auth-chunk', + content: + 'import { tokenStore } from "./token-store";\nexport class AuthService {\n getToken() { return tokenStore.read(); }\n}\nexport const AUTH_TOKEN = "auth";', + filePath: path.join(tempRoot, 'src', 'auth', 'auth.service.ts'), + relativePath: 'src/auth/auth.service.ts', + startLine: 1, + endLine: 5, + language: 'ts', + dependencies: [], + imports: [ + 'src/auth/token-store.ts', + 'src/auth/session.ts', + 'src/shared/logger.ts', + 'src/config/env.ts', + 'src/http/client.ts' + ], + exports: ['AuthService', 'AUTH_TOKEN'], + tags: ['service'], + metadata: { + className: 'AuthService', + symbolAware: true, + symbolName: 'AuthService', + symbolKind: 'class' + } + }; + + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify( + { + header: { buildId: 'test-build-compact', formatVersion: INDEX_FORMAT_VERSION }, + chunks: [actualChunk] + }, + null, + 2 + ), + 'utf-8' + ); + + const actualSearchModule = await vi.importActual( + '../src/core/search.js' + ); + const searcher = new actualSearchModule.CodebaseSearcher(tempRoot); + const results = await searcher.search('AuthService token', 5, undefined, { + useSemanticSearch: false, + useKeywordSearch: true, + enableReranker: false + }); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(actualChunk.filePath); + expect(results[0].imports).toEqual(actualChunk.imports); + expect(results[0].exports).toEqual(actualChunk.exports); + }); + it('adds a warning only when the final full payload exceeds the compact budget threshold', async () => { const oversizedSummary = 'Token-heavy summary '.repeat(1200); const oversizedSnippet = 'const token = authService.getToken();\n'.repeat(600);