/** * `chant dev surface-diff ` command implementation. * * Regenerates a lexicon from its spec, validates the result, extracts the * public API surface, and diffs it against the committed baseline snapshot. * * Outputs a human-readable delta to stdout and optionally a machine-readable * JSON result to a file. Never auto-fixes: failures are captured and reported. * * The snapshot file is written to `/surface.snapshot.json`. * Pass `--update-snapshot` to commit the fresh snapshot after a successful run. * * An update run is exempt from the `surface-matches-snapshot` validate check * and from nothing else (#1825): that check fails on the stale baseline the * run exists to replace, and with an `"always"` gate (#1475) the documented * re-baseline flow would deadlock. Any other failing step still refuses the * write, so a broken generate cannot be baselined. */ import { existsSync, readFileSync } from "fs"; import { resolve, join } from "path"; import { regenLexicon, writeSurfaceSnapshot, SNAPSHOT_FILENAME, type RegenResult } from "../../codegen/lexicon-regen"; import { bumpForSeverity, bumpPackageJsonVersion } from "../../codegen/version-bump"; // ── Types ───────────────────────────────────────────────────────────── export interface SurfaceDiffOptions { /** Resolved path to the lexicon directory. */ lexiconDir: string; /** Force re-fetch of upstream spec (bypass cache). */ force?: boolean; /** Print subprocess output while running. */ verbose?: boolean; /** Skip the bundle step. */ skipBundle?: boolean; /** Skip the build (tsc) step. */ skipBuild?: boolean; /** Skip chant lint on examples. */ skipLint?: boolean; /** Run the example build harness (opt-in; needs cloud creds). */ runExamples?: boolean; /** Path to a SHA-256 digest file pinning the spec. */ pinnedDigestPath?: string; /** After a successful regen, write the fresh snapshot as the new baseline. */ updateSnapshot?: boolean; /** * When re-baselining (`updateSnapshot`), also bump the lexicon's package.json * version by the drift severity so the accepted surface is publishable (#616). */ bump?: boolean; } // ── Main entry ──────────────────────────────────────────────────────── /** * Run the surface-diff pipeline and return the result. * Logs human-readable output to stderr; machine-readable JSON goes to the caller. */ export async function runSurfaceDiff(opts: SurfaceDiffOptions): Promise { const dir = resolve(opts.lexiconDir); if (!existsSync(dir)) { const failure = { step: "setup", output: `Lexicon directory not found: ${dir}`, }; return { ok: false, changed: false, severity: "none", delta: { added: [], changed: [], removed: [], renamed: [], severity: "none" }, deltaText: "", failures: [failure], freshSnapshot: null, }; } const result = await regenLexicon({ lexiconDir: dir, force: opts.force, verbose: opts.verbose, skipBundle: opts.skipBundle, skipBuild: opts.skipBuild, skipLint: opts.skipLint, skipExamples: !opts.runExamples, pinnedDigestPath: opts.pinnedDigestPath, // The update run skips only the surface-matches-snapshot validate check — // the staleness it is about to fix (#1825). updatingSnapshot: opts.updateSnapshot, }); // Update snapshot when requested and the run succeeded if (opts.updateSnapshot && result.ok && result.freshSnapshot) { writeSurfaceSnapshot(dir, result.freshSnapshot); process.stderr.write(`Snapshot updated: ${dir}/${SNAPSHOT_FILENAME}\n`); // #616: accepting drift on a rolling lexicon changes the surface but nothing // bumps the version — so skip-if-unchanged would strand the new surface at // publish time. `--bump` raises the version by the drift severity (0.x-aware) // so the accepted drift actually ships. if (opts.bump && result.changed && result.severity !== "none") { const pkgPath = join(dir, "package.json"); if (existsSync(pkgPath)) { const current = (JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }).version ?? "0.0.0"; const bumped = bumpForSeverity(current, result.severity); if (bumped) { bumpPackageJsonVersion(pkgPath, bumped); process.stderr.write(`Version bumped: ${current} -> ${bumped} (${result.severity})\n`); } else { process.stderr.write(`Version unchanged: could not bump ${current} for severity ${result.severity}\n`); } } } } return result; } // ── Human-readable output ───────────────────────────────────────────── const COLORS = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", gray: "\x1b[90m", bold: "\x1b[1m", reset: "\x1b[0m", }; function useColors(): boolean { return !process.env.NO_COLOR && process.stdout.isTTY !== false; } function c(text: string, code: string): string { return useColors() ? `${code}${text}${COLORS.reset}` : text; } /** * Print the surface-diff result as human-readable text to stdout. */ export function printSurfaceDiffResult(result: RegenResult, json: boolean): void { if (json) { console.log(JSON.stringify( { ok: result.ok, changed: result.changed, severity: result.severity, delta: result.delta, failures: result.failures, }, null, 2, )); return; } // Surface delta if (result.deltaText) { console.log(result.deltaText); } else if (result.changed) { console.log("Surface changed (no baseline to diff against)."); } else { console.log(c("No surface changes.", COLORS.green)); } // Failures if (result.failures.length > 0) { console.log(""); console.log(c(`${result.failures.length} step(s) failed:`, COLORS.bold)); for (const f of result.failures) { console.log(c(` FAIL [${f.step}]${f.exitCode !== undefined ? ` (exit ${f.exitCode})` : ""}`, COLORS.red)); if (f.output) { const indented = f.output .split("\n") .map((l) => ` ${l}`) .join("\n"); console.log(c(indented, COLORS.gray)); } } } console.log(""); // Summary line const severityColor = result.severity === "breaking" ? COLORS.red : result.severity === "additive" ? COLORS.yellow : COLORS.green; const statusLabel = result.ok ? c("ok", COLORS.green) : c("FAILED", COLORS.red); const severityLabel = c(result.severity, severityColor); console.log(`Status: ${statusLabel} Severity: ${severityLabel} Changed: ${result.changed}`); }