Skip to content
Merged
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
16 changes: 7 additions & 9 deletions src/core/codebase-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,13 @@ function enrichLayers(
graphExports: Record<string, Array<{ name: string; type: string }>>
): 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
Expand Down
4 changes: 4 additions & 0 deletions src/core/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/tools/search-codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export interface SearchResultItem {
callers?: string[];
tests?: string[];
};
imports?: string[];
exports?: string[];
snippet?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ export interface SearchResult {

trend?: 'Rising' | 'Stable' | 'Declining';
patternWarning?: string;
imports?: string[];
exports?: string[];

relationships?: RelationshipData;

Expand Down
57 changes: 57 additions & 0 deletions tests/codebase-map.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 });
}
});
});

// ---------------------------------------------------------------------------
Expand Down
129 changes: 126 additions & 3 deletions tests/search-compact-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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.`
Expand Down Expand Up @@ -439,6 +443,123 @@ 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('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<typeof import('../src/core/search.js')>(
'../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);
Expand Down Expand Up @@ -482,7 +603,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.`
Expand Down
Loading