|
| 1 | +import { resolve } from "node:path"; |
| 2 | +import { |
| 3 | + createConfiguredCommandAdapters, |
| 4 | + runAdapters, |
| 5 | + type AdapterResult |
| 6 | +} from "@submuxhq/codedecay-adapters"; |
| 7 | +import { loadCodeDecayConfig, type LoadedCodeDecayConfig } from "@submuxhq/codedecay-config"; |
| 8 | +import { CODEDECAY_VERSION } from "@submuxhq/codedecay-core"; |
| 9 | +import { createGitWorktree, removeGitWorktree } from "@submuxhq/codedecay-git"; |
| 10 | +import { CliExit } from "../errors"; |
| 11 | +import { parseDifferentialArgs } from "../parsers/args"; |
| 12 | +import { renderDifferentialReport } from "../renderers/differential"; |
| 13 | +import type { |
| 14 | + CliCommandContext, |
| 15 | + CliRuntime, |
| 16 | + ConfigFormat, |
| 17 | + DifferentialOptions, |
| 18 | + DifferentialProbeResult, |
| 19 | + DifferentialReport, |
| 20 | + DifferentialSideResult, |
| 21 | + DifferentialStatus, |
| 22 | + DifferentialSummary |
| 23 | +} from "../types"; |
| 24 | + |
| 25 | +export interface RunDifferentialCommandDependencies { |
| 26 | + formatGitError(error: unknown, cwd: string, options: { base?: string | undefined; head?: string | undefined; format: string }): Error; |
| 27 | + resolveRepoRoot(cwd: string, options: { base?: string | undefined; head?: string | undefined; format: string }): string; |
| 28 | + writeOutput(input: { |
| 29 | + cwd: string; |
| 30 | + output?: string | undefined; |
| 31 | + rendered: string; |
| 32 | + runtime: CliRuntime; |
| 33 | + }): void; |
| 34 | +} |
| 35 | + |
| 36 | +export async function runDifferentialCommand( |
| 37 | + context: CliCommandContext, |
| 38 | + dependencies: RunDifferentialCommandDependencies |
| 39 | +): Promise<void> { |
| 40 | + const options = parseDifferentialArgs(context.args); |
| 41 | + const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); |
| 42 | + const refs = requireDifferentialRefs(options); |
| 43 | + const rootDir = dependencies.resolveRepoRoot(cwd, { base: refs.base, head: refs.head, format: "markdown" }); |
| 44 | + const loadedConfig = loadCodeDecayConfig({ cwd: rootDir }); |
| 45 | + let report: DifferentialReport; |
| 46 | + |
| 47 | + try { |
| 48 | + report = await createDifferentialReport(rootDir, refs, loadedConfig); |
| 49 | + } catch (error: unknown) { |
| 50 | + throw dependencies.formatGitError(error, rootDir, { base: refs.base, head: refs.head, format: "markdown" }); |
| 51 | + } |
| 52 | + |
| 53 | + dependencies.writeOutput({ |
| 54 | + cwd, |
| 55 | + output: options.output, |
| 56 | + rendered: renderDifferentialReport(report, options.format), |
| 57 | + runtime: context.runtime |
| 58 | + }); |
| 59 | + |
| 60 | + if (isDifferentialFailure(report.summary.status)) { |
| 61 | + throw new CliExit(1); |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +function requireDifferentialRefs(options: DifferentialOptions): { base: string; head: string } { |
| 66 | + if (!options.base || !options.head) { |
| 67 | + throw new Error("codedecay differential requires --base <ref> and --head <ref>."); |
| 68 | + } |
| 69 | + |
| 70 | + return { |
| 71 | + base: options.base, |
| 72 | + head: options.head |
| 73 | + }; |
| 74 | +} |
| 75 | + |
| 76 | +async function createDifferentialReport( |
| 77 | + rootDir: string, |
| 78 | + refs: { base: string; head: string }, |
| 79 | + loadedConfig: LoadedCodeDecayConfig |
| 80 | +): Promise<DifferentialReport> { |
| 81 | + const startedAt = Date.now(); |
| 82 | + const configuredProbes = createConfiguredCommandAdapters(loadedConfig.config).filter((item) => item.kind === "probe"); |
| 83 | + let baseWorktree: { path: string } | undefined; |
| 84 | + let headWorktree: { path: string } | undefined; |
| 85 | + |
| 86 | + try { |
| 87 | + baseWorktree = createGitWorktree({ cwd: rootDir, ref: refs.base, prefix: "base" }); |
| 88 | + headWorktree = createGitWorktree({ cwd: rootDir, ref: refs.head, prefix: "head" }); |
| 89 | + |
| 90 | + const results: DifferentialProbeResult[] = []; |
| 91 | + for (const probe of configuredProbes) { |
| 92 | + const baseResult = await runDifferentialSide(probe.adapter, baseWorktree.path, loadedConfig); |
| 93 | + const headResult = await runDifferentialSide(probe.adapter, headWorktree.path, loadedConfig); |
| 94 | + const differences = compareDifferentialSides(baseResult, headResult); |
| 95 | + const status = differentialProbeStatus(baseResult, headResult, differences); |
| 96 | + |
| 97 | + results.push({ |
| 98 | + id: probe.adapter.id, |
| 99 | + name: probe.adapter.name, |
| 100 | + command: probe.command, |
| 101 | + status, |
| 102 | + differences, |
| 103 | + base: baseResult, |
| 104 | + head: headResult |
| 105 | + }); |
| 106 | + } |
| 107 | + |
| 108 | + const report: DifferentialReport = { |
| 109 | + tool: "CodeDecay", |
| 110 | + version: CODEDECAY_VERSION, |
| 111 | + generatedAt: new Date().toISOString(), |
| 112 | + base: refs.base, |
| 113 | + head: refs.head, |
| 114 | + summary: createDifferentialSummary(results, elapsed(startedAt)), |
| 115 | + results |
| 116 | + }; |
| 117 | + |
| 118 | + if (loadedConfig.sourcePath) { |
| 119 | + report.configSource = loadedConfig.sourcePath; |
| 120 | + } |
| 121 | + |
| 122 | + return report; |
| 123 | + } finally { |
| 124 | + if (headWorktree) { |
| 125 | + removeGitWorktree({ cwd: rootDir, path: headWorktree.path }); |
| 126 | + } |
| 127 | + |
| 128 | + if (baseWorktree) { |
| 129 | + removeGitWorktree({ cwd: rootDir, path: baseWorktree.path }); |
| 130 | + } |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +async function runDifferentialSide( |
| 135 | + adapter: ReturnType<typeof createConfiguredCommandAdapters>[number]["adapter"], |
| 136 | + rootDir: string, |
| 137 | + loadedConfig: LoadedCodeDecayConfig |
| 138 | +): Promise<DifferentialSideResult> { |
| 139 | + const [result] = await runAdapters([adapter], { |
| 140 | + rootDir, |
| 141 | + changedFiles: [], |
| 142 | + config: loadedConfig.config |
| 143 | + }); |
| 144 | + |
| 145 | + if (!result) { |
| 146 | + return { |
| 147 | + status: "error", |
| 148 | + durationMs: 0, |
| 149 | + stdout: "", |
| 150 | + stderr: "", |
| 151 | + error: "Adapter did not return a result." |
| 152 | + }; |
| 153 | + } |
| 154 | + |
| 155 | + return toDifferentialSide(result); |
| 156 | +} |
| 157 | + |
| 158 | +function toDifferentialSide(result: AdapterResult): DifferentialSideResult { |
| 159 | + const side: DifferentialSideResult = { |
| 160 | + status: result.status, |
| 161 | + durationMs: result.durationMs, |
| 162 | + stdout: result.stdout, |
| 163 | + stderr: result.stderr |
| 164 | + }; |
| 165 | + |
| 166 | + if (result.exitCode !== undefined) { |
| 167 | + side.exitCode = result.exitCode; |
| 168 | + } |
| 169 | + |
| 170 | + if (result.error) { |
| 171 | + side.error = result.error; |
| 172 | + } |
| 173 | + |
| 174 | + const structuredOutput = parseStructuredOutput(result.stdout); |
| 175 | + if (structuredOutput !== undefined) { |
| 176 | + side.structuredOutput = structuredOutput; |
| 177 | + } |
| 178 | + |
| 179 | + return side; |
| 180 | +} |
| 181 | + |
| 182 | +function createDifferentialSummary(results: DifferentialProbeResult[], durationMs: number): DifferentialSummary { |
| 183 | + const changed = results.filter((result) => result.status === "changed").length; |
| 184 | + const failed = results.filter((result) => result.status === "failed").length; |
| 185 | + const skipped = results.filter((result) => result.status === "skipped").length; |
| 186 | + const unchanged = results.filter((result) => result.status === "passed").length; |
| 187 | + |
| 188 | + return { |
| 189 | + status: differentialStatus(results, { changed, failed, skipped }), |
| 190 | + total: results.length, |
| 191 | + unchanged, |
| 192 | + changed, |
| 193 | + skipped, |
| 194 | + failed, |
| 195 | + durationMs |
| 196 | + }; |
| 197 | +} |
| 198 | + |
| 199 | +function differentialStatus( |
| 200 | + results: DifferentialProbeResult[], |
| 201 | + counts: Pick<DifferentialSummary, "changed" | "failed" | "skipped"> |
| 202 | +): DifferentialStatus { |
| 203 | + if (counts.failed > 0) { |
| 204 | + return "failed"; |
| 205 | + } |
| 206 | + |
| 207 | + if (counts.changed > 0) { |
| 208 | + return "changed"; |
| 209 | + } |
| 210 | + |
| 211 | + if (results.length === 0 || counts.skipped === results.length) { |
| 212 | + return "skipped"; |
| 213 | + } |
| 214 | + |
| 215 | + return "passed"; |
| 216 | +} |
| 217 | + |
| 218 | +function differentialProbeStatus( |
| 219 | + base: DifferentialSideResult, |
| 220 | + head: DifferentialSideResult, |
| 221 | + differences: string[] |
| 222 | +): DifferentialStatus { |
| 223 | + if (isDifferentialSideInfrastructureFailure(base) || isDifferentialSideInfrastructureFailure(head)) { |
| 224 | + return "failed"; |
| 225 | + } |
| 226 | + |
| 227 | + if (base.status === "skipped" && head.status === "skipped") { |
| 228 | + return "skipped"; |
| 229 | + } |
| 230 | + |
| 231 | + return differences.length > 0 ? "changed" : "passed"; |
| 232 | +} |
| 233 | + |
| 234 | +function isDifferentialSideInfrastructureFailure(side: DifferentialSideResult): boolean { |
| 235 | + return side.status === "error" || side.status === "timed_out"; |
| 236 | +} |
| 237 | + |
| 238 | +function compareDifferentialSides(base: DifferentialSideResult, head: DifferentialSideResult): string[] { |
| 239 | + const differences: string[] = []; |
| 240 | + |
| 241 | + if (base.status !== head.status) { |
| 242 | + differences.push(`status changed from ${base.status} to ${head.status}`); |
| 243 | + } |
| 244 | + |
| 245 | + if (base.exitCode !== head.exitCode) { |
| 246 | + differences.push(`exit code changed from ${formatOptionalNumber(base.exitCode)} to ${formatOptionalNumber(head.exitCode)}`); |
| 247 | + } |
| 248 | + |
| 249 | + if (base.structuredOutput !== undefined || head.structuredOutput !== undefined) { |
| 250 | + if (stableJson(base.structuredOutput) !== stableJson(head.structuredOutput)) { |
| 251 | + differences.push("structured stdout changed"); |
| 252 | + } |
| 253 | + } else if (normalizeOutput(base.stdout) !== normalizeOutput(head.stdout)) { |
| 254 | + differences.push("stdout changed"); |
| 255 | + } |
| 256 | + |
| 257 | + if (normalizeOutput(base.stderr) !== normalizeOutput(head.stderr)) { |
| 258 | + differences.push("stderr changed"); |
| 259 | + } |
| 260 | + |
| 261 | + return differences; |
| 262 | +} |
| 263 | + |
| 264 | +function parseStructuredOutput(output: string): unknown { |
| 265 | + const trimmed = output.trim(); |
| 266 | + if (!trimmed) { |
| 267 | + return undefined; |
| 268 | + } |
| 269 | + |
| 270 | + try { |
| 271 | + return JSON.parse(trimmed); |
| 272 | + } catch { |
| 273 | + return undefined; |
| 274 | + } |
| 275 | +} |
| 276 | + |
| 277 | +function stableJson(value: unknown): string { |
| 278 | + return JSON.stringify(sortJsonValue(value)); |
| 279 | +} |
| 280 | + |
| 281 | +function sortJsonValue(value: unknown): unknown { |
| 282 | + if (Array.isArray(value)) { |
| 283 | + return value.map(sortJsonValue); |
| 284 | + } |
| 285 | + |
| 286 | + if (value && typeof value === "object") { |
| 287 | + return Object.fromEntries( |
| 288 | + Object.entries(value) |
| 289 | + .sort(([left], [right]) => left.localeCompare(right)) |
| 290 | + .map(([key, nested]) => [key, sortJsonValue(nested)]) |
| 291 | + ); |
| 292 | + } |
| 293 | + |
| 294 | + return value; |
| 295 | +} |
| 296 | + |
| 297 | +function normalizeOutput(value: string): string { |
| 298 | + return value.trim().replace(/\r\n/g, "\n"); |
| 299 | +} |
| 300 | + |
| 301 | +function formatOptionalNumber(value: number | undefined): string { |
| 302 | + return value === undefined ? "none" : String(value); |
| 303 | +} |
| 304 | + |
| 305 | +function isDifferentialFailure(status: DifferentialStatus): boolean { |
| 306 | + return status === "changed" || status === "failed"; |
| 307 | +} |
| 308 | + |
| 309 | +function elapsed(startedAt: number): number { |
| 310 | + return Math.max(0, Date.now() - startedAt); |
| 311 | +} |
0 commit comments