From 5e4a00d51faa165130e45bb303c8b343461f35b5 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 17 Apr 2026 16:38:03 +0200 Subject: [PATCH] feat(health): surface file risk in search --- src/constants/codebase-context.ts | 1 + src/core/index-meta.ts | 60 +++++++++ src/core/indexer.ts | 20 ++- src/health/derive.ts | 207 ++++++++++++++++++++++++++++++ src/health/store.ts | 118 +++++++++++++++++ src/index.ts | 3 +- src/project-state.ts | 2 + src/tools/get-codebase-health.ts | 109 ++++++++++++++++ src/tools/index.ts | 7 +- src/tools/search-codebase.ts | 56 ++++++++ src/tools/types.ts | 9 ++ src/types/index.ts | 33 +++++ tests/get-codebase-health.test.ts | 125 ++++++++++++++++++ tests/search-health-scope.test.ts | 151 ++++++++++++++++++++++ tests/tools/dispatch.test.ts | 9 +- 15 files changed, 904 insertions(+), 6 deletions(-) create mode 100644 src/health/derive.ts create mode 100644 src/health/store.ts create mode 100644 src/tools/get-codebase-health.ts create mode 100644 tests/get-codebase-health.test.ts create mode 100644 tests/search-health-scope.test.ts diff --git a/src/constants/codebase-context.ts b/src/constants/codebase-context.ts index 3f57bfa..473748f 100644 --- a/src/constants/codebase-context.ts +++ b/src/constants/codebase-context.ts @@ -20,6 +20,7 @@ export const INDEX_META_FILENAME = 'index-meta.json' as const; export const MEMORY_FILENAME = 'memory.json' as const; export const INTELLIGENCE_FILENAME = 'intelligence.json' as const; +export const HEALTH_FILENAME = 'health.json' as const; export const KEYWORD_INDEX_FILENAME = 'index.json' as const; export const INDEXING_STATS_FILENAME = 'indexing-stats.json' as const; export const VECTOR_DB_DIRNAME = 'index' as const; diff --git a/src/core/index-meta.ts b/src/core/index-meta.ts index 8353dba..4994235 100644 --- a/src/core/index-meta.ts +++ b/src/core/index-meta.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { CODEBASE_CONTEXT_DIRNAME, + HEALTH_FILENAME, INDEX_FORMAT_VERSION, INDEX_META_FILENAME, INDEX_META_VERSION, @@ -41,6 +42,30 @@ const RelationshipsFileSchema = z }) .passthrough(); +const HealthFileSchema = z.object({ + header: ArtifactHeaderSchema, + generatedAt: z.string().datetime(), + summary: z + .object({ + files: z.number().int().nonnegative(), + highRiskFiles: z.number().int().nonnegative(), + mediumRiskFiles: z.number().int().nonnegative(), + lowRiskFiles: z.number().int().nonnegative() + }) + .passthrough(), + files: z.array( + z + .object({ + file: z.string().min(1), + level: z.enum(['low', 'medium', 'high']), + score: z.number().nonnegative(), + reasons: z.array(z.string()), + signals: z.record(z.string(), z.number()).optional() + }) + .passthrough() + ) +}); + export const IndexMetaSchema = z.object({ metaVersion: z.number().int().positive(), formatVersion: z.number().int().nonnegative(), @@ -59,6 +84,11 @@ export const IndexMetaSchema = z.object({ embeddingModel: z.string().optional() }), intelligence: z + .object({ + path: z.string().min(1) + }) + .optional(), + health: z .object({ path: z.string().min(1) }) @@ -270,4 +300,34 @@ export async function validateIndexArtifacts(rootDir: string, meta: IndexMeta): throw asIndexCorrupted('Relationships sidecar corrupted (rebuild required)', error); } } + + // Optional health sidecar: validate if present, but do not require. + const healthPath = path.join(contextDir, HEALTH_FILENAME); + if (await pathExists(healthPath)) { + try { + const raw = await fs.readFile(healthPath, 'utf-8'); + const json = JSON.parse(raw); + const parsed = HealthFileSchema.safeParse(json); + if (!parsed.success) { + throw new IndexCorruptedError( + `Health schema mismatch (rebuild required): ${parsed.error.message}` + ); + } + + const { buildId, formatVersion } = parsed.data.header; + if (formatVersion !== meta.formatVersion) { + throw new IndexCorruptedError( + `Health formatVersion mismatch (rebuild required): meta=${meta.formatVersion}, health.json=${formatVersion}` + ); + } + if (buildId !== meta.buildId) { + throw new IndexCorruptedError( + `Health buildId mismatch (rebuild required): meta=${meta.buildId}, health.json=${buildId}` + ); + } + } catch (error) { + if (error instanceof IndexCorruptedError) throw error; + throw asIndexCorrupted('Health sidecar corrupted (rebuild required)', error); + } + } } diff --git a/src/core/indexer.ts b/src/core/indexer.ts index d6bb842..9530576 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -42,6 +42,7 @@ import { getFileCommitDates } from '../utils/git-dates.js'; import { CODEBASE_CONTEXT_DIRNAME, EXCLUDED_GLOB_PATTERNS, + HEALTH_FILENAME, INDEX_FORMAT_VERSION, INDEXING_STATS_FILENAME, INDEX_META_FILENAME, @@ -52,6 +53,7 @@ import { RELATIONSHIPS_FILENAME, VECTOR_DB_DIRNAME } from '../constants/codebase-context.js'; +import { deriveCodebaseHealth } from '../health/derive.js'; const STAGING_DIRNAME = '.staging'; const PREVIOUS_DIRNAME = '.previous'; @@ -104,6 +106,7 @@ async function atomicSwapStagingToActive( const activeManifestPath = path.join(contextDir, MANIFEST_FILENAME); const activeStatsPath = path.join(contextDir, INDEXING_STATS_FILENAME); const activeRelationshipsPath = path.join(contextDir, RELATIONSHIPS_FILENAME); + const activeHealthPath = path.join(contextDir, HEALTH_FILENAME); const stagingMetaPath = path.join(stagingDir, INDEX_META_FILENAME); const stagingIndexPath = path.join(stagingDir, KEYWORD_INDEX_FILENAME); @@ -112,6 +115,7 @@ async function atomicSwapStagingToActive( const stagingManifestPath = path.join(stagingDir, MANIFEST_FILENAME); const stagingStatsPath = path.join(stagingDir, INDEXING_STATS_FILENAME); const stagingRelationshipsPath = path.join(stagingDir, RELATIONSHIPS_FILENAME); + const stagingHealthPath = path.join(stagingDir, HEALTH_FILENAME); // Step 1: Create .previous directory and move current active there await fs.mkdir(previousDir, { recursive: true }); @@ -149,6 +153,7 @@ async function atomicSwapStagingToActive( await moveIfExists(activeManifestPath, path.join(previousDir, MANIFEST_FILENAME)); await moveIfExists(activeStatsPath, path.join(previousDir, INDEXING_STATS_FILENAME)); await moveIfExists(activeRelationshipsPath, path.join(previousDir, RELATIONSHIPS_FILENAME)); + await moveIfExists(activeHealthPath, path.join(previousDir, HEALTH_FILENAME)); await moveDirIfExists(activeVectorDir, path.join(previousDir, VECTOR_DB_DIRNAME)); // Step 2: Move staging artifacts to active location @@ -159,6 +164,7 @@ async function atomicSwapStagingToActive( await moveIfExists(stagingManifestPath, activeManifestPath); await moveIfExists(stagingStatsPath, activeStatsPath); await moveIfExists(stagingRelationshipsPath, activeRelationshipsPath); + await moveIfExists(stagingHealthPath, activeHealthPath); await moveDirIfExists(stagingVectorDir, activeVectorDir); // Step 3: Clean up .previous and staging directories @@ -188,6 +194,7 @@ async function atomicSwapStagingToActive( await moveIfExists(path.join(previousDir, MANIFEST_FILENAME), activeManifestPath); await moveIfExists(path.join(previousDir, INDEXING_STATS_FILENAME), activeStatsPath); await moveIfExists(path.join(previousDir, RELATIONSHIPS_FILENAME), activeRelationshipsPath); + await moveIfExists(path.join(previousDir, HEALTH_FILENAME), activeHealthPath); await moveDirIfExists(path.join(previousDir, VECTOR_DB_DIRNAME), activeVectorDir); console.error('Rollback successful'); } catch (rollbackError) { @@ -980,6 +987,16 @@ export class CodebaseIndexer { }; await fs.writeFile(relationshipsPath, JSON.stringify(relationships, null, 2)); + const healthPath = path.join(activeContextDir, HEALTH_FILENAME); + const health = deriveCodebaseHealth({ + buildId, + formatVersion: INDEX_FORMAT_VERSION, + generatedAt, + chunks: allChunks, + graph: internalFileGraph + }); + await fs.writeFile(healthPath, JSON.stringify(health, null, 2)); + // Write manifest (both full and incremental) // For full rebuild, write to staging; for incremental, write to active const activeManifestPath = path.join(activeContextDir, MANIFEST_FILENAME); @@ -1021,7 +1038,8 @@ export class CodebaseIndexer { intelligence: { path: INTELLIGENCE_FILENAME }, manifest: { path: MANIFEST_FILENAME }, indexingStats: { path: INDEXING_STATS_FILENAME }, - relationships: { path: RELATIONSHIPS_FILENAME } + relationships: { path: RELATIONSHIPS_FILENAME }, + health: { path: HEALTH_FILENAME } } }, null, diff --git a/src/health/derive.ts b/src/health/derive.ts new file mode 100644 index 0000000..0286876 --- /dev/null +++ b/src/health/derive.ts @@ -0,0 +1,207 @@ +import type { CodeChunk, CodebaseHealthArtifact, CodebaseHealthFile } from '../types/index.js'; +import { InternalFileGraph } from '../utils/usage-tracker.js'; + +interface DeriveCodebaseHealthParams { + buildId: string; + formatVersion: number; + generatedAt: string; + chunks: CodeChunk[]; + graph: InternalFileGraph; +} + +interface FileMetrics { + importCount: number; + importerCount: number; + cycleCount: number; + maxCyclomaticComplexity: number; + hotspotRank?: number; +} + +type FileMetricsMap = Map; + +function normalizePathLike(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function collectFileMetrics(chunks: CodeChunk[], graph: InternalFileGraph): FileMetricsMap { + const metrics = new Map(); + const graphJson = graph.toJSON(); + const reverseImports = new Map>(); + + for (const [file, deps] of Object.entries(graphJson.imports)) { + const normalizedFile = normalizePathLike(file); + const fileMetrics = metrics.get(normalizedFile) ?? { + importCount: 0, + importerCount: 0, + cycleCount: 0, + maxCyclomaticComplexity: 0 + }; + fileMetrics.importCount = deps.length; + metrics.set(normalizedFile, fileMetrics); + + for (const dependency of deps) { + const normalizedDependency = normalizePathLike(dependency); + const importers = reverseImports.get(normalizedDependency) ?? new Set(); + importers.add(normalizedFile); + reverseImports.set(normalizedDependency, importers); + } + } + + for (const [file, importers] of reverseImports.entries()) { + const fileMetrics = metrics.get(file) ?? { + importCount: 0, + importerCount: 0, + cycleCount: 0, + maxCyclomaticComplexity: 0 + }; + fileMetrics.importerCount = importers.size; + metrics.set(file, fileMetrics); + } + + for (const chunk of chunks) { + const file = normalizePathLike(chunk.relativePath || chunk.filePath); + const fileMetrics = metrics.get(file) ?? { + importCount: 0, + importerCount: 0, + cycleCount: 0, + maxCyclomaticComplexity: 0 + }; + const chunkComplexity = + typeof chunk.metadata?.cyclomaticComplexity === 'number' + ? chunk.metadata.cyclomaticComplexity + : typeof chunk.metadata?.complexity === 'number' + ? chunk.metadata.complexity + : 0; + fileMetrics.maxCyclomaticComplexity = Math.max( + fileMetrics.maxCyclomaticComplexity, + chunkComplexity + ); + metrics.set(file, fileMetrics); + } + + const hotspotRanks = Array.from(metrics.entries()) + .map(([file, fileMetrics]) => ({ + file, + combined: fileMetrics.importCount + fileMetrics.importerCount + })) + .filter((entry) => entry.combined > 0) + .sort((a, b) => b.combined - a.combined || a.file.localeCompare(b.file)); + + hotspotRanks.forEach((entry, index) => { + const fileMetrics = metrics.get(entry.file); + if (fileMetrics) { + fileMetrics.hotspotRank = index + 1; + } + }); + + for (const cycle of graph.findCycles()) { + for (const file of cycle.files.slice(0, -1)) { + const normalizedFile = normalizePathLike(file); + const fileMetrics = metrics.get(normalizedFile) ?? { + importCount: 0, + importerCount: 0, + cycleCount: 0, + maxCyclomaticComplexity: 0 + }; + fileMetrics.cycleCount += 1; + metrics.set(normalizedFile, fileMetrics); + } + } + + return metrics; +} + +function getHealthLevel(fileMetrics: FileMetrics): CodebaseHealthFile { + const reasons: string[] = []; + let score = 0; + + if (fileMetrics.cycleCount > 0) { + score += 3; + reasons.push( + `Participates in ${fileMetrics.cycleCount} circular dependenc${fileMetrics.cycleCount === 1 ? 'y' : 'ies'}` + ); + } + + if (fileMetrics.importerCount >= 8) { + score += 2; + reasons.push(`High fan-in: ${fileMetrics.importerCount} files depend on it`); + } else if (fileMetrics.importerCount >= 4) { + score += 1; + reasons.push(`Shared dependency for ${fileMetrics.importerCount} files`); + } + + if (fileMetrics.hotspotRank && fileMetrics.hotspotRank <= 5) { + score += 2; + reasons.push(`Hotspot rank #${fileMetrics.hotspotRank} by graph centrality`); + } else if (fileMetrics.hotspotRank && fileMetrics.hotspotRank <= 10) { + score += 1; + reasons.push('Top-10 hotspot by graph centrality'); + } + + if (fileMetrics.maxCyclomaticComplexity >= 18) { + score += 2; + reasons.push(`Complex implementation (cyclomatic ${fileMetrics.maxCyclomaticComplexity})`); + } else if (fileMetrics.maxCyclomaticComplexity >= 10) { + score += 1; + reasons.push(`Moderate code complexity (cyclomatic ${fileMetrics.maxCyclomaticComplexity})`); + } + + const level = score >= 4 ? 'high' : score >= 2 ? 'medium' : ('low' as const); + + return { + file: '', + level, + score, + reasons: reasons.slice(0, 3), + signals: { + ...(fileMetrics.hotspotRank ? { hotspotRank: fileMetrics.hotspotRank } : {}), + ...(fileMetrics.importerCount > 0 ? { importerCount: fileMetrics.importerCount } : {}), + ...(fileMetrics.importCount > 0 ? { importCount: fileMetrics.importCount } : {}), + ...(fileMetrics.cycleCount > 0 ? { cycleCount: fileMetrics.cycleCount } : {}), + ...(fileMetrics.maxCyclomaticComplexity > 0 + ? { maxCyclomaticComplexity: fileMetrics.maxCyclomaticComplexity } + : {}) + } + }; +} + +export function deriveCodebaseHealth({ + buildId, + formatVersion, + generatedAt, + chunks, + graph +}: DeriveCodebaseHealthParams): CodebaseHealthArtifact { + const fileMetrics = collectFileMetrics(chunks, graph); + const files = Array.from(fileMetrics.entries()) + .map(([file, metrics]) => { + const health = getHealthLevel(metrics); + return { + ...health, + file + }; + }) + .sort((a, b) => { + const priority = { high: 0, medium: 1, low: 2 }; + const levelDelta = priority[a.level] - priority[b.level]; + if (levelDelta !== 0) return levelDelta; + if (b.score !== a.score) return b.score - a.score; + return a.file.localeCompare(b.file); + }); + + const highRiskFiles = files.filter((file) => file.level === 'high').length; + const mediumRiskFiles = files.filter((file) => file.level === 'medium').length; + const lowRiskFiles = files.length - highRiskFiles - mediumRiskFiles; + + return { + header: { buildId, formatVersion }, + generatedAt, + summary: { + files: files.length, + highRiskFiles, + mediumRiskFiles, + lowRiskFiles + }, + files + }; +} diff --git a/src/health/store.ts b/src/health/store.ts new file mode 100644 index 0000000..2278f86 --- /dev/null +++ b/src/health/store.ts @@ -0,0 +1,118 @@ +import { promises as fs } from 'fs'; +import type { CodebaseHealthArtifact, CodebaseHealthFile } from '../types/index.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizePathLike(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function normalizeHealthFile(raw: unknown): CodebaseHealthFile | null { + if (!isRecord(raw)) return null; + const file = typeof raw.file === 'string' ? normalizePathLike(raw.file) : undefined; + const level = + raw.level === 'low' || raw.level === 'medium' || raw.level === 'high' ? raw.level : undefined; + const score = typeof raw.score === 'number' ? raw.score : undefined; + const reasons = Array.isArray(raw.reasons) + ? raw.reasons.filter((value): value is string => typeof value === 'string') + : []; + + if (!file || !level || score === undefined) return null; + + const rawSignals = isRecord(raw.signals) ? raw.signals : undefined; + const signals = rawSignals + ? { + ...(typeof rawSignals.hotspotRank === 'number' ? { hotspotRank: rawSignals.hotspotRank } : {}), + ...(typeof rawSignals.importerCount === 'number' + ? { importerCount: rawSignals.importerCount } + : {}), + ...(typeof rawSignals.importCount === 'number' ? { importCount: rawSignals.importCount } : {}), + ...(typeof rawSignals.cycleCount === 'number' ? { cycleCount: rawSignals.cycleCount } : {}), + ...(typeof rawSignals.maxCyclomaticComplexity === 'number' + ? { maxCyclomaticComplexity: rawSignals.maxCyclomaticComplexity } + : {}) + } + : undefined; + + return { + file, + level, + score, + reasons, + ...(signals && Object.keys(signals).length > 0 && { signals }) + }; +} + +export function normalizeHealthArtifact(raw: unknown): CodebaseHealthArtifact | null { + if (!isRecord(raw) || !isRecord(raw.header) || !isRecord(raw.summary) || !Array.isArray(raw.files)) { + return null; + } + + const buildId = + typeof raw.header.buildId === 'string' && raw.header.buildId ? raw.header.buildId : undefined; + const formatVersion = + typeof raw.header.formatVersion === 'number' ? raw.header.formatVersion : undefined; + const generatedAt = + typeof raw.generatedAt === 'string' && raw.generatedAt ? raw.generatedAt : undefined; + + if (!buildId || formatVersion === undefined || !generatedAt) { + return null; + } + + const files = raw.files + .map((entry) => normalizeHealthFile(entry)) + .filter((entry): entry is CodebaseHealthFile => entry !== null); + + const summary = raw.summary; + const filesCount = typeof summary.files === 'number' ? summary.files : files.length; + const highRiskFiles = typeof summary.highRiskFiles === 'number' ? summary.highRiskFiles : 0; + const mediumRiskFiles = typeof summary.mediumRiskFiles === 'number' ? summary.mediumRiskFiles : 0; + const lowRiskFiles = typeof summary.lowRiskFiles === 'number' ? summary.lowRiskFiles : 0; + + return { + header: { buildId, formatVersion }, + generatedAt, + summary: { + files: filesCount, + highRiskFiles, + mediumRiskFiles, + lowRiskFiles + }, + files + }; +} + +export async function readHealthFile(healthPath: string): Promise { + try { + const content = await fs.readFile(healthPath, 'utf-8'); + return normalizeHealthArtifact(JSON.parse(content)); + } catch { + return null; + } +} + +export function normalizeHealthLookupKey(filePath: string, rootPath?: string): string { + const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, ''); + if (!rootPath) { + return normalized; + } + const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, ''); + if (normalized.startsWith(normalizedRoot)) { + return normalized.slice(normalizedRoot.length).replace(/^\//, ''); + } + return normalized; +} + +export function indexHealthByFile( + artifact: CodebaseHealthArtifact | null, + rootPath?: string +): Map { + const map = new Map(); + if (!artifact) return map; + for (const fileHealth of artifact.files) { + map.set(normalizeHealthLookupKey(fileHealth.file, rootPath), fileHealth); + } + return map; +} diff --git a/src/index.ts b/src/index.ts index a4d7c73..1f6bc43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -558,7 +558,8 @@ export const INDEX_CONSUMING_TOOL_NAMES = [ 'get_symbol_references', 'detect_circular_dependencies', 'get_team_patterns', - 'get_codebase_metadata' + 'get_codebase_metadata', + 'get_codebase_health' ] as const; export const INDEX_CONSUMING_RESOURCE_NAMES = ['Codebase Intelligence'] as const; diff --git a/src/project-state.ts b/src/project-state.ts index bf12129..d4a75d6 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -1,6 +1,7 @@ import path from 'path'; import { CODEBASE_CONTEXT_DIRNAME, + HEALTH_FILENAME, MEMORY_FILENAME, INTELLIGENCE_FILENAME, KEYWORD_INDEX_FILENAME, @@ -34,6 +35,7 @@ export function makePaths(rootPath: string): ToolPaths { baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME), intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME), + health: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, HEALTH_FILENAME), keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) }; diff --git a/src/tools/get-codebase-health.ts b/src/tools/get-codebase-health.ts new file mode 100644 index 0000000..e4f243e --- /dev/null +++ b/src/tools/get-codebase-health.ts @@ -0,0 +1,109 @@ +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolContext, ToolResponse } from './types.js'; +import { indexHealthByFile, normalizeHealthLookupKey, readHealthFile } from '../health/store.js'; + +export const definition: Tool = { + name: 'get_codebase_health', + description: + 'Get actionable codebase health signals from the latest index. Returns the highest-risk files and their reasons, or a single file when requested.', + inputSchema: { + type: 'object', + properties: { + file: { + type: 'string', + description: 'Optional file path to inspect a single file-level health record.' + }, + limit: { + type: 'number', + description: 'Maximum number of files to return when no file is specified (default: 10).', + default: 10 + }, + level: { + type: 'string', + enum: ['low', 'medium', 'high'], + description: 'Optional minimum health level to return.' + } + } + } +}; + +export async function handle( + args: Record, + ctx: ToolContext +): Promise { + const file = typeof args.file === 'string' ? args.file.trim() : undefined; + const limit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 10; + const level = + args.level === 'low' || args.level === 'medium' || args.level === 'high' ? args.level : undefined; + + const health = await readHealthFile(ctx.paths.health); + if (!health) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + status: 'no_data', + message: 'No codebase health artifact found. Run refresh_index to generate health.json.' + }, + null, + 2 + ) + } + ] + }; + } + + const orderedLevels = { high: 3, medium: 2, low: 1 }; + const minLevel = level ? orderedLevels[level] : 1; + + if (file) { + const byFile = indexHealthByFile(health, ctx.rootPath); + const fileHealth = byFile.get(normalizeHealthLookupKey(file, ctx.rootPath)); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + fileHealth + ? { + status: 'success', + generatedAt: health.generatedAt, + file: fileHealth + } + : { + status: 'not_found', + message: `No health record found for ${file}.`, + generatedAt: health.generatedAt + }, + null, + 2 + ) + } + ] + }; + } + + const files = health.files + .filter((entry) => orderedLevels[entry.level] >= minLevel) + .slice(0, Math.max(1, Math.floor(limit))); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + status: 'success', + generatedAt: health.generatedAt, + summary: health.summary, + files + }, + null, + 2 + ) + } + ] + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 6cd8b82..def32ac 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -12,6 +12,7 @@ import { definition as d7, handle as h7 } from './get-symbol-references.js'; import { definition as d8, handle as h8 } from './detect-circular-dependencies.js'; import { definition as d9, handle as h9 } from './remember.js'; import { definition as d10, handle as h10 } from './get-memory.js'; +import { definition as d11, handle as h11 } from './get-codebase-health.js'; import type { ToolContext, ToolResponse } from './types.js'; @@ -51,7 +52,9 @@ function withProjectSelector(definition: Tool): Tool { }; } -export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectSelector); +export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10, d11].map( + withProjectSelector +); export async function dispatchTool( name: string, @@ -79,6 +82,8 @@ export async function dispatchTool( return h9(args, ctx); case 'get_memory': return h10(args, ctx); + case 'get_codebase_health': + return h11(args, ctx); default: return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index 7128e45..ed1a067 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -23,6 +23,7 @@ import { getRerankerStatus } from '../core/reranker.js'; import { IndexCorruptedError } from '../errors/index.js'; import { readMemoriesFile, withConfidence } from '../memory/store.js'; import type { MemoryWithConfidence } from '../memory/store.js'; +import { indexHealthByFile, normalizeHealthLookupKey, readHealthFile } from '../health/store.js'; import { InternalFileGraph } from '../utils/usage-tracker.js'; import type { FileExport } from '../utils/usage-tracker.js'; import { RELATIONSHIPS_FILENAME } from '../constants/codebase-context.js'; @@ -284,6 +285,9 @@ export async function handle( /* graceful degradation — relationships sidecar may not exist yet */ } + const healthArtifact = await readHealthFile(ctx.paths.health); + const healthByFile = indexHealthByFile(healthArtifact, ctx.rootPath); + // Helper to get imports graph from relationships sidecar (preferred) or intelligence function getImportsGraph(): Record | null { if (relationships?.graph?.imports) { @@ -575,6 +579,49 @@ export async function handle( .slice(0, 2); } + function getResultHealth(filePath: string): + | { level: 'low' | 'medium' | 'high'; reasons?: string[] } + | undefined { + const fileHealth = healthByFile.get(normalizeHealthLookupKey(filePath, ctx.rootPath)); + if (!fileHealth || fileHealth.level === 'low') { + return undefined; + } + return { + level: fileHealth.level, + ...(fileHealth.reasons.length > 0 && { reasons: fileHealth.reasons.slice(0, 2) }) + }; + } + + function summarizeResultHealth(resultPaths: string[]): + | { level: 'low' | 'medium' | 'high'; reasons?: string[] } + | undefined { + const matched = resultPaths + .map((filePath) => healthByFile.get(normalizeHealthLookupKey(filePath, ctx.rootPath))) + .filter((entry): entry is NonNullable => Boolean(entry)); + if (matched.length === 0) { + return undefined; + } + + const priority = { high: 3, medium: 2, low: 1 }; + matched.sort((a, b) => { + if (priority[b.level] !== priority[a.level]) return priority[b.level] - priority[a.level]; + if (b.score !== a.score) return b.score - a.score; + return a.file.localeCompare(b.file); + }); + + const top = matched[0]; + const reasons = [...top.reasons]; + const sameLevelCount = matched.filter((entry) => entry.level === top.level).length; + if (sameLevelCount > 1) { + reasons.push(`${sameLevelCount} result files are marked ${top.level}-risk`); + } + + return { + level: top.level, + ...(reasons.length > 0 && { reasons: reasons.slice(0, 3) }) + }; + } + // Build a 1-line pattern summary string from intelligence.json patterns (compact mode) function buildPatternSummary(): string | undefined { const patterns = intelligence?.patterns; @@ -951,6 +998,11 @@ export async function handle( } } + const healthSummary = summarizeResultHealth(resultPaths); + if (healthSummary) { + decisionCard.health = healthSummary; + } + // Add whatWouldHelp from evidenceLock if (evidenceLock.whatWouldHelp && evidenceLock.whatWouldHelp.length > 0) { decisionCard.whatWouldHelp = evidenceLock.whatWouldHelp; @@ -1084,6 +1136,7 @@ export async function handle( const importedByCount = getImportedByCount(r); const topExports = getTopExports(r.filePath); const scope = buildScopeHeader(r.metadata); + const health = getResultHealth(r.filePath); // First 3 lines of chunk content as a lightweight signature preview const signaturePreview = r.snippet ? r.snippet @@ -1110,6 +1163,7 @@ export async function handle( ...(r.metadata?.symbolName && { symbol: r.metadata.symbolName }), ...(r.metadata?.symbolKind && { symbolKind: r.metadata.symbolKind }), ...(scope && { scope }), + ...(health && { health }), ...(signaturePreview && { signaturePreview }) }; }), @@ -1143,6 +1197,7 @@ export async function handle( ? enrichSnippetWithScope(r.snippet, r.metadata, r.filePath, r.startLine) : undefined; const scope = buildScopeHeader(r.metadata); + const health = getResultHealth(r.filePath); // Chunk-level imports/exports (top 5 each) + complexity const chunkImports = r.imports?.slice(0, 5); const chunkExports = r.exports?.slice(0, 5); @@ -1168,6 +1223,7 @@ export async function handle( ...(scope && { scope }), ...(chunkImports && chunkImports.length > 0 && { imports: chunkImports }), ...(chunkExports && chunkExports.length > 0 && { exports: chunkExports }), + ...(health && { health }), ...(r.metadata?.cyclomaticComplexity && { complexity: r.metadata.cyclomaticComplexity }) diff --git a/src/tools/types.ts b/src/tools/types.ts index e216660..5de5ee8 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -17,6 +17,10 @@ export interface DecisionCard { files?: string[]; details?: Array<{ file: string; line?: number; hop: 1 | 2 }>; }; + health?: { + level: 'low' | 'medium' | 'high'; + reasons?: string[]; + }; whatWouldHelp?: string[]; } @@ -24,6 +28,7 @@ export interface ToolPaths { baseDir: string; memory: string; intelligence: string; + health: string; keywordIndex: string; vectorDb: string; } @@ -118,6 +123,10 @@ export interface SearchResultItem { imports?: string[]; exports?: string[]; complexity?: number; + health?: { + level: 'low' | 'medium' | 'high'; + reasons?: string[]; + }; snippet?: string; } diff --git a/src/types/index.ts b/src/types/index.ts index 0576996..10b3662 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -597,6 +597,39 @@ export interface Memory { source?: 'user' | 'git'; } +export type CodebaseHealthLevel = 'low' | 'medium' | 'high'; + +export interface CodebaseHealthFile { + file: string; + level: CodebaseHealthLevel; + score: number; + reasons: string[]; + signals?: { + hotspotRank?: number; + importerCount?: number; + importCount?: number; + cycleCount?: number; + maxCyclomaticComplexity?: number; + }; +} + +export interface CodebaseHealthSummary { + files: number; + highRiskFiles: number; + mediumRiskFiles: number; + lowRiskFiles: number; +} + +export interface CodebaseHealthArtifact { + header: { + buildId: string; + formatVersion: number; + }; + generatedAt: string; + summary: CodebaseHealthSummary; + files: CodebaseHealthFile[]; +} + // ============================================================================ // SHARED PRIMITIVES // ============================================================================ diff --git a/tests/get-codebase-health.test.ts b/tests/get-codebase-health.test.ts new file mode 100644 index 0000000..3082629 --- /dev/null +++ b/tests/get-codebase-health.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { handle } from '../src/tools/get-codebase-health.js'; +import type { ToolContext } from '../src/tools/types.js'; + +async function createContextRoot(): Promise<{ root: string; ctx: ToolContext }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codebase-health-tool-')); + const healthPath = path.join(root, '.codebase-context', 'health.json'); + await fs.mkdir(path.dirname(healthPath), { recursive: true }); + + const ctx: ToolContext = { + indexState: { status: 'ready' }, + paths: { + baseDir: path.join(root, '.codebase-context'), + memory: path.join(root, '.codebase-context', 'memory.json'), + intelligence: path.join(root, '.codebase-context', 'intelligence.json'), + health: healthPath, + keywordIndex: path.join(root, '.codebase-context', 'index.json'), + vectorDb: path.join(root, '.codebase-context', 'index') + }, + rootPath: root, + performIndexing: () => undefined + }; + + return { root, ctx }; +} + +describe('get_codebase_health', () => { + it('returns filtered top-risk files from health.json', async () => { + const { root, ctx } = await createContextRoot(); + try { + await fs.writeFile( + ctx.paths.health, + JSON.stringify( + { + header: { buildId: 'build-1', formatVersion: 1 }, + generatedAt: '2026-04-17T00:00:00.000Z', + summary: { + files: 2, + highRiskFiles: 1, + mediumRiskFiles: 1, + lowRiskFiles: 0 + }, + files: [ + { + file: 'src/auth/auth.service.ts', + level: 'high', + score: 5, + reasons: ['High fan-in: 9 files depend on it'] + }, + { + file: 'src/auth/token.store.ts', + level: 'medium', + score: 2, + reasons: ['Moderate code complexity (cyclomatic 11)'] + } + ] + }, + null, + 2 + ) + ); + + const response = await handle({ level: 'high' }, ctx); + const payload = JSON.parse(response.content?.[0]?.text ?? '{}') as { + status: string; + files: Array<{ file: string; level: string }>; + }; + + expect(payload.status).toBe('success'); + expect(payload.files).toHaveLength(1); + expect(payload.files[0]).toMatchObject({ + file: 'src/auth/auth.service.ts', + level: 'high' + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('returns a single file record lookup', async () => { + const { root, ctx } = await createContextRoot(); + try { + await fs.writeFile( + ctx.paths.health, + JSON.stringify( + { + header: { buildId: 'build-2', formatVersion: 1 }, + generatedAt: '2026-04-17T00:00:00.000Z', + summary: { + files: 1, + highRiskFiles: 1, + mediumRiskFiles: 0, + lowRiskFiles: 0 + }, + files: [ + { + file: 'src/auth/auth.service.ts', + level: 'high', + score: 4, + reasons: ['Hotspot rank #2 by graph centrality'] + } + ] + }, + null, + 2 + ) + ); + + const response = await handle({ file: 'src/auth/auth.service.ts' }, ctx); + const payload = JSON.parse(response.content?.[0]?.text ?? '{}') as { + status: string; + file?: { file: string; level: string; reasons: string[] }; + }; + + expect(payload.status).toBe('success'); + expect(payload.file?.level).toBe('high'); + expect(payload.file?.reasons[0]).toContain('Hotspot rank'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/search-health-scope.test.ts b/tests/search-health-scope.test.ts new file mode 100644 index 0000000..a066b7d --- /dev/null +++ b/tests/search-health-scope.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { handle } from '../src/tools/search-codebase.js'; +import type { ToolContext } from '../src/tools/types.js'; + +const searchMocks = vi.hoisted(() => ({ + search: vi.fn() +})); + +vi.mock('../src/core/search.js', async () => { + class CodebaseSearcher { + constructor(_rootPath: string) {} + + async search(query: string, limit: number, filters?: unknown) { + return searchMocks.search(query, limit, filters); + } + } + + return { CodebaseSearcher }; +}); + +describe('search_codebase health surface', () => { + let tempRoot: string; + let ctx: ToolContext; + + beforeEach(async () => { + searchMocks.search.mockReset(); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-health-surface-')); + + const contextDir = path.join(tempRoot, '.codebase-context'); + await fs.mkdir(contextDir, { recursive: true }); + + ctx = { + indexState: { status: 'ready' }, + paths: { + baseDir: contextDir, + memory: path.join(contextDir, 'memory.json'), + intelligence: path.join(contextDir, 'intelligence.json'), + health: path.join(contextDir, 'health.json'), + keywordIndex: path.join(contextDir, 'index.json'), + vectorDb: path.join(contextDir, 'index') + }, + rootPath: tempRoot, + performIndexing: () => undefined + }; + + await fs.writeFile( + ctx.paths.memory, + JSON.stringify( + [ + { + id: 'global-memory', + type: 'decision', + category: 'architecture', + memory: 'Use auth interceptors', + reason: 'They keep HTTP token injection consistent.', + date: '2026-04-17T00:00:00.000Z' + } + ], + null, + 2 + ) + ); + + await fs.writeFile( + ctx.paths.intelligence, + JSON.stringify( + { + header: { buildId: 'build-1', formatVersion: 1 }, + generatedAt: '2026-04-17T00:00:00.000Z', + patterns: { + stateManagement: { + primary: { + name: 'Signals', + frequency: '78%', + trend: 'Stable' + } + } + }, + goldenFiles: [{ file: 'src/auth/auth.service.ts', score: 0.97 }], + internalFileGraph: { + imports: { + 'src/app/auth-shell.ts': ['src/auth/auth.service.ts'] + } + } + }, + null, + 2 + ) + ); + + await fs.writeFile( + ctx.paths.health, + JSON.stringify( + { + header: { buildId: 'build-1', formatVersion: 1 }, + generatedAt: '2026-04-17T00:00:00.000Z', + summary: { + files: 1, + highRiskFiles: 1, + mediumRiskFiles: 0, + lowRiskFiles: 0 + }, + files: [ + { + file: 'src/auth/auth.service.ts', + level: 'high', + score: 5, + reasons: ['High fan-in: 9 files depend on it'] + } + ] + }, + null, + 2 + ) + ); + }); + + afterEach(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it('surfaces file health in search results and preflight', async () => { + searchMocks.search.mockResolvedValueOnce([ + { + summary: 'Auth service token management', + snippet: 'export class AuthService { getToken() { return token; } }', + filePath: 'src/auth/auth.service.ts', + startLine: 1, + endLine: 20, + score: 0.91, + language: 'ts', + metadata: { symbolName: 'AuthService', symbolKind: 'class', symbolPath: ['AuthService'] }, + relevanceReason: 'Matches auth service token query' + } + ]); + + const response = await handle({ query: 'auth service token', intent: 'edit' }, ctx); + const payload = JSON.parse(response.content?.[0]?.text ?? '{}') as { + preflight?: { health?: { level: string; reasons?: string[] } }; + results: Array<{ health?: { level: string; reasons?: string[] } }>; + }; + + expect(payload.preflight?.health?.level).toBe('high'); + expect(payload.preflight?.health?.reasons?.[0]).toContain('High fan-in'); + expect(payload.results[0]?.health?.level).toBe('high'); + expect(payload.results[0]?.health?.reasons?.[0]).toContain('High fan-in'); + }); +}); diff --git a/tests/tools/dispatch.test.ts b/tests/tools/dispatch.test.ts index b180d3d..d51f313 100644 --- a/tests/tools/dispatch.test.ts +++ b/tests/tools/dispatch.test.ts @@ -3,8 +3,8 @@ import { TOOLS, dispatchTool } from '../../src/tools/index.js'; import type { ToolContext } from '../../src/tools/types.js'; describe('Tool Dispatch', () => { - it('exports all 10 tools', () => { - expect(TOOLS.length).toBe(10); + it('exports all 11 tools', () => { + expect(TOOLS.length).toBe(11); expect(TOOLS.map((t) => t.name)).toEqual([ 'search_codebase', 'get_codebase_metadata', @@ -15,7 +15,8 @@ describe('Tool Dispatch', () => { 'get_symbol_references', 'detect_circular_dependencies', 'remember', - 'get_memory' + 'get_memory', + 'get_codebase_health' ]); }); @@ -59,6 +60,7 @@ describe('Tool Dispatch', () => { baseDir: '/tmp', memory: '/tmp/memory.jsonl', intelligence: '/tmp/intelligence.json', + health: '/tmp/health.json', keywordIndex: '/tmp/index.json', vectorDb: '/tmp/vector-db' }, @@ -80,6 +82,7 @@ describe('Tool Dispatch', () => { baseDir: '/tmp', memory: '/tmp/memory.jsonl', intelligence: '/tmp/intelligence.json', + health: '/tmp/health.json', keywordIndex: '/tmp/index.json', vectorDb: '/tmp/vector-db' },