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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ docs/visuals.md
.repolore/
.opencode/
.agents/
CODEBASE_MAP.md
repos/
9 changes: 9 additions & 0 deletions src/cli-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function printMapUsage(): void {
console.log('Output the conventions map for the current codebase.');
console.log('');
console.log('Options:');
console.log(' --export Write CODEBASE_MAP.md to project root (overrides other flags)');
console.log(' --json Output raw JSON (CodebaseMapSummary)');
console.log(' --pretty Terminal-friendly box layout');
console.log(' --help Show this help');
Expand All @@ -42,6 +43,7 @@ function printMapUsage(): void {
export async function handleMapCli(args: string[]): Promise<void> {
const useJson = args.includes('--json');
const usePretty = args.includes('--pretty');
const useExport = args.includes('--export');
const showHelp = args.includes('--help') || args.includes('-h');

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

if (useExport) {
const outPath = path.join(rootPath, 'CODEBASE_MAP.md');
await fs.writeFile(outPath, renderMapMarkdown(map), 'utf-8');
console.log(`Wrote ${outPath}`);
return;
}

if (useJson) {
console.log(JSON.stringify(map, null, 2));
} else if (usePretty) {
Expand Down
233 changes: 223 additions & 10 deletions src/core/codebase-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ import type {
CodebaseMapPattern,
CodebaseMapExample,
CodebaseMapNextCall,
CodebaseMapKeyInterface,
CodebaseMapApiSurface,
CodebaseMapHotspot,
IntelligenceData,
PatternsData
PatternsData,
CodeChunk
} from '../types/index.js';
import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
import { RELATIONSHIPS_FILENAME, KEYWORD_INDEX_FILENAME } from '../constants/codebase-context.js';

// ---------------------------------------------------------------------------
// Internal types for relationships.json
Expand All @@ -29,6 +33,7 @@ import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js';
interface RelationshipsGraph {
imports?: Record<string, string[]>;
importedBy?: Record<string, string[]>;
exports?: Record<string, Array<{ name: string; type: string }>>;
stats?: {
files?: number;
edges?: number;
Expand Down Expand Up @@ -58,7 +63,7 @@ const ENTRYPOINT_EXCLUSION_RE =

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

// Read index.json (keyword index — contains CodeChunk[] with ChunkMetadata)
const idxPath = path.join(path.dirname(project.paths.intelligence), KEYWORD_INDEX_FILENAME);
let chunks: CodeChunk[] = [];
try {
const raw = await fs.readFile(idxPath, 'utf-8');
const parsed = JSON.parse(raw) as { chunks?: unknown };
if (parsed && Array.isArray(parsed.chunks)) chunks = parsed.chunks as CodeChunk[];
} catch {
// Degrade gracefully
}

const graph = relationships.graph ?? {};
const graphImports = graph.imports ?? {};
const graphImportedBy = graph.importedBy ?? {};
const graphExports = graph.exports ?? {};
// relationships.json has stats at top level OR inside graph
const statsSource =
relationships.stats ??
Expand All @@ -105,11 +122,12 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM
layerCounts.set(segment, (layerCounts.get(segment) ?? 0) + 1);
}
}
const layers: CodebaseMapLayer[] = sortByCountThenAlpha(
const rawLayers: CodebaseMapLayer[] = sortByCountThenAlpha(
Array.from(layerCounts.entries()).map(([name, fileCount]) => ({ name, fileCount })),
(l) => l.fileCount,
(l) => l.name
);
const layers = enrichLayers(rawLayers, graphImportedBy, graphExports);

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

// --- Key interfaces ---
const keyInterfaces = deriveKeyInterfaces(chunks, graphImportedBy);

// --- API surface ---
const apiSurface = deriveApiSurface(entrypoints, graphExports);

// --- Dependency hotspots ---
const hotspots = deriveHotspots(graphImports, graphImportedBy);

// --- Active patterns ---
const patterns: PatternsData = intelligence.patterns ?? {};
const activePatterns: CodebaseMapPattern[] = [];
Expand Down Expand Up @@ -183,14 +210,125 @@ export async function buildCodebaseMap(project: ProjectState): Promise<CodebaseM

return {
project: projectName,
architecture: { layers, entrypoints, hubFiles },
architecture: { layers, entrypoints, hubFiles, keyInterfaces, apiSurface, hotspots },
activePatterns,
bestExamples,
graphStats,
suggestedNextCalls
};
}

// ---------------------------------------------------------------------------
// Structural skeleton derivations
// ---------------------------------------------------------------------------

const SYMBOL_KINDS = new Set(['interface', 'class', 'type', 'enum']);

function buildSignatureHint(content: string): string {
const lines = content
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
const hint = lines.slice(0, 3).join('\n');
const truncated = hint.length > 200 ? hint.slice(0, 197) + '...' : hint;
return truncated.replace(/\s*\{$/, '').trim();
}

function deriveKeyInterfaces(
chunks: CodeChunk[],
graphImportedBy: Record<string, string[]>
): CodebaseMapKeyInterface[] {
const symbolChunks = chunks.filter(
(c) => c.metadata?.symbolAware === true && SYMBOL_KINDS.has(c.metadata.symbolKind ?? '')
);
const scored = symbolChunks.map((c) => ({
chunk: c,
importerCount: graphImportedBy[c.relativePath]?.length ?? 0
}));
scored.sort((a, b) => {
if (b.importerCount !== a.importerCount) return b.importerCount - a.importerCount;
const lenDiff = a.chunk.content.length - b.chunk.content.length;
if (lenDiff !== 0) return lenDiff;
return a.chunk.relativePath.localeCompare(b.chunk.relativePath);
});
return scored.slice(0, 10).map(({ chunk, importerCount }) => ({
name: chunk.metadata.symbolName ?? path.basename(chunk.relativePath),
kind: chunk.metadata.symbolKind ?? 'unknown',
file: chunk.relativePath,
importerCount,
signatureHint: buildSignatureHint(chunk.content)
}));
}

function deriveApiSurface(
entrypoints: string[],
graphExports: Record<string, Array<{ name: string; type: string }>>
): CodebaseMapApiSurface[] {
const results: CodebaseMapApiSurface[] = [];
for (const ep of entrypoints) {
const exps = graphExports[ep];
if (!exps || exps.length === 0) continue;
const names = exps
.map((e) => e.name)
.filter((n) => n && n !== 'default')
.slice(0, 5);
if (names.length === 0) continue;
results.push({ file: ep, exports: names });
}
return results;
}

function deriveHotspots(
graphImports: Record<string, string[]>,
graphImportedBy: Record<string, string[]>
): CodebaseMapHotspot[] {
const allFiles = new Set([...Object.keys(graphImports), ...Object.keys(graphImportedBy)]);
const hotspots: CodebaseMapHotspot[] = [];
for (const file of allFiles) {
const importerCount = graphImportedBy[file]?.length ?? 0;
const importCount = graphImports[file]?.length ?? 0;
const combined = importerCount + importCount;
if (combined === 0) continue;
hotspots.push({ file, importerCount, importCount, combined });
}
hotspots.sort((a, b) => {
if (b.combined !== a.combined) return b.combined - a.combined;
return a.file.localeCompare(b.file);
});
return hotspots.slice(0, 5);
}

function enrichLayers(
layers: CodebaseMapLayer[],
graphImportedBy: Record<string, string[]>,
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;
}
}
if (!bestFile) return layer;
const exps = graphExports[bestFile];
const hubExports = exps
? exps
.map((e) => e.name)
.filter((n) => n && n !== 'default')
.slice(0, 3)
: [];
return {
...layer,
hubFile: bestFile,
...(hubExports.length > 0 ? { hubExports } : {})
};
});
}
Comment on lines +306 to +330
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-deterministic hub-file tie-breaking in enrichLayers

When two files in the same layer share the same importerCount, the current > bestCount comparison preserves whichever candidate happens to be visited last (JS object key iteration order). A secondary alphabetical comparison would make the output deterministic across environments and rebuild cycles.

Suggested change
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;
}
}
if (!bestFile) return layer;
const exps = graphExports[bestFile];
const hubExports = exps
? exps
.map((e) => e.name)
.filter((n) => n && n !== 'default')
.slice(0, 3)
: [];
return {
...layer,
hubFile: bestFile,
...(hubExports.length > 0 ? { hubExports } : {})
};
});
}
if (importers.length > bestCount ||
(importers.length === bestCount && bestFile !== undefined && file < bestFile)) {
bestCount = importers.length;
bestFile = file;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. When two files in a layer share equal importerCount, JS object-iteration order determines the winner, which varies across JSON serializers and rebuild cycles. Bundling the alphabetic tie-break fix into Phase 15.5 alongside the P1 SearchResult shape work so both land with consistent test coverage.


// ---------------------------------------------------------------------------
// Suggested next calls
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -253,16 +391,22 @@ export function renderMapMarkdown(map: CodebaseMapSummary): string {
lines.push(`# Codebase Map — ${map.project}`);
lines.push('');

// Architecture
// Architecture layers
lines.push('## Architecture Layers');
lines.push('');
if (map.architecture.layers.length === 0) {
lines.push('_No index data available._');
} else {
for (const layer of map.architecture.layers) {
lines.push(
`- **${layer.name}** (${layer.fileCount} file${layer.fileCount === 1 ? '' : 's'})`
);
let line = `- **${layer.name}** (${layer.fileCount} file${layer.fileCount === 1 ? '' : 's'})`;
if (layer.hubFile) {
const exStr =
layer.hubExports && layer.hubExports.length > 0
? ` → ${layer.hubExports.join(', ')}`
: '';
line += ` — hub: \`${layer.hubFile}\`${exStr}`;
}
lines.push(line);
}
}
lines.push('');
Expand Down Expand Up @@ -291,6 +435,51 @@ export function renderMapMarkdown(map: CodebaseMapSummary): string {
}
lines.push('');

// Key Interfaces
lines.push('## Key Interfaces');
lines.push('');
if (map.architecture.keyInterfaces.length === 0) {
lines.push('_None detected._');
} else {
for (const ki of map.architecture.keyInterfaces) {
lines.push(
`- **${ki.name}** \`${ki.kind}\` — \`${ki.file}\` (imported by ${ki.importerCount})`
);
if (ki.signatureHint) {
lines.push(' ```');
lines.push(` ${ki.signatureHint.split('\n').join('\n ')}`);
lines.push(' ```');
}
}
}
lines.push('');

// API Surface
lines.push('## API Surface');
lines.push('');
if (map.architecture.apiSurface.length === 0) {
lines.push('_None detected._');
} else {
for (const s of map.architecture.apiSurface) {
lines.push(`- \`${s.file}\` — exports: ${s.exports.join(', ')}`);
}
}
lines.push('');

// Dependency Hotspots
lines.push('## Dependency Hotspots');
lines.push('');
if (map.architecture.hotspots.length === 0) {
lines.push('_None detected._');
} else {
for (const h of map.architecture.hotspots) {
lines.push(
`- \`${h.file}\` — imported by ${h.importerCount}, imports ${h.importCount} (combined: ${h.combined})`
);
}
}
lines.push('');

// Patterns
lines.push('## Active Patterns');
lines.push('');
Expand Down Expand Up @@ -376,7 +565,11 @@ export function renderMapPretty(map: CodebaseMapSummary): string {
const layerLines =
map.architecture.layers.length === 0
? ['(none)']
: map.architecture.layers.map((l) => `${l.name} ${l.fileCount} files`);
: map.architecture.layers.map((l) =>
l.hubFile
? `${l.name} ${l.fileCount} files [${l.hubFile}]`
: `${l.name} ${l.fileCount} files`
);
sections.push(box('Architecture Layers', layerLines));

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

const kiLines =
map.architecture.keyInterfaces.length === 0
? ['(none detected)']
: map.architecture.keyInterfaces.map(
(ki) => `${ki.name} ${ki.kind} ${ki.file} (×${ki.importerCount})`
);
sections.push(box('Key Interfaces', kiLines));

const apiLines =
map.architecture.apiSurface.length === 0
? ['(none detected)']
: map.architecture.apiSurface.map((s) => `${s.file}: ${s.exports.join(', ')}`);
sections.push(box('API Surface', apiLines));

const hotspotLines =
map.architecture.hotspots.length === 0
? ['(none detected)']
: map.architecture.hotspots.map((h) => `${h.file} +${h.importerCount}/-${h.importCount}`);
sections.push(box('Dependency Hotspots', hotspotLines));

const patternLines =
map.activePatterns.length === 0
? ['(no patterns)']
Expand Down
Loading
Loading