Skip to content

Commit 31fa094

Browse files
committed
fix(search): wire SearchResult imports/exports and stabilize map hub selection
1 parent 2d08c89 commit 31fa094

6 files changed

Lines changed: 138 additions & 14 deletions

File tree

src/core/codebase-map.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,13 @@ function enrichLayers(
304304
graphExports: Record<string, Array<{ name: string; type: string }>>
305305
): CodebaseMapLayer[] {
306306
return layers.map((layer) => {
307-
let bestFile: string | undefined;
308-
let bestCount = 0;
309-
for (const [file, importers] of Object.entries(graphImportedBy)) {
310-
if (file.split('/')[0] !== layer.name) continue;
311-
if (importers.length > bestCount) {
312-
bestCount = importers.length;
313-
bestFile = file;
314-
}
315-
}
307+
const bestFile = sortByCountThenAlpha(
308+
Object.entries(graphImportedBy)
309+
.filter(([file]) => file.split('/')[0] === layer.name)
310+
.map(([file, importers]) => ({ file, count: importers.length })),
311+
(entry) => entry.count,
312+
(entry) => entry.file
313+
)[0]?.file;
316314
if (!bestFile) return layer;
317315
const exps = graphExports[bestFile];
318316
const hubExports = exps

src/core/search.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,8 @@ export class CodebaseSearcher {
699699
framework: chunk.framework,
700700
componentType: chunk.componentType,
701701
layer: chunk.layer,
702+
imports: chunk.imports,
703+
exports: chunk.exports,
702704
metadata: chunk.metadata,
703705
trend,
704706
patternWarning: warning
@@ -794,6 +796,8 @@ export class CodebaseSearcher {
794796
framework: bestTestChunk.chunk.framework,
795797
componentType: bestTestChunk.chunk.componentType,
796798
layer: bestTestChunk.chunk.layer,
799+
imports: bestTestChunk.chunk.imports,
800+
exports: bestTestChunk.chunk.exports,
797801
metadata: bestTestChunk.chunk.metadata,
798802
trend,
799803
patternWarning: warning

src/tools/search-codebase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,8 +1144,8 @@ export async function handle(
11441144
: undefined;
11451145
const scope = buildScopeHeader(r.metadata);
11461146
// Chunk-level imports/exports (top 5 each) + complexity
1147-
const chunkImports = (r as unknown as { imports?: string[] }).imports?.slice(0, 5);
1148-
const chunkExports = (r as unknown as { exports?: string[] }).exports?.slice(0, 5);
1147+
const chunkImports = r.imports?.slice(0, 5);
1148+
const chunkExports = r.exports?.slice(0, 5);
11491149

11501150
return {
11511151
file: `${r.filePath}:${r.startLine}-${r.endLine}`,

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ export interface SearchResult {
360360

361361
trend?: 'Rising' | 'Stable' | 'Declining';
362362
patternWarning?: string;
363+
imports?: string[];
364+
exports?: string[];
363365

364366
relationships?: RelationshipData;
365367

tests/codebase-map.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import { describe, it, expect } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import os from 'os';
24
import path from 'path';
35
import { fileURLToPath } from 'url';
46
import { createProjectState } from '../src/project-state.js';
57
import { buildCodebaseMap, renderMapMarkdown, renderMapPretty } from '../src/core/codebase-map.js';
68
import { generateCodebaseIntelligence } from '../src/resources/codebase-intelligence.js';
9+
import {
10+
CODEBASE_CONTEXT_DIRNAME,
11+
INTELLIGENCE_FILENAME,
12+
KEYWORD_INDEX_FILENAME,
13+
RELATIONSHIPS_FILENAME
14+
} from '../src/constants/codebase-context.js';
715

816
// Resolve fixture path relative to this test file — portable across CWD setups.
917
const __filename = fileURLToPath(import.meta.url);
@@ -200,6 +208,55 @@ describe('buildCodebaseMap', () => {
200208
// search.ts has no exports in fixture → hubExports should be absent
201209
expect(srcLayer.hubExports).toBeUndefined();
202210
});
211+
212+
it('breaks equal layer hub-file ties alphabetically', async () => {
213+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'map-layer-tie-break-'));
214+
215+
try {
216+
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
217+
await fs.mkdir(ctxDir, { recursive: true });
218+
219+
await fs.writeFile(
220+
path.join(ctxDir, INTELLIGENCE_FILENAME),
221+
JSON.stringify({}, null, 2),
222+
'utf-8'
223+
);
224+
await fs.writeFile(
225+
path.join(ctxDir, KEYWORD_INDEX_FILENAME),
226+
JSON.stringify({ chunks: [] }, null, 2),
227+
'utf-8'
228+
);
229+
await fs.writeFile(
230+
path.join(ctxDir, RELATIONSHIPS_FILENAME),
231+
JSON.stringify(
232+
{
233+
graph: {
234+
importedBy: {
235+
'src/a.ts': ['src/app.ts', 'src/root.ts'],
236+
'src/b.ts': ['src/app.ts', 'src/root.ts']
237+
},
238+
exports: {
239+
'src/a.ts': [{ name: 'alpha', type: 'function' }],
240+
'src/b.ts': [{ name: 'beta', type: 'function' }]
241+
}
242+
}
243+
},
244+
null,
245+
2
246+
),
247+
'utf-8'
248+
);
249+
250+
const project = createProjectState(tempRoot);
251+
const map = await buildCodebaseMap(project);
252+
const srcLayer = map.architecture.layers.find((layer) => layer.name === 'src');
253+
254+
expect(srcLayer?.hubFile).toBe('src/a.ts');
255+
expect(srcLayer?.hubExports).toEqual(['alpha']);
256+
} finally {
257+
await fs.rm(tempRoot, { recursive: true, force: true });
258+
}
259+
});
203260
});
204261

205262
// ---------------------------------------------------------------------------

tests/search-compact-mode.test.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ describe('search_codebase compact/full mode', () => {
359359
[key: string]: unknown;
360360
};
361361

362-
expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4));
362+
expect(payload.searchQuality.tokenEstimate).toBe(
363+
Math.ceil(response.content[0].text.length / 4)
364+
);
363365
expect(payload.searchQuality.warning).toBeUndefined();
364366
});
365367

@@ -396,7 +398,9 @@ describe('search_codebase compact/full mode', () => {
396398
};
397399
};
398400

399-
expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4));
401+
expect(payload.searchQuality.tokenEstimate).toBe(
402+
Math.ceil(response.content[0].text.length / 4)
403+
);
400404
expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000);
401405
expect(payload.searchQuality.warning).toBe(
402406
`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', () => {
439443
expect(Array.isArray(hints.callers)).toBe(true);
440444
});
441445

446+
it('full mode serializes chunk-level imports and exports', async () => {
447+
searchMocks.search.mockResolvedValueOnce([
448+
makeResult({
449+
imports: [
450+
'src/auth/token-store.ts',
451+
'src/auth/session.ts',
452+
'src/shared/logger.ts',
453+
'src/config/env.ts',
454+
'src/http/client.ts',
455+
'src/extra/ignored.ts'
456+
],
457+
exports: ['AuthService', 'createAuthService', 'AUTH_TOKEN', 'defaultIgnored']
458+
})
459+
]);
460+
461+
const { server } = await import('../src/index.js');
462+
const handler = (
463+
server as {
464+
_requestHandlers?: Map<
465+
string,
466+
(r: unknown) => Promise<{ content: Array<{ type: string; text: string }> }>
467+
>;
468+
}
469+
)._requestHandlers?.get('tools/call');
470+
if (!handler) throw new Error('Expected tools/call handler');
471+
472+
const response = await handler({
473+
jsonrpc: '2.0',
474+
id: 1,
475+
method: 'tools/call',
476+
params: {
477+
name: 'search_codebase',
478+
arguments: { query: 'auth service', mode: 'full' }
479+
}
480+
});
481+
482+
const payload = JSON.parse(response.content[0].text) as {
483+
budget: { mode: string };
484+
results: Array<{ imports?: string[]; exports?: string[] }>;
485+
};
486+
487+
expect(payload.budget.mode).toBe('full');
488+
expect(payload.results[0].imports).toEqual([
489+
'src/auth/token-store.ts',
490+
'src/auth/session.ts',
491+
'src/shared/logger.ts',
492+
'src/config/env.ts',
493+
'src/http/client.ts'
494+
]);
495+
expect(payload.results[0].exports).toEqual([
496+
'AuthService',
497+
'createAuthService',
498+
'AUTH_TOKEN',
499+
'defaultIgnored'
500+
]);
501+
});
502+
442503
it('adds a warning only when the final full payload exceeds the compact budget threshold', async () => {
443504
const oversizedSummary = 'Token-heavy summary '.repeat(1200);
444505
const oversizedSnippet = 'const token = authService.getToken();\n'.repeat(600);
@@ -482,7 +543,9 @@ describe('search_codebase compact/full mode', () => {
482543
[key: string]: unknown;
483544
};
484545

485-
expect(payload.searchQuality.tokenEstimate).toBe(Math.ceil(response.content[0].text.length / 4));
546+
expect(payload.searchQuality.tokenEstimate).toBe(
547+
Math.ceil(response.content[0].text.length / 4)
548+
);
486549
expect(payload.searchQuality.tokenEstimate).toBeGreaterThan(4000);
487550
expect(payload.searchQuality.warning).toBe(
488551
`Large search payload: estimated ${payload.searchQuality.tokenEstimate} tokens. Prefer compact mode or tighter filters before pasting into an agent.`

0 commit comments

Comments
 (0)