diff --git a/package.json b/package.json index ad47a5a..5b23ad5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codebase-context", "version": "1.7.0", - "description": "Second brain for AI agents working on your codebase - team coding patterns detection, persistent memory, preflight checks, and hybrid search with evidence scoring. Local-first MCP server", + "description": "Second brain for AI agents working on your codebase - team coding patterns detection, persistent memory, edit readiness checks, and hybrid search with evidence scoring. Local-first MCP server", "type": "module", "main": "./dist/lib.js", "types": "./dist/lib.d.ts", @@ -128,7 +128,7 @@ "@typescript-eslint/typescript-estree": "^7.0.0", "fuse.js": "^7.0.0", "glob": "^10.3.10", - "hono": "^4.11.10", + "hono": "^4.12.2", "ignore": "^5.3.1", "typescript": "^5.3.3", "uuid": "^9.0.1", @@ -161,7 +161,7 @@ ], "overrides": { "@modelcontextprotocol/sdk>ajv": "8.18.0", - "minimatch": ">=10.2.1" + "minimatch": "10.2.3" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ae007..7894424 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: '@modelcontextprotocol/sdk>ajv': 8.18.0 - minimatch: '>=10.2.1' + minimatch: 10.2.3 importers: @@ -31,8 +31,8 @@ importers: specifier: ^10.3.10 version: 10.5.0 hono: - specifier: ^4.11.10 - version: 4.12.1 + specifier: ^4.12.2 + version: 4.12.3 ignore: specifier: ^5.3.1 version: 5.3.2 @@ -1476,8 +1476,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.12.1: - resolution: {integrity: sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==} + hono@4.12.3: + resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -1737,8 +1737,8 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} engines: {node: 18 || 20 || >=22} minimist@1.2.8: @@ -2542,7 +2542,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.3 transitivePeerDependencies: - supports-color @@ -2563,7 +2563,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 10.2.2 + minimatch: 10.2.3 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -2579,9 +2579,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@hono/node-server@1.19.9(hono@4.12.1)': + '@hono/node-server@1.19.9(hono@4.12.3)': dependencies: - hono: 4.12.1 + hono: 4.12.3 '@huggingface/jinja@0.5.5': {} @@ -2746,7 +2746,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@4.3.4)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.1) + '@hono/node-server': 1.19.9(hono@4.12.3) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -2756,7 +2756,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.1 + hono: 4.12.3 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -2986,7 +2986,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.3 semver: 7.7.3 ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: @@ -3001,7 +3001,7 @@ snapshots: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.3 semver: 7.7.3 tinyglobby: 0.2.15 ts-api-utils: 2.3.0(typescript@5.9.3) @@ -3527,7 +3527,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.3 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -3583,7 +3583,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 10.2.2 + minimatch: 10.2.3 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -3812,7 +3812,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 10.2.2 + minimatch: 10.2.3 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -3870,7 +3870,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.1: {} + hono@4.12.3: {} http-errors@2.0.1: dependencies: @@ -4111,7 +4111,7 @@ snapshots: dependencies: mime-db: 1.54.0 - minimatch@10.2.2: + minimatch@10.2.3: dependencies: brace-expansion: 5.0.2 diff --git a/src/cli-formatters.ts b/src/cli-formatters.ts new file mode 100644 index 0000000..04251ac --- /dev/null +++ b/src/cli-formatters.ts @@ -0,0 +1,713 @@ +/** + * Human-readable CLI formatters for codebase-context commands. + * Use --json flag for raw JSON output instead. + */ + +import path from 'path'; +import type { + PatternResponse, + PatternEntry, + LibraryEntry, + SearchResponse, + SearchResultItem, + RefsResponse, + RefsUsage, + CyclesResponse, + CycleItem, + MetadataResponse, + MetadataDependency, + StyleGuideResponse +} from './tools/types.js'; + +export const BOX_WIDTH = 72; + +export function shortPath(filePath: string, rootPath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const normalizedRoot = rootPath.replace(/\\/g, '/'); + if (normalized.startsWith(normalizedRoot)) { + return normalized.slice(normalizedRoot.length).replace(/^\//, ''); + } + // Also strip common Repos/ prefix patterns + const reposIdx = normalized.indexOf('/Repos/'); + if (reposIdx >= 0) { + const afterRepos = normalized.slice(reposIdx + 7); + const slashIdx = afterRepos.indexOf('/'); + return slashIdx >= 0 ? afterRepos.slice(slashIdx + 1) : afterRepos; + } + return path.basename(filePath); +} + +export function formatTrend(trend?: string): string { + if (trend === 'Rising') return 'rising'; + if (trend === 'Declining') return 'declining'; + return ''; +} + +export function formatType(type?: string): string { + if (!type) return ''; + // "interceptor:core" → "interceptor (core)", "resolver:unknown" → "resolver" + const [compType, layer] = type.split(':'); + if (!layer || layer === 'unknown') return compType; + return `${compType} (${layer})`; +} + +export function padRight(str: string, len: number): string { + return str.length >= len ? str : str + ' '.repeat(len - str.length); +} + +export function padLeft(str: string, len: number): string { + return str.length >= len ? str : ' '.repeat(len - str.length) + str; +} + +export function barChart(pct: number, width: number = 10): string { + const clamped = Math.max(0, Math.min(100, pct)); + const filled = Math.round((clamped / 100) * width); + return '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled); +} + +export function scoreBar(score: number, width: number = 10): string { + return barChart(Math.round(score * 100), width); +} + +export function parsePercent(s?: string): number { + if (!s) return 0; + const m = s.match(/(\d+)/); + return m ? parseInt(m[1], 10) : 0; +} + +export function wrapLine(text: string, maxWidth: number): string[] { + if (text.length <= maxWidth) return [text]; + const words = text.split(' '); + const out: string[] = []; + let cur = ''; + for (const w of words) { + const candidate = cur ? `${cur} ${w}` : w; + if (candidate.length > maxWidth) { + if (cur) out.push(cur); + cur = w; + } else cur = candidate; + } + if (cur) out.push(cur); + return out; +} + +export function drawBox(title: string, lines: string[], width: number = 60): string[] { + const output: string[] = []; + const inner = width - 4; // 2 for "| " + 2 for " |" + const dashes = '\u2500'; + const titlePart = `\u250c\u2500 ${title} `; + const remaining = Math.max(0, width - titlePart.length - 1); + output.push(titlePart + dashes.repeat(remaining) + '\u2510'); + for (const line of lines) { + const wrapped = wrapLine(line, inner); + for (const wl of wrapped) { + const padded = wl + ' '.repeat(Math.max(0, inner - wl.length)); + output.push(`\u2502 ${padded} \u2502`); + } + } + output.push('\u2514' + dashes.repeat(width - 2) + '\u2518'); + return output; +} + +export function getCycleFiles(cycle: CycleItem): string[] { + if (cycle.files && cycle.files.length > 0) return cycle.files; + return cycle.cycle ?? []; +} + +export function formatPatterns(data: PatternResponse): void { + const { patterns, goldenFiles, memories, conflicts } = data; + const lines: string[] = []; + + if (patterns) { + const entries = Object.entries(patterns); + for (let ei = 0; ei < entries.length; ei++) { + const [category, catData] = entries[ei]; + const label = category + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (s) => s.toUpperCase()) + .trim(); + if (ei > 0) { + lines.push(' ' + '\u2500'.repeat(66)); + } + lines.push(''); + lines.push(label.toUpperCase()); + + const renderEntry = (entry: PatternEntry, isAlt: boolean): void => { + const raw = entry as unknown as Record; + const guidance = typeof raw.guidance === 'string' ? raw.guidance : null; + const prefix = isAlt ? 'alt ' : ' '; + if (guidance) { + lines.push(`${prefix}${guidance}`); + } else { + const name = padRight(entry.name ?? '', 30); + const freq = padLeft(entry.frequency ?? '', 6); + const trend = formatTrend(entry.trend); + lines.push(`${prefix}${name} ${freq}${trend ? ` ${trend}` : ''}`); + } + }; + + const primary = catData.primary; + renderEntry(primary, false); + + const alsoDetected = catData.alsoDetected; + if (alsoDetected) { + for (const alt of alsoDetected) renderEntry(alt, true); + } + } + } + + const topUsed = data.topUsed; + if (topUsed && topUsed.length > 0) { + lines.push(''); + lines.push('\u2500'.repeat(66)); + lines.push(''); + lines.push('TOP LIBRARIES'); + for (const lib of topUsed.slice(0, 15) as LibraryEntry[]) { + const src = padRight(lib.source ?? '', 52); + lines.push(` ${src} ${lib.count} imports`); + } + } + + if (goldenFiles && goldenFiles.length > 0) { + lines.push(''); + lines.push('\u2500'.repeat(66)); + lines.push(''); + lines.push('GOLDEN FILES'); + for (const gf of goldenFiles.slice(0, 5)) { + const file = padRight(gf.file ?? '', 52); + lines.push(` ${file} score: ${gf.score}`); + } + } + + if (conflicts && conflicts.length > 0) { + lines.push(''); + lines.push('\u2500'.repeat(66)); + lines.push(''); + lines.push('CONFLICTS'); + for (const c of conflicts) { + const p = c.primary; + const a = c.alternative; + const pTrend = p.trend ? ` (${p.trend})` : ''; + const aTrend = a.trend ? ` (${a.trend})` : ''; + lines.push(` split: ${p.name} ${p.adoption}${pTrend} vs ${a.name} ${a.adoption}${aTrend}`); + } + } + + if (memories && memories.length > 0) { + lines.push(''); + lines.push('\u2500'.repeat(66)); + lines.push(''); + lines.push('MEMORIES'); + for (const m of memories.slice(0, 5)) { + lines.push(` [${m.type}] ${m.memory}`); + } + } + + lines.push(''); + + const boxLines = drawBox('Team Patterns', lines, BOX_WIDTH); + console.log(''); + for (const l of boxLines) { + console.log(l); + } + console.log(''); +} + +export function formatSearch( + data: SearchResponse, + rootPath: string, + query?: string, + intent?: string +): void { + const { searchQuality: quality, preflight, results, relatedMemories: memories } = data; + + const boxLines: string[] = []; + + const showPreflight = intent === 'edit' || intent === 'refactor' || intent === 'migrate'; + + if (quality) { + const status = quality.status === 'ok' ? 'ok' : 'low confidence'; + const conf = quality.confidence ?? ''; + const confStr = typeof conf === 'number' ? conf.toFixed(2) : String(conf); + boxLines.push(`Quality: ${status} (${confStr})`); + if (quality.hint) { + boxLines.push(`Hint: ${quality.hint}`); + } + } + + if (preflight && showPreflight) { + const readyLabel = preflight.ready ? 'YES' : 'NO'; + boxLines.push(`Ready to edit: ${readyLabel}`); + + if (preflight.nextAction) { + boxLines.push(`Next: ${preflight.nextAction}`); + } + + const patterns = preflight.patterns; + if (patterns) { + if ( + (patterns.do && patterns.do.length > 0) || + (patterns.avoid && patterns.avoid.length > 0) + ) { + boxLines.push(''); + boxLines.push('Patterns:'); + for (const p of patterns.do ?? []) { + boxLines.push(` do: ${p}`); + } + for (const p of patterns.avoid ?? []) { + boxLines.push(` avoid: ${p}`); + } + } + } + + if (preflight.bestExample) { + boxLines.push(''); + boxLines.push(`Best example: ${shortPath(preflight.bestExample, rootPath)}`); + } + + const impact = preflight.impact; + if (impact?.coverage) { + boxLines.push(`Callers: ${impact.coverage}`); + } + if (impact?.files && impact.files.length > 0) { + const shown = impact.files.slice(0, 3).map((f) => shortPath(f, rootPath)); + boxLines.push(`Files: ${shown.join(', ')}`); + } + + const whatWouldHelp = preflight.whatWouldHelp; + if (whatWouldHelp && whatWouldHelp.length > 0) { + boxLines.push(''); + for (const h of whatWouldHelp) { + boxLines.push(`\u2192 ${h}`); + } + } + } + + const titleParts: string[] = []; + if (query) titleParts.push(`"${query}"`); + if (intent) titleParts.push(`intent: ${intent}`); + const boxTitle = + titleParts.length > 0 ? `Search: ${titleParts.join(' \u2500\u2500\u2500 ')}` : 'Search'; + + console.log(''); + if (boxLines.length > 0) { + const boxOut = drawBox(boxTitle, boxLines, BOX_WIDTH); + for (const l of boxOut) { + console.log(l); + } + console.log(''); + } else if (quality) { + const status = quality.status === 'ok' ? 'ok' : 'low confidence'; + console.log(` ${results?.length ?? 0} results · quality: ${status}`); + console.log(''); + } + + if (results && results.length > 0) { + for (let i = 0; i < results.length; i++) { + const r: SearchResultItem = results[i]; + const file = shortPath(r.file ?? '', rootPath); + const scoreValue = Number(r.score ?? 0); + const score = scoreValue.toFixed(2); + const typePart = formatType(r.type); + const trendPart = formatTrend(r.trend); + + const metaParts = [`confidence: ${scoreBar(scoreValue)} ${score}`]; + if (typePart) metaParts.push(typePart); + if (trendPart) metaParts.push(trendPart); + + console.log(`${i + 1}. ${file}`); + console.log(` ${metaParts.join(' \u00b7 ')}`); + + const summary = r.summary ?? ''; + if (summary) { + const short = summary.length > 120 ? summary.slice(0, 117) + '...' : summary; + console.log(` ${short}`); + } + + if (r.patternWarning) { + console.log(` \u26a0 ${r.patternWarning}`); + } + + const hints = r.hints; + if (hints?.callers && hints.callers.length > 0) { + const shortCallers = hints.callers.slice(0, 3).map((c) => shortPath(c, rootPath)); + const total = r.relationships?.importedByCount ?? hints.callers.length; + const more = total > 3 ? ` (+${total - 3} more)` : ''; + console.log(` used by: ${shortCallers.join(', ')}${more}`); + } + + const snippet = r.snippet ?? ''; + if (snippet) { + const snippetLines = snippet.split('\n'); + const trimmed = snippetLines.map((l) => l.trimEnd()); + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') trimmed.pop(); + const shown = trimmed.slice(0, 8); + for (const sl of shown) { + console.log(` \u2502 ${sl}`); + } + } + + console.log(''); + } + } + + if (memories && memories.length > 0) { + console.log('Memories:'); + for (const m of memories) { + console.log(` ${m}`); + } + console.log(''); + } +} + +export function formatRefs(data: RefsResponse, rootPath: string): void { + const { symbol, usageCount: count, confidence, usages } = data; + + const lines: string[] = []; + lines.push(''); + lines.push(String(symbol)); + + if (usages && usages.length > 0) { + lines.push('\u2502'); + for (let i = 0; i < usages.length; i++) { + const u: RefsUsage = usages[i]; + const isLast = i === usages.length - 1; + const branch = isLast ? '\u2514\u2500' : '\u251c\u2500'; + const file = shortPath(u.file ?? '', rootPath); + lines.push(`${branch} ${file}:${u.line}`); + + const preview = u.preview ?? ''; + if (preview) { + const nonEmpty = preview + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 2); + const indent = isLast ? ' ' : '\u2502 '; + const maxPrev = BOX_WIDTH - 10; + for (const pl of nonEmpty) { + const clipped = pl.length > maxPrev ? pl.slice(0, maxPrev - 3) + '...' : pl; + lines.push(`${indent} ${clipped}`); + } + } + + if (!isLast) { + lines.push('\u2502'); + } + } + } + + lines.push(''); + + const confLabel = + confidence === 'syntactic' ? 'static analysis' : (confidence ?? 'static analysis'); + const boxTitle = `${symbol} \u2500\u2500\u2500 ${count} references \u2500\u2500\u2500 ${confLabel}`; + const boxOut = drawBox(boxTitle, lines, BOX_WIDTH); + console.log(''); + for (const l of boxOut) { + console.log(l); + } + console.log(''); +} + +export function formatCycles(data: CyclesResponse, rootPath: string): void { + const cycles = data.cycles ?? []; + const stats = data.graphStats; + + const statParts: string[] = []; + if (cycles.length === 0) { + statParts.push('No cycles found'); + } else { + statParts.push(`${cycles.length} cycle${cycles.length === 1 ? '' : 's'}`); + } + if (stats?.files != null) statParts.push(`${stats.files} files`); + if (stats?.edges != null) statParts.push(`${stats.edges} edges`); + if (stats?.avgDependencies != null) + statParts.push(`${stats.avgDependencies.toFixed(1)} avg deps`); + + const lines: string[] = []; + lines.push(''); + lines.push(statParts.join(' \u00b7 ')); + + for (const c of cycles) { + const sev = (c.severity ?? 'low').toLowerCase(); + const sevLabel = sev === 'high' ? 'HIGH' : sev === 'medium' ? 'MED ' : 'LOW '; + const nodes = getCycleFiles(c).map((f) => shortPath(f, rootPath)); + + lines.push(''); + if (nodes.length === 2) { + lines.push(` ${sevLabel} ${nodes[0]} \u2194 ${nodes[1]}`); + } else { + const arrow = ' \u2192 '; + const full = nodes.join(arrow); + if (full.length <= 60) { + lines.push(` ${sevLabel} ${full}`); + } else { + const indent = ' '; + let current = ` ${sevLabel} ${nodes[0]}`; + for (let ni = 1; ni < nodes.length; ni++) { + const next = arrow + nodes[ni]; + if (current.length + next.length > 68) { + lines.push(current); + current = indent + nodes[ni]; + } else { + current += next; + } + } + lines.push(current); + } + } + } + + lines.push(''); + + const boxOut = drawBox('Circular Dependencies', lines, BOX_WIDTH); + console.log(''); + for (const l of boxOut) { + console.log(l); + } + console.log(''); +} + +export function formatMetadata(data: MetadataResponse): void { + const m = data.metadata; + if (!m) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + const lines: string[] = []; + lines.push(''); + + // Framework line + const fw = m.framework; + const fwName = fw ? `${fw.name ?? ''}${fw.version ? ` ${fw.version}` : ''}`.trim() : ''; + const archType = m.architecture?.type ?? ''; + if (fwName || archType) { + const parts: string[] = []; + if (fwName) parts.push(`Framework: ${fwName}`); + if (archType) parts.push(`Architecture: ${archType}`); + lines.push(parts.join(' ')); + } + + // Languages line + const langs = m.languages ?? []; + if (langs.length > 0) { + const langStr = langs + .slice(0, 4) + .map((l) => { + const pct = l.percentage != null ? ` ${l.percentage}%` : ''; + const files = l.fileCount != null ? ` (${l.fileCount} files)` : ''; + return `${l.name ?? ''}${pct}${files}`; + }) + .join(' '); + lines.push(langStr); + } + + // Stats line + const stats = m.statistics; + if (stats) { + const statParts: string[] = []; + if (stats.totalFiles != null) statParts.push(`${stats.totalFiles} files`); + if (stats.totalLines != null) statParts.push(`${stats.totalLines.toLocaleString()} lines`); + if (stats.totalComponents != null) statParts.push(`${stats.totalComponents} components`); + if (statParts.length > 0) lines.push(statParts.join(' · ')); + } + + // Dependencies + const deps = m.dependencies ?? []; + if (deps.length > 0) { + lines.push(''); + lines.push( + `Dependencies: ${deps + .slice(0, 6) + .map((d: MetadataDependency) => d.name) + .join(' · ')}${deps.length > 6 ? ` (+${deps.length - 6} more)` : ''}` + ); + } + + // Framework extras: state, testing, ui + if (fw) { + const extras: string[] = []; + if (fw.stateManagement && fw.stateManagement.length > 0) { + extras.push(`State: ${fw.stateManagement.join(', ')}`); + } + if (fw.testingFrameworks && fw.testingFrameworks.length > 0) { + extras.push(`Testing: ${fw.testingFrameworks.join(', ')}`); + } + if (fw.uiLibraries && fw.uiLibraries.length > 0) { + extras.push(`UI: ${fw.uiLibraries.join(', ')}`); + } + if (extras.length > 0) { + lines.push(''); + for (const e of extras) lines.push(e); + } + } + + // Modules (if any) + const modules = m.architecture?.modules; + if (modules && modules.length > 0) { + lines.push(''); + lines.push( + `Modules: ${modules + .slice(0, 6) + .map((mod) => mod.name) + .join(' · ')}${modules.length > 6 ? ` (+${modules.length - 6})` : ''}` + ); + } + + lines.push(''); + + const projectName = m.name ?? 'Project'; + const structureType = m.projectStructure?.type; + const titleSuffix = structureType ? ` [${structureType}]` : ''; + const boxOut = drawBox(`${projectName}${titleSuffix}`, lines, BOX_WIDTH); + console.log(''); + for (const l of boxOut) { + console.log(l); + } + console.log(''); +} + +export function formatStyleGuide(data: StyleGuideResponse, rootPath: string): void { + if (data.status === 'no_results' || !data.results || data.results.length === 0) { + console.log(''); + console.log('No style guides found.'); + if (data.hint) { + console.log(` Hint: ${data.hint}`); + } + if (data.searchedPatterns && data.searchedPatterns.length > 0) { + console.log(` Searched: .md files matching ${data.searchedPatterns.join(', ')}`); + } + console.log(''); + return; + } + + const lines: string[] = []; + lines.push(''); + + const totalFiles = data.totalFiles ?? data.results.length; + const totalMatches = data.totalMatches ?? 0; + const countParts: string[] = []; + if (data.limited) { + countParts.push(`showing top ${totalFiles} of ${totalMatches}`); + } else { + const filePart = `${totalFiles} file${totalFiles === 1 ? '' : 's'}`; + const matchPart = `${totalMatches} match${totalMatches === 1 ? '' : 'es'}`; + countParts.push(`${filePart} · ${matchPart}`); + } + lines.push(countParts[0]); + + if (data.notice) { + lines.push(`\u2192 ${data.notice}`); + } + + for (const result of data.results) { + lines.push(''); + lines.push(shortPath(result.file ?? '', rootPath)); + for (const section of result.relevantSections ?? []) { + const stripped = section.replace(/^#+\s*/, ''); + lines.push(` \u00a7 ${stripped}`); + } + } + + lines.push(''); + + const titlePart = data.query ? `Style Guide: "${data.query}"` : 'Style Guide'; + const boxOut = drawBox(titlePart, lines, BOX_WIDTH); + console.log(''); + for (const l of boxOut) { + console.log(l); + } + console.log(''); +} + +export function formatJson( + json: string, + useJson: boolean, + command?: string, + rootPath?: string, + query?: string, + intent?: string +): void { + if (useJson) { + console.log(json); + return; + } + + let data: unknown; + try { + data = JSON.parse(json); + } catch { + console.log(json); + return; + } + + // Tools return { status: 'error', message: '...' } in their JSON payload but + // don't always set isError on the MCP envelope. Route these to stderr before + // a command formatter would render a misleading empty box. + if ( + typeof data === 'object' && + data !== null && + (data as Record).status === 'error' + ) { + const d = data as Record; + const msg = typeof d.message === 'string' ? d.message : JSON.stringify(d, null, 2); + process.stderr.write(`Error: ${msg}\n`); + return; + } + + switch (command) { + case 'metadata': { + try { + formatMetadata(data as MetadataResponse); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + case 'style-guide': { + try { + formatStyleGuide(data as StyleGuideResponse, rootPath ?? ''); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + case 'patterns': { + try { + formatPatterns(data as PatternResponse); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + case 'search': { + try { + formatSearch(data as SearchResponse, rootPath ?? '', query, intent); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + case 'refs': { + try { + formatRefs(data as RefsResponse, rootPath ?? ''); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + case 'cycles': { + try { + formatCycles(data as CyclesResponse, rootPath ?? ''); + } catch { + console.log(JSON.stringify(data, null, 2)); + } + break; + } + default: { + console.log(JSON.stringify(data, null, 2)); + } + } +} diff --git a/src/cli-memory.ts b/src/cli-memory.ts new file mode 100644 index 0000000..82cbb6e --- /dev/null +++ b/src/cli-memory.ts @@ -0,0 +1,211 @@ +/** + * CLI handler for memory subcommands: list, add, remove. + */ + +import path from 'path'; +import type { Memory, MemoryCategory, MemoryType } from './types/index.js'; +import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js'; +import { + appendMemoryFile, + readMemoriesFile, + removeMemory, + filterMemories, + withConfidence +} from './memory/store.js'; + +const MEMORY_CATEGORIES = [ + 'tooling', + 'architecture', + 'testing', + 'dependencies', + 'conventions' +] as const satisfies readonly MemoryCategory[]; + +const MEMORY_TYPES = [ + 'convention', + 'decision', + 'gotcha', + 'failure' +] as const satisfies readonly MemoryType[]; + +const MEMORY_CATEGORY_SET: ReadonlySet = new Set(MEMORY_CATEGORIES); +function isMemoryCategory(value: string): value is MemoryCategory { + return MEMORY_CATEGORY_SET.has(value); +} + +const MEMORY_TYPE_SET: ReadonlySet = new Set(MEMORY_TYPES); +function isMemoryType(value: string): value is MemoryType { + return MEMORY_TYPE_SET.has(value); +} + +function exitWithError(message: string): never { + console.error(message); + process.exit(1); +} + +export async function handleMemoryCli(args: string[]): Promise { + // Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path) + const cliRoot = process.env.CODEBASE_ROOT || process.cwd(); + const memoryPath = path.join(cliRoot, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME); + const subcommand = args[0]; // list | add | remove + + if (subcommand === 'list') { + const memories = await readMemoriesFile(memoryPath); + const opts: { category?: MemoryCategory; type?: MemoryType; query?: string } = {}; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--category') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError( + `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}` + ); + } + if (!isMemoryCategory(value)) { + exitWithError( + `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}` + ); + } + opts.category = value; + i++; + } else if (args[i] === '--type') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`); + } + if (!isMemoryType(value)) { + exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`); + } + opts.type = value; + i++; + } else if (args[i] === '--query') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError('Error: --query requires a value.'); + } + opts.query = value; + i++; + } else if (args[i] === '--json') { + // handled below + } + } + + const filtered = filterMemories(memories, opts); + const enriched = withConfidence(filtered); + const useJson = args.includes('--json'); + + if (useJson) { + console.log(JSON.stringify(enriched, null, 2)); + } else { + if (enriched.length === 0) { + console.log('No memories found.'); + } else { + for (const m of enriched) { + const staleTag = m.stale ? ' [STALE]' : ''; + console.log(`[${m.id}] ${m.type}/${m.category}: ${m.memory}${staleTag}`); + console.log(` Reason: ${m.reason}`); + console.log(` Date: ${m.date} | Confidence: ${m.effectiveConfidence}`); + console.log(''); + } + console.log(`${enriched.length} memor${enriched.length === 1 ? 'y' : 'ies'} total.`); + } + } + } else if (subcommand === 'add') { + let type: MemoryType = 'decision'; + let category: MemoryCategory | undefined; + let memory: string | undefined; + let reason: string | undefined; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--type') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError(`Error: --type requires a value. Allowed: ${MEMORY_TYPES.join(', ')}`); + } + if (!isMemoryType(value)) { + exitWithError(`Error: invalid --type "${value}". Allowed: ${MEMORY_TYPES.join(', ')}`); + } + type = value; + i++; + } else if (args[i] === '--category') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError( + `Error: --category requires a value. Allowed: ${MEMORY_CATEGORIES.join(', ')}` + ); + } + if (!isMemoryCategory(value)) { + exitWithError( + `Error: invalid --category "${value}". Allowed: ${MEMORY_CATEGORIES.join(', ')}` + ); + } + category = value; + i++; + } else if (args[i] === '--memory') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError('Error: --memory requires a value.'); + } + memory = value; + i++; + } else if (args[i] === '--reason') { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + exitWithError('Error: --reason requires a value.'); + } + reason = value; + i++; + } + } + + if (!category || !memory || !reason) { + console.error( + 'Usage: codebase-context memory add --type --category --memory --reason ' + ); + console.error('Required: --category, --memory, --reason'); + process.exit(1); + } + + const crypto = await import('crypto'); + const hashContent = `${type}:${category}:${memory}:${reason}`; + const hash = crypto.createHash('sha256').update(hashContent).digest('hex'); + const id = hash.substring(0, 12); + + const newMemory: Memory = { + id, + type, + category, + memory, + reason, + date: new Date().toISOString() + }; + const result = await appendMemoryFile(memoryPath, newMemory); + + if (result.status === 'duplicate') { + console.log(`Already exists: [${id}] ${memory}`); + } else { + console.log(`Added: [${id}] ${memory}`); + } + } else if (subcommand === 'remove') { + const id = args[1]; + if (!id) { + console.error('Usage: codebase-context memory remove '); + process.exit(1); + } + + const result = await removeMemory(memoryPath, id); + if (result.status === 'not_found') { + console.error(`Memory not found: ${id}`); + process.exit(1); + } else { + console.log(`Removed: ${id}`); + } + } else { + console.error('Usage: codebase-context memory '); + console.error(''); + console.error(' list [--category ] [--type ] [--query ] [--json]'); + console.error(' add --type --category --memory --reason '); + console.error(' remove '); + process.exit(1); + } +} diff --git a/src/cli.ts b/src/cli.ts index d0cb2cd..eedc326 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,6 @@ import path from 'path'; import { promises as fs } from 'fs'; -import type { Memory, MemoryCategory, MemoryType } from './types/index.js'; import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME, @@ -14,13 +13,6 @@ import { KEYWORD_INDEX_FILENAME, VECTOR_DB_DIRNAME } from './constants/codebase-context.js'; -import { - appendMemoryFile, - readMemoriesFile, - removeMemory, - filterMemories, - withConfidence -} from './memory/store.js'; import { CodebaseIndexer } from './core/indexer.js'; import { dispatchTool } from './tools/index.js'; import type { ToolContext } from './tools/index.js'; @@ -28,6 +20,9 @@ import type { IndexState } from './tools/types.js'; import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; +import { formatJson } from './cli-formatters.js'; +import { handleMemoryCli } from './cli-memory.js'; +export { handleMemoryCli } from './cli-memory.js'; analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); @@ -46,6 +41,25 @@ const _CLI_COMMANDS = [ type CliCommand = (typeof _CLI_COMMANDS)[number]; +const CLI_COMMAND_SET: ReadonlySet = new Set(_CLI_COMMANDS); +function isCliCommand(value: string): value is CliCommand { + return CLI_COMMAND_SET.has(value); +} + +const SEARCH_INTENTS = ['explore', 'edit', 'refactor', 'migrate'] as const; +type SearchIntent = (typeof SEARCH_INTENTS)[number]; +const SEARCH_INTENT_SET: ReadonlySet = new Set(SEARCH_INTENTS); +function isSearchIntent(value: string): value is SearchIntent { + return SEARCH_INTENT_SET.has(value); +} + +const TEAM_PATTERN_CATEGORIES = ['all', 'di', 'state', 'testing', 'libraries'] as const; +type TeamPatternCategory = (typeof TEAM_PATTERN_CATEGORIES)[number]; +const TEAM_PATTERN_CATEGORY_SET: ReadonlySet = new Set(TEAM_PATTERN_CATEGORIES); +function isTeamPatternCategory(value: string): value is TeamPatternCategory { + return TEAM_PATTERN_CATEGORY_SET.has(value); +} + function printUsage(): void { console.log('codebase-context [options]'); console.log(''); @@ -81,7 +95,6 @@ async function initToolContext(): Promise { vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) }; - // Check if index exists to determine initial status let indexExists = false; try { await fs.access(paths.keywordIndex); @@ -94,10 +107,10 @@ async function initToolContext(): Promise { status: indexExists ? 'ready' : 'idle' }; - const performIndexing = async (incrementalOnly?: boolean): Promise => { + const performIndexing = async (incrementalOnly?: boolean, reason?: string): Promise => { indexState.status = 'indexing'; const mode = incrementalOnly ? 'incremental' : 'full'; - console.error(`Indexing (${mode}): ${rootPath}`); + console.error(`Indexing (${mode})${reason ? ` — ${reason}` : ''}: ${rootPath}`); try { let lastLoggedProgress = { phase: '', percentage: -1 }; @@ -141,31 +154,16 @@ function extractText(result: { content?: Array<{ type: string; text: string }> } return result.content?.[0]?.text ?? ''; } -function formatJson(json: string, useJson: boolean): void { - if (useJson) { - console.log(json); - return; - } - // Pretty-print already-formatted JSON as-is (it's already readable) - console.log(json); -} - -export async function handleCliCommand(argv: string[]): Promise { - const command = argv[0] as CliCommand | '--help' | undefined; - - if (!command || command === '--help') { - printUsage(); - return; - } - - if (command === 'memory') { - return handleMemoryCli(argv.slice(1)); - } +type FlagValue = string | true; +type Flags = Record; - const useJson = argv.includes('--json'); +function exitWithError(message: string): never { + console.error(message); + process.exit(1); +} - // Parse flags into a map - const flags: Record = {}; +function parseFlags(argv: string[]): Flags { + const flags: Flags = {}; for (let i = 1; i < argv.length; i++) { const arg = argv[i]; if (arg === '--json') continue; @@ -180,88 +178,213 @@ export async function handleCliCommand(argv: string[]): Promise { } } } + return flags; +} + +function requireStringFlag(flags: Flags, key: string, usage: string): string { + const value = flags[key]; + if (value === undefined) { + exitWithError(`Error: --${key} is required\nUsage: ${usage}`); + } + if (typeof value !== 'string') { + exitWithError(`Error: --${key} requires a value\nUsage: ${usage}`); + } + return value; +} + +function optionalStringFlag(flags: Flags, key: string, usage: string): string | undefined { + const value = flags[key]; + if (value === true) { + exitWithError(`Error: --${key} requires a value\nUsage: ${usage}`); + } + return typeof value === 'string' ? value : undefined; +} + +function optionalPositiveIntFlag(flags: Flags, key: string, usage: string): number | undefined { + const value = flags[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string') { + exitWithError(`Error: --${key} requires a value\nUsage: ${usage}`); + } + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) { + exitWithError(`Error: --${key} must be a positive number\nUsage: ${usage}`); + } + return Math.floor(num); +} + +function booleanFlag(flags: Flags, key: string, usage: string): boolean { + const value = flags[key]; + if (value === undefined) return false; + if (value === true) return true; + + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + + exitWithError(`Error: --${key} must be a boolean (true/false)\nUsage: ${usage}`); +} + +export async function handleCliCommand(argv: string[]): Promise { + const rawCommand = argv[0]; + + if (!rawCommand || rawCommand === '--help') { + printUsage(); + return; + } + + if (!isCliCommand(rawCommand)) { + console.error(`Unknown command: ${rawCommand}`); + console.error(''); + printUsage(); + process.exit(1); + } + + const command: CliCommand = rawCommand; + + if (command === 'memory') { + return handleMemoryCli(argv.slice(1)); + } + + const useJson = argv.includes('--json'); + + const flags = parseFlags(argv); const ctx = await initToolContext(); - let toolName: string; - let toolArgs: Record = {}; + type DispatchSpec = + | { toolName: 'search_codebase'; toolArgs: SearchToolArgs } + | { toolName: 'get_codebase_metadata'; toolArgs: Record } + | { toolName: 'get_indexing_status'; toolArgs: Record } + | { toolName: 'get_style_guide'; toolArgs: StyleGuideToolArgs } + | { toolName: 'get_team_patterns'; toolArgs: TeamPatternsToolArgs } + | { toolName: 'get_symbol_references'; toolArgs: SymbolReferencesToolArgs } + | { toolName: 'detect_circular_dependencies'; toolArgs: DetectCircularDependenciesToolArgs }; + + type SearchToolArgs = { + query: string; + includeSnippets: boolean; + intent?: SearchIntent; + limit?: number; + filters?: { language?: string; framework?: string; layer?: string }; + }; + + type StyleGuideToolArgs = { query?: string; category?: string }; + type TeamPatternsToolArgs = { category?: TeamPatternCategory }; + type SymbolReferencesToolArgs = { symbol: string; limit?: number }; + type DetectCircularDependenciesToolArgs = { scope?: string }; + + let dispatch: DispatchSpec; + let formatQuery: string | undefined; + let formatIntent: string | undefined; switch (command) { case 'search': { - if (!flags['query']) { - console.error('Error: --query is required'); - console.error('Usage: codebase-context search --query [--intent ] [--limit ]'); - process.exit(1); + const usage = 'codebase-context search --query [--intent ] [--limit ]'; + const query = requireStringFlag(flags, 'query', usage); + const intentValue = optionalStringFlag(flags, 'intent', usage); + let intent: SearchIntent | undefined; + if (intentValue) { + if (!isSearchIntent(intentValue)) { + exitWithError( + `Error: invalid --intent "${intentValue}". Allowed: ${SEARCH_INTENTS.join(', ')}\nUsage: ${usage}` + ); + } + intent = intentValue; } - toolName = 'search_codebase'; - toolArgs = { - query: flags['query'], - ...(flags['intent'] ? { intent: flags['intent'] } : {}), - ...(flags['limit'] ? { limit: Number(flags['limit']) } : {}), - ...(flags['lang'] || flags['framework'] || flags['layer'] - ? { - filters: { - ...(flags['lang'] ? { language: flags['lang'] } : {}), - ...(flags['framework'] ? { framework: flags['framework'] } : {}), - ...(flags['layer'] ? { layer: flags['layer'] } : {}) - } - } - : {}) + const limit = optionalPositiveIntFlag(flags, 'limit', usage); + const lang = optionalStringFlag(flags, 'lang', usage); + const framework = optionalStringFlag(flags, 'framework', usage); + const layer = optionalStringFlag(flags, 'layer', usage); + + const filters: { language?: string; framework?: string; layer?: string } = {}; + if (lang) filters.language = lang; + if (framework) filters.framework = framework; + if (layer) filters.layer = layer; + + const args: SearchToolArgs = { + query, + includeSnippets: true, + ...(intent ? { intent } : {}), + ...(limit != null ? { limit } : {}), + ...(Object.keys(filters).length > 0 ? { filters } : {}) }; + dispatch = { toolName: 'search_codebase', toolArgs: args }; + formatQuery = query; + formatIntent = intentValue; break; } case 'metadata': { - toolName = 'get_codebase_metadata'; + dispatch = { toolName: 'get_codebase_metadata', toolArgs: {} }; break; } case 'status': { - toolName = 'get_indexing_status'; + dispatch = { toolName: 'get_indexing_status', toolArgs: {} }; break; } case 'reindex': { - toolName = 'refresh_index'; - toolArgs = { - ...(flags['incremental'] ? { incrementalOnly: true } : {}), - ...(flags['reason'] ? { reason: flags['reason'] } : {}) - }; - // For CLI, reindex must be awaited (fire-and-forget won't work in a process that exits) - await ctx.performIndexing(Boolean(flags['incremental'])); + const usage = 'codebase-context reindex [--incremental] [--reason ]'; + const reason = optionalStringFlag(flags, 'reason', usage); + const incremental = booleanFlag(flags, 'incremental', usage); + await ctx.performIndexing(incremental, reason); const statusResult = await dispatchTool('get_indexing_status', {}, ctx); formatJson(extractText(statusResult), useJson); return; } case 'style-guide': { - toolName = 'get_style_guide'; - toolArgs = { - ...(flags['query'] ? { query: flags['query'] } : {}), - ...(flags['category'] ? { category: flags['category'] } : {}) + const usage = 'codebase-context style-guide [--query ] [--category ]'; + const query = optionalStringFlag(flags, 'query', usage); + const category = optionalStringFlag(flags, 'category', usage); + dispatch = { + toolName: 'get_style_guide', + toolArgs: { + ...(query ? { query } : {}), + ...(category ? { category } : {}) + } }; break; } case 'patterns': { - toolName = 'get_team_patterns'; - toolArgs = { - ...(flags['category'] ? { category: flags['category'] } : {}) + const usage = 'codebase-context patterns [--category all|di|state|testing|libraries]'; + const categoryValue = optionalStringFlag(flags, 'category', usage); + let category: TeamPatternCategory | undefined; + if (categoryValue) { + if (!isTeamPatternCategory(categoryValue)) { + exitWithError( + `Error: invalid --category "${categoryValue}". Allowed: ${TEAM_PATTERN_CATEGORIES.join(', ')}\nUsage: ${usage}` + ); + } + category = categoryValue; + } + dispatch = { + toolName: 'get_team_patterns', + toolArgs: { + ...(category ? { category } : {}) + } }; break; } case 'refs': { - if (!flags['symbol']) { - console.error('Error: --symbol is required'); - console.error('Usage: codebase-context refs --symbol [--limit ]'); - process.exit(1); - } - toolName = 'get_symbol_references'; - toolArgs = { - symbol: flags['symbol'], - ...(flags['limit'] ? { limit: Number(flags['limit']) } : {}) + const usage = 'codebase-context refs --symbol [--limit ]'; + const symbol = requireStringFlag(flags, 'symbol', usage); + const limit = optionalPositiveIntFlag(flags, 'limit', usage); + dispatch = { + toolName: 'get_symbol_references', + toolArgs: { + symbol, + ...(limit != null ? { limit } : {}) + } }; break; } case 'cycles': { - toolName = 'detect_circular_dependencies'; - toolArgs = { - ...(flags['scope'] ? { scope: flags['scope'] } : {}) + const usage = 'codebase-context cycles [--scope ]'; + const scope = optionalStringFlag(flags, 'scope', usage); + dispatch = { + toolName: 'detect_circular_dependencies', + toolArgs: { + ...(scope ? { scope } : {}) + } }; break; } @@ -274,118 +397,14 @@ export async function handleCliCommand(argv: string[]): Promise { } try { - const result = await dispatchTool(toolName, toolArgs, ctx); + const result = await dispatchTool(dispatch.toolName, dispatch.toolArgs, ctx); if (result.isError) { console.error(extractText(result)); process.exit(1); } - formatJson(extractText(result), useJson); + formatJson(extractText(result), useJson, command, ctx.rootPath, formatQuery, formatIntent); } catch (error) { console.error('Error:', error instanceof Error ? error.message : String(error)); process.exit(1); } } - -export async function handleMemoryCli(args: string[]): Promise { - // Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path) - const cliRoot = process.env.CODEBASE_ROOT || process.cwd(); - const memoryPath = path.join(cliRoot, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME); - const subcommand = args[0]; // list | add | remove - - if (subcommand === 'list') { - const memories = await readMemoriesFile(memoryPath); - const opts: { category?: MemoryCategory; type?: MemoryType; query?: string } = {}; - - for (let i = 1; i < args.length; i++) { - if (args[i] === '--category' && args[i + 1]) opts.category = args[++i] as MemoryCategory; - else if (args[i] === '--type' && args[i + 1]) opts.type = args[++i] as MemoryType; - else if (args[i] === '--query' && args[i + 1]) opts.query = args[++i]; - else if (args[i] === '--json') { - // handled below - } - } - - const filtered = filterMemories(memories, opts); - const enriched = withConfidence(filtered); - const useJson = args.includes('--json'); - - if (useJson) { - console.log(JSON.stringify(enriched, null, 2)); - } else { - if (enriched.length === 0) { - console.log('No memories found.'); - } else { - for (const m of enriched) { - const staleTag = m.stale ? ' [STALE]' : ''; - console.log(`[${m.id}] ${m.type}/${m.category}: ${m.memory}${staleTag}`); - console.log(` Reason: ${m.reason}`); - console.log(` Date: ${m.date} | Confidence: ${m.effectiveConfidence}`); - console.log(''); - } - console.log(`${enriched.length} memor${enriched.length === 1 ? 'y' : 'ies'} total.`); - } - } - } else if (subcommand === 'add') { - let type: MemoryType = 'decision'; - let category: MemoryCategory | undefined; - let memory: string | undefined; - let reason: string | undefined; - - for (let i = 1; i < args.length; i++) { - if (args[i] === '--type' && args[i + 1]) type = args[++i] as MemoryType; - else if (args[i] === '--category' && args[i + 1]) category = args[++i] as MemoryCategory; - else if (args[i] === '--memory' && args[i + 1]) memory = args[++i]; - else if (args[i] === '--reason' && args[i + 1]) reason = args[++i]; - } - - if (!category || !memory || !reason) { - console.error( - 'Usage: codebase-context memory add --type --category --memory --reason ' - ); - console.error('Required: --category, --memory, --reason'); - process.exit(1); - } - - const crypto = await import('crypto'); - const hashContent = `${type}:${category}:${memory}:${reason}`; - const hash = crypto.createHash('sha256').update(hashContent).digest('hex'); - const id = hash.substring(0, 12); - - const newMemory: Memory = { - id, - type, - category, - memory, - reason, - date: new Date().toISOString() - }; - const result = await appendMemoryFile(memoryPath, newMemory); - - if (result.status === 'duplicate') { - console.log(`Already exists: [${id}] ${memory}`); - } else { - console.log(`Added: [${id}] ${memory}`); - } - } else if (subcommand === 'remove') { - const id = args[1]; - if (!id) { - console.error('Usage: codebase-context memory remove '); - process.exit(1); - } - - const result = await removeMemory(memoryPath, id); - if (result.status === 'not_found') { - console.error(`Memory not found: ${id}`); - process.exit(1); - } else { - console.log(`Removed: ${id}`); - } - } else { - console.error('Usage: codebase-context memory '); - console.error(''); - console.error(' list [--category ] [--type ] [--query ] [--json]'); - console.error(' add --type --category --memory --reason '); - console.error(' remove '); - process.exit(1); - } -} diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 3ee9c2e..508dd5b 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -322,6 +322,13 @@ export class CodebaseIndexer { let stagingDir: string | null = null; try { + // Ensure there is at least a generic fallback analyzer registered when the indexer + // is used directly (e.g. in tests or standalone scripts). + if (analyzerRegistry.getAll().length === 0) { + const { GenericAnalyzer } = await import('../analyzers/generic/index.js'); + analyzerRegistry.register(new GenericAnalyzer()); + } + const buildId = randomUUID(); const generatedAt = new Date().toISOString(); const toolVersion = await getToolVersion(); diff --git a/src/core/search.ts b/src/core/search.ts index 13bd1d8..09cc6e5 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -253,9 +253,11 @@ export class CodebaseSearcher { } this.patternIntelligence = { decliningPatterns, risingPatterns, patternWarnings }; - console.error( - `[search] Loaded pattern intelligence: ${decliningPatterns.size} declining, ${risingPatterns.size} rising patterns` - ); + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error( + `[search] Loaded pattern intelligence: ${decliningPatterns.size} declining, ${risingPatterns.size} rising patterns` + ); + } this.importCentrality = new Map(); if (intelligence.internalFileGraph && intelligence.internalFileGraph.imports) { @@ -277,7 +279,9 @@ export class CodebaseSearcher { this.importCentrality.set(file, count / maxImports); } - console.error(`[search] Computed import centrality for ${importCounts.size} files`); + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`[search] Computed import centrality for ${importCounts.size} files`); + } } } catch (error) { console.warn( diff --git a/src/embeddings/transformers.ts b/src/embeddings/transformers.ts index 43388c5..c2340ba 100644 --- a/src/embeddings/transformers.ts +++ b/src/embeddings/transformers.ts @@ -47,8 +47,10 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider { private async _initialize(): Promise { try { - console.error(`Loading embedding model: ${this.modelName}`); - console.error('(First run will download the model - this may take a moment)'); + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`Loading embedding model: ${this.modelName}`); + console.error('(First run will download the model - this may take a moment)'); + } const { pipeline } = await import('@huggingface/transformers'); @@ -64,7 +66,9 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider { }); this.ready = true; - console.error(`Model loaded successfully: ${this.modelName}`); + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`Model loaded successfully: ${this.modelName}`); + } } catch (error) { console.error('Failed to initialize embedding model:', error); throw error; diff --git a/src/storage/lancedb.ts b/src/storage/lancedb.ts index 27be1b4..8d54f2c 100644 --- a/src/storage/lancedb.ts +++ b/src/storage/lancedb.ts @@ -63,7 +63,7 @@ export class LanceDBStorageProvider implements VectorStorageProvider { if (!hasVectorColumn) { throw new IndexCorruptedError('LanceDB index corrupted: missing vector column'); } - console.error('Opened existing LanceDB table'); + if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('Opened existing LanceDB table'); } else if (options?.expectExisting) { throw new IndexCorruptedError( `LanceDB index missing: no code_chunks table found at ${storagePath}` diff --git a/src/tools/get-team-patterns.ts b/src/tools/get-team-patterns.ts index 79af7a4..dd907bf 100644 --- a/src/tools/get-team-patterns.ts +++ b/src/tools/get-team-patterns.ts @@ -49,14 +49,26 @@ export async function handle( result.tsconfigPaths = intel.tsconfigPaths; } } else if (category === 'di') { - result.dependencyInjection = intel.patterns?.dependencyInjection; + result.patterns = {}; + if (intel.patterns?.dependencyInjection) + (result.patterns as Record).dependencyInjection = + intel.patterns.dependencyInjection; } else if (category === 'state') { - result.stateManagement = intel.patterns?.stateManagement; + result.patterns = {}; + if (intel.patterns?.stateManagement) + (result.patterns as Record).stateManagement = + intel.patterns.stateManagement; } else if (category === 'testing') { - result.unitTestFramework = intel.patterns?.unitTestFramework; - result.e2eFramework = intel.patterns?.e2eFramework; - result.testingFramework = intel.patterns?.testingFramework; - result.testMocking = intel.patterns?.testMocking; + result.patterns = {}; + for (const k of [ + 'unitTestFramework', + 'e2eFramework', + 'testingFramework', + 'testMocking' + ] as const) { + if (intel.patterns?.[k]) + (result.patterns as Record)[k] = intel.patterns[k]; + } } else if (category === 'libraries') { result.topUsed = intel.importGraph?.topUsed || []; if (intel.tsconfigPaths) { diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index d9258c5..dc5981d 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -655,10 +655,10 @@ export async function handle( // Add patterns (do/avoid, capped at 3 each, with adoption %) const doPatterns = preferredPatternsForOutput .slice(0, 3) - .map((p) => `${p.pattern} — ${p.adoption ? ` ${p.adoption}% adoption` : ''}`); + .map((p) => `${p.pattern} — ${p.adoption ? `${p.adoption} adoption` : ''}`); const avoidPatterns = avoidPatternsForOutput .slice(0, 3) - .map((p) => `${p.pattern} — ${p.adoption ? ` ${p.adoption}% adoption` : ''} (declining)`); + .map((p) => `${p.pattern} — ${p.adoption ? `${p.adoption} adoption` : ''} (declining)`); if (doPatterns.length > 0 || avoidPatterns.length > 0) { decisionCard.patterns = { ...(doPatterns.length > 0 && { do: doPatterns }), @@ -737,16 +737,29 @@ export async function handle( return null; } + function formatSnippetFallbackHeader(filePath: string, startLine: number): string { + const rel = path.relative(ctx.rootPath, filePath).replace(/\\/g, '/'); + const displayPath = + rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : path.basename(filePath); + return `${displayPath}:${startLine}`; + } + function enrichSnippetWithScope( snippet: string | undefined, - metadata: ChunkMetadata + metadata: ChunkMetadata, + filePath: string, + startLine: number ): string | undefined { if (!snippet) return undefined; - const scopeHeader = buildScopeHeader(metadata); - if (scopeHeader) { - return `// ${scopeHeader}\n${snippet}`; + + const cleanedSnippet = snippet.replace(/^\r?\n+/, ''); + if (cleanedSnippet.startsWith('//')) { + return cleanedSnippet; } - return snippet; + + const scopeHeader = + buildScopeHeader(metadata) ?? formatSnippetFallbackHeader(filePath, startLine); + return `// ${scopeHeader}\n${cleanedSnippet}`; } return { @@ -768,14 +781,16 @@ export async function handle( results: results.map((r) => { const relationshipsAndHints = buildRelationshipHints(r); const enrichedSnippet = includeSnippets - ? enrichSnippetWithScope(r.snippet, r.metadata) + ? enrichSnippetWithScope(r.snippet, r.metadata, r.filePath, r.startLine) : undefined; return { file: `${r.filePath}:${r.startLine}-${r.endLine}`, summary: r.summary, score: Math.round(r.score * 100) / 100, - ...(r.componentType && r.layer && { type: `${r.componentType}:${r.layer}` }), + ...(r.componentType && + r.layer && + r.layer !== 'unknown' && { type: `${r.componentType}:${r.layer}` }), ...(r.trend && r.trend !== 'Stable' && { trend: r.trend }), ...(r.patternWarning && { patternWarning: r.patternWarning }), ...(relationshipsAndHints.relationships && { diff --git a/src/tools/types.ts b/src/tools/types.ts index 837643f..8f1e011 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -37,7 +37,7 @@ export interface ToolContext { indexState: IndexState; paths: ToolPaths; rootPath: string; - performIndexing: (incrementalOnly?: boolean) => void; + performIndexing: (incrementalOnly?: boolean, reason?: string) => void; } export interface ToolResponse { @@ -45,3 +45,171 @@ export interface ToolResponse { isError?: boolean; [key: string]: unknown; } + +// --- Search response types --- +export interface SearchQuality { + status: 'ok' | 'low_confidence'; + confidence: number | string; + hint?: string; +} + +export interface SearchResultItem { + file: string; // "path:startLine-endLine" + summary: string; + score: number; + type?: string; // "componentType:layer" + trend?: 'Rising' | 'Declining'; + patternWarning?: string; + relationships?: { + importedByCount?: number; + hasTests?: boolean; + }; + hints?: { + callers?: string[]; + consumers?: string[]; + tests?: string[]; + }; + snippet?: string; +} + +export interface SearchResponse { + status: string; + searchQuality: SearchQuality; + preflight?: DecisionCard; + results: SearchResultItem[]; + totalResults: number; + relatedMemories?: string[]; +} + +// --- Pattern response types --- +export interface PatternEntry { + name: string; + frequency: string; + trend?: string; + adoption?: string; +} + +export interface PatternCategory { + primary: PatternEntry; + alsoDetected?: PatternEntry[]; +} + +export interface PatternConflict { + category: string; + primary: { name: string; adoption: string; trend?: string }; + alternative: { name: string; adoption: string; trend?: string }; +} + +export interface GoldenFile { + file: string; + score: number; +} + +export interface LibraryEntry { + source: string; + count: number; +} + +export interface PatternResponse { + patterns?: Record; + goldenFiles?: GoldenFile[]; + memories?: Array<{ type: string; memory: string }>; + conflicts?: PatternConflict[]; + topUsed?: LibraryEntry[]; +} + +// --- Metadata response types --- +export interface MetadataDependency { + name: string; + version?: string; + category?: string; +} + +export interface MetadataFramework { + name?: string; + version?: string; + stateManagement?: string[]; + testingFrameworks?: string[]; + uiLibraries?: string[]; +} + +export interface MetadataLanguage { + name?: string; + percentage?: number; + fileCount?: number; + lineCount?: number; +} + +export interface MetadataStatistics { + totalFiles?: number; + totalLines?: number; + totalComponents?: number; +} + +export interface MetadataInner { + name?: string; + framework?: MetadataFramework; + languages?: MetadataLanguage[]; + dependencies?: MetadataDependency[]; + architecture?: { type?: string; modules?: Array<{ name: string }> }; + projectStructure?: { type?: string }; + statistics?: MetadataStatistics; +} + +export interface MetadataResponse { + status?: string; + metadata?: MetadataInner; +} + +// --- Style guide response types --- +export interface StyleGuideResult { + file?: string; + relevantSections?: string[]; +} + +export interface StyleGuideResponse { + status?: string; // 'success' | 'no_results' + query?: string; + category?: string; + results?: StyleGuideResult[]; + totalFiles?: number; + totalMatches?: number; + limited?: boolean; + notice?: string; + // no_results shape: + message?: string; + hint?: string; + searchedPatterns?: string[]; +} + +// --- Cycles response types --- +export interface CycleItem { + files?: string[]; + cycle?: string[]; + severity?: string; +} + +export interface GraphStats { + files?: number; + edges?: number; + avgDependencies?: number; +} + +export interface CyclesResponse { + cycles?: CycleItem[]; + graphStats?: GraphStats; +} + +// --- Refs response types --- +export interface RefsUsage { + file: string; + line: number; + preview: string; +} + +export interface RefsResponse { + symbol: string; + usageCount: number; + confidence: string; + usages: RefsUsage[]; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..4fb411d --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const toolMocks = vi.hoisted(() => ({ + dispatchTool: vi.fn() +})); + +vi.mock('../src/tools/index.js', () => ({ + dispatchTool: toolMocks.dispatchTool +})); + +import { handleCliCommand, handleMemoryCli } from '../src/cli.js'; + +describe('CLI', () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + let logSpy: ReturnType; + let originalEnvRoot: string | undefined; + + beforeEach(() => { + toolMocks.dispatchTool.mockReset(); + + originalEnvRoot = process.env.CODEBASE_ROOT; + delete process.env.CODEBASE_ROOT; + + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((_code?: number): never => { + throw new Error(`process.exit:${_code ?? ''}`); + }) as unknown as typeof process.exit); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + + if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT; + else process.env.CODEBASE_ROOT = originalEnvRoot; + }); + + it('search errors when --query has no value', async () => { + await expect(handleCliCommand(['search', '--query', '--json'])).rejects.toThrow(/process\.exit:1/); + expect(toolMocks.dispatchTool).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('search dispatches with typed args', async () => { + toolMocks.dispatchTool.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] + }); + + await handleCliCommand([ + 'search', + '--query', + 'foo', + '--intent', + 'edit', + '--limit', + '3', + '--lang', + 'ts', + '--framework', + 'angular', + '--layer', + 'core', + '--json' + ]); + + expect(toolMocks.dispatchTool).toHaveBeenCalledTimes(1); + const [toolName, toolArgs] = toolMocks.dispatchTool.mock.calls[0] ?? []; + expect(toolName).toBe('search_codebase'); + expect(toolArgs).toEqual({ + query: 'foo', + includeSnippets: true, + intent: 'edit', + limit: 3, + filters: { language: 'ts', framework: 'angular', layer: 'core' } + }); + }); + + it('patterns errors on invalid category', async () => { + await expect(handleCliCommand(['patterns', '--category', 'nope'])).rejects.toThrow(/process\.exit:1/); + expect(toolMocks.dispatchTool).not.toHaveBeenCalled(); + }); + + it('formatting falls back safely on unexpected JSON', async () => { + toolMocks.dispatchTool.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ foo: 'bar' }) }] + }); + + await handleCliCommand(['search', '--query', 'foo']); + expect(toolMocks.dispatchTool).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalled(); + }); + + it('memory list errors on invalid --type', async () => { + await expect(handleMemoryCli(['list', '--type', 'nope'])).rejects.toThrow(/process\.exit:1/); + expect(errorSpy).toHaveBeenCalled(); + }); +}); + diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..6b6958c --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + testTimeout: 15000 + } +}); + diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 504a389..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - environment: "node", - testTimeout: 15000, - }, -});