Skip to content

Commit 2adc264

Browse files
refactor(cli): extract differential command
Refs #262
1 parent 1435773 commit 2adc264

3 files changed

Lines changed: 391 additions & 355 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)