/** * Contract Mode — Phase 2: the prove-compliant loop as a PURE primitive. * * A validate-fix-validate loop over UI code: run the conform engine, apply its * deterministic fixes, and re-validate until the engine proves zero findings, * the pass budget is exhausted, or progress stalls. When deterministic fixes * stall on residual issues, an optional async `sampler` repairs them (model * call), and the loop re-validates the result — a sampled fix is never trusted * without a clean re-validation pass. * * This is the engine behind `contract.prove` (architecture §2 move #4). It is * deliberately framework-agnostic: it takes a `conform` callback (so Cloud, * CLI, and the standalone MCP each inject their own engine binding) and returns * a plain result — no MCP plumbing, no task store, no transport coupling. * * Determinism (architecture §6.3): the sampler receives `(seq, codeHashAtRequest)` * so a durable-replay layer can key recorded sampler output on the exact code it * was asked to repair, and hard-invalidate on a hash mismatch — instead of the * pass-index-only key the standalone MCP tool uses. * * Browser-safe: depends only on the conform contract types + the pure SHA-256 * in this package. No Node APIs. */ import type { ConformInput, ConformResult, ConformSuggestion, ConformUnresolved, } from "./conform.js"; import { sha256Hex } from "./contract/hash.js"; /** Upper bound on validate/fix passes. */ export const PROVE_MAX_PASSES = 8; /** Default validate/fix passes when the caller does not specify. */ export const PROVE_DEFAULT_MAX_PASSES = 4; /** * 3-valued verdict. `insufficient_coverage` is NOT a pass: a clean run over an * empty catalog (no tokens, no component mappings) proves nothing, so the loop * never certifies compliance it could not actually evaluate. */ export type ProveVerdict = "pass" | "fail" | "insufficient_coverage"; export interface ProvePassSampling { requested?: boolean; used?: boolean; model?: string; stopReason?: string; reason?: string; } export interface ProvePass { pass: number; issues: number; deterministicChanges: number; suggestions: number; unresolved: number; changed: boolean; sampling?: ProvePassSampling; } export interface ProveResidual { suggestions: ConformSuggestion[]; unresolved: ConformUnresolved[]; } export interface ProveSample { /** Repaired code, or omitted/empty when the sampler declined. */ code?: string; model?: string; stopReason?: string; reason?: string; } export interface ProveSamplerArgs { /** The current code the sampler is asked to repair. */ code: string; /** The residual findings deterministic fixes could not resolve. */ residual: ProveResidual; metadata: { /** 1-indexed validate/fix pass that stalled. */ pass: number; /** 0-indexed sampler attempt counter (independent of `pass`). */ seq: number; /** SHA-256 of `code` — the durable-replay key + hard-invalidation guard. */ codeHashAtRequest: string; }; } export interface ProveCallbacks { /** Validate + apply deterministic fixes for one pass. */ conform(input: ConformInput): Promise | ConformResult; /** Optional model-backed repair for residual issues conform cannot resolve. */ sampler?(args: ProveSamplerArgs): Promise | ProveSample | null; } export interface ProveOptions { /** Max validate/fix passes (clamped to 1..8). Defaults to 4. */ maxPasses?: number; /** Allow the sampler to repair residual issues. Defaults to true. */ allowSampling?: boolean; } export interface ProveLoopResult { /** Code after all passes + sampling. */ conformed: string; /** Whether `conformed` differs from the input code. */ changed: boolean; verdict: ProveVerdict; /** True only on a clean, evaluated pass. */ provedCompliant: boolean; /** Findings remaining in the final pass (changes + suggestions + unresolved). */ issueCount: number; /** Total deterministic edits applied across all passes. */ corrections: number; passHistory: ProvePass[]; residual: ProveResidual; /** The final conform result (for receipt / summary rendering). */ result: ConformResult; /** Human-readable reason for the verdict. */ reason: string; } /** Total findings in a conform result: applied changes + suggestions + unresolved. */ export function proveIssueCount(result: ConformResult): number { return result.changes.length + result.suggestions.length + result.unresolved.length; } /** A neutral conform result for the "no result produced" fallback path. */ export function emptyConformResult(code: string): ConformResult { return { conformed: code, changed: false, summary: "No conform result was produced.", changes: [], suggestions: [], unresolved: [], }; } /** * Resolve the 3-valued verdict. Coverage trumps everything: an unevaluated * catalog is `insufficient_coverage` regardless of issue count. */ export function resolveProveVerdict(args: { coverageEvaluated: boolean; provedCompliant: boolean; }): ProveVerdict { if (!args.coverageEvaluated) return "insufficient_coverage"; return args.provedCompliant ? "pass" : "fail"; } /** Clamp a requested pass count into the supported 1..8 range (default 4). */ export function clampMaxPasses(value: number | undefined): number { if (typeof value !== "number" || Number.isNaN(value)) { return PROVE_DEFAULT_MAX_PASSES; } return Math.min(PROVE_MAX_PASSES, Math.max(1, Math.trunc(value))); } function incompleteReason(args: { passCount: number; maxPasses: number; finalIssues: number; allowSampling: boolean; hasSampler: boolean; }): string { if (args.passCount >= args.maxPasses) { return `stopped after ${args.maxPasses} passes with ${args.finalIssues} finding(s).`; } if (!args.allowSampling) { return "sampling was disabled and residual issues remain."; } if (!args.hasSampler) { return "no sampler was available and residual issues remain."; } return `${args.finalIssues} residual finding(s) remain after deterministic and sampled fixes.`; } /** * Run the validate-fix-validate loop. Pure: never mutates `input`; all state * flows through the return value. */ export async function proveCompliant( input: ConformInput, callbacks: ProveCallbacks, options: ProveOptions = {} ): Promise { const maxPasses = clampMaxPasses(options.maxPasses); const allowSampling = options.allowSampling !== false; const { filename } = input; const hasSampler = typeof callbacks.sampler === "function"; let current = input.code; const passHistory: ProvePass[] = []; let corrections = 0; let samplingAttempts = 0; let lastResult: ConformResult | null = null; while (passHistory.length < maxPasses) { const pass = passHistory.length + 1; const result = await callbacks.conform({ code: current, filename, apply: "deterministic", }); lastResult = result; const issues = proveIssueCount(result); passHistory.push({ pass, issues, deterministicChanges: result.changes.length, suggestions: result.suggestions.length, unresolved: result.unresolved.length, changed: result.changed, }); corrections += result.changes.length; if (issues === 0) { const coverageEvaluated = result.coverage?.evaluated !== false; return { conformed: current, changed: current !== input.code, verdict: resolveProveVerdict({ coverageEvaluated, provedCompliant: coverageEvaluated, }), provedCompliant: coverageEvaluated, issueCount: 0, corrections, passHistory, residual: { suggestions: [], unresolved: [] }, result, reason: coverageEvaluated ? `0 findings after ${pass} pass${pass === 1 ? "" : "es"}.` : "the design-system catalog has no tokens or components, so compliance could not be evaluated.", }; } // Deterministic progress — keep looping on the rewritten code. if (result.changed && result.conformed !== current) { current = result.conformed; continue; } // Conform stalled with residual issues. Try the sampler if it is available // and we are under the attempt cap (kept strictly below maxPasses so a // revalidation pass always remains in budget). // // Sampling semantics (intentional, and DIFFERENT from the standalone MCP // tool): the loop re-samples only while the sampler keeps producing *new* // code (each fresh snippet is re-validated on the next pass); a sample that // returns nothing new — null, no `code`, or byte-identical code — ends the // loop. Re-asking the model with the identical residual rarely helps and // burns tokens. The MCP tool instead retries up to its cap on each stall // (its suspend/resume transport makes per-stall retry the natural shape); // both are bounded by maxPasses. See docs/contract-mode/01-architecture.md. const residual: ProveResidual = { suggestions: result.suggestions, unresolved: result.unresolved, }; const canSample = allowSampling && hasSampler && samplingAttempts < maxPasses - 1 && (residual.suggestions.length > 0 || residual.unresolved.length > 0); if (canSample) { const seq = samplingAttempts; const codeHashAtRequest = sha256Hex(current); const last = passHistory[passHistory.length - 1]; if (last) last.sampling = { requested: true }; const sampled = await callbacks.sampler!({ code: current, residual, metadata: { pass, seq, codeHashAtRequest }, }); samplingAttempts += 1; if (last) { last.sampling = { ...(last.sampling ?? {}), used: Boolean(sampled?.code), model: sampled?.model, stopReason: sampled?.stopReason, reason: sampled?.reason, }; } if (sampled?.code && sampled.code !== current) { current = sampled.code; continue; } } break; } const fallback = lastResult ?? emptyConformResult(current); const finalIssues = proveIssueCount(fallback); const coverageEvaluated = fallback.coverage?.evaluated !== false; return { conformed: current, changed: current !== input.code, verdict: resolveProveVerdict({ coverageEvaluated, provedCompliant: false }), provedCompliant: false, issueCount: finalIssues, corrections, passHistory, residual: { suggestions: fallback.suggestions, unresolved: fallback.unresolved, }, result: fallback, reason: incompleteReason({ passCount: passHistory.length, maxPasses, finalIssues, allowSampling, hasSampler, }), }; }