diff --git a/docs/architecture.md b/docs/architecture.md index 5be8db7..17f38c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,10 @@ Analyzer | produces: ForceAnalysis (tension files, bridges, extraction candidates) v Core (shared computation) - | result builders used by both MCP and CLI + | result builders used by MCP, CLI, and operation descriptors + |\ + | \-> Operation Registry + | typed descriptors, input schemas, CLI/MCP names, result wrappers v MCP (stdio) CLI (terminal/CI) | 17 tools, 2 prompts, | 18 commands with text + JSON @@ -35,10 +38,11 @@ src/ graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation core/index.ts <- Shared result computation (MCP + CLI) + operations/index.ts <- Analysis operation descriptors + typed input schemas 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 - mcp/hints.ts <- Next-step hints for MCP tool responses + mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine process/index.ts <- Entry point detection + call chain tracing @@ -69,11 +73,15 @@ analyzeGraph(builtGraph, parsedFiles) startMcpServer(codebaseGraph) -> stdio MCP server with 17 tools, 2 prompts, 3 resources + +runOperation(operation, codebaseGraph, input, context) + -> { ok: true, data } | { ok: false, error, data? } ``` ## 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. CLI/MCP handlers still call `src/core/` directly until the adapter migration lands. - **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. - **Monorepo import resolution**: Root `tsconfig.json` path aliases and local `package.json` package names resolve to source files before graph construction. @@ -90,5 +98,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. **mcp/index.ts** — Expose via `find_hotspots` enum or new tool -5. **Tests** — Cover parser extraction + analyzer computation +4. **operations/index.ts** — Add or update the operation descriptor, input schema, 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 a1cb1e4..2dbd42f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -24,6 +24,12 @@ Analyzer | computes: churn, complexity, blast radius, dead exports, test coverage | produces: ForceAnalysis (tension files, bridges, extraction candidates) v +Core (shared computation) + | result builders used by MCP, CLI, and operation descriptors + |\ + | \-> Operation Registry + | typed descriptors, input schemas, CLI/MCP names, result wrappers + v MCP (stdio) + CLI | MCP: 17 tools, 2 prompts, 3 resources for LLM agents | CLI: 18 commands with formatted + JSON output for humans/CI @@ -38,8 +44,9 @@ src/ graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation core/index.ts <- Shared result computation (MCP + CLI) + operations/index.ts <- Analysis operation descriptors + typed input schemas mcp/index.ts <- 17 MCP tools for LLM integration - mcp/hints.ts <- Next-step hints for MCP tool responses + mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine process/index.ts <- Entry point detection + call chain tracing @@ -66,11 +73,15 @@ analyzeGraph(builtGraph, parsedFiles) fileMetrics, moduleMetrics, forceAnalysis, stats, groups, processes, clusters } + +runOperation(operation, codebaseGraph, input, context) + -> { ok: true, data } | { ok: false, error, data? } ``` ## 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. CLI/MCP handlers still call `src/core/` directly until the adapter migration lands. - **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. - **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. diff --git a/roadmap.md b/roadmap.md index f2c24b1..30c56a2 100644 --- a/roadmap.md +++ b/roadmap.md @@ -260,15 +260,22 @@ Package public entrypoints already have coverage. Only fix remaining framework/c Collapse CLI + MCP operation duplication into one descriptor registry before adding more analyzers. -**To do:** +**Foundation slice:** - Add `Operation` descriptors per analysis operation. -- Use one input schema per operation for CLI coercion and MCP schemas. -- Return discriminated result/error unions instead of process exits in shared code. +- Add typed operation names, CLI command names, MCP tool names, and input schemas. +- Add `runOperation(...)` discriminated result/error wrapper for descriptor-level tests. +- Type MCP next-step hint keys against the operation-name union. +- Add CH-P1-01 coverage for registry -> CLI JSON -> MCP JSON overview parity. + +**Remaining:** + +- Reuse registry schemas in CLI coercion and MCP tool registration. +- Move shared CLI/MCP failures over descriptor-level validation errors. - Use one graph-load pipeline with progress callbacks. -- Type hint keys against the operation-name union. - Move text/SARIF/markdown formatting over result objects into formatters. -- Target tests at `op.run(graph, input)` instead of duplicating CLI and MCP behavior tests for every operation. +- Expand CH-P1-01 from overview to every operation. +- Add CH-P1-02 coverage for success, invalid input, parse failure, and cache reuse through registry adapters. ### Type/Shape Layer diff --git a/src/impact/index.ts b/src/impact/index.ts index 7436b3d..71d4dd1 100644 --- a/src/impact/index.ts +++ b/src/impact/index.ts @@ -1,31 +1,31 @@ import type { CodebaseGraph, CallConfidence } from "../types/index.js"; -interface AffectedSymbol { +export interface AffectedSymbol { file: string; symbol: string; confidence: CallConfidence; } -interface ImpactLevel { +export interface ImpactLevel { depth: number; risk: "WILL BREAK" | "LIKELY" | "MAY NEED TESTING"; affected: AffectedSymbol[]; } -interface ImpactResult { +export interface ImpactResult { symbol: string; levels: ImpactLevel[]; totalAffected: number; notFound?: boolean; } -interface RenameReference { +export interface RenameReference { file: string; symbol: string; confidence: CallConfidence; } -interface RenameResult { +export interface RenameResult { dryRun: boolean; oldName: string; newName: string; diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index ef6e218..964ea1e 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -1,51 +1,53 @@ -const TOOL_HINTS: Record = { - codebase_overview: [ +import { getOperationByMcpTool, type OperationName } from "../operations/index.js"; + +const OPERATION_HINTS: Record = { + overview: [ "Use file_context to drill into a specific file", "Use find_hotspots with metric='coupling' to find tightly coupled files", "Use get_module_structure to see cross-module dependencies", "Use analyze_forces to check module cohesion and tension", ], - file_context: [ + fileContext: [ "Use get_dependents to see blast radius if this file changes", "Use symbol_context to inspect a specific function or class", "Use find_dead_exports to check for unused exports in this file's module", "Use analyze_forces to check if this file is under tension", ], - get_dependents: [ + dependents: [ "Use file_context on high-impact dependents to understand coupling", "Use find_hotspots with metric='blast_radius' for system-wide view", "Use get_module_structure to see if dependencies cross module boundaries", ], - find_hotspots: [ + hotspots: [ "Use file_context on top hotspots to understand why they score high", "Use analyze_forces to find structural issues behind hotspots", "Use get_dependents on hotspot files to assess change risk", ], - get_module_structure: [ + moduleStructure: [ "Use analyze_forces to find junk-drawer modules with low cohesion", "Use find_hotspots with metric='escape_velocity' to find extractable modules", "Use file_context on cross-module boundary files", ], - analyze_forces: [ + forces: [ "Use file_context on tension files to understand what pulls them", "Use get_module_structure on junk-drawer modules to plan restructuring", "Use find_dead_exports on low-cohesion modules to find cleanup opportunities", ], - find_dead_exports: [ + deadExports: [ "Use file_context on files with dead exports to check if they're truly unused", "Use codebase_overview to see overall API surface reduction opportunity", ], - find_opportunities: [ + opportunities: [ "Use file_context on the top opportunity target before editing", "Use get_dependents for stabilize-hotspot or add-tests opportunities", "Use analyze_forces for extract-seam, move-file, and split-module opportunities", ], - get_groups: [ + groups: [ "Use get_module_structure for detailed per-module breakdown", "Use find_hotspots with metric='coupling' to find cross-group coupling", "Use analyze_forces to check group-level cohesion", ], - symbol_context: [ + symbolContext: [ "Use file_context on the file containing this symbol for file-level view", "Use get_dependents on the file to assess change blast radius", "Use find_hotspots with metric='fan_in' to find other high-traffic symbols", @@ -55,33 +57,38 @@ const TOOL_HINTS: Record = { "Use symbol_context on a matched symbol for callers/callees", "Refine query: try camelCase names, class names, or module paths", ], - detect_changes: [ + changes: [ "Use symbol_context on changed symbols to assess impact", "Use get_dependents on affected files for full blast radius", "Use file_context on changed files for detailed metrics", ], - impact_analysis: [ + impact: [ "Use file_context on WILL BREAK files to understand coupling", "Use rename_symbol to plan safe refactoring of impacted symbols", "Use get_module_structure to check if impact crosses module boundaries", ], - rename_symbol: [ + rename: [ "Use impact_analysis on the symbol first to understand full blast radius", "Use file_context on referenced files to check for indirect usages", "Use detect_changes after renaming to verify all references updated", ], - get_processes: [ + processes: [ "Use symbol_context on an entry point symbol for detailed callers/callees", "Use file_context on files in the process steps for metrics", "Use get_module_structure to see how process crosses module boundaries", ], - get_clusters: [ + clusters: [ "Use file_context on files within a cluster for detailed metrics", "Use get_module_structure to compare clusters against directory structure", "Use analyze_forces to check if cluster boundaries reveal tension", ], }; +export function getHintsForOperation(operationName: OperationName): string[] { + return OPERATION_HINTS[operationName]; +} + export function getHints(toolName: string): string[] { - return TOOL_HINTS[toolName] ?? []; + const operation = getOperationByMcpTool(toolName); + return operation ? getHintsForOperation(operation.name) : []; } diff --git a/src/operations/index.ts b/src/operations/index.ts new file mode 100644 index 0000000..ba483ac --- /dev/null +++ b/src/operations/index.ts @@ -0,0 +1,328 @@ +import { z } from "zod"; +import { + CHANGE_SCOPES, + HOTSPOT_METRICS, + computeChanges, + computeClusters, + computeDeadExports, + computeDependents, + computeFileContext, + computeForces, + computeGroups, + computeHotspots, + computeModuleStructure, + computeOpportunities, + computeOverview, + computeProcesses, + computeSearch, + computeSymbolContext, + impactAnalysis, + renameSymbol, +} from "../core/index.js"; +import type { CodebaseGraph } from "../types/index.js"; + +export const operationNames = [ + "overview", + "fileContext", + "dependents", + "hotspots", + "moduleStructure", + "forces", + "deadExports", + "opportunities", + "groups", + "symbolContext", + "search", + "changes", + "impact", + "rename", + "processes", + "clusters", +] as const; + +export type OperationName = typeof operationNames[number]; + +export interface OperationContext { + rootDir?: string; +} + +export interface Operation { + name: OperationName; + cliCommand: string; + mcpTool: string; + description: string; + inputSchema: z.ZodType; + run: (graph: CodebaseGraph, input: TInput, context: OperationContext) => TResult; +} + +export type OperationRunResult = + | { ok: true; data: TResult } + | { ok: false; error: string; data?: TResult }; + +const overviewInputSchema = z.object({ + depth: z.number().int().positive().optional(), +}).strict(); +const fileContextInputSchema = z.object({ filePath: z.string().min(1) }).strict(); +const dependentsInputSchema = z.object({ + filePath: z.string().min(1), + depth: z.number().int().positive().optional(), +}).strict(); +const hotspotsInputSchema = z.object({ + metric: z.enum(HOTSPOT_METRICS), + limit: z.number().int().positive().optional(), +}).strict(); +const moduleStructureInputSchema = z.object({ + depth: z.number().int().positive().optional(), +}).strict(); +const forcesInputSchema = z.object({ + cohesionThreshold: z.number().optional(), + tensionThreshold: z.number().optional(), + escapeThreshold: z.number().optional(), +}).strict(); +const deadExportsInputSchema = z.object({ + module: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), +}).strict(); +const opportunitiesInputSchema = z.object({ + limit: z.number().int().positive().optional(), +}).strict(); +const emptyInputSchema = z.object({}).strict(); +const symbolContextInputSchema = z.object({ name: z.string().min(1) }).strict(); +const searchInputSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().positive().optional(), +}).strict(); +const changesInputSchema = z.object({ + scope: z.enum(CHANGE_SCOPES).optional(), +}).strict(); +const impactInputSchema = z.object({ symbol: z.string().min(1) }).strict(); +const renameInputSchema = z.object({ + oldName: z.string().min(1), + newName: z.string().min(1), + dryRun: z.boolean().optional(), +}).strict(); +const processesInputSchema = z.object({ + entryPoint: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), +}).strict(); +const clustersInputSchema = z.object({ + minFiles: z.number().int().positive().optional(), +}).strict(); + +type OverviewInput = z.infer; +type FileContextInput = z.infer; +type DependentsInput = z.infer; +type HotspotsInput = z.infer; +type ModuleStructureInput = z.infer; +type ForcesInput = z.infer; +type DeadExportsInput = z.infer; +type OpportunitiesInput = z.infer; +type EmptyInput = z.infer; +type SymbolContextInput = z.infer; +type SearchInput = z.infer; +type ChangesInput = z.infer; +type ImpactInput = z.infer; +type RenameInput = z.infer; +type ProcessesInput = z.infer; +type ClustersInput = z.infer; + +export const operations = { + overview: { + name: "overview", + cliCommand: "overview", + mcpTool: "codebase_overview", + description: "High-level codebase snapshot: files, functions, modules, dependencies.", + inputSchema: overviewInputSchema, + run: (graph: CodebaseGraph, _input: OverviewInput) => computeOverview(graph), + } satisfies Operation>, + fileContext: { + name: "fileContext", + cliCommand: "file", + mcpTool: "file_context", + description: "Detailed file context: exports, imports, dependents, and metrics.", + inputSchema: fileContextInputSchema, + run: (graph: CodebaseGraph, input: FileContextInput) => computeFileContext(graph, input.filePath), + } satisfies Operation>, + dependents: { + name: "dependents", + cliCommand: "dependents", + mcpTool: "get_dependents", + description: "File-level blast radius with direct and transitive dependents.", + inputSchema: dependentsInputSchema, + run: (graph: CodebaseGraph, input: DependentsInput) => computeDependents(graph, input.filePath, input.depth), + } satisfies Operation>, + hotspots: { + name: "hotspots", + cliCommand: "hotspots", + mcpTool: "find_hotspots", + description: "Rank files or modules by architectural risk metric.", + inputSchema: hotspotsInputSchema, + run: (graph: CodebaseGraph, input: HotspotsInput) => computeHotspots(graph, input.metric, input.limit), + } satisfies Operation>, + moduleStructure: { + name: "moduleStructure", + cliCommand: "modules", + mcpTool: "get_module_structure", + description: "Module structure with cohesion, cross-module dependencies, and circular dependencies.", + inputSchema: moduleStructureInputSchema, + run: (graph: CodebaseGraph, _input: ModuleStructureInput) => computeModuleStructure(graph), + } satisfies Operation>, + forces: { + name: "forces", + cliCommand: "forces", + mcpTool: "analyze_forces", + description: "Architectural force analysis for tension, bridge files, and extraction candidates.", + inputSchema: forcesInputSchema, + run: (graph: CodebaseGraph, input: ForcesInput) => + computeForces(graph, input.cohesionThreshold, input.tensionThreshold, input.escapeThreshold), + } satisfies Operation>, + deadExports: { + name: "deadExports", + cliCommand: "dead-exports", + mcpTool: "find_dead_exports", + description: "Unused exports across the codebase.", + inputSchema: deadExportsInputSchema, + run: (graph: CodebaseGraph, input: DeadExportsInput) => computeDeadExports(graph, input.module, input.limit), + } satisfies Operation>, + opportunities: { + name: "opportunities", + cliCommand: "opportunities", + mcpTool: "find_opportunities", + description: "Ranked code quality and refactoring opportunities.", + inputSchema: opportunitiesInputSchema, + run: (graph: CodebaseGraph, input: OpportunitiesInput) => computeOpportunities(graph, input.limit), + } satisfies Operation>, + groups: { + name: "groups", + cliCommand: "groups", + mcpTool: "get_groups", + description: "Top-level directory groups with aggregate metrics.", + inputSchema: emptyInputSchema, + run: (graph: CodebaseGraph, _input: EmptyInput) => computeGroups(graph), + } satisfies Operation>, + symbolContext: { + name: "symbolContext", + cliCommand: "symbol", + mcpTool: "symbol_context", + description: "Symbol callers, callees, and importance metrics.", + inputSchema: symbolContextInputSchema, + run: (graph: CodebaseGraph, input: SymbolContextInput) => computeSymbolContext(graph, input.name), + } satisfies Operation>, + search: { + name: "search", + cliCommand: "search", + mcpTool: "search", + description: "Keyword search across files and symbols.", + inputSchema: searchInputSchema, + run: (graph: CodebaseGraph, input: SearchInput) => computeSearch(graph, input.query, input.limit), + } satisfies Operation>, + changes: { + name: "changes", + cliCommand: "changes", + mcpTool: "detect_changes", + description: "Git diff analysis with changed files, symbols, and risk metrics.", + inputSchema: changesInputSchema, + run: (graph: CodebaseGraph, input: ChangesInput, context: OperationContext) => + computeChanges(graph, input.scope, context.rootDir), + } satisfies Operation>, + impact: { + name: "impact", + cliCommand: "impact", + mcpTool: "impact_analysis", + description: "Symbol-level blast radius with depth-grouped impact levels.", + inputSchema: impactInputSchema, + run: (graph: CodebaseGraph, input: ImpactInput) => impactAnalysis(graph, input.symbol), + } satisfies Operation>, + rename: { + name: "rename", + cliCommand: "rename", + mcpTool: "rename_symbol", + description: "Read-only rename reference planning for a symbol.", + inputSchema: renameInputSchema, + run: (graph: CodebaseGraph, input: RenameInput) => + renameSymbol(graph, input.oldName, input.newName, input.dryRun ?? true), + } satisfies Operation>, + processes: { + name: "processes", + cliCommand: "processes", + mcpTool: "get_processes", + description: "Execution flows from entry points through the call graph.", + inputSchema: processesInputSchema, + run: (graph: CodebaseGraph, input: ProcessesInput) => computeProcesses(graph, input.entryPoint, input.limit), + } satisfies Operation>, + clusters: { + name: "clusters", + cliCommand: "clusters", + mcpTool: "get_clusters", + description: "Community-detected file clusters.", + inputSchema: clustersInputSchema, + run: (graph: CodebaseGraph, input: ClustersInput) => computeClusters(graph, input.minFiles), + } satisfies Operation>, +} as const; + +export type RegisteredOperation = typeof operations[OperationName]; +export type OperationCliCommand = RegisteredOperation["cliCommand"]; +export type OperationMcpTool = RegisteredOperation["mcpTool"]; + +export const operationList: readonly RegisteredOperation[] = operationNames.map((name) => operations[name]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessageForData(data: unknown): string | undefined { + if (!isRecord(data)) return undefined; + if (typeof data.error === "string") return data.error; + if (data.notFound === true && typeof data.symbol === "string") return `Symbol not found: ${data.symbol}`; + if (data.notFound === true) return "Result not found"; + return undefined; +} + +function validationErrorMessage(error: z.ZodError): string { + return error.issues + .map((issue) => { + const issuePath = issue.path.join("."); + return issuePath ? `${issuePath}: ${issue.message}` : issue.message; + }) + .join("; "); +} + +export function getOperation(name: OperationName): RegisteredOperation { + return operations[name]; +} + +export function getOperationByCliCommand(command: string): RegisteredOperation | undefined { + return operationList.find((operation) => operation.cliCommand === command); +} + +export function getOperationByMcpTool(toolName: string): RegisteredOperation | undefined { + return operationList.find((operation) => operation.mcpTool === toolName); +} + +export function parseOperationInput( + operation: Operation, + input: unknown, +): OperationRunResult { + const parsed = operation.inputSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: validationErrorMessage(parsed.error) }; + } + return { ok: true, data: parsed.data }; +} + +export function runOperation( + operation: Operation, + graph: CodebaseGraph, + input: TInput, + context: OperationContext = {}, +): OperationRunResult { + try { + const data = operation.run(graph, input, context); + const error = errorMessageForData(data); + if (error) return { ok: false, error, data }; + return { ok: true, data }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { ok: false, error }; + } +} diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts new file mode 100644 index 0000000..b9cd9bc --- /dev/null +++ b/tests/operation-registry.e2e.test.ts @@ -0,0 +1,95 @@ +import { spawnSync, execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { OverviewResult } from "../src/core/index.js"; +import { operations, runOperation } from "../src/operations/index.js"; +import { createFixtureMcp } from "./helpers/mcp.js"; +import { getFixturePipeline, getFixtureSrcPath } from "./helpers/pipeline.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const cli = path.join(repoRoot, "dist", "cli.js"); + +interface OverviewParityPayload { + totalFiles: unknown; + totalFunctions: unknown; + totalDependencies: unknown; + modules: unknown; + topDependedFiles: unknown; + metrics: unknown; + analysis: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonObject(value: string): Record { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed)) { + throw new Error("Expected JSON object"); + } + return parsed; +} + +function normalizeOverviewResult(result: OverviewResult): OverviewParityPayload { + return { + totalFiles: result.totalFiles, + totalFunctions: result.totalFunctions, + totalDependencies: result.totalDependencies, + modules: result.modules, + topDependedFiles: result.topDependedFiles, + metrics: result.metrics, + analysis: result.analysis, + }; +} + +function normalizeOverviewPayload(payload: Record): OverviewParityPayload { + return { + totalFiles: payload.totalFiles, + totalFunctions: payload.totalFunctions, + totalDependencies: payload.totalDependencies, + modules: payload.modules, + topDependedFiles: payload.topDependedFiles, + metrics: payload.metrics, + analysis: payload.analysis, + }; +} + +function runCliOverview(): Record { + const res = spawnSync("node", [cli, "overview", getFixtureSrcPath(), "--json", "--force"], { + cwd: repoRoot, + env: { ...process.env }, + encoding: "utf-8", + }); + + expect(res.status).toBe(0); + expect(res.stderr).toContain("Parsed"); + return parseJsonObject(res.stdout); +} + +beforeAll(() => { + if (!fs.existsSync(cli)) execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +describe("operation registry chained parity", () => { + it("CH-P1-01: returns equivalent overview facts through registry, CLI, and MCP", async () => { + const { codebaseGraph } = getFixturePipeline(); + const registryResult = runOperation(operations.overview, codebaseGraph, {}); + expect(registryResult.ok).toBe(true); + if (!registryResult.ok) throw new Error(registryResult.error); + + const cliPayload = runCliOverview(); + expect(cliPayload).toHaveProperty("cache"); + + const mcp = await createFixtureMcp(); + const mcpPayload = await mcp.callTool("codebase_overview"); + expect(mcpPayload).toHaveProperty("nextSteps"); + + const registryOverview = normalizeOverviewResult(registryResult.data); + expect(normalizeOverviewPayload(cliPayload)).toEqual(registryOverview); + expect(normalizeOverviewPayload(mcpPayload)).toEqual(registryOverview); + }); +}); diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts new file mode 100644 index 0000000..7476f18 --- /dev/null +++ b/tests/operation-registry.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { computeOverview } from "../src/core/index.js"; +import { getHints, getHintsForOperation } from "../src/mcp/hints.js"; +import { + getOperation, + getOperationByCliCommand, + getOperationByMcpTool, + operationList, + operationNames, + operations, + parseOperationInput, + runOperation, + type OperationName, +} from "../src/operations/index.js"; +import { getFixturePipeline } from "./helpers/pipeline.js"; + +const expectedOperations: Array<{ + name: OperationName; + cliCommand: string; + mcpTool: string; + sampleInput: Record; +}> = [ + { name: "overview", cliCommand: "overview", mcpTool: "codebase_overview", sampleInput: {} }, + { name: "fileContext", cliCommand: "file", mcpTool: "file_context", sampleInput: { filePath: "index.ts" } }, + { name: "dependents", cliCommand: "dependents", mcpTool: "get_dependents", sampleInput: { filePath: "index.ts", depth: 2 } }, + { name: "hotspots", cliCommand: "hotspots", mcpTool: "find_hotspots", sampleInput: { metric: "coupling", limit: 3 } }, + { name: "moduleStructure", cliCommand: "modules", mcpTool: "get_module_structure", sampleInput: {} }, + { name: "forces", cliCommand: "forces", mcpTool: "analyze_forces", sampleInput: { cohesionThreshold: 0.6 } }, + { name: "deadExports", cliCommand: "dead-exports", mcpTool: "find_dead_exports", sampleInput: { limit: 5 } }, + { name: "opportunities", cliCommand: "opportunities", mcpTool: "find_opportunities", sampleInput: { limit: 5 } }, + { name: "groups", cliCommand: "groups", mcpTool: "get_groups", sampleInput: {} }, + { name: "symbolContext", cliCommand: "symbol", mcpTool: "symbol_context", sampleInput: { name: "getUserById" } }, + { name: "search", cliCommand: "search", mcpTool: "search", sampleInput: { query: "auth", limit: 5 } }, + { name: "changes", cliCommand: "changes", mcpTool: "detect_changes", sampleInput: { scope: "all" } }, + { name: "impact", cliCommand: "impact", mcpTool: "impact_analysis", sampleInput: { symbol: "getUserById" } }, + { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", sampleInput: { oldName: "getUserById", newName: "findUserById" } }, + { name: "processes", cliCommand: "processes", mcpTool: "get_processes", sampleInput: { entryPoint: "main" } }, + { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", sampleInput: { minFiles: 2 } }, +]; + +describe("operation registry", () => { + it("declares every analysis operation with unique CLI and MCP surfaces", () => { + expect(operationNames).toEqual(expectedOperations.map((operation) => operation.name)); + expect(operationList).toHaveLength(expectedOperations.length); + expect(new Set(operationList.map((operation) => operation.name)).size).toBe(operationList.length); + expect(new Set(operationList.map((operation) => operation.cliCommand)).size).toBe(operationList.length); + expect(new Set(operationList.map((operation) => operation.mcpTool)).size).toBe(operationList.length); + + for (const expected of expectedOperations) { + const operation = getOperation(expected.name); + expect(operation.cliCommand).toBe(expected.cliCommand); + expect(operation.mcpTool).toBe(expected.mcpTool); + expect(operation.inputSchema.safeParse(expected.sampleInput).success).toBe(true); + expect(getOperationByCliCommand(expected.cliCommand)).toBe(operation); + expect(getOperationByMcpTool(expected.mcpTool)).toBe(operation); + } + }); + + it("runs operation descriptors against the shared graph", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = runOperation(operations.overview, codebaseGraph, {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual(computeOverview(codebaseGraph)); + } + }); + + it("wraps shared operation errors without losing the original payload", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = runOperation(operations.fileContext, codebaseGraph, { filePath: "missing.ts" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("File not found"); + expect(result.data).toHaveProperty("suggestions"); + } + }); + + it("normalizes schema validation failures for adapter reuse", () => { + const result = parseOperationInput(operations.hotspots, { metric: "not-a-metric" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Invalid enum value"); + } + }); + + it("types next-step hints by operation name and preserves MCP tool lookup", () => { + expect(getHintsForOperation("overview")).toEqual(getHints("codebase_overview")); + expect(getHints("unknown_tool")).toEqual([]); + }); +});