/** * `oke doctor --diff` — CI gate blocking undeclared contract breaks. * * Default baseline: git merge-base with `main`/`master` vs the working tree. * Escape hatch: explicit `--before` / `--after` manifest JSON paths. */ import { resolve } from "node:path"; import { diffManifest } from "../manifest/diff.ts"; import type { Manifest, ManifestChange } from "../manifest/types.ts"; import { undeclaredContractBreaks } from "../manifest/undeclared.ts"; import { EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from "./exit.ts"; import { loadManifest } from "./load-config.ts"; /** Default remote base branch candidates. */ const BASE_BRANCH_CANDIDATES = ["main", "master", "origin/main", "origin/master"] as const; /** Options for {@link runDoctorDiff}. */ export interface DoctorDiffOptions { readonly cwd?: string; /** Explicit before manifest (JSON path or injected). */ readonly beforePath?: string; /** Explicit after manifest (JSON path or injected). */ readonly afterPath?: string; /** Injected before manifest (tests). */ readonly before?: Manifest; /** Injected after manifest (tests). */ readonly after?: Manifest; /** Preferred base branch for merge-base (default: first of main/master). */ readonly baseBranch?: string; /** * Resolve the merge-base commit (tests). * * @param cwd - Repo root * @param baseBranch - Preferred base */ readonly resolveMergeBase?: (cwd: string, baseBranch?: string) => Promise; /** * Read a file at a git revision (tests). * * @param cwd - Repo root * @param rev - Commit-ish * @param path - Path relative to repo root */ readonly readAtRevision?: (cwd: string, rev: string, path: string) => Promise; /** Manifest relative path when using git (default: auto-detect). */ readonly manifestPath?: string; readonly write?: (text: string) => void; } /** Result of a doctor --diff run. */ export interface DoctorDiffResult { readonly code: number; readonly undeclared: readonly ManifestChange[]; readonly allChanges: readonly ManifestChange[]; } const COMMENT_CATEGORIES = ["contract-breaking", "permission-widening", "effect-widening"] as const; const CATEGORY_LABEL: Record<(typeof COMMENT_CATEGORIES)[number], string> = { "contract-breaking": "Contract breaks", "permission-widening": "Permission widening", "effect-widening": "Effect widening", }; /** * Plain-language pull-request comment for the three behavioural categories. * Widening is informational. Contract breaks are the lines that fail CI * when they are undeclared. * * @param changes - {@link diffManifest} changes */ export function formatManifestDiffComment(changes: readonly ManifestChange[]): string { const lines = ["## Manifest diff", ""]; for (const category of COMMENT_CATEGORIES) { const rows = changes.filter((change) => change.category === category); lines.push(`### ${CATEGORY_LABEL[category]}`); if (rows.length === 0) { lines.push("None."); } else { for (const row of rows) { lines.push(`- \`${row.path}\` — ${row.summary}`); } } lines.push(""); } lines.push( "Undeclared contract breaks fail the check. Permission and effect widening are comments only. Acknowledge an intentional break with `breaking: true` on the HTTP, call, MCP, or resource contract.", ); return lines.join("\n"); } /** * Diff two manifests and fail on undeclared contract breaks. * * @param options - Paths / injections / git helpers */ export async function runDoctorDiff(options: DoctorDiffOptions = {}): Promise { const write = options.write ?? ((t) => process.stdout.write(t)); const cwd = options.cwd ?? process.cwd(); const { before, after } = await resolveManifests(cwd, options); const all = diffManifest(before, after).changes; const undeclared = undeclaredContractBreaks(before, after); if (undeclared.length === 0) { write( all.length === 0 ? "oke doctor --diff: ok (no behavioural changes)\n" : `oke doctor --diff: ok (${all.length} change(s), no undeclared contract breaks)\n`, ); return { code: EXIT_OK, undeclared, allChanges: all }; } write(`oke doctor --diff: ${undeclared.length} undeclared contract-breaking change(s)\n`); for (const c of undeclared) { write(` [contract-breaking] ${c.path}: ${c.summary}\n`); } write( "Acknowledge intentional breaks with `breaking: true` on the HTTP / call / MCP / resource contract bag.\n", ); return { code: EXIT_RUNTIME, undeclared, allChanges: all }; } /** * Resolve before/after manifests from options or git merge-base. * * @param cwd - Project root * @param options - Diff options */ async function resolveManifests( cwd: string, options: DoctorDiffOptions, ): Promise<{ before: Manifest; after: Manifest }> { if (options.before && options.after) { return { before: options.before, after: options.after }; } if (options.beforePath && options.afterPath) { return { before: options.before ?? (await loadManifest(resolve(cwd, options.beforePath))), after: options.after ?? (await loadManifest(resolve(cwd, options.afterPath))), }; } if (options.beforePath || options.afterPath) { throw new Error( "oke doctor --diff: pass both --before and --after, or neither (git merge-base default)", ); } const rel = options.manifestPath ?? (await detectManifestRel(cwd)); if (!rel) { throw new Error( "oke doctor --diff: no oke.manifest.json or manifest.oke.json in cwd — pass --before/--after", ); } const after = options.after ?? (await loadManifest(resolve(cwd, rel))); const resolveBase = options.resolveMergeBase ?? gitMergeBase; const readAt = options.readAtRevision ?? gitShow; const mergeBase = await resolveBase(cwd, options.baseBranch); if (!mergeBase) { // No git history / first commit — nothing to break against. return { before: after, after }; } const beforeText = await readAt(cwd, mergeBase, rel); if (beforeText === null) { // Manifest did not exist at merge-base — treat as empty baseline. return { before: { oke: after.oke, app: after.app }, after, }; } const before = options.before ?? (JSON.parse(beforeText) as Manifest); return { before, after }; } /** * Detect a committed manifest filename under cwd. * * @param cwd - Project root */ async function detectManifestRel(cwd: string): Promise { for (const name of ["oke.manifest.json", "manifest.oke.json"]) { if (await Bun.file(resolve(cwd, name)).exists()) return name; } return null; } /** * Resolve merge-base with main/master (or a preferred branch). * * @param cwd - Repo root * @param baseBranch - Preferred base */ export async function gitMergeBase(cwd: string, baseBranch?: string): Promise { const candidates = baseBranch ? [baseBranch, ...BASE_BRANCH_CANDIDATES.filter((b) => b !== baseBranch)] : [...BASE_BRANCH_CANDIDATES]; for (const base of candidates) { const proc = Bun.spawn(["git", "merge-base", "HEAD", base], { cwd, stdout: "pipe", stderr: "pipe", }); const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); if (exitCode === 0) { const rev = stdout.trim(); if (rev.length > 0) return rev; } } return null; } /** * Read a file blob at a git revision. * * @param cwd - Repo root * @param rev - Commit-ish * @param path - Path relative to repo root */ export async function gitShow(cwd: string, rev: string, path: string): Promise { const proc = Bun.spawn(["git", "show", `${rev}:${path}`], { cwd, stdout: "pipe", stderr: "pipe", }); const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); if (exitCode !== 0) return null; return stdout; } /** * CLI entry for `oke doctor --diff`. * * @param args - Full args after `doctor` (includes `--diff`) */ export async function doctorDiffCli(args: readonly string[]): Promise { let beforePath: string | undefined; let afterPath: string | undefined; let baseBranch: string | undefined; let manifestPath: string | undefined; let cwd: string | undefined; for (let i = 0; i < args.length; i++) { const a = args[i]!; if (a === "--diff" || a === "-d") continue; if (a === "--before" || a === "-b") beforePath = args[++i]; else if (a === "--after" || a === "-a") afterPath = args[++i]; else if (a === "--base" || a === "-B") baseBranch = args[++i]; else if (a === "--manifest" || a === "-m") manifestPath = args[++i]; else if (a === "--cwd") cwd = args[++i]; else if (a === "--help" || a === "-h") { console.log(`oke doctor --diff|-d [--before|-b --after|-a ] [--base|-B main] CI gate: fail on undeclared contract-breaking Manifest changes. Default: git merge-base (main/master) vs working-tree manifest. Acknowledge intentional breaks with breaking: true on the HTTP / call / MCP / resource contract bag. `); return EXIT_OK; } } try { const { code } = await runDoctorDiff({ cwd, beforePath, afterPath, baseBranch, manifestPath, }); return code; } catch (err) { console.error(err instanceof Error ? err.message : String(err)); return EXIT_USAGE; } }