Skip to content

Commit 8650c0a

Browse files
authored
feat(v2.1): map structural skeleton + search metadata surface (#95)
* feat(v2.1): upgrade map to structural skeleton - Add Key Interfaces section: top types/interfaces/classes by import centrality with signature hints - Add API Surface section: top 5 named exports per entrypoint file - Add Dependency Hotspots section: top 5 files by combined import+importedBy count - Enrich Architecture Layers with hub file + top exports per layer - Add --export flag: writes CODEBASE_MAP.md to project root - Update fixture relationships.json with exports field - Full test coverage for new sections + graceful degradation + --export flag - Gitignore CODEBASE_MAP.md (generated) and repos/ (benchmark fixtures) All data derived from existing index.json + relationships.json — no new I/O. * feat(v2.1): surface structural metadata in search + fix reranker retry regression search-codebase: compact results now include symbol, symbolKind, scope, signaturePreview; full results include chunk imports/exports/complexity. Surfaces reranker health in searchQualityBlock when unavailable. reranker: add RerankerStatus type + getRerankerStatus() export. Add cache-corruption detection (Protobuf/parse errors trigger cache clear). Fix retry regression: replace initPromise=null reset with initFailed guard so failed loads fast-fail on subsequent calls instead of retrying the expensive model download — restoring test suite stability. * chore: fix prettier formatting in codebase-map and reranker * fix(reranker): narrow initFailed guard to corrupt-cache errors only Transient load failures (network, timeout, etc.) now reset initPromise=null so the next call can retry. Only Protobuf/parse/corrupt errors are marked permanently failed — those require a cache re-download in a new session. Long-lived MCP servers can now recover from transient load failures without requiring a restart. Addresses grey-area identified during PR #95 Greptile audit.
1 parent 3c91c12 commit 8650c0a

11 files changed

Lines changed: 574 additions & 33 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ docs/visuals.md
2929
.repolore/
3030
.opencode/
3131
.agents/
32+
CODEBASE_MAP.md
33+
repos/

src/cli-map.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function printMapUsage(): void {
3232
console.log('Output the conventions map for the current codebase.');
3333
console.log('');
3434
console.log('Options:');
35+
console.log(' --export Write CODEBASE_MAP.md to project root (overrides other flags)');
3536
console.log(' --json Output raw JSON (CodebaseMapSummary)');
3637
console.log(' --pretty Terminal-friendly box layout');
3738
console.log(' --help Show this help');
@@ -42,6 +43,7 @@ function printMapUsage(): void {
4243
export async function handleMapCli(args: string[]): Promise<void> {
4344
const useJson = args.includes('--json');
4445
const usePretty = args.includes('--pretty');
46+
const useExport = args.includes('--export');
4547
const showHelp = args.includes('--help') || args.includes('-h');
4648

4749
if (showHelp) {
@@ -77,6 +79,13 @@ export async function handleMapCli(args: string[]): Promise<void> {
7779
try {
7880
const map = await buildCodebaseMap(project);
7981

82+
if (useExport) {
83+
const outPath = path.join(rootPath, 'CODEBASE_MAP.md');
84+
await fs.writeFile(outPath, renderMapMarkdown(map), 'utf-8');
85+
console.log(`Wrote ${outPath}`);
86+
return;
87+
}
88+
8089
if (useJson) {
8190
console.log(JSON.stringify(map, null, 2));
8291
} else if (usePretty) {

src/core/codebase-map.ts

Lines changed: 223 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ import type {
1717
CodebaseMapPattern,
1818
CodebaseMapExample,
1919
CodebaseMapNextCall,
20+
CodebaseMapKeyInterface,
21+
CodebaseMapApiSurface,
22+
CodebaseMapHotspot,
2023
IntelligenceData,
21-
PatternsData
24+
PatternsData,
25+
CodeChunk
2226
} from '../types/index.js';
23-
import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
27+
import { RELATIONSHIPS_FILENAME, KEYWORD_INDEX_FILENAME } from '../constants/codebase-context.js';
2428

2529
// ---------------------------------------------------------------------------
2630
// Internal types for relationships.json
@@ -29,6 +33,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
2933
interface RelationshipsGraph {
3034
imports?: Record<string, string[]>;
3135
importedBy?: Record<string, string[]>;
36+
exports?: Record<string, Array<{ name: string; type: string }>>;
3237
stats?: {
3338
files?: number;
3439
edges?: number;
@@ -58,7 +63,7 @@ const ENTRYPOINT_EXCLUSION_RE =
5863

5964
/**
6065
* Build a `CodebaseMapSummary` from the project's index artifacts.
61-
* Reads `intelligence.json` and `relationships.json` from project paths.
66+
* Reads `intelligence.json`, `relationships.json`, and `index.json` from project paths.
6267
* Degrades gracefully when artifacts are missing.
6368
*/
6469
export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseMapSummary> {
@@ -83,9 +88,21 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM
8388
// Degrade gracefully
8489
}
8590

91+
// Read index.json (keyword index — contains CodeChunk[] with ChunkMetadata)
92+
const idxPath = path.join(path.dirname(project.paths.intelligence), KEYWORD_INDEX_FILENAME);
93+
let chunks: CodeChunk[] = [];
94+
try {
95+
const raw = await fs.readFile(idxPath, 'utf-8');
96+
const parsed = JSON.parse(raw) as { chunks?: unknown };
97+
if (parsed && Array.isArray(parsed.chunks)) chunks = parsed.chunks as CodeChunk[];
98+
} catch {
99+
// Degrade gracefully
100+
}
101+
86102
const graph = relationships.graph ?? {};
87103
const graphImports = graph.imports ?? {};
88104
const graphImportedBy = graph.importedBy ?? {};
105+
const graphExports = graph.exports ?? {};
89106
// relationships.json has stats at top level OR inside graph
90107
const statsSource =
91108
relationships.stats ??
@@ -105,11 +122,12 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM
105122
layerCounts.set(segment, (layerCounts.get(segment) ?? 0) + 1);
106123
}
107124
}
108-
const layers: CodebaseMapLayer[] = sortByCountThenAlpha(
125+
const rawLayers: CodebaseMapLayer[] = sortByCountThenAlpha(
109126
Array.from(layerCounts.entries()).map(([name, fileCount]) => ({ name, fileCount })),
110127
(l) => l.fileCount,
111128
(l) => l.name
112129
);
130+
const layers = enrichLayers(rawLayers, graphImportedBy, graphExports);
113131

114132
// --- Entrypoints ---
115133
const entrypoints: string[] = [];
@@ -135,6 +153,15 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM
135153
.slice(0, 5)
136154
.map((x) => x.file);
137155

156+
// --- Key interfaces ---
157+
const keyInterfaces = deriveKeyInterfaces(chunks, graphImportedBy);
158+
159+
// --- API surface ---
160+
const apiSurface = deriveApiSurface(entrypoints, graphExports);
161+
162+
// --- Dependency hotspots ---
163+
const hotspots = deriveHotspots(graphImports, graphImportedBy);
164+
138165
// --- Active patterns ---
139166
const patterns: PatternsData = intelligence.patterns ?? {};
140167
const activePatterns: CodebaseMapPattern[] = [];
@@ -183,14 +210,125 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM
183210

184211
return {
185212
project: projectName,
186-
architecture: { layers, entrypoints, hubFiles },
213+
architecture: { layers, entrypoints, hubFiles, keyInterfaces, apiSurface, hotspots },
187214
activePatterns,
188215
bestExamples,
189216
graphStats,
190217
suggestedNextCalls
191218
};
192219
}
193220

221+
// ---------------------------------------------------------------------------
222+
// Structural skeleton derivations
223+
// ---------------------------------------------------------------------------
224+
225+
const SYMBOL_KINDS = new Set(['interface', 'class', 'type', 'enum']);
226+
227+
function buildSignatureHint(content: string): string {
228+
const lines = content
229+
.split('\n')
230+
.map((l) => l.trim())
231+
.filter(Boolean);
232+
const hint = lines.slice(0, 3).join('\n');
233+
const truncated = hint.length > 200 ? hint.slice(0, 197) + '...' : hint;
234+
return truncated.replace(/\s*\{$/, '').trim();
235+
}
236+
237+
function deriveKeyInterfaces(
238+
chunks: CodeChunk[],
239+
graphImportedBy: Record<string, string[]>
240+
): CodebaseMapKeyInterface[] {
241+
const symbolChunks = chunks.filter(
242+
(c) => c.metadata?.symbolAware === true && SYMBOL_KINDS.has(c.metadata.symbolKind ?? '')
243+
);
244+
const scored = symbolChunks.map((c) => ({
245+
chunk: c,
246+
importerCount: graphImportedBy[c.relativePath]?.length ?? 0
247+
}));
248+
scored.sort((a, b) => {
249+
if (b.importerCount !== a.importerCount) return b.importerCount - a.importerCount;
250+
const lenDiff = a.chunk.content.length - b.chunk.content.length;
251+
if (lenDiff !== 0) return lenDiff;
252+
return a.chunk.relativePath.localeCompare(b.chunk.relativePath);
253+
});
254+
return scored.slice(0, 10).map(({ chunk, importerCount }) => ({
255+
name: chunk.metadata.symbolName ?? path.basename(chunk.relativePath),
256+
kind: chunk.metadata.symbolKind ?? 'unknown',
257+
file: chunk.relativePath,
258+
importerCount,
259+
signatureHint: buildSignatureHint(chunk.content)
260+
}));
261+
}
262+
263+
function deriveApiSurface(
264+
entrypoints: string[],
265+
graphExports: Record<string, Array<{ name: string; type: string }>>
266+
): CodebaseMapApiSurface[] {
267+
const results: CodebaseMapApiSurface[] = [];
268+
for (const ep of entrypoints) {
269+
const exps = graphExports[ep];
270+
if (!exps || exps.length === 0) continue;
271+
const names = exps
272+
.map((e) => e.name)
273+
.filter((n) => n && n !== 'default')
274+
.slice(0, 5);
275+
if (names.length === 0) continue;
276+
results.push({ file: ep, exports: names });
277+
}
278+
return results;
279+
}
280+
281+
function deriveHotspots(
282+
graphImports: Record<string, string[]>,
283+
graphImportedBy: Record<string, string[]>
284+
): CodebaseMapHotspot[] {
285+
const allFiles = new Set([...Object.keys(graphImports), ...Object.keys(graphImportedBy)]);
286+
const hotspots: CodebaseMapHotspot[] = [];
287+
for (const file of allFiles) {
288+
const importerCount = graphImportedBy[file]?.length ?? 0;
289+
const importCount = graphImports[file]?.length ?? 0;
290+
const combined = importerCount + importCount;
291+
if (combined === 0) continue;
292+
hotspots.push({ file, importerCount, importCount, combined });
293+
}
294+
hotspots.sort((a, b) => {
295+
if (b.combined !== a.combined) return b.combined - a.combined;
296+
return a.file.localeCompare(b.file);
297+
});
298+
return hotspots.slice(0, 5);
299+
}
300+
301+
function enrichLayers(
302+
layers: CodebaseMapLayer[],
303+
graphImportedBy: Record<string, string[]>,
304+
graphExports: Record<string, Array<{ name: string; type: string }>>
305+
): CodebaseMapLayer[] {
306+
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+
}
316+
if (!bestFile) return layer;
317+
const exps = graphExports[bestFile];
318+
const hubExports = exps
319+
? exps
320+
.map((e) => e.name)
321+
.filter((n) => n && n !== 'default')
322+
.slice(0, 3)
323+
: [];
324+
return {
325+
...layer,
326+
hubFile: bestFile,
327+
...(hubExports.length > 0 ? { hubExports } : {})
328+
};
329+
});
330+
}
331+
194332
// ---------------------------------------------------------------------------
195333
// Suggested next calls
196334
// ---------------------------------------------------------------------------
@@ -253,16 +391,22 @@ export function renderMapMarkdown(map: CodebaseMapSummary): string {
253391
lines.push(`# Codebase Map — ${map.project}`);
254392
lines.push('');
255393

256-
// Architecture
394+
// Architecture layers
257395
lines.push('## Architecture Layers');
258396
lines.push('');
259397
if (map.architecture.layers.length === 0) {
260398
lines.push('_No index data available._');
261399
} else {
262400
for (const layer of map.architecture.layers) {
263-
lines.push(
264-
`- **${layer.name}** (${layer.fileCount} file${layer.fileCount === 1 ? '' : 's'})`
265-
);
401+
let line = `- **${layer.name}** (${layer.fileCount} file${layer.fileCount === 1 ? '' : 's'})`;
402+
if (layer.hubFile) {
403+
const exStr =
404+
layer.hubExports && layer.hubExports.length > 0
405+
? ` → ${layer.hubExports.join(', ')}`
406+
: '';
407+
line += ` — hub: \`${layer.hubFile}\`${exStr}`;
408+
}
409+
lines.push(line);
266410
}
267411
}
268412
lines.push('');
@@ -291,6 +435,51 @@ export function renderMapMarkdown(map: CodebaseMapSummary): string {
291435
}
292436
lines.push('');
293437

438+
// Key Interfaces
439+
lines.push('## Key Interfaces');
440+
lines.push('');
441+
if (map.architecture.keyInterfaces.length === 0) {
442+
lines.push('_None detected._');
443+
} else {
444+
for (const ki of map.architecture.keyInterfaces) {
445+
lines.push(
446+
`- **${ki.name}** \`${ki.kind}\` — \`${ki.file}\` (imported by ${ki.importerCount})`
447+
);
448+
if (ki.signatureHint) {
449+
lines.push(' ```');
450+
lines.push(` ${ki.signatureHint.split('\n').join('\n ')}`);
451+
lines.push(' ```');
452+
}
453+
}
454+
}
455+
lines.push('');
456+
457+
// API Surface
458+
lines.push('## API Surface');
459+
lines.push('');
460+
if (map.architecture.apiSurface.length === 0) {
461+
lines.push('_None detected._');
462+
} else {
463+
for (const s of map.architecture.apiSurface) {
464+
lines.push(`- \`${s.file}\` — exports: ${s.exports.join(', ')}`);
465+
}
466+
}
467+
lines.push('');
468+
469+
// Dependency Hotspots
470+
lines.push('## Dependency Hotspots');
471+
lines.push('');
472+
if (map.architecture.hotspots.length === 0) {
473+
lines.push('_None detected._');
474+
} else {
475+
for (const h of map.architecture.hotspots) {
476+
lines.push(
477+
`- \`${h.file}\` — imported by ${h.importerCount}, imports ${h.importCount} (combined: ${h.combined})`
478+
);
479+
}
480+
}
481+
lines.push('');
482+
294483
// Patterns
295484
lines.push('## Active Patterns');
296485
lines.push('');
@@ -376,7 +565,11 @@ export function renderMapPretty(map: CodebaseMapSummary): string {
376565
const layerLines =
377566
map.architecture.layers.length === 0
378567
? ['(none)']
379-
: map.architecture.layers.map((l) => `${l.name} ${l.fileCount} files`);
568+
: map.architecture.layers.map((l) =>
569+
l.hubFile
570+
? `${l.name} ${l.fileCount} files [${l.hubFile}]`
571+
: `${l.name} ${l.fileCount} files`
572+
);
380573
sections.push(box('Architecture Layers', layerLines));
381574

382575
const epLines =
@@ -387,6 +580,26 @@ export function renderMapPretty(map: CodebaseMapSummary): string {
387580
map.architecture.hubFiles.length === 0 ? ['(none detected)'] : map.architecture.hubFiles;
388581
sections.push(box('Hub Files', hubLines));
389582

583+
const kiLines =
584+
map.architecture.keyInterfaces.length === 0
585+
? ['(none detected)']
586+
: map.architecture.keyInterfaces.map(
587+
(ki) => `${ki.name} ${ki.kind} ${ki.file}${ki.importerCount})`
588+
);
589+
sections.push(box('Key Interfaces', kiLines));
590+
591+
const apiLines =
592+
map.architecture.apiSurface.length === 0
593+
? ['(none detected)']
594+
: map.architecture.apiSurface.map((s) => `${s.file}: ${s.exports.join(', ')}`);
595+
sections.push(box('API Surface', apiLines));
596+
597+
const hotspotLines =
598+
map.architecture.hotspots.length === 0
599+
? ['(none detected)']
600+
: map.architecture.hotspots.map((h) => `${h.file} +${h.importerCount}/-${h.importCount}`);
601+
sections.push(box('Dependency Hotspots', hotspotLines));
602+
390603
const patternLines =
391604
map.activePatterns.length === 0
392605
? ['(no patterns)']

0 commit comments

Comments
 (0)