diff --git a/.changeset/add-compare-gate-mode.md b/.changeset/add-compare-gate-mode.md new file mode 100644 index 0000000..57b38c5 --- /dev/null +++ b/.changeset/add-compare-gate-mode.md @@ -0,0 +1,5 @@ +--- +"ghost-drift": minor +--- + +Add `--gate` mode to `ghost-drift compare` that reads `.ghost-sync.json` and reports per-dimension verdicts (aligned / covered / reconverging / uncovered). Exits 0 when no uncovered drift, 1 when uncovered, 2 on hard error. Versioned JSON output via `--format json` (schema: `ghost.compare.gate/v1`). Composes over existing `compareFingerprints`, `readSyncManifest`, and `checkBounds` — no new orchestration. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 0b2b25a..ac48d9a 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-19T14:36:11.941Z", + "generatedAt": "2026-05-19T16:33:13.019Z", "tools": [ { "tool": "ghost", @@ -265,6 +265,30 @@ "takesValue": true, "negated": false }, + { + "rawName": "--gate", + "name": "gate", + "description": "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--sync ", + "name": "sync", + "description": "Sync manifest path for --gate (default: ./.ghost-sync.json)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--max-divergence-days ", + "name": "maxDivergenceDays", + "description": "For --gate: flag diverging dimensions older than this many days as uncovered", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--format ", "name": "format", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 6c1a34d..124a95f 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -24,6 +24,7 @@ import { formatTemporalComparisonJSON, readHistory, readSyncManifest, + runGateCli, runGhostDriftCheck, } from "./core/index.js"; import { @@ -56,9 +57,34 @@ export function buildCli(): ReturnType { "--history-dir ", "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", ) + .option( + "--gate", + "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", + ) + .option( + "--sync ", + "Sync manifest path for --gate (default: ./.ghost-sync.json)", + ) + .option( + "--max-divergence-days ", + "For --gate: flag diverging dimensions older than this many days as uncovered", + ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (fingerprints: string[], opts) => { try { + if (opts.gate) { + await runGateCli({ + fingerprints, + cwd: process.cwd(), + sync: opts.sync, + format: opts.format, + maxDivergenceDays: opts.maxDivergenceDays, + loadFingerprint: loadComparableFingerprint, + compare, + }); + return; + } + const exprs = await Promise.all( fingerprints.map((path) => loadComparableFingerprint(path)), ); diff --git a/packages/ghost/src/core/gate.ts b/packages/ghost/src/core/gate.ts new file mode 100644 index 0000000..5d8abee --- /dev/null +++ b/packages/ghost/src/core/gate.ts @@ -0,0 +1,378 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { + DimensionAck, + Fingerprint, + FingerprintComparison, + SyncManifest, +} from "#ghost-core"; +import type { CompareResult } from "./compare.js"; +import { checkBounds } from "./evolution/sync.js"; + +const DEFAULT_SYNC_PATH = ".ghost-sync.json"; + +const GATE_SCHEMA = "ghost.compare.gate/v1" as const; +const ALIGNED_THRESHOLD = 0.01; +const DEFAULT_TOLERANCE = 0.05; + +export type GateDimensionVerdict = + | "aligned" + | "covered" + | "reconverging" + | "uncovered"; + +export type GateOverallVerdict = "aligned" | "covered" | "uncovered"; + +export interface GateDimensionReport { + distance: number; + ackDistance?: number; + stance?: DimensionAck["stance"]; + verdict: GateDimensionVerdict; + reason?: string; +} + +export interface GateReport { + schema: typeof GATE_SCHEMA; + trackedFingerprintId: string; + localFingerprintId: string; + overall: { + distance: number; + verdict: GateOverallVerdict; + }; + dimensions: Record; +} + +export interface BuildGateReportArgs { + comparison: FingerprintComparison; + manifest: SyncManifest; + tolerance?: number; + maxDivergenceDays?: number; +} + +/** + * Reconcile a pairwise comparison against a recorded sync manifest and + * produce a per-dimension verdict suitable for CI / programmatic gating. + * + * Composes over the existing `checkBounds` helper: a dimension is + * `uncovered` when checkBounds flags it (or when the comparison surfaces + * a dimension the manifest doesn't cover), `reconverging` when checkBounds + * marks it as such, `aligned` when the current distance is ~0, and + * otherwise `covered`. + */ +export function buildGateReport(args: BuildGateReportArgs): GateReport { + const { comparison, manifest } = args; + const tolerance = args.tolerance ?? DEFAULT_TOLERANCE; + + const bounds = checkBounds(manifest, comparison, { + tolerance, + maxDivergenceDays: args.maxDivergenceDays, + }); + const exceededSet = new Set(bounds.dimensions); + const reconvergingSet = new Set(bounds.reconverging); + + const dimensions: Record = {}; + + for (const [key, delta] of Object.entries(comparison.dimensions)) { + const ack = manifest.dimensions[key]; + const distance = delta.distance; + + if (!ack) { + dimensions[key] = { + distance, + verdict: "uncovered", + reason: "no ack recorded", + }; + continue; + } + + if (exceededSet.has(key)) { + // `effectiveTolerance` mirrors the per-dimension override that + // `checkBounds` already applied; we recompute it here only to surface + // the same number in the human-readable reason string. + const effectiveTolerance = ack.tolerance ?? tolerance; + const reason = + ack.stance === "diverging" + ? buildDivergenceExceededReason(ack, args.maxDivergenceDays) + : `current ${formatNumber(distance)} exceeds acked ${formatNumber( + ack.distance, + )} + tolerance ${formatNumber(effectiveTolerance)}`; + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "uncovered", + reason, + }; + continue; + } + + if (reconvergingSet.has(key)) { + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "reconverging", + }; + continue; + } + + if ( + ack.stance === "aligned" && + distance < ALIGNED_THRESHOLD && + ack.distance < ALIGNED_THRESHOLD + ) { + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "aligned", + }; + continue; + } + + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "covered", + }; + } + + const verdicts = Object.values(dimensions).map((d) => d.verdict); + let overall: GateOverallVerdict; + if (verdicts.some((v) => v === "uncovered")) { + overall = "uncovered"; + } else if (verdicts.length > 0 && verdicts.every((v) => v === "aligned")) { + overall = "aligned"; + } else { + overall = "covered"; + } + + return { + schema: GATE_SCHEMA, + trackedFingerprintId: comparison.source.id, + localFingerprintId: comparison.target.id, + overall: { + distance: comparison.distance, + verdict: overall, + }, + dimensions, + }; +} + +/** + * Map a gate report to its CI exit code. + * - 0 when no uncovered drift (aligned, covered, or reconverging). + * - 1 when any dimension is uncovered. + */ +export function gateExitCode(report: GateReport): 0 | 1 { + return report.overall.verdict === "uncovered" ? 1 : 0; +} + +export function formatGateReportJSON(report: GateReport): string { + return JSON.stringify(report); +} + +const MARKERS: Record = { + aligned: "✓", + covered: "=", + reconverging: "~", + uncovered: "✗", +}; + +export function formatGateReportCLI(report: GateReport): string { + const lines: string[] = []; + lines.push( + `Gate: ${report.trackedFingerprintId} vs ${report.localFingerprintId}`, + ); + + const dimEntries = Object.entries(report.dimensions); + const nameWidth = dimEntries.reduce( + (max, [name]) => Math.max(max, name.length), + 0, + ); + + for (const [name, dim] of dimEntries) { + const marker = MARKERS[dim.verdict]; + const distance = formatPercent(dim.distance); + const tail = dim.reason ? ` ${dim.reason}` : ""; + lines.push( + ` ${marker} ${name.padEnd(nameWidth)} ${distance.padStart(6)} ${dim.verdict}${tail}`, + ); + } + + lines.push(""); + lines.push( + `Overall: ${report.overall.verdict} (${formatPercent(report.overall.distance)})`, + ); + return `${lines.join("\n")}\n`; +} + +function formatNumber(n: number): string { + return Number.isFinite(n) ? n.toFixed(3).replace(/\.?0+$/, "") : String(n); +} + +function formatPercent(n: number): string { + return `${(n * 100).toFixed(1)}%`; +} + +function buildDivergenceExceededReason( + ack: DimensionAck, + maxDivergenceDays?: number, +): string { + if (maxDivergenceDays === undefined || !ack.divergedAt) { + return "diverging stance exceeded recorded bounds"; + } + const divergedDate = new Date(ack.divergedAt); + const days = Math.floor( + (Date.now() - divergedDate.getTime()) / (1000 * 60 * 60 * 24), + ); + return `diverging for ${days} days exceeds --max-divergence-days ${maxDivergenceDays}`; +} + +// --- CLI runner --- + +export interface RunGateCliOptions { + fingerprints: string[]; + cwd: string; + /** From `--sync `; defaults to `./.ghost-sync.json`. */ + sync?: string; + /** From `--format `; "cli" (default) or "json". */ + format?: string; + /** + * From `--max-divergence-days `. cac may forward this as a number + * when it parses cleanly or as the raw string, so both shapes are + * accepted; `parseMaxDivergenceDays` validates inside this module. + */ + maxDivergenceDays?: number | string; + loadFingerprint: (path: string) => Promise; + compare: (fingerprints: Fingerprint[]) => CompareResult; +} + +type GateRunResult = + | { kind: "error"; code: 2; message: string } + | { kind: "ok"; code: 0 | 1; stdout: string }; + +/** + * CLI adapter for `ghost-drift compare --gate`. Validates inputs, loads + * the sync manifest, runs the comparison, and writes the verdict to + * stdout. Calls `process.exit` exactly once at the end with the gate + * exit code (or 2 on any validation/error path). + */ +export async function runGateCli(opts: RunGateCliOptions): Promise { + const result = await computeGateRun(opts); + if (result.kind === "error") { + console.error(`Error: ${result.message}`); + } else { + await writeAndFlush(`${result.stdout}\n`); + } + process.exit(result.code); +} + +async function computeGateRun(opts: RunGateCliOptions): Promise { + if (opts.fingerprints.length !== 2) { + return { + kind: "error", + code: 2, + message: `--gate requires exactly 2 fingerprints (got ${opts.fingerprints.length}).`, + }; + } + + const syncPath = resolve(opts.cwd, opts.sync ?? DEFAULT_SYNC_PATH); + if (!existsSync(syncPath)) { + return { + kind: "error", + code: 2, + message: `sync manifest not found at ${syncPath}. Run \`ghost-drift ack\` first or pass --sync .`, + }; + } + + let manifest: SyncManifest; + try { + manifest = JSON.parse(await readFile(syncPath, "utf-8")) as SyncManifest; + } catch (err) { + return { + kind: "error", + code: 2, + message: `failed to load sync manifest at ${syncPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { + return { + kind: "error", + code: 2, + message: `sync manifest at ${syncPath} is malformed (missing dimensions).`, + }; + } + + const maxDivergenceDays = parseMaxDivergenceDays(opts.maxDivergenceDays); + if (maxDivergenceDays === "invalid") { + return { + kind: "error", + code: 2, + message: "--max-divergence-days must be a non-negative integer.", + }; + } + + let fingerprints: Fingerprint[]; + try { + fingerprints = await Promise.all( + opts.fingerprints.map((path) => opts.loadFingerprint(path)), + ); + } catch (err) { + return { + kind: "error", + code: 2, + message: `failed to load fingerprints: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + const compared = opts.compare(fingerprints); + if (compared.mode !== "pairwise") { + return { + kind: "error", + code: 2, + message: "--gate requires pairwise comparison.", + }; + } + + const report = buildGateReport({ + comparison: compared.comparison, + manifest, + maxDivergenceDays, + }); + + const stdout = + opts.format === "json" + ? formatGateReportJSON(report) + : formatGateReportCLI(report); + + return { kind: "ok", code: gateExitCode(report), stdout }; +} + +/** + * Write to stdout and wait for the stream to flush before resolving. + * `process.exit` does not drain async stdout (e.g., when the gate + * report is piped into another command on Unix), so the explicit + * callback flush prevents truncated JSON output. + */ +async function writeAndFlush(text: string): Promise { + await new Promise((resolve) => { + process.stdout.write(text, () => resolve()); + }); +} + +function parseMaxDivergenceDays( + raw: number | string | undefined, +): number | undefined | "invalid" { + if (raw === undefined) return undefined; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return "invalid"; + return n; +} diff --git a/packages/ghost/src/core/index.ts b/packages/ghost/src/core/index.ts index cf50753..f50990e 100644 --- a/packages/ghost/src/core/index.ts +++ b/packages/ghost/src/core/index.ts @@ -94,6 +94,21 @@ export { resolveTrackedFingerprint, writeSyncManifest, } from "./evolution/index.js"; +export type { + BuildGateReportArgs, + GateDimensionReport, + GateDimensionVerdict, + GateOverallVerdict, + GateReport, + RunGateCliOptions, +} from "./gate.js"; +export { + buildGateReport, + formatGateReportCLI, + formatGateReportJSON, + gateExitCode, + runGateCli, +} from "./gate.js"; export { formatCompositeComparison, formatCompositeComparisonJSON, diff --git a/packages/ghost/src/skill-bundle/references/review.md b/packages/ghost/src/skill-bundle/references/review.md index e40fbd9..cff0679 100644 --- a/packages/ghost/src/skill-bundle/references/review.md +++ b/packages/ghost/src/skill-bundle/references/review.md @@ -65,7 +65,24 @@ Bad advisory topics: - restating pattern prose without a diff location - enforcing a rule that is not in `checks.yml` -### 4. Promote Durable Rules Later +### 4. Deterministic gate (CI / programmatic) + +When a non-interactive caller (CI, another agent, a script) needs a structured +pass/fail signal — not advisory prose — reach for the `--gate` mode of +`compare`. It reconciles the current pairwise distance against the recorded +ack stance in `.ghost-sync.json` and prints a per-dimension verdict +(`aligned` / `covered` / `reconverging` / `uncovered`). + +```bash +ghost-drift compare --gate --sync .ghost-sync.json --format json +``` + +Exit codes: `0` no uncovered drift, `1` at least one dimension is uncovered +(or new and unacked), `2` hard error (missing manifest, malformed JSON, N≠2). +The JSON schema is `ghost.compare.gate/v1` and is safe for programmatic +consumers to parse. + +### 5. Promote Durable Rules Later If an advisory finding recurs and can be detected deterministically, propose a new `ghost.checks/v1` entry. Do not add it to `checks.yml` unless a human diff --git a/packages/ghost/test/gate.test.ts b/packages/ghost/test/gate.test.ts new file mode 100644 index 0000000..ccde13b --- /dev/null +++ b/packages/ghost/test/gate.test.ts @@ -0,0 +1,468 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + DimensionAck, + Fingerprint, + FingerprintComparison, + SyncManifest, +} from "@ghost/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildCli } from "../src/cli.js"; +import { + buildGateReport, + formatGateReportCLI, + formatGateReportJSON, + gateExitCode, +} from "../src/core/gate.js"; + +function makeFingerprint(id: string): Fingerprint { + return { + id, + source: "registry", + timestamp: new Date().toISOString(), + palette: { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "muted", + contrast: "moderate", + }, + spacing: { scale: [], regularity: 0, baseUnit: null }, + typography: { + families: [], + sizeRamp: [], + weightDistribution: {}, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [], + shadowComplexity: "deliberate-none", + borderUsage: "minimal", + }, + embedding: [], + }; +} + +function makeComparison( + dimensions: Record, + ids: { source: string; target: string } = { + source: "tracked", + target: "local", + }, +): FingerprintComparison { + const distances = Object.values(dimensions); + const distance = + distances.length === 0 + ? 0 + : distances.reduce((a, b) => a + b, 0) / distances.length; + return { + source: makeFingerprint(ids.source), + target: makeFingerprint(ids.target), + distance, + dimensions: Object.fromEntries( + Object.entries(dimensions).map(([key, dist]) => [ + key, + { dimension: key, distance: dist, description: "" }, + ]), + ), + summary: "", + }; +} + +function makeManifest( + dimensions: Record>, +): SyncManifest { + const fullDimensions: Record = {}; + for (const [key, partial] of Object.entries(dimensions)) { + fullDimensions[key] = { + distance: 0.1, + stance: "accepted", + ackedAt: new Date().toISOString(), + ...partial, + }; + } + return { + tracks: { type: "path", value: "./tracked.fingerprint.md" }, + ackedAt: new Date().toISOString(), + trackedFingerprintId: "tracked", + localFingerprintId: "local", + dimensions: fullDimensions, + overallDistance: 0, + }; +} + +describe("buildGateReport", () => { + it("schema field is present and correctly versioned", () => { + const report = buildGateReport({ + comparison: makeComparison({ palette: 0 }), + manifest: makeManifest({ palette: { distance: 0, stance: "aligned" } }), + }); + expect(report.schema).toBe("ghost.compare.gate/v1"); + }); + + it("aligned: zero distances and matching aligned acks", () => { + const comparison = makeComparison({ palette: 0, spacing: 0 }); + const manifest = makeManifest({ + palette: { distance: 0, stance: "aligned" }, + spacing: { distance: 0, stance: "aligned" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("aligned"); + expect(report.dimensions.spacing.verdict).toBe("aligned"); + expect(report.overall.verdict).toBe("aligned"); + expect(gateExitCode(report)).toBe(0); + }); + + it("covered (accepted): distances match acks, stance accepted", () => { + const comparison = makeComparison({ palette: 0.2, spacing: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + spacing: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("covered"); + expect(report.dimensions.spacing.verdict).toBe("covered"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("diverging covered: current ≤ acked, stance diverging", () => { + const comparison = makeComparison({ + palette: 0.2, + decisions: 0.0, + }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + decisions: { distance: 0.0, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.decisions.verdict).toBe("covered"); + expect(report.dimensions.decisions.stance).toBe("diverging"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("reconverging surfaced: diverging dim with current < 50% of acked", () => { + const comparison = makeComparison({ palette: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.4, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("reconverging"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("uncovered (exceeded tolerance): exit 1, reason explains exceedance", () => { + const comparison = makeComparison({ palette: 0.95, spacing: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.875, stance: "accepted" }, + spacing: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.palette.reason).toContain("exceeds"); + expect(report.dimensions.spacing.verdict).toBe("covered"); + expect(report.overall.verdict).toBe("uncovered"); + expect(gateExitCode(report)).toBe(1); + }); + + it("uncovered (new dimension): comparison has dimension manifest doesn't cover", () => { + const comparison = makeComparison({ + palette: 0.1, + newDimension: 0.4, + }); + const manifest = makeManifest({ + palette: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.newDimension.verdict).toBe("uncovered"); + expect(report.dimensions.newDimension.reason).toBe("no ack recorded"); + expect(report.overall.verdict).toBe("uncovered"); + expect(gateExitCode(report)).toBe(1); + }); + + it("diverging exceeded --max-divergence-days flagged uncovered", () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); + const comparison = makeComparison({ palette: 0.5 }); + const manifest = makeManifest({ + palette: { + distance: 0.4, + stance: "diverging", + divergedAt: oldDate.toISOString(), + }, + }); + const report = buildGateReport({ + comparison, + manifest, + maxDivergenceDays: 30, + }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.palette.reason).toContain("max-divergence-days"); + expect(gateExitCode(report)).toBe(1); + }); + + it("CLI output: per-dimension lines with markers and final overall verdict", () => { + const comparison = makeComparison({ + palette: 0.95, + spacing: 0.0, + }); + const manifest = makeManifest({ + palette: { distance: 0.875, stance: "accepted" }, + spacing: { distance: 0.0, stance: "aligned" }, + }); + const report = buildGateReport({ comparison, manifest }); + const text = formatGateReportCLI(report); + expect(text).toContain("✓"); // aligned + expect(text).toContain("✗"); // uncovered + expect(text).toMatch(/Overall: uncovered/); + expect(text).toMatch(/palette/); + expect(text).toMatch(/spacing/); + }); + + it("JSON output: structured shape with schema field", () => { + const comparison = makeComparison({ palette: 0.2 }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + }); + const json = formatGateReportJSON( + buildGateReport({ comparison, manifest }), + ); + expect(json).toContain('"schema":"ghost.compare.gate/v1"'); + const parsed = JSON.parse(json); + expect(parsed.dimensions.palette.verdict).toBe("covered"); + }); +}); + +// --- CLI integration tests --- + +const FINGERPRINT = `--- +id: local +source: llm +timestamp: 2026-04-24T00:00:00.000Z +palette: + dominant: + - { role: primary, value: "#111111" } + neutrals: { steps: ["#ffffff", "#111111"], count: 2 } + semantic: [] + saturationProfile: muted + contrast: high +spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 } +typography: + families: ["Inter"] + sizeRamp: [12, 16, 24] + weightDistribution: { 400: 1 } + lineHeightPattern: normal +surfaces: + borderRadii: [4, 8] + shadowComplexity: deliberate-none + borderUsage: minimal +--- + +# Character + +Quiet and direct. + +# Decisions + +### shape-language +Use modest radii. +`; + +function fingerprintWithId(id: string): string { + return FINGERPRINT.replace("id: local", `id: ${id}`); +} + +async function runCli(argv: string[], cwd: string) { + const cli = buildCli(); + const previousCwd = process.cwd(); + let stdout = ""; + let stderr = ""; + let exitCode: number | undefined; + let finish: () => void = () => {}; + const done = new Promise((resolve) => { + finish = resolve; + }); + + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array, ...rest: unknown[]) => { + stdout += chunk.toString(); + // Honor the optional flush callback so writers using the + // `write(chunk, cb)` signature (see runGateCli's writeAndFlush) + // resolve instead of hanging on the test's mocked stdout. + const cb = rest[rest.length - 1]; + if (typeof cb === "function") cb(); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { + stdout += `${args.join(" ")}\n`; + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + stderr += `${args.join(" ")}\n`; + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + exitCode = typeof code === "number" ? code : 0; + finish(); + return undefined as never; + }); + + try { + process.chdir(cwd); + cli.parse(["node", "ghost-drift", ...argv]); + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command did not exit")), 5000), + ), + ]); + } finally { + process.chdir(previousCwd); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } + + return { stdout, stderr, code: exitCode ?? 0 }; +} + +describe("ghost-drift compare --gate (CLI)", () => { + let dir: string; + + beforeEach(async () => { + dir = await import("node:fs/promises").then((fs) => + fs.mkdtemp(join(tmpdir(), "ghost-drift-gate-")), + ); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("missing sync file with --gate exits 2 and mentions the path", async () => { + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); + + const { stderr, code } = await runCli( + ["compare", "a.fingerprint.md", "b.fingerprint.md", "--gate"], + dir, + ); + + expect(code).toBe(2); + expect(stderr).toMatch(/sync manifest not found/); + expect(stderr).toContain(join(dir, ".ghost-sync.json")); + }); + + it("--gate --format json emits ghost.compare.gate/v1", async () => { + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); + const manifest: SyncManifest = { + tracks: { type: "path", value: "./a.fingerprint.md" }, + ackedAt: new Date().toISOString(), + trackedFingerprintId: "a", + localFingerprintId: "b", + // Pre-populate every dimension with a generous tolerance so identical + // fingerprints come back fully covered. + dimensions: { + decisions: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + palette: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + spacing: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + typography: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + surfaces: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + }, + overallDistance: 0, + }; + await writeFile( + join(dir, ".ghost-sync.json"), + JSON.stringify(manifest, null, 2), + ); + + const { stdout, code } = await runCli( + [ + "compare", + "a.fingerprint.md", + "b.fingerprint.md", + "--gate", + "--format", + "json", + ], + dir, + ); + + expect(code).toBe(0); + expect(stdout).toContain('"schema":"ghost.compare.gate/v1"'); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.overall.verdict).toMatch(/aligned|covered/); + }); + + it("--gate with N≠2 exits 2", async () => { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + const { code, stderr } = await runCli( + ["compare", "a.fingerprint.md", "--gate"], + dir, + ); + expect(code).toBe(2); + expect(stderr).toMatch(/--gate requires exactly 2/); + }); +}); + +describe("uncovered JSON schema snippet (visible to caller)", () => { + it("matches the documented gate report shape for an uncovered dim", () => { + const comparison = makeComparison( + { + spacing: 0.73, + palette: 0.95, + decisions: 0.0, + newDimension: 0.4, + }, + { source: "market-theme", target: "example-app" }, + ); + const manifest = makeManifest({ + spacing: { distance: 0.73, stance: "accepted" }, + palette: { distance: 0.875, stance: "accepted" }, + decisions: { distance: 0.0, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.newDimension.verdict).toBe("uncovered"); + expect(report.overall.verdict).toBe("uncovered"); + // Dump for the caller to verify the produced shape. + process.stdout.write(`${formatGateReportJSON(report)}\n`); + }); +});