// sdk/cli/commands/audit.ts — Plan 21-09 Task 5 (SDK-21). // // `gdd-sdk audit` — regression + verification dry-run. // // 1. Probe connections (via in-process probe_connections handler — // Plan 21-09 deliberately avoids the MCP stdio roundtrip here; // Plan 21-10 exercises the full MCP boundary). // 2. Read current STATE.md; enumerate must_haves and evaluate each. // 3. (Optional --baseline ) compare the connections map + a // minimal manifest signature to a baseline snapshot. // 4. Print a summary report (JSON or human-readable) to stdout. // // Exit codes: // * 0 — all probes green + all must_haves pass + no baseline drift. // * 1 — one or more regressions detected. // * 3 — arg / config error. // // Note: the PROBE handler in probe_connections.ts expects a caller- // supplied `probe_results` array. Here we do not have live probe data // (the actual figma / refero health checks live in Phase-20 skill // flows); instead, `audit` inspects the LAST-KNOWN connections map from // STATE.md and flags any `unavailable` entries as degraded. A future // plan (21-10 cross-harness) can wire in live probes. import { existsSync, readFileSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { read } from '../../state/index.ts'; import type { ConnectionStatus, ParsedState } from '../../state/types.ts'; import { coerceFlags, COMMON_FLAGS, type FlagSpec, type ParsedArgs, } from '../parse-args.ts'; // --------------------------------------------------------------------------- // Flag spec + help. // --------------------------------------------------------------------------- const AUDIT_FLAGS: readonly FlagSpec[] = [ ...COMMON_FLAGS, { name: 'baseline', type: 'string' }, { name: 'state-path', type: 'string' }, ]; const USAGE = `gdd-sdk audit [flags] Probe connections + dry-run verify. Flags: --baseline Compare connections + must-haves against baseline snapshot (expects /STATE.md with pre-recorded state) --state-path Override STATE.md path --cwd Working directory --json Emit JSON report (default: human-readable) Exit codes: 0 clean — all probes available, all must-haves pass, no baseline drift 1 regressions — any probe unavailable OR any must-have failed OR baseline drift 3 arg / config error `; // --------------------------------------------------------------------------- // Report types. // --------------------------------------------------------------------------- export interface ConnectionReport { readonly name: string; readonly status: ConnectionStatus; readonly ok: boolean; } export interface MustHaveReport { readonly id: string; readonly text: string; readonly status: 'pending' | 'pass' | 'fail'; readonly ok: boolean; } export interface BaselineReport { readonly ok: boolean; readonly connection_drift: readonly string[]; readonly must_have_drift: readonly string[]; } export interface AuditReport { readonly connections: readonly ConnectionReport[]; readonly must_haves: readonly MustHaveReport[]; readonly baseline?: BaselineReport; // `true` when there is no active cycle (.design/STATE.md absent). The audit // then runs only the static checks that do not require cycle state and exits // 0 with this flag set, rather than failing. Omitted (undefined) on a normal // run with an active cycle. readonly degraded?: boolean; readonly summary: { readonly connections_ok: boolean; readonly must_haves_ok: boolean; readonly baseline_ok: boolean; readonly overall_ok: boolean; }; } // --------------------------------------------------------------------------- // Deps. // --------------------------------------------------------------------------- export type ReadFn = typeof read; export interface AuditCommandDeps { readonly readState?: ReadFn; readonly stdout?: NodeJS.WritableStream; readonly stderr?: NodeJS.WritableStream; } // --------------------------------------------------------------------------- // Entry point. // --------------------------------------------------------------------------- export async function auditCommand( args: ParsedArgs, deps: AuditCommandDeps = {}, ): Promise { const stdout = deps.stdout ?? process.stdout; const stderr = deps.stderr ?? process.stderr; if (args.flags['help'] === true || args.flags['h'] === true) { stdout.write(USAGE); return 0; } let flags: Record; try { flags = coerceFlags(args, AUDIT_FLAGS); } catch (err) { stderr.write(`gdd-sdk audit: ${errMessage(err)}\n`); return 3; } const cwd: string = typeof flags['cwd'] === 'string' ? (flags['cwd'] as string) : process.cwd(); const explicitStatePath: boolean = typeof flags['state-path'] === 'string' && (flags['state-path'] as string).length > 0; const statePath: string = explicitStatePath ? resolvePath(cwd, flags['state-path'] as string) : resolvePath(cwd, '.design', 'STATE.md'); if (!existsSync(statePath)) { // An explicit --state-path that does not exist is an arg error: the // caller pointed us at a specific file that should be present. if (explicitStatePath) { stderr.write(`gdd-sdk audit: STATE.md not found at ${statePath}\n`); return 3; } // No active cycle (default .design/STATE.md absent). Graceful degrade: // emit a clear message + a degraded report covering the static checks // that do not require cycle state, then exit 0 — never throw. return emitDegraded(flags, stdout, stderr); } const readFn = deps.readState ?? read; let state: ParsedState; try { state = await readFn(statePath); } catch (err) { stderr.write(`gdd-sdk audit: failed to read STATE.md: ${errMessage(err)}\n`); return 3; } // 1. Connection report. const connections: ConnectionReport[] = []; let connectionsOk = true; for (const [name, status] of Object.entries(state.connections ?? {})) { const ok = status === 'available' || status === 'not_configured'; if (!ok) connectionsOk = false; connections.push(Object.freeze({ name, status, ok })); } // 2. Must-have report. const mustHaves: MustHaveReport[] = []; let mustHavesOk = true; for (const mh of state.must_haves ?? []) { // `pending` and `pass` are acceptable at audit time; only `fail` is // a definite regression. (Pending items may be verify-stage // responsibilities still in progress.) const ok = mh.status !== 'fail'; if (!ok) mustHavesOk = false; mustHaves.push( Object.freeze({ id: mh.id, text: mh.text, status: mh.status, ok, }), ); } // 3. Optional baseline drift check. let baselineReport: BaselineReport | undefined; const baselineFlag = flags['baseline']; if (typeof baselineFlag === 'string' && baselineFlag.length > 0) { try { baselineReport = computeBaselineDrift(state, resolvePath(cwd, baselineFlag)); } catch (err) { stderr.write(`gdd-sdk audit: baseline error: ${errMessage(err)}\n`); return 3; } } const baselineOk = baselineReport === undefined ? true : baselineReport.ok; const overallOk = connectionsOk && mustHavesOk && baselineOk; const report: AuditReport = { connections: Object.freeze(connections), must_haves: Object.freeze(mustHaves), ...(baselineReport !== undefined ? { baseline: baselineReport } : {}), summary: { connections_ok: connectionsOk, must_haves_ok: mustHavesOk, baseline_ok: baselineOk, overall_ok: overallOk, }, }; if (flags['json'] === true) { stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { stdout.write(renderHuman(report)); } return overallOk ? 0 : 1; } // --------------------------------------------------------------------------- // Degraded (no active cycle) path. // --------------------------------------------------------------------------- /** * Emit a degraded audit report when there is no active cycle (no * `.design/STATE.md`). Connections + must-haves are sourced from STATE.md, * so without a cycle there is nothing cycle-bound to evaluate; we report * empty sets and exit 0. The `degraded` flag signals callers (and the JSON * consumers) that this was a no-cycle run, not a clean active-cycle audit. */ function emitDegraded( flags: Record, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream, ): number { // Human-facing notice on stderr so JSON on stdout stays machine-parseable. stderr.write('gdd-sdk audit: no active cycle — run /gdd:start\n'); const report: AuditReport = { connections: Object.freeze([]), must_haves: Object.freeze([]), degraded: true, summary: { connections_ok: true, must_haves_ok: true, baseline_ok: true, overall_ok: true, }, }; if (flags['json'] === true) { stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { stdout.write(renderHuman(report)); } return 0; } // --------------------------------------------------------------------------- // Baseline comparison. // --------------------------------------------------------------------------- /** * Compare current `state` against a baseline STATE.md at * `/STATE.md`. Returns drift as arrays of human-readable * strings; `ok` is `true` iff both arrays are empty. * * The comparison is intentionally minimal: * * Connection drift: any name whose status differs between baseline * and current (or is missing from one side). * * Must-have drift: any baseline must-have whose status got WORSE * (e.g., baseline=pass → current=fail). Improvements are not drift. */ function computeBaselineDrift( current: ParsedState, baselineDir: string, ): BaselineReport { const baselinePath = resolvePath(baselineDir, 'STATE.md'); if (!existsSync(baselinePath)) { throw new Error(`baseline STATE.md not found at ${baselinePath}`); } // Load + parse the baseline. We re-use `read()` for consistency, but // baseline may live outside any lock regime — that's fine since we // never mutate it. const baselineRaw: string = readFileSync(baselinePath, 'utf8'); // Use a lazy require of the parser to avoid the async indirection — // baseline comparison should be synchronous + deterministic. // We can safely `JSON.parse` a tiny normalized block... actually, the // simplest correct approach is to also call `read()`. Do that inline. const baselineState = parseBaselineStateSync(baselineRaw); const connectionDrift: string[] = []; const cur = current.connections ?? {}; const base = baselineState.connections ?? {}; const allConnKeys = new Set([...Object.keys(cur), ...Object.keys(base)]); for (const k of allConnKeys) { const a = cur[k]; const b = base[k]; if (a === undefined && b !== undefined) { connectionDrift.push(`${k}: missing in current (baseline=${b})`); continue; } if (a !== undefined && b === undefined) { connectionDrift.push(`${k}: new in current (current=${a})`); continue; } if (a !== b) { connectionDrift.push(`${k}: ${b} → ${a}`); } } const mustHaveDrift: string[] = []; const byId = new Map(); // id → current status for (const mh of current.must_haves ?? []) { byId.set(mh.id, mh.status); } for (const bMh of baselineState.must_haves ?? []) { const curStatus = byId.get(bMh.id); if (curStatus === undefined) { mustHaveDrift.push(`${bMh.id}: missing in current (baseline=${bMh.status})`); continue; } // Worst-first ordering: fail > pending > pass. A regression is // moving in that direction. const rank = (s: string): number => (s === 'fail' ? 2 : s === 'pending' ? 1 : 0); if (rank(curStatus) > rank(bMh.status)) { mustHaveDrift.push(`${bMh.id}: ${bMh.status} → ${curStatus}`); } } const ok = connectionDrift.length === 0 && mustHaveDrift.length === 0; return { ok, connection_drift: Object.freeze(connectionDrift), must_have_drift: Object.freeze(mustHaveDrift), }; } /** * Synchronous baseline parse. We want a stand-alone parser that works * on the baseline file without taking a lock — gdd-state's `read()` is * async but lock-free, so we wrap it with a top-level await via * readFileSync + the gdd-state parser exports. Since parser.ts is not * exported from the public index, we duplicate the minimal parse here: * grab the `` and `` blocks via regex. This is * fine because baseline audit tolerates a simplified shape — any * baseline drift we can detect is enough. */ function parseBaselineStateSync(raw: string): Pick { const connections: Record = {}; const connBlock = /([\s\S]*?)<\/connections>/.exec(raw); if (connBlock) { const body = connBlock[1] ?? ''; for (const line of body.split(/\r?\n/)) { // Shape: "- name: status" or "name: status" const m = /^\s*[-*]?\s*([A-Za-z0-9_-]+)\s*:\s*(available|unavailable|not_configured)\s*$/.exec( line, ); if (m !== null && m[1] !== undefined && m[2] !== undefined) { connections[m[1]] = m[2] as ConnectionStatus; } } } const mustHaves: { id: string; text: string; status: 'pending' | 'pass' | 'fail' }[] = []; const mhBlock = /([\s\S]*?)<\/must_haves>/.exec(raw); if (mhBlock) { const body = mhBlock[1] ?? ''; for (const line of body.split(/\r?\n/)) { // Shape: "- M-01 [status] text" or "M-01 [status] text" const m = /^\s*[-*]?\s*(M-\d+)\s*\[(pending|pass|fail)\]\s*(.*)$/.exec(line) ?? /^\s*[-*]?\s*(M-\d+)\s*:\s*(pending|pass|fail)\s*(.*)$/.exec(line); if (m !== null && m[1] !== undefined && m[2] !== undefined) { mustHaves.push({ id: m[1], status: m[2] as 'pending' | 'pass' | 'fail', text: (m[3] ?? '').trim(), }); } } } return { connections, must_haves: mustHaves }; } // --------------------------------------------------------------------------- // Human-readable summary. // --------------------------------------------------------------------------- function renderHuman(report: AuditReport): string { const lines: string[] = []; if (report.degraded === true) { lines.push('audit: degraded (no active cycle — run /gdd:start)'); lines.push(''); lines.push('No active cycle: skipped connection + must-have checks.'); return lines.join('\n') + '\n'; } lines.push(`audit: ${report.summary.overall_ok ? 'clean' : 'REGRESSIONS'}`); lines.push(''); lines.push(`connections (${report.summary.connections_ok ? 'ok' : 'degraded'}):`); for (const c of report.connections) { lines.push(` ${c.name}: ${c.status}${c.ok ? '' : ' ← degraded'}`); } lines.push(''); lines.push(`must-haves (${report.summary.must_haves_ok ? 'ok' : 'failing'}):`); for (const m of report.must_haves) { lines.push(` ${m.id} [${m.status}] ${m.text}${m.ok ? '' : ' ← fail'}`); } if (report.baseline !== undefined) { lines.push(''); lines.push(`baseline (${report.baseline.ok ? 'no drift' : 'drift'}):`); for (const d of report.baseline.connection_drift) { lines.push(` connection drift: ${d}`); } for (const d of report.baseline.must_have_drift) { lines.push(` must-have drift: ${d}`); } } return lines.join('\n') + '\n'; } function errMessage(err: unknown): string { if (err instanceof Error) return err.message; return String(err); }