diff --git a/docs/architecture.md b/docs/architecture.md index 1c0c49d..5a54fb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ Core (shared computation) | result builders used by MCP, CLI, and operation descriptors |\ | \-> Operation Registry - | typed descriptors, input schemas, CLI/MCP adapters, result wrappers + | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) CLI (terminal/CI) | 17 tools, 2 prompts, | 18 commands with text + JSON @@ -40,6 +40,7 @@ src/ graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events core/index.ts <- Shared result computation (MCP + CLI) operations/index.ts <- Analysis operation descriptors + typed input schemas + operations/formatters.ts <- Result-object text formatters for CLI commands config/index.ts <- Config discovery + zod validation rules/index.ts <- Rules engine + registry (check command + MCP check tool) mcp/index.ts <- 17 MCP tools for LLM integration @@ -86,7 +87,7 @@ runOperation(operation, codebaseGraph, input, context) ## Key Design Decisions - **Dual interface**: MCP stdio for LLM agents, CLI subcommands for humans/CI. Both consume `src/core/`. -- **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands. +- **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, discriminated run results, and result-object text formatters. MCP tool registration and CLI command execution consume those descriptors; CLI JSON remains raw result data plus cache facts. - **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. @@ -104,6 +105,6 @@ Vertical slice through all layers: 1. **types/index.ts** — Add field to `FileMetrics` (and `ParsedFile`/`ParsedExport` if extracted at parse time) 2. **parser/index.ts** — Extract raw data from AST or external source (git, filesystem) 3. **analyzer/index.ts** — Compute derived metric, store in `fileMetrics` map -4. **operations/index.ts** — Add or update the operation descriptor, input schema, and CLI/MCP mapping +4. **operations/index.ts / operations/formatters.ts** — Add or update the operation descriptor, input schema, text formatter, and CLI/MCP mapping 5. **mcp/index.ts / cli.ts** — Expose via existing adapter or new command/tool 6. **Tests** — Cover parser extraction, analyzer computation, operation descriptor parity, and adapter output diff --git a/llms-full.txt b/llms-full.txt index fceef37..8cbea7c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -28,7 +28,7 @@ Core (shared computation) | result builders used by MCP, CLI, and operation descriptors |\ | \-> Operation Registry - | typed descriptors, input schemas, CLI/MCP adapters, result wrappers + | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) + CLI | MCP: 17 tools, 2 prompts, 3 resources for LLM agents @@ -46,6 +46,7 @@ src/ graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events core/index.ts <- Shared result computation (MCP + CLI) operations/index.ts <- Analysis operation descriptors + typed input schemas + operations/formatters.ts <- Result-object text formatters for CLI commands mcp/index.ts <- 17 MCP tools for LLM integration mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning @@ -86,7 +87,7 @@ runOperation(operation, codebaseGraph, input, context) ## Key Design Decisions - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. -- **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands. +- **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, discriminated run results, and result-object text formatters. MCP tool registration and CLI command execution consume those descriptors; CLI JSON remains raw result data plus cache facts. - **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports. diff --git a/roadmap.md b/roadmap.md index 1261c05..6b7ec5c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -277,10 +277,11 @@ Collapse CLI + MCP operation duplication into one descriptor registry before add - Expand CH-P1-02 coverage for descriptor validation, CLI parse failure, and cache reuse through registry-adapted commands. - Use one graph-load pipeline with progress callbacks. - Extend CH-P1-02 coverage to MCP/stdio graph-load behavior. +- Move operation text formatting over result objects into descriptor-backed formatters. **Remaining:** -- Move text/SARIF/markdown formatting over result objects into formatters. +- None for the operation-registry foundation. Future non-text output variants remain tracked under P2 Output Formats + Actionability. ### Type/Shape Layer diff --git a/src/cli.ts b/src/cli.ts index 09c667c..070c05a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,8 +92,8 @@ function printOperationError(result: { error: string; data?: unknown }): never { process.exit(1); } -function parseCliOperationInput( - operation: Operation, +function parseCliOperationInput( + operation: Operation, input: unknown, ): TInput { const parsed = parseOperationInput(operation, input); @@ -115,6 +115,14 @@ function runCliOperation( return result.data; } +function outputOperationText( + operation: Operation, + result: TResult, + input: TInput, +): void { + output(operation.formatText(result, input)); +} + function reportGraphLoadProgress(event: GraphLoadProgress): void { progress(event.message); } @@ -250,29 +258,7 @@ program return; } - output(`Codebase Overview`); - output(`─────────────────`); - output(`Files: ${result.totalFiles}`); - output(`Functions: ${result.totalFunctions}`); - output(`Dependencies: ${result.totalDependencies}`); - output(`Analysis: ${result.analysis.mode} (${result.analysis.callGraphPrecision} call graph)`); - output(`Avg LOC: ${result.metrics.avgLOC}`); - output(`Max Depth: ${result.metrics.maxDepth}`); - output(`Circular: ${result.metrics.circularDeps}`); - output(``); - output(`Modules`); - output(`${"Path".padEnd(40)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Coupling".padStart(10)} ${"Cohesion".padStart(10)}`); - output(`${"─".repeat(40)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(10)}`); - for (const m of result.modules) { - output( - `${m.path.padEnd(40)} ${String(m.files).padStart(6)} ${String(m.loc).padStart(8)} ${m.avgCoupling.padStart(10)} ${m.cohesion.toFixed(2).padStart(10)}`, - ); - } - output(``); - output(`Top Depended Files`); - for (const f of result.topDependedFiles) { - output(` ${f}`); - } + outputOperationText(operations.overview, result, input); }); // ── Subcommand: hotspots ──────────────────────────────────── @@ -302,15 +288,7 @@ program progress(`Showing coupling (default). Use --metric to change.`); } - output(`Hotspots: ${result.metric}`); - output(`──────────${"─".repeat(result.metric.length)}`); - output(`${"Path".padEnd(50)} ${"Score".padStart(10)} Reason`); - output(`${"─".repeat(50)} ${"─".repeat(10)} ${"─".repeat(30)}`); - for (const h of result.hotspots) { - output(`${h.path.padEnd(50)} ${h.score.toFixed(2).padStart(10)} ${h.reason}`); - } - output(``); - output(result.summary); + outputOperationText(operations.hotspots, result, input); }); // ── Subcommand: file ──────────────────────────────────────── @@ -333,53 +311,7 @@ program return; } - output(`File: ${result.path}`); - output("─".repeat(6 + result.path.length)); - output(`LOC: ${result.loc}`); - output(``); - - if (result.exports.length > 0) { - output(`Exports (${result.exports.length})`); - for (const e of result.exports) { - output(` ${e.type.padEnd(12)} ${e.name} (${e.loc} LOC)`); - } - output(``); - } - - if (result.imports.length > 0) { - output(`Imports (${result.imports.length})`); - for (const i of result.imports) { - const typeTag = i.isTypeOnly ? " [type]" : ""; - output(` ${i.from} → {${i.symbols.join(", ")}}${typeTag}`); - } - output(``); - } - - if (result.dependents.length > 0) { - output(`Dependents (${result.dependents.length})`); - for (const d of result.dependents) { - const typeTag = d.isTypeOnly ? " [type]" : ""; - output(` ${d.path} → {${d.symbols.join(", ")}}${typeTag}`); - } - output(``); - } - - output(`Metrics`); - output(` PageRank: ${result.metrics.pageRank}`); - output(` Betweenness: ${result.metrics.betweenness}`); - output(` Fan-in: ${result.metrics.fanIn}`); - output(` Fan-out: ${result.metrics.fanOut}`); - output(` Coupling: ${result.metrics.coupling}`); - output(` Tension: ${result.metrics.tension}`); - output(` Bridge: ${result.metrics.isBridge ? "yes" : "no"}`); - output(` Churn: ${result.metrics.churn}`); - output(` Complexity: ${result.metrics.cyclomaticComplexity}`); - output(` Blast radius: ${result.metrics.blastRadius}`); - output(` Has tests: ${result.metrics.hasTests ? `yes (${result.metrics.testFile})` : "no"}`); - - if (result.metrics.deadExports.length > 0) { - output(` Dead exports: ${result.metrics.deadExports.join(", ")}`); - } + outputOperationText(operations.fileContext, result, input); }); // ── Subcommand: search ────────────────────────────────────── @@ -405,22 +337,7 @@ program return; } - if (result.results.length === 0) { - output(`No results for "${query}"`); - if (result.suggestions && result.suggestions.length > 0) { - output(`\nDid you mean: ${result.suggestions.join(", ")}?`); - } - return; - } - - output(`Search: "${query}" (${result.results.length} results)`); - output("─".repeat(40)); - for (const r of result.results) { - output(`${r.file} (score: ${r.score.toFixed(2)})`); - for (const s of r.symbols) { - output(` ${s.type.padEnd(12)} ${s.name} (${s.loc} LOC, relevance: ${s.relevance.toFixed(2)})`); - } - } + outputOperationText(operations.search, result, input); }); // ── Subcommand: changes ───────────────────────────────────── @@ -443,46 +360,7 @@ program return; } - output(`Changes (${result.scope})`); - output("─".repeat(20)); - - if (result.changedFiles.length === 0) { - output(`No changes detected.`); - return; - } - - output(`Changed files (${result.changedFiles.length}):`); - for (const f of result.changedFiles) { - output(` ${f}`); - } - - if (result.changedSymbols.length > 0) { - output(``); - output(`Changed symbols:`); - for (const cs of result.changedSymbols) { - output(` ${cs.file}: ${cs.symbols.join(", ")}`); - } - } - - if (result.affectedFiles.length > 0) { - output(``); - output(`Affected files (${result.affectedFiles.length}):`); - for (const f of result.affectedFiles) { - output(` ${f}`); - } - } - - if (result.fileRiskMetrics.length > 0) { - output(``); - output(`Risk Metrics`); - output(`${"File".padEnd(50)} ${"Blast".padStart(8)} ${"Cmplx".padStart(8)} ${"Churn".padStart(8)}`); - output(`${"─".repeat(50)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)}`); - for (const m of result.fileRiskMetrics) { - output( - `${m.file.padEnd(50)} ${String(m.blastRadius).padStart(8)} ${m.complexity.toFixed(1).padStart(8)} ${String(m.churn).padStart(8)}`, - ); - } - } + outputOperationText(operations.changes, result, input); }); // ── Subcommand: dependents ────────────────────────────────── @@ -509,26 +387,7 @@ program return; } - output(`Dependents: ${result.file}`); - output("─".repeat(13 + result.file.length)); - output(`Risk level: ${result.riskLevel}`); - output(`Total affected: ${result.totalAffected}`); - output(``); - - if (result.directDependents.length > 0) { - output(`Direct dependents (${result.directDependents.length}):`); - for (const d of result.directDependents) { - output(` ${d.path} → {${d.symbols.join(", ")}}`); - } - output(``); - } - - if (result.transitiveDependents.length > 0) { - output(`Transitive dependents (${result.transitiveDependents.length}):`); - for (const t of result.transitiveDependents) { - output(` ${t.path} (depth ${t.depth}, via ${t.throughPath.join(" → ")})`); - } - } + outputOperationText(operations.dependents, result, input); }); // ── Subcommand: modules ──────────────────────────────────── @@ -549,31 +408,7 @@ program return; } - output(`Module Structure`); - output(`────────────────`); - output(`${"Path".padEnd(30)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Cohesion".padStart(10)} ${"EscVel".padStart(8)}`); - output(`${"─".repeat(30)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(8)}`); - for (const m of result.modules) { - output( - `${m.path.padEnd(30)} ${String(m.files).padStart(6)} ${String(m.loc).padStart(8)} ${m.cohesion.toFixed(2).padStart(10)} ${m.escapeVelocity.toFixed(2).padStart(8)}`, - ); - } - - if (result.crossModuleDeps.length > 0) { - output(``); - output(`Cross-Module Dependencies (${result.crossModuleDeps.length}):`); - for (const d of result.crossModuleDeps.slice(0, 20)) { - output(` ${d.from} → ${d.to} (weight: ${d.weight})`); - } - } - - if (result.circularDeps.length > 0) { - output(``); - output(`Circular Dependencies (${result.circularDeps.length}):`); - for (const c of result.circularDeps) { - output(` [${c.severity}] ${c.cycle.map((p) => p.join(" → ")).join("; ")}`); - } - } + outputOperationText(operations.moduleStructure, result, input); }); // ── Subcommand: forces ───────────────────────────────────── @@ -601,79 +436,7 @@ program return; } - output(`Force Analysis`); - output(`──────────────`); - output(result.summary); - output(``); - - output(`Module Cohesion:`); - for (const m of result.moduleCohesion) { - output(` ${m.path.padEnd(30)} ${m.verdict.padEnd(14)} cohesion: ${m.cohesion.toFixed(2)}`); - } - - if (result.tensionFiles.length > 0) { - output(``); - output(`Tension Files (${result.tensionFiles.length}):`); - for (const t of result.tensionFiles) { - output(` ${t.file} (tension: ${t.tension.toFixed(2)})`); - for (const p of t.pulledBy) { - output(` ← ${p.module} (strength: ${p.strength.toFixed(2)}, symbols: ${p.symbols.join(", ")})`); - } - } - } - - if (result.bridgeFiles.length > 0) { - output(``); - output(`Bridge Files (${result.bridgeFiles.length}):`); - for (const b of result.bridgeFiles) { - output(` ${b.file} (betweenness: ${b.betweenness.toFixed(3)}, role: ${b.role})`); - } - } - - if (result.extractionCandidates.length > 0) { - output(``); - output(`Extraction Candidates (${result.extractionCandidates.length}):`); - for (const e of result.extractionCandidates) { - output(` ${e.target} (escape velocity: ${e.escapeVelocity.toFixed(2)})`); - output(` ${e.recommendation}`); - } - } - - if (result.shallowModules.length > 0) { - output(``); - output(`Shallow Modules (${result.shallowModules.length}):`); - for (const m of result.shallowModules) { - output(` ${m.module} (${m.exports} exports, cohesion: ${m.cohesion.toFixed(2)})`); - output(` ${m.evidence}`); - } - } - - if (result.deepModules.length > 0) { - output(``); - output(`Deep Modules (${result.deepModules.length}):`); - for (const m of result.deepModules) { - output(` ${m.module} (${m.exports} exports, depended by: ${m.dependedByModules})`); - output(` ${m.evidence}`); - } - } - - if (result.seamCandidates.length > 0) { - output(``); - output(`Seam Candidates (${result.seamCandidates.length}):`); - for (const seam of result.seamCandidates) { - output(` ${seam.target} [${seam.scope}] (dependents: ${seam.dependentModules}, fan-in: ${seam.fanIn})`); - output(` ${seam.evidence}`); - } - } - - if (result.localityRisks.length > 0) { - output(``); - output(`Locality Risks (${result.localityRisks.length}):`); - for (const risk of result.localityRisks) { - output(` ${risk.file} [${risk.kind}] (blast radius: ${risk.blastRadius}, tension: ${risk.tension.toFixed(2)})`); - output(` ${risk.evidence}`); - } - } + outputOperationText(operations.forces, result, input); }); // ── Subcommand: dead-exports ─────────────────────────────── @@ -699,20 +462,7 @@ program return; } - output(`Dead Exports`); - output(`────────────`); - output(result.summary); - - if (result.files.length > 0) { - output(``); - for (const f of result.files) { - output(`${f.path} (${f.deadExports.length}/${f.totalExports} unused, ${f.confidence} confidence):`); - if (f.packageEntrypointReason) output(` public API: ${f.packageEntrypointReason}`); - for (const e of f.deadExports) { - output(` - ${e}`); - } - } - } + outputOperationText(operations.deadExports, result, input); }); // ── Subcommand: opportunities ────────────────────────────── @@ -736,20 +486,7 @@ program return; } - output(`Opportunities (${result.opportunities.length} of ${result.totalOpportunities})`); - output("─".repeat(40)); - output(result.summary); - - for (const opportunity of result.opportunities) { - output(``); - output(`#${opportunity.rank} [${opportunity.priority}] ${opportunity.title}`); - output(` Target: ${opportunity.target}`); - output(` Kind: ${opportunity.kind}`); - output(` Score: ${opportunity.score.toFixed(1)} (${opportunity.confidence} confidence)`); - output(` Why: ${opportunity.why}`); - output(` Evidence: ${opportunity.evidence.join("; ")}`); - output(` Next: ${opportunity.suggestedCommands[0] ?? "Review target manually"}`); - } + outputOperationText(operations.opportunities, result, input); }); // ── Subcommand: groups ───────────────────────────────────── @@ -770,15 +507,7 @@ program return; } - output(`Groups`); - output(`──────`); - output(`${"#".padStart(3)} ${"Name".padEnd(20)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Importance".padStart(12)} ${"Coupling".padStart(10)}`); - output(`${"─".repeat(3)} ${"─".repeat(20)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(12)} ${"─".repeat(10)}`); - for (const g of result.groups) { - output( - `${String(g.rank).padStart(3)} ${g.name.padEnd(20)} ${String(g.files).padStart(6)} ${String(g.loc).padStart(8)} ${g.importance.padStart(12)} ${String(g.coupling.total).padStart(10)}`, - ); - } + outputOperationText(operations.groups, result, input); }); // ── Subcommand: symbol ───────────────────────────────────── @@ -801,33 +530,7 @@ program return; } - output(`Symbol: ${result.name}`); - output("─".repeat(8 + result.name.length)); - output(`File: ${result.file}`); - output(`Type: ${result.type}`); - output(`LOC: ${result.loc}`); - output(`Default: ${result.isDefault ? "yes" : "no"}`); - output(`Complexity: ${result.complexity}`); - output(`Fan-in: ${result.fanIn}`); - output(`Fan-out: ${result.fanOut}`); - output(`PageRank: ${result.pageRank}`); - output(`Betweenness:${result.betweenness}`); - - if (result.callers.length > 0) { - output(``); - output(`Callers (${result.callers.length}):`); - for (const c of result.callers) { - output(` ${c.symbol} (${c.file}) [${c.confidence}]`); - } - } - - if (result.callees.length > 0) { - output(``); - output(`Callees (${result.callees.length}):`); - for (const c of result.callees) { - output(` ${c.symbol} (${c.file}) [${c.confidence}]`); - } - } + outputOperationText(operations.symbolContext, result, input); }); // ── Subcommand: impact ───────────────────────────────────── @@ -849,19 +552,7 @@ program return; } - output(`Impact Analysis: ${symbol}`); - output("─".repeat(18 + symbol.length)); - output(`Total affected: ${result.totalAffected}`); - - if (result.levels.length > 0) { - output(``); - for (const level of result.levels) { - output(`Depth ${level.depth} — ${level.risk} (${level.affected.length}):`); - for (const a of level.affected) { - output(` ${a.symbol} (${a.file}) [${a.confidence}]`); - } - } - } + outputOperationText(operations.impact, result, input); }); // ── Subcommand: rename ───────────────────────────────────── @@ -886,18 +577,7 @@ program return; } - output(`Rename: ${oldName} → ${newName}${dryRun ? " (dry run)" : ""}`); - output("─".repeat(40)); - - if (result.references.length === 0) { - output(`No references found for "${oldName}"`); - return; - } - - output(`References (${result.references.length}):`); - for (const ref of result.references) { - output(` ${ref.file} [${ref.confidence}] ${ref.symbol}`); - } + outputOperationText(operations.rename, result, input); }); // ── Subcommand: processes ────────────────────────────────── @@ -923,22 +603,7 @@ program return; } - output(`Processes (${result.processes.length} of ${result.totalProcesses})`); - output("─".repeat(30)); - - if (result.processes.length === 0) { - output(`No processes found.`); - return; - } - - for (const p of result.processes) { - output(``); - output(`${p.name} (depth: ${p.depth}, modules: ${p.modulesTouched.join(", ")})`); - output(` Entry: ${p.entryPoint.file}::${p.entryPoint.symbol}`); - for (const s of p.steps) { - output(` ${String(s.step).padStart(3)}. ${s.file}::${s.symbol}`); - } - } + outputOperationText(operations.processes, result, input); }); // ── Subcommand: clusters ─────────────────────────────────── @@ -962,16 +627,7 @@ program return; } - output(`Clusters (${result.clusters.length} of ${result.totalClusters})`); - output("─".repeat(30)); - - for (const c of result.clusters) { - output(``); - output(`${c.name} (${c.fileCount} files, cohesion: ${c.cohesion.toFixed(2)})`); - for (const f of c.files) { - output(` ${f}`); - } - } + outputOperationText(operations.clusters, result, input); }); // ── Subcommand: init ─────────────────────────────────────── diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 6865401..aa59d48 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -54,8 +54,8 @@ function errorPayload( return nextSteps ? { ...payload, nextSteps } : payload; } -function mcpInputSchema( - operation: Operation, +function mcpInputSchema( + operation: Operation, ): z.ZodType> { const shape: z.ZodRawShape = {}; for (const [key, schema] of Object.entries(operation.inputShape)) { diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts new file mode 100644 index 0000000..ad7b33e --- /dev/null +++ b/src/operations/formatters.ts @@ -0,0 +1,549 @@ +import type { + ChangesError, + ChangesResult, + ClustersResult, + DeadExportsResult, + DependentsError, + DependentsResult, + FileContextError, + FileContextResult, + ForcesResult, + GroupsResult, + HotspotsResult, + ModuleStructureResult, + OpportunitiesResult, + OverviewResult, + ProcessesResult, + SearchResult, + SymbolContextError, + SymbolContextResult, +} from "../core/index.js"; +import type { ImpactResult, RenameResult } from "../impact/index.js"; + +function text(lines: readonly string[]): string { + return lines.join("\n"); +} + +/** + * Format an overview operation result for human CLI output. + */ +export function formatOverviewText(result: OverviewResult): string { + const lines = [ + "Codebase Overview", + "─────────────────", + `Files: ${result.totalFiles}`, + `Functions: ${result.totalFunctions}`, + `Dependencies: ${result.totalDependencies}`, + `Analysis: ${result.analysis.mode} (${result.analysis.callGraphPrecision} call graph)`, + `Avg LOC: ${result.metrics.avgLOC}`, + `Max Depth: ${result.metrics.maxDepth}`, + `Circular: ${result.metrics.circularDeps}`, + "", + "Modules", + `${"Path".padEnd(40)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Coupling".padStart(10)} ${"Cohesion".padStart(10)}`, + `${"─".repeat(40)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(10)}`, + ...result.modules.map( + (m) => + `${m.path.padEnd(40)} ${String(m.files).padStart(6)} ${String(m.loc).padStart(8)} ${m.avgCoupling.padStart(10)} ${m.cohesion.toFixed(2).padStart(10)}`, + ), + "", + "Top Depended Files", + ...result.topDependedFiles.map((file) => ` ${file}`), + ]; + return text(lines); +} + +/** + * Format a hotspots operation result for human CLI output. + */ +export function formatHotspotsText(result: HotspotsResult): string { + return text([ + `Hotspots: ${result.metric}`, + `──────────${"─".repeat(result.metric.length)}`, + `${"Path".padEnd(50)} ${"Score".padStart(10)} Reason`, + `${"─".repeat(50)} ${"─".repeat(10)} ${"─".repeat(30)}`, + ...result.hotspots.map((hotspot) => `${hotspot.path.padEnd(50)} ${hotspot.score.toFixed(2).padStart(10)} ${hotspot.reason}`), + "", + result.summary, + ]); +} + +/** + * Format a file-context operation result for human CLI output. + */ +export function formatFileContextText(result: FileContextResult | FileContextError): string { + if ("error" in result) return result.error; + + const lines = [ + `File: ${result.path}`, + "─".repeat(6 + result.path.length), + `LOC: ${result.loc}`, + "", + ]; + + if (result.exports.length > 0) { + lines.push( + `Exports (${result.exports.length})`, + ...result.exports.map((fileExport) => ` ${fileExport.type.padEnd(12)} ${fileExport.name} (${fileExport.loc} LOC)`), + "", + ); + } + + if (result.imports.length > 0) { + lines.push( + `Imports (${result.imports.length})`, + ...result.imports.map((fileImport) => { + const typeTag = fileImport.isTypeOnly ? " [type]" : ""; + return ` ${fileImport.from} → {${fileImport.symbols.join(", ")}}${typeTag}`; + }), + "", + ); + } + + if (result.dependents.length > 0) { + lines.push( + `Dependents (${result.dependents.length})`, + ...result.dependents.map((dependent) => { + const typeTag = dependent.isTypeOnly ? " [type]" : ""; + return ` ${dependent.path} → {${dependent.symbols.join(", ")}}${typeTag}`; + }), + "", + ); + } + + lines.push( + "Metrics", + ` PageRank: ${result.metrics.pageRank}`, + ` Betweenness: ${result.metrics.betweenness}`, + ` Fan-in: ${result.metrics.fanIn}`, + ` Fan-out: ${result.metrics.fanOut}`, + ` Coupling: ${result.metrics.coupling}`, + ` Tension: ${result.metrics.tension}`, + ` Bridge: ${result.metrics.isBridge ? "yes" : "no"}`, + ` Churn: ${result.metrics.churn}`, + ` Complexity: ${result.metrics.cyclomaticComplexity}`, + ` Blast radius: ${result.metrics.blastRadius}`, + ` Has tests: ${result.metrics.hasTests ? `yes (${result.metrics.testFile})` : "no"}`, + ); + + if (result.metrics.deadExports.length > 0) { + lines.push(` Dead exports: ${result.metrics.deadExports.join(", ")}`); + } + + return text(lines); +} + +/** + * Format a search operation result for human CLI output. + */ +export function formatSearchText(result: SearchResult): string { + if (result.results.length === 0) { + const lines = [`No results for "${result.query}"`]; + if (result.suggestions && result.suggestions.length > 0) { + lines.push("", `Did you mean: ${result.suggestions.join(", ")}?`); + } + return text(lines); + } + + const lines = [ + `Search: "${result.query}" (${result.results.length} results)`, + "─".repeat(40), + ]; + for (const searchResult of result.results) { + lines.push(`${searchResult.file} (score: ${searchResult.score.toFixed(2)})`); + lines.push( + ...searchResult.symbols.map( + (symbol) => ` ${symbol.type.padEnd(12)} ${symbol.name} (${symbol.loc} LOC, relevance: ${symbol.relevance.toFixed(2)})`, + ), + ); + } + return text(lines); +} + +/** + * Format a changes operation result for human CLI output. + */ +export function formatChangesText(result: ChangesResult | ChangesError): string { + if ("error" in result) return result.error; + + const lines = [ + `Changes (${result.scope})`, + "─".repeat(20), + ]; + + if (result.changedFiles.length === 0) { + lines.push("No changes detected."); + return text(lines); + } + + lines.push( + `Changed files (${result.changedFiles.length}):`, + ...result.changedFiles.map((file) => ` ${file}`), + ); + + if (result.changedSymbols.length > 0) { + lines.push( + "", + "Changed symbols:", + ...result.changedSymbols.map((changedSymbol) => ` ${changedSymbol.file}: ${changedSymbol.symbols.join(", ")}`), + ); + } + + if (result.affectedFiles.length > 0) { + lines.push( + "", + `Affected files (${result.affectedFiles.length}):`, + ...result.affectedFiles.map((file) => ` ${file}`), + ); + } + + if (result.fileRiskMetrics.length > 0) { + lines.push( + "", + "Risk Metrics", + `${"File".padEnd(50)} ${"Blast".padStart(8)} ${"Cmplx".padStart(8)} ${"Churn".padStart(8)}`, + `${"─".repeat(50)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)}`, + ...result.fileRiskMetrics.map( + (metric) => + `${metric.file.padEnd(50)} ${String(metric.blastRadius).padStart(8)} ${metric.complexity.toFixed(1).padStart(8)} ${String(metric.churn).padStart(8)}`, + ), + ); + } + + return text(lines); +} + +/** + * Format a dependents operation result for human CLI output. + */ +export function formatDependentsText(result: DependentsResult | DependentsError): string { + if ("error" in result) return result.error; + + const lines = [ + `Dependents: ${result.file}`, + "─".repeat(13 + result.file.length), + `Risk level: ${result.riskLevel}`, + `Total affected: ${result.totalAffected}`, + "", + ]; + + if (result.directDependents.length > 0) { + lines.push( + `Direct dependents (${result.directDependents.length}):`, + ...result.directDependents.map((dependent) => ` ${dependent.path} → {${dependent.symbols.join(", ")}}`), + "", + ); + } + + if (result.transitiveDependents.length > 0) { + lines.push( + `Transitive dependents (${result.transitiveDependents.length}):`, + ...result.transitiveDependents.map( + (dependent) => ` ${dependent.path} (depth ${dependent.depth}, via ${dependent.throughPath.join(" → ")})`, + ), + ); + } + + return text(lines); +} + +/** + * Format a module-structure operation result for human CLI output. + */ +export function formatModuleStructureText(result: ModuleStructureResult): string { + const lines = [ + "Module Structure", + "────────────────", + `${"Path".padEnd(30)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Cohesion".padStart(10)} ${"EscVel".padStart(8)}`, + `${"─".repeat(30)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(8)}`, + ...result.modules.map( + (module) => + `${module.path.padEnd(30)} ${String(module.files).padStart(6)} ${String(module.loc).padStart(8)} ${module.cohesion.toFixed(2).padStart(10)} ${module.escapeVelocity.toFixed(2).padStart(8)}`, + ), + ]; + + if (result.crossModuleDeps.length > 0) { + lines.push( + "", + `Cross-Module Dependencies (${result.crossModuleDeps.length}):`, + ...result.crossModuleDeps.slice(0, 20).map((dependency) => ` ${dependency.from} → ${dependency.to} (weight: ${dependency.weight})`), + ); + } + + if (result.circularDeps.length > 0) { + lines.push( + "", + `Circular Dependencies (${result.circularDeps.length}):`, + ...result.circularDeps.map((cycle) => ` [${cycle.severity}] ${cycle.cycle.map((path) => path.join(" → ")).join("; ")}`), + ); + } + + return text(lines); +} + +/** + * Format a forces operation result for human CLI output. + */ +export function formatForcesText(result: ForcesResult): string { + const lines = [ + "Force Analysis", + "──────────────", + result.summary, + "", + "Module Cohesion:", + ...result.moduleCohesion.map((module) => ` ${module.path.padEnd(30)} ${module.verdict.padEnd(14)} cohesion: ${module.cohesion.toFixed(2)}`), + ]; + + if (result.tensionFiles.length > 0) { + lines.push("", `Tension Files (${result.tensionFiles.length}):`); + for (const tensionFile of result.tensionFiles) { + lines.push(` ${tensionFile.file} (tension: ${tensionFile.tension.toFixed(2)})`); + lines.push( + ...tensionFile.pulledBy.map( + (pull) => ` ← ${pull.module} (strength: ${pull.strength.toFixed(2)}, symbols: ${pull.symbols.join(", ")})`, + ), + ); + } + } + + if (result.bridgeFiles.length > 0) { + lines.push( + "", + `Bridge Files (${result.bridgeFiles.length}):`, + ...result.bridgeFiles.map((bridge) => ` ${bridge.file} (betweenness: ${bridge.betweenness.toFixed(3)}, role: ${bridge.role})`), + ); + } + + if (result.extractionCandidates.length > 0) { + lines.push("", `Extraction Candidates (${result.extractionCandidates.length}):`); + for (const extraction of result.extractionCandidates) { + lines.push(` ${extraction.target} (escape velocity: ${extraction.escapeVelocity.toFixed(2)})`); + lines.push(` ${extraction.recommendation}`); + } + } + + if (result.shallowModules.length > 0) { + lines.push("", `Shallow Modules (${result.shallowModules.length}):`); + for (const module of result.shallowModules) { + lines.push(` ${module.module} (${module.exports} exports, cohesion: ${module.cohesion.toFixed(2)})`); + lines.push(` ${module.evidence}`); + } + } + + if (result.deepModules.length > 0) { + lines.push("", `Deep Modules (${result.deepModules.length}):`); + for (const module of result.deepModules) { + lines.push(` ${module.module} (${module.exports} exports, depended by: ${module.dependedByModules})`); + lines.push(` ${module.evidence}`); + } + } + + if (result.seamCandidates.length > 0) { + lines.push("", `Seam Candidates (${result.seamCandidates.length}):`); + for (const seam of result.seamCandidates) { + lines.push(` ${seam.target} [${seam.scope}] (dependents: ${seam.dependentModules}, fan-in: ${seam.fanIn})`); + lines.push(` ${seam.evidence}`); + } + } + + if (result.localityRisks.length > 0) { + lines.push("", `Locality Risks (${result.localityRisks.length}):`); + for (const risk of result.localityRisks) { + lines.push(` ${risk.file} [${risk.kind}] (blast radius: ${risk.blastRadius}, tension: ${risk.tension.toFixed(2)})`); + lines.push(` ${risk.evidence}`); + } + } + + return text(lines); +} + +/** + * Format a dead-exports operation result for human CLI output. + */ +export function formatDeadExportsText(result: DeadExportsResult): string { + const lines = [ + "Dead Exports", + "────────────", + result.summary, + ]; + + if (result.files.length > 0) { + lines.push(""); + for (const file of result.files) { + lines.push(`${file.path} (${file.deadExports.length}/${file.totalExports} unused, ${file.confidence} confidence):`); + if (file.packageEntrypointReason) lines.push(` public API: ${file.packageEntrypointReason}`); + lines.push(...file.deadExports.map((deadExport) => ` - ${deadExport}`)); + } + } + + return text(lines); +} + +/** + * Format an opportunities operation result for human CLI output. + */ +export function formatOpportunitiesText(result: OpportunitiesResult): string { + const lines = [ + `Opportunities (${result.opportunities.length} of ${result.totalOpportunities})`, + "─".repeat(40), + result.summary, + ]; + + for (const opportunity of result.opportunities) { + lines.push( + "", + `#${opportunity.rank} [${opportunity.priority}] ${opportunity.title}`, + ` Target: ${opportunity.target}`, + ` Kind: ${opportunity.kind}`, + ` Score: ${opportunity.score.toFixed(1)} (${opportunity.confidence} confidence)`, + ` Why: ${opportunity.why}`, + ` Evidence: ${opportunity.evidence.join("; ")}`, + ` Next: ${opportunity.suggestedCommands[0] ?? "Review target manually"}`, + ); + } + + return text(lines); +} + +/** + * Format a groups operation result for human CLI output. + */ +export function formatGroupsText(result: GroupsResult): string { + return text([ + "Groups", + "──────", + `${"#".padStart(3)} ${"Name".padEnd(20)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Importance".padStart(12)} ${"Coupling".padStart(10)}`, + `${"─".repeat(3)} ${"─".repeat(20)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(12)} ${"─".repeat(10)}`, + ...result.groups.map( + (group) => + `${String(group.rank).padStart(3)} ${group.name.padEnd(20)} ${String(group.files).padStart(6)} ${String(group.loc).padStart(8)} ${group.importance.padStart(12)} ${String(group.coupling.total).padStart(10)}`, + ), + ]); +} + +/** + * Format a symbol-context operation result for human CLI output. + */ +export function formatSymbolContextText(result: SymbolContextResult | SymbolContextError): string { + if ("error" in result) return result.error; + + const lines = [ + `Symbol: ${result.name}`, + "─".repeat(8 + result.name.length), + `File: ${result.file}`, + `Type: ${result.type}`, + `LOC: ${result.loc}`, + `Default: ${result.isDefault ? "yes" : "no"}`, + `Complexity: ${result.complexity}`, + `Fan-in: ${result.fanIn}`, + `Fan-out: ${result.fanOut}`, + `PageRank: ${result.pageRank}`, + `Betweenness:${result.betweenness}`, + ]; + + if (result.callers.length > 0) { + lines.push( + "", + `Callers (${result.callers.length}):`, + ...result.callers.map((caller) => ` ${caller.symbol} (${caller.file}) [${caller.confidence}]`), + ); + } + + if (result.callees.length > 0) { + lines.push( + "", + `Callees (${result.callees.length}):`, + ...result.callees.map((callee) => ` ${callee.symbol} (${callee.file}) [${callee.confidence}]`), + ); + } + + return text(lines); +} + +/** + * Format an impact operation result for human CLI output. + */ +export function formatImpactText(result: ImpactResult): string { + const lines = [ + `Impact Analysis: ${result.symbol}`, + "─".repeat(18 + result.symbol.length), + `Total affected: ${result.totalAffected}`, + ]; + + if (result.levels.length > 0) { + lines.push(""); + for (const level of result.levels) { + lines.push(`Depth ${level.depth} — ${level.risk} (${level.affected.length}):`); + lines.push(...level.affected.map((affected) => ` ${affected.symbol} (${affected.file}) [${affected.confidence}]`)); + } + } + + return text(lines); +} + +/** + * Format a rename operation result for human CLI output. + */ +export function formatRenameText(result: RenameResult): string { + const lines = [ + `Rename: ${result.oldName} → ${result.newName}${result.dryRun ? " (dry run)" : ""}`, + "─".repeat(40), + ]; + + if (result.references.length === 0) { + lines.push(`No references found for "${result.oldName}"`); + return text(lines); + } + + lines.push( + `References (${result.references.length}):`, + ...result.references.map((reference) => ` ${reference.file} [${reference.confidence}] ${reference.symbol}`), + ); + return text(lines); +} + +/** + * Format a processes operation result for human CLI output. + */ +export function formatProcessesText(result: ProcessesResult): string { + const lines = [ + `Processes (${result.processes.length} of ${result.totalProcesses})`, + "─".repeat(30), + ]; + + if (result.processes.length === 0) { + lines.push("No processes found."); + return text(lines); + } + + for (const process of result.processes) { + lines.push( + "", + `${process.name} (depth: ${process.depth}, modules: ${process.modulesTouched.join(", ")})`, + ` Entry: ${process.entryPoint.file}::${process.entryPoint.symbol}`, + ...process.steps.map((step) => ` ${String(step.step).padStart(3)}. ${step.file}::${step.symbol}`), + ); + } + + return text(lines); +} + +/** + * Format a clusters operation result for human CLI output. + */ +export function formatClustersText(result: ClustersResult): string { + const lines = [ + `Clusters (${result.clusters.length} of ${result.totalClusters})`, + "─".repeat(30), + ]; + + for (const cluster of result.clusters) { + lines.push( + "", + `${cluster.name} (${cluster.fileCount} files, cohesion: ${cluster.cohesion.toFixed(2)})`, + ...cluster.files.map((file) => ` ${file}`), + ); + } + + return text(lines); +} diff --git a/src/operations/index.ts b/src/operations/index.ts index 65844c0..4bc10c1 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -20,6 +20,24 @@ import { renameSymbol, } from "../core/index.js"; import type { CodebaseGraph } from "../types/index.js"; +import { + formatChangesText, + formatClustersText, + formatDeadExportsText, + formatDependentsText, + formatFileContextText, + formatForcesText, + formatGroupsText, + formatHotspotsText, + formatImpactText, + formatModuleStructureText, + formatOpportunitiesText, + formatOverviewText, + formatProcessesText, + formatRenameText, + formatSearchText, + formatSymbolContextText, +} from "./formatters.js"; export const operationNames = [ "overview", @@ -54,6 +72,7 @@ export interface Operation { inputShape: z.ZodRawShape; inputSchema: z.ZodType; run: (graph: CodebaseGraph, input: TInput, context: OperationContext) => TResult; + formatText: (result: TResult, input: TInput) => string; } export type OperationRunResult = @@ -158,6 +177,7 @@ export const operations = { inputShape: overviewInputShape, inputSchema: overviewInputSchema, run: (graph: CodebaseGraph, _input: OverviewInput) => computeOverview(graph), + formatText: formatOverviewText, } satisfies Operation>, fileContext: { name: "fileContext", @@ -167,6 +187,7 @@ export const operations = { inputShape: fileContextInputShape, inputSchema: fileContextInputSchema, run: (graph: CodebaseGraph, input: FileContextInput) => computeFileContext(graph, input.filePath), + formatText: formatFileContextText, } satisfies Operation>, dependents: { name: "dependents", @@ -176,6 +197,7 @@ export const operations = { inputShape: dependentsInputShape, inputSchema: dependentsInputSchema, run: (graph: CodebaseGraph, input: DependentsInput) => computeDependents(graph, input.filePath, input.depth), + formatText: formatDependentsText, } satisfies Operation>, hotspots: { name: "hotspots", @@ -185,6 +207,7 @@ export const operations = { inputShape: hotspotsInputShape, inputSchema: hotspotsInputSchema, run: (graph: CodebaseGraph, input: HotspotsInput) => computeHotspots(graph, input.metric, input.limit), + formatText: formatHotspotsText, } satisfies Operation>, moduleStructure: { name: "moduleStructure", @@ -194,6 +217,7 @@ export const operations = { inputShape: moduleStructureInputShape, inputSchema: moduleStructureInputSchema, run: (graph: CodebaseGraph, _input: ModuleStructureInput) => computeModuleStructure(graph), + formatText: formatModuleStructureText, } satisfies Operation>, forces: { name: "forces", @@ -204,6 +228,7 @@ export const operations = { inputSchema: forcesInputSchema, run: (graph: CodebaseGraph, input: ForcesInput) => computeForces(graph, input.cohesionThreshold, input.tensionThreshold, input.escapeThreshold), + formatText: formatForcesText, } satisfies Operation>, deadExports: { name: "deadExports", @@ -213,6 +238,7 @@ export const operations = { inputShape: deadExportsInputShape, inputSchema: deadExportsInputSchema, run: (graph: CodebaseGraph, input: DeadExportsInput) => computeDeadExports(graph, input.module, input.limit), + formatText: formatDeadExportsText, } satisfies Operation>, opportunities: { name: "opportunities", @@ -222,6 +248,7 @@ export const operations = { inputShape: opportunitiesInputShape, inputSchema: opportunitiesInputSchema, run: (graph: CodebaseGraph, input: OpportunitiesInput) => computeOpportunities(graph, input.limit), + formatText: formatOpportunitiesText, } satisfies Operation>, groups: { name: "groups", @@ -231,6 +258,7 @@ export const operations = { inputShape: emptyInputShape, inputSchema: emptyInputSchema, run: (graph: CodebaseGraph, _input: EmptyInput) => computeGroups(graph), + formatText: formatGroupsText, } satisfies Operation>, symbolContext: { name: "symbolContext", @@ -240,6 +268,7 @@ export const operations = { inputShape: symbolContextInputShape, inputSchema: symbolContextInputSchema, run: (graph: CodebaseGraph, input: SymbolContextInput) => computeSymbolContext(graph, input.name), + formatText: formatSymbolContextText, } satisfies Operation>, search: { name: "search", @@ -249,6 +278,7 @@ export const operations = { inputShape: searchInputShape, inputSchema: searchInputSchema, run: (graph: CodebaseGraph, input: SearchInput) => computeSearch(graph, input.query, input.limit), + formatText: formatSearchText, } satisfies Operation>, changes: { name: "changes", @@ -259,6 +289,7 @@ export const operations = { inputSchema: changesInputSchema, run: (graph: CodebaseGraph, input: ChangesInput, context: OperationContext) => computeChanges(graph, input.scope, context.rootDir), + formatText: formatChangesText, } satisfies Operation>, impact: { name: "impact", @@ -268,6 +299,7 @@ export const operations = { inputShape: impactInputShape, inputSchema: impactInputSchema, run: (graph: CodebaseGraph, input: ImpactInput) => impactAnalysis(graph, input.symbol), + formatText: formatImpactText, } satisfies Operation>, rename: { name: "rename", @@ -278,6 +310,7 @@ export const operations = { inputSchema: renameInputSchema, run: (graph: CodebaseGraph, input: RenameInput) => renameSymbol(graph, input.oldName, input.newName, input.dryRun ?? true), + formatText: formatRenameText, } satisfies Operation>, processes: { name: "processes", @@ -287,6 +320,7 @@ export const operations = { inputShape: processesInputShape, inputSchema: processesInputSchema, run: (graph: CodebaseGraph, input: ProcessesInput) => computeProcesses(graph, input.entryPoint, input.limit), + formatText: formatProcessesText, } satisfies Operation>, clusters: { name: "clusters", @@ -296,6 +330,7 @@ export const operations = { inputShape: clustersInputShape, inputSchema: clustersInputSchema, run: (graph: CodebaseGraph, input: ClustersInput) => computeClusters(graph, input.minFiles), + formatText: formatClustersText, } satisfies Operation>, } as const; @@ -338,8 +373,8 @@ export function getOperationByMcpTool(toolName: string): RegisteredOperation | u return operationList.find((operation) => operation.mcpTool === toolName); } -export function parseOperationInput( - operation: Operation, +export function parseOperationInput( + operation: Operation, input: unknown, ): OperationRunResult { const parsed = operation.inputSchema.safeParse(input); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index e57f774..4ca3239 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -105,6 +105,19 @@ function runCli(args: string[], options: CliRunOptions = {}): SpawnSyncReturns { + const cliArgs = ["node", cli, ...args]; + if (options.force !== false) { + cliArgs.push("--force"); + } + + return spawnSync(cliArgs[0], cliArgs.slice(1), { + cwd: repoRoot, + env: { ...process.env }, + encoding: "utf-8", + }); +} + function runCliJson(args: string[], options: CliRunOptions = {}): Record { const res = runCli(args, options); @@ -131,6 +144,23 @@ function expectCliMatchesRegistry( expect(withoutCache(cliPayload)).toEqual(registryResult.data); } +function expectCliTextMatchesFormatter( + operation: Operation, + input: TInput, + cliArgs: string[], + graph: CodebaseGraph, + context: OperationContext = {}, + runOptions: CliRunOptions = {}, +): void { + const registryResult = runOperation(operation, graph, input, context); + expect(registryResult.ok).toBe(true); + if (!registryResult.ok) throw new Error(registryResult.error); + + const cliResult = runCliText(cliArgs, runOptions); + expect(cliResult.status).toBe(0); + expect(cliResult.stdout).toBe(`${operation.formatText(registryResult.data, input)}\n`); +} + beforeAll(() => { execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); }, 120_000); @@ -268,6 +298,120 @@ describe("operation registry chained parity", () => { ); }); + it("CH-P1-01: registry-adapted CLI text commands render through descriptor formatters for every operation", () => { + const { codebaseGraph } = getFixturePipeline(); + runCliJson(["overview", getFixtureSrcPath()]); + const cachedRun = { force: false }; + + expectCliTextMatchesFormatter(operations.overview, {}, ["overview", getFixtureSrcPath()], codebaseGraph, {}, cachedRun); + expectCliTextMatchesFormatter( + operations.fileContext, + { filePath: "index.ts" }, + ["file", getFixtureSrcPath(), "index.ts"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.dependents, + { filePath: "auth/auth-service.ts", depth: 2 }, + ["dependents", getFixtureSrcPath(), "auth/auth-service.ts", "--depth", "2"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.hotspots, + { metric: "coupling", limit: 3 }, + ["hotspots", getFixtureSrcPath(), "--metric", "coupling", "--limit", "3"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter(operations.moduleStructure, {}, ["modules", getFixtureSrcPath()], codebaseGraph, {}, cachedRun); + expectCliTextMatchesFormatter( + operations.forces, + { cohesionThreshold: 0.6, tensionThreshold: 0.3, escapeThreshold: 0.5 }, + ["forces", getFixtureSrcPath(), "--cohesion", "0.6", "--tension", "0.3", "--escape", "0.5"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.deadExports, + { limit: 5 }, + ["dead-exports", getFixtureSrcPath(), "--limit", "5"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.opportunities, + { limit: 5 }, + ["opportunities", getFixtureSrcPath(), "--limit", "5"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter(operations.groups, {}, ["groups", getFixtureSrcPath()], codebaseGraph, {}, cachedRun); + expectCliTextMatchesFormatter( + operations.symbolContext, + { name: "getUserById" }, + ["symbol", getFixtureSrcPath(), "getUserById"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.search, + { query: "auth", limit: 5 }, + ["search", getFixtureSrcPath(), "auth", "--limit", "5"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.changes, + { scope: "all" }, + ["changes", getFixtureSrcPath(), "--scope", "all"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.impact, + { symbol: "getUserById" }, + ["impact", getFixtureSrcPath(), "getUserById"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.rename, + { oldName: "getUserById", newName: "findUserById", dryRun: true }, + ["rename", getFixtureSrcPath(), "getUserById", "findUserById"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.processes, + { entryPoint: "main", limit: 1 }, + ["processes", getFixtureSrcPath(), "--entry", "main", "--limit", "1"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.clusters, + { minFiles: 2 }, + ["clusters", getFixtureSrcPath(), "--min-files", "2"], + codebaseGraph, + {}, + cachedRun, + ); + }); + it("CH-P1-02: invalid input uses shared descriptor validation before indexing", async () => { const res = runCli(["hotspots", getFixtureSrcPath(), "--metric", "bad"], { force: false }); diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index b2e571b..a34afeb 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -54,6 +54,7 @@ describe("operation registry", () => { expect(operation.mcpTool).toBe(expected.mcpTool); expect(Object.keys(operation.inputShape)).toEqual(expected.inputKeys); expect(operation.inputSchema.safeParse(expected.sampleInput).success).toBe(true); + expect(typeof operation.formatText).toBe("function"); expect(getOperationByCliCommand(expected.cliCommand)).toBe(operation); expect(getOperationByMcpTool(expected.mcpTool)).toBe(operation); } @@ -66,6 +67,7 @@ describe("operation registry", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.data).toEqual(computeOverview(codebaseGraph)); + expect(operations.overview.formatText(result.data)).toContain("Codebase Overview"); } });