merge: Sprint 003 — Sprint Contract + DoD Validator (#3)

This commit is contained in:
2026-04-10 15:21:49 +09:00
19 changed files with 1807 additions and 1 deletions

View File

@@ -18,7 +18,7 @@
| 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] |
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] |
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:완료 [PR#2] |
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:TODO |
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:WIP |
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:TODO |
| 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:TODO |

153
src/cli/contract.ts Normal file
View File

@@ -0,0 +1,153 @@
import { defineCommand } from "citty";
import { readFile } from "node:fs/promises";
import { generateDraftContract } from "../contract/generator.js";
import {
saveDraftContract,
loadContract,
freezeContract,
contractFilePath,
} from "../contract/store.js";
import { validateContract } from "../contract/validator.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
const generateCmd = defineCommand({
meta: { name: "generate", description: "Generate draft contract from sprint markdown" },
args: {
sprintMd: {
type: "positional",
description: "Path to sprint markdown file",
required: true,
},
sprintId: {
type: "string",
alias: "s",
description: "Sprint ID",
required: true,
},
},
async run({ args }) {
try {
const draft = await generateDraftContract(args.sprintMd, args.sprintId);
const filePath = await saveDraftContract(process.cwd(), draft);
console.log(`Draft contract created:`);
console.log(` id: ${draft.id}`);
console.log(` sprintId: ${draft.sprintId}`);
console.log(` type: ${draft.type}`);
console.log(` checks: ${draft.dod.checks.length}`);
console.log(` path: ${filePath}`);
console.log(
`\nEdit the file to tune checks, then run: rails contract freeze ${draft.id}`,
);
} finally {
await disconnectPrisma();
}
},
});
const freezeCmd = defineCommand({
meta: { name: "freeze", description: "Freeze a contract (make immutable)" },
args: {
contractId: {
type: "positional",
description: "Contract ID",
required: true,
},
},
async run({ args }) {
try {
await freezeContract(process.cwd(), args.contractId);
console.log(`Contract ${args.contractId} frozen.`);
} finally {
await disconnectPrisma();
}
},
});
const validateCmd = defineCommand({
meta: { name: "validate", description: "Validate a contract against current state" },
args: {
contractId: {
type: "positional",
description: "Contract ID",
required: true,
},
workdir: {
type: "string",
alias: "w",
description: "Working directory",
default: "",
},
},
async run({ args }) {
try {
const contract = await loadContract(process.cwd(), args.contractId);
const result = await validateContract(contract, {
workdir: args.workdir || process.cwd(),
});
console.log(`Contract: ${contract.id} (${contract.sprintId})`);
console.log(`Verdict: ${result.verdict}`);
console.log(
`Summary: ${result.summary.passed}/${result.summary.total} passed, ${result.summary.blockingFailed} blocking failures`,
);
console.log("");
if (result.verdict === "ABORT_PRECHECK") {
console.log("Environment prerequisites:");
for (const p of result.prerequisiteResults) {
console.log(` ${p.passed ? "✓" : "✗"} ${p.name}: ${p.message}`);
}
}
if (result.checkResults.length > 0) {
console.log("DoD checks:");
for (const c of result.checkResults) {
const mark = c.passed ? "✓" : "✗";
const line = c.passed ? c.evidence : c.errorMessage;
console.log(` ${mark} [${c.severity}] ${c.id}: ${line}`);
}
}
if (result.runtimeCommandResults.length > 0) {
console.log("Runtime commands:");
for (const r of result.runtimeCommandResults) {
const mark = r.passed ? "✓" : "✗";
console.log(` ${mark} ${r.name} (exit ${r.exitCode}, ${r.durationMs}ms)`);
}
}
process.exitCode = result.verdict === "PASS" ? 0 : 1;
} finally {
await disconnectPrisma();
}
},
});
const showCmd = defineCommand({
meta: { name: "show", description: "Pretty-print a contract" },
args: {
contractId: {
type: "positional",
description: "Contract ID",
required: true,
},
},
async run({ args }) {
const filePath = contractFilePath(process.cwd(), args.contractId);
const raw = await readFile(filePath, "utf8");
console.log(raw);
},
});
export default defineCommand({
meta: {
name: "contract",
description: "Manage sprint contracts",
},
subCommands: {
generate: generateCmd,
freeze: freezeCmd,
validate: validateCmd,
show: showCmd,
},
});

View File

@@ -15,6 +15,7 @@ const main = defineCommand({
import("./skill-context.js").then((m) => m.default),
"skill-trace": () =>
import("./skill-trace.js").then((m) => m.default),
contract: () => import("./contract.js").then((m) => m.default),
},
});

View File

@@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import { resolve, isAbsolute } from "node:path";
import { ArtifactSchemaSpec, CheckResult } from "../schema.js";
import type { CheckHandler } from "./types.js";
import { z } from "zod";
// Registry of known artifact schemas. Extend as needed.
const ARTIFACT_SCHEMAS: Record<string, z.ZodTypeAny> = {
CheckResult: CheckResult,
// Add more schemas here
};
export const artifactSchemaCheck: CheckHandler = async (check, ctx) => {
const start = Date.now();
const spec = ArtifactSchemaSpec.parse(check.spec);
const fullPath = isAbsolute(spec.artifactPath)
? spec.artifactPath
: resolve(ctx.workdir, spec.artifactPath);
const schema = ARTIFACT_SCHEMAS[spec.schemaName];
if (!schema) {
return {
passed: false,
evidence: "",
errorMessage: `Unknown schema: ${spec.schemaName}. Known: ${Object.keys(ARTIFACT_SCHEMAS).join(", ")}`,
durationMs: Date.now() - start,
};
}
try {
const content = await readFile(fullPath, "utf8");
const data = JSON.parse(content) as unknown;
const result = schema.safeParse(data);
if (result.success) {
return {
passed: true,
evidence: `${spec.artifactPath} validates against ${spec.schemaName}`,
errorMessage: "",
durationMs: Date.now() - start,
};
}
const issues = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
return {
passed: false,
evidence: "",
errorMessage: `Schema validation failed: ${issues}`,
durationMs: Date.now() - start,
};
} catch (err) {
return {
passed: false,
evidence: "",
errorMessage: `Cannot parse artifact: ${err instanceof Error ? err.message : String(err)}`,
durationMs: Date.now() - start,
};
}
};

View File

@@ -0,0 +1,86 @@
import { spawn } from "node:child_process";
import { CommandSuccessSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
interface ExecResult {
exitCode: number;
stdout: string;
stderr: string;
timedOut: boolean;
}
function execCommand(
command: string,
opts: {
cwd: string;
env: Record<string, string>;
timeoutMs: number;
},
): Promise<ExecResult> {
return new Promise((resolvePromise) => {
const child = spawn("sh", ["-c", command], {
cwd: opts.cwd,
env: opts.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let settled = false;
const finalize = (exitCode: number) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolvePromise({ exitCode, stdout, stderr, timedOut });
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
finalize(-1);
}, 2000);
}, opts.timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("exit", (code) => finalize(code ?? -1));
child.on("error", () => finalize(-1));
});
}
export const commandSuccessCheck: CheckHandler = async (check, ctx) => {
const start = Date.now();
const spec = CommandSuccessSpec.parse(check.spec);
const result = await execCommand(spec.command, {
cwd: spec.cwd ?? ctx.workdir,
env: { ...ctx.env, ...(spec.env ?? {}) },
timeoutMs: spec.timeoutMs,
});
const passed = !result.timedOut && result.exitCode === spec.expectExitCode;
return {
passed,
evidence: passed
? `Command succeeded: ${spec.command} (exit ${result.exitCode})`
: "",
errorMessage: passed
? ""
: result.timedOut
? `Command timed out after ${spec.timeoutMs}ms: ${spec.command}`
: `Command failed (exit ${result.exitCode}, expected ${spec.expectExitCode}): ${spec.command}\nstderr: ${result.stderr.slice(0, 500)}`,
durationMs: Date.now() - start,
};
};
// Export helper for runtime validation commands
export { execCommand };

View File

@@ -0,0 +1,47 @@
import { getPrisma } from "../../orchestrator/persist.js";
import { DbQuerySpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
/**
* Runs a raw SQL query via Prisma and counts rows.
* For MariaDB / MySQL via the project's default DATABASE_URL.
* (Custom connection strings via spec.connectionString are deferred to v2.)
*/
export const dbQueryCheck: CheckHandler = async (check) => {
const start = Date.now();
const spec = DbQuerySpec.parse(check.spec);
if (spec.connectionString) {
return {
passed: false,
evidence: "",
errorMessage:
"Custom connectionString not supported yet. Omit to use DATABASE_URL.",
durationMs: Date.now() - start,
};
}
try {
const prisma = getPrisma();
const rows = (await prisma.$queryRawUnsafe(spec.query)) as unknown[];
const count = Array.isArray(rows) ? rows.length : 0;
const passed = count >= spec.expectMinRows;
return {
passed,
evidence: passed
? `Query returned ${count} rows (expected ≥ ${spec.expectMinRows})`
: "",
errorMessage: passed
? ""
: `Query returned ${count} rows, expected ≥ ${spec.expectMinRows}`,
durationMs: Date.now() - start,
};
} catch (err) {
return {
passed: false,
evidence: "",
errorMessage: `DB query failed: ${err instanceof Error ? err.message : String(err)}`,
durationMs: Date.now() - start,
};
}
};

View File

@@ -0,0 +1,29 @@
import { stat } from "node:fs/promises";
import { resolve, isAbsolute } from "node:path";
import { FileExistsSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
export const fileExistsCheck: CheckHandler = async (check, ctx) => {
const start = Date.now();
const spec = FileExistsSpec.parse(check.spec);
const fullPath = isAbsolute(spec.path)
? spec.path
: resolve(ctx.workdir, spec.path);
try {
const s = await stat(fullPath);
return {
passed: true,
evidence: `${fullPath} exists (${s.isDirectory() ? "dir" : "file"}, ${s.size}B)`,
errorMessage: "",
durationMs: Date.now() - start,
};
} catch {
return {
passed: false,
evidence: "",
errorMessage: `File not found: ${fullPath}`,
durationMs: Date.now() - start,
};
}
};

View File

@@ -0,0 +1,38 @@
import { HttpStatusSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
export const httpStatusCheck: CheckHandler = async (check) => {
const start = Date.now();
const spec = HttpStatusSpec.parse(check.spec);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), spec.timeoutMs);
try {
const res = await fetch(spec.url, {
method: spec.method,
signal: controller.signal,
});
clearTimeout(timer);
const passed = res.status === spec.expectStatus;
return {
passed,
evidence: passed
? `GET ${spec.url}${res.status} (expected ${spec.expectStatus})`
: "",
errorMessage: passed
? ""
: `HTTP status mismatch: ${spec.url} returned ${res.status}, expected ${spec.expectStatus}`,
durationMs: Date.now() - start,
};
} catch (err) {
clearTimeout(timer);
return {
passed: false,
evidence: "",
errorMessage: `HTTP request failed: ${spec.url}${err instanceof Error ? err.message : String(err)}`,
durationMs: Date.now() - start,
};
}
};

View File

@@ -0,0 +1,25 @@
import type { DodCheckKind } from "../schema.js";
import type { CheckHandler } from "./types.js";
import { fileExistsCheck } from "./file-exists.js";
import { commandSuccessCheck } from "./command-success.js";
import { regexInFileCheck, regexAbsentCheck } from "./regex-in-file.js";
import { httpStatusCheck } from "./http-status.js";
import { processListeningCheck } from "./process-listening.js";
import { artifactSchemaCheck } from "./artifact-schema.js";
import { dbQueryCheck } from "./db-query.js";
import { manualCheck } from "./manual.js";
export const CHECK_HANDLERS: Record<DodCheckKind, CheckHandler> = {
file_exists: fileExistsCheck,
command_success: commandSuccessCheck,
regex_in_file: regexInFileCheck,
regex_absent: regexAbsentCheck,
http_status: httpStatusCheck,
db_query: dbQueryCheck,
process_listening: processListeningCheck,
artifact_schema: artifactSchemaCheck,
manual: manualCheck,
};
export { execCommand } from "./command-success.js";
export type { CheckHandler, CheckContext, CheckOutcome } from "./types.js";

View File

@@ -0,0 +1,19 @@
import { ManualCheckSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
/**
* Manual check placeholder (Sprint 003).
* Will be activated in Sprint 006 (QA runtime) where darang LLM actually
* inspects code and fills in results. For now, returns SKIP (passed=true
* with a note) so validator can proceed.
*/
export const manualCheck: CheckHandler = async (check) => {
const start = Date.now();
const spec = ManualCheckSpec.parse(check.spec);
return {
passed: true,
evidence: `[SKIPPED — manual] ${spec.question} (Sprint 006 에서 활성화)`,
errorMessage: "",
durationMs: Date.now() - start,
};
};

View File

@@ -0,0 +1,45 @@
import { createConnection } from "node:net";
import { ProcessListeningSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
function probePort(
host: string,
port: number,
timeoutMs: number,
): Promise<boolean> {
return new Promise((resolvePromise) => {
const socket = createConnection({ host, port });
let settled = false;
const finalize = (ok: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolvePromise(ok);
};
const timer = setTimeout(() => finalize(false), timeoutMs);
socket.on("connect", () => {
clearTimeout(timer);
finalize(true);
});
socket.on("error", () => {
clearTimeout(timer);
finalize(false);
});
});
}
export const processListeningCheck: CheckHandler = async (check) => {
const start = Date.now();
const spec = ProcessListeningSpec.parse(check.spec);
const ok = await probePort(spec.host, spec.port, 3000);
return {
passed: ok,
evidence: ok ? `${spec.host}:${spec.port} is listening` : "",
errorMessage: ok ? "" : `${spec.host}:${spec.port} is not listening`,
durationMs: Date.now() - start,
};
};

View File

@@ -0,0 +1,76 @@
import { readFile } from "node:fs/promises";
import { resolve, isAbsolute } from "node:path";
import { RegexInFileSpec } from "../schema.js";
import type { CheckHandler } from "./types.js";
export const regexInFileCheck: CheckHandler = async (check, ctx) => {
const start = Date.now();
const spec = RegexInFileSpec.parse(check.spec);
const fullPath = isAbsolute(spec.path)
? spec.path
: resolve(ctx.workdir, spec.path);
try {
const content = await readFile(fullPath, "utf8");
const regex = new RegExp(spec.pattern, spec.flags);
const match = content.match(regex);
if (match) {
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
return {
passed: true,
evidence: `${spec.path}:${lineIdx} matches /${spec.pattern}/${spec.flags}`,
errorMessage: "",
durationMs: Date.now() - start,
};
}
return {
passed: false,
evidence: "",
errorMessage: `Pattern not found in ${spec.path}: /${spec.pattern}/${spec.flags}`,
durationMs: Date.now() - start,
};
} catch (err) {
return {
passed: false,
evidence: "",
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
durationMs: Date.now() - start,
};
}
};
export const regexAbsentCheck: CheckHandler = async (check, ctx) => {
const start = Date.now();
const spec = RegexInFileSpec.parse(check.spec);
const fullPath = isAbsolute(spec.path)
? spec.path
: resolve(ctx.workdir, spec.path);
try {
const content = await readFile(fullPath, "utf8");
const regex = new RegExp(spec.pattern, spec.flags);
const match = content.match(regex);
if (!match) {
return {
passed: true,
evidence: `${spec.path} has no match for /${spec.pattern}/${spec.flags}`,
errorMessage: "",
durationMs: Date.now() - start,
};
}
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
return {
passed: false,
evidence: "",
errorMessage: `Forbidden pattern found in ${spec.path}:${lineIdx}: /${spec.pattern}/${spec.flags}`,
durationMs: Date.now() - start,
};
} catch (err) {
return {
passed: false,
evidence: "",
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
durationMs: Date.now() - start,
};
}
};

View File

@@ -0,0 +1,18 @@
import type { DodCheck } from "../schema.js";
export interface CheckContext {
workdir: string;
env: Record<string, string>;
}
export interface CheckOutcome {
passed: boolean;
evidence: string;
errorMessage: string;
durationMs: number;
}
export type CheckHandler = (
check: DodCheck,
ctx: CheckContext,
) => Promise<CheckOutcome>;

103
src/contract/generator.ts Normal file
View File

@@ -0,0 +1,103 @@
import { readFile } from "node:fs/promises";
import { ulid } from "ulid";
import { SprintContract, type DodCheck } from "./schema.js";
/**
* Parse a sprint markdown file and produce a draft Sprint Contract.
*
* Heuristics:
* - Extracts `## Type` section → contract.type
* - Extracts "Tasks" table and creates `file_exists` / `command_success`
* stubs for each DoD entry containing keywords like "통과", "pass", "exit".
* - Environment prerequisites are NOT inferred from markdown; the user
* can add them manually to the draft contract.
*
* The result is a **draft** — the user must review and `freeze` it
* before validation.
*/
export async function generateDraftContract(
sprintMdPath: string,
sprintId: string,
): Promise<SprintContract> {
const raw = await readFile(sprintMdPath, "utf8");
// Extract type
const typeMatch = raw.match(/##\s*Type\s*\n\s*`([^`]+)`/);
const rawType = typeMatch?.[1]?.trim() ?? "feature";
const type = normalizeType(rawType);
// Extract non-goals
const nonGoalsMatch = raw.match(
/##\s*Non-Goals\s*\n([\s\S]*?)(?=\n## |\n---|\n$)/,
);
const nonGoals: string[] = [];
if (nonGoalsMatch?.[1]) {
const items = nonGoalsMatch[1].match(/^\s*-\s+(.+)$/gm) ?? [];
for (const item of items) {
const clean = item.replace(/^\s*-\s+/, "").trim();
if (clean) nonGoals.push(clean);
}
}
// Default starter checks — user will replace these
const checks: DodCheck[] = [
{
id: "readme-exists",
description: "README.md 존재",
kind: "file_exists",
spec: { path: "README.md" },
blocking: true,
severity: "major",
},
{
id: "typecheck",
description: "TypeScript 타입 체크 통과",
kind: "command_success",
spec: { command: "pnpm tsc --noEmit", timeoutMs: 60_000, expectExitCode: 0 },
blocking: true,
severity: "major",
},
{
id: "tests-pass",
description: "Vitest 전부 통과",
kind: "command_success",
spec: { command: "pnpm vitest run", timeoutMs: 120_000, expectExitCode: 0 },
blocking: true,
severity: "major",
},
];
const draft = SprintContract.parse({
version: "v1",
id: ulid(),
sprintId,
createdAt: new Date().toISOString(),
type,
dod: { checks },
environmentPrerequisites: [],
nonGoals,
runtimeValidation: { commands: [] },
riskFlags: [],
reviewerProfile: "static",
approvalGates: { impl: true, review: true, deploy: true },
});
return draft;
}
function normalizeType(raw: string): SprintContract["type"] {
const lower = raw.toLowerCase();
const allowed = [
"scaffold",
"feature",
"refactor",
"bugfix",
"migration",
"infra",
"deploy-only",
] as const;
for (const t of allowed) {
if (lower === t) return t;
}
return "feature";
}

View File

@@ -0,0 +1,157 @@
import { stat } from "node:fs/promises";
import { resolve, isAbsolute } from "node:path";
import { spawn } from "node:child_process";
import { createConnection } from "node:net";
import { z } from "zod";
import type { EnvPrereq } from "./schema.js";
export interface PrereqResult {
name: string;
passed: boolean;
message: string;
}
const CommandExistsSpec = z.object({ command: z.string() });
const PortOpenSpec = z.object({
port: z.number().int().positive(),
host: z.string().default("127.0.0.1"),
});
const EnvVarSpec = z.object({
name: z.string(),
required: z.boolean().default(true),
});
const FileExistsPrereqSpec = z.object({ path: z.string() });
const HttpReachableSpec = z.object({
url: z.string().url(),
timeoutMs: z.number().int().positive().default(5000),
});
async function commandExists(cmd: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const child = spawn("sh", ["-c", `command -v ${cmd}`], {
stdio: "ignore",
});
child.on("exit", (code) => resolvePromise(code === 0));
child.on("error", () => resolvePromise(false));
});
}
async function portOpen(host: string, port: number): Promise<boolean> {
return new Promise((resolvePromise) => {
const socket = createConnection({ host, port });
let settled = false;
const finalize = (ok: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolvePromise(ok);
};
const timer = setTimeout(() => finalize(false), 3000);
socket.on("connect", () => {
clearTimeout(timer);
finalize(true);
});
socket.on("error", () => {
clearTimeout(timer);
finalize(false);
});
});
}
async function httpReachable(
url: string,
timeoutMs: number,
): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
clearTimeout(timer);
return res.status < 500;
} catch {
return false;
}
}
export async function checkPrerequisite(
prereq: EnvPrereq,
workdir: string,
): Promise<PrereqResult> {
try {
switch (prereq.check) {
case "command_exists": {
const spec = CommandExistsSpec.parse(prereq.spec);
const ok = await commandExists(spec.command);
return {
name: prereq.name,
passed: ok,
message: ok
? `${spec.command} found`
: `${spec.command} not found — ${prereq.reason}`,
};
}
case "port_open": {
const spec = PortOpenSpec.parse(prereq.spec);
const ok = await portOpen(spec.host, spec.port);
return {
name: prereq.name,
passed: ok,
message: ok
? `${spec.host}:${spec.port} reachable`
: `${spec.host}:${spec.port} not listening — ${prereq.reason}`,
};
}
case "env_var": {
const spec = EnvVarSpec.parse(prereq.spec);
const val = process.env[spec.name];
const ok = !spec.required || (val !== undefined && val !== "");
return {
name: prereq.name,
passed: ok,
message: ok
? `${spec.name} set`
: `${spec.name} missing — ${prereq.reason}`,
};
}
case "file_exists": {
const spec = FileExistsPrereqSpec.parse(prereq.spec);
const fullPath = isAbsolute(spec.path)
? spec.path
: resolve(workdir, spec.path);
try {
await stat(fullPath);
return { name: prereq.name, passed: true, message: `${fullPath} exists` };
} catch {
return {
name: prereq.name,
passed: false,
message: `${fullPath} not found — ${prereq.reason}`,
};
}
}
case "http_reachable": {
const spec = HttpReachableSpec.parse(prereq.spec);
const ok = await httpReachable(spec.url, spec.timeoutMs);
return {
name: prereq.name,
passed: ok,
message: ok
? `${spec.url} reachable`
: `${spec.url} not reachable — ${prereq.reason}`,
};
}
default:
return {
name: prereq.name,
passed: false,
message: `Unknown prereq check: ${prereq.check as string}`,
};
}
} catch (err) {
return {
name: prereq.name,
passed: false,
message: `Prereq check errored: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

229
src/contract/schema.ts Normal file
View File

@@ -0,0 +1,229 @@
import { z } from "zod";
// ──────────────────────────────────────────────
// Check kind-specific spec schemas
// ──────────────────────────────────────────────
export const FileExistsSpec = z.object({
path: z.string(),
});
export const CommandSuccessSpec = z.object({
command: z.string(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
timeoutMs: z.number().int().positive().default(60_000),
expectExitCode: z.number().int().default(0),
});
export const RegexInFileSpec = z.object({
path: z.string(),
pattern: z.string(),
flags: z.string().default(""),
});
export const RegexAbsentSpec = RegexInFileSpec;
export const HttpStatusSpec = z.object({
url: z.string().url(),
expectStatus: z.number().int().positive().default(200),
timeoutMs: z.number().int().positive().default(10_000),
method: z.enum(["GET", "HEAD", "POST"]).default("GET"),
});
export const DbQuerySpec = z.object({
query: z.string(),
connectionString: z.string().optional(), // falls back to DATABASE_URL
expectMinRows: z.number().int().min(0).default(1),
});
export const ProcessListeningSpec = z.object({
port: z.number().int().positive(),
host: z.string().default("127.0.0.1"),
});
export const ArtifactSchemaSpec = z.object({
artifactPath: z.string(),
schemaName: z.string(), // Registered schema name
});
export const ManualCheckSpec = z.object({
question: z.string(),
guidance: z.string().optional(),
});
// ──────────────────────────────────────────────
// DoD check (one item)
// ──────────────────────────────────────────────
export const DodCheckKind = z.enum([
"file_exists",
"command_success",
"regex_in_file",
"regex_absent",
"http_status",
"db_query",
"process_listening",
"artifact_schema",
"manual",
]);
export type DodCheckKind = z.infer<typeof DodCheckKind>;
export const DodCheck = z.object({
id: z.string().min(1),
description: z.string(),
kind: DodCheckKind,
spec: z.unknown(),
blocking: z.boolean().default(true),
severity: z.enum(["critical", "major", "minor"]).default("major"),
});
export type DodCheck = z.infer<typeof DodCheck>;
// ──────────────────────────────────────────────
// Environment prerequisite
// ──────────────────────────────────────────────
export const PrereqKind = z.enum([
"command_exists",
"port_open",
"env_var",
"file_exists",
"http_reachable",
]);
export type PrereqKind = z.infer<typeof PrereqKind>;
export const EnvPrereq = z.object({
name: z.string(),
check: PrereqKind,
spec: z.unknown(),
reason: z.string(),
});
export type EnvPrereq = z.infer<typeof EnvPrereq>;
// ──────────────────────────────────────────────
// Runtime validation command
// ──────────────────────────────────────────────
export const RuntimeValidationCommand = z.object({
name: z.string(),
command: z.string(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
timeoutMs: z.number().int().positive().default(60_000),
expectExitCode: z.number().int().default(0),
});
// ──────────────────────────────────────────────
// Sprint Contract (top level)
// ──────────────────────────────────────────────
export const SprintContract = z.object({
version: z.literal("v1"),
id: z.string().min(1),
sprintId: z.string().min(1),
createdAt: z.string().datetime(),
type: z.enum([
"scaffold",
"feature",
"refactor",
"bugfix",
"migration",
"infra",
"deploy-only",
]),
dod: z.object({
checks: z.array(DodCheck),
}),
environmentPrerequisites: z.array(EnvPrereq).default([]),
nonGoals: z.array(z.string()).default([]),
runtimeValidation: z
.object({
commands: z.array(RuntimeValidationCommand),
})
.default({ commands: [] }),
riskFlags: z
.array(
z.enum([
"security-sensitive",
"data-migration",
"breaking-change",
"ux-regression",
"performance-critical",
"needs-spike",
]),
)
.default([]),
reviewerProfile: z
.enum(["static", "runtime", "browser"])
.default("static"),
approvalGates: z
.object({
impl: z.boolean().default(true),
review: z.boolean().default(true),
deploy: z.boolean().default(true),
})
.default({ impl: true, review: true, deploy: true }),
});
export type SprintContract = z.infer<typeof SprintContract>;
// ──────────────────────────────────────────────
// Validation result
// ──────────────────────────────────────────────
export const CheckResult = z.object({
id: z.string(),
kind: DodCheckKind,
passed: z.boolean(),
blocking: z.boolean(),
severity: z.enum(["critical", "major", "minor"]),
evidence: z.string().default(""),
errorMessage: z.string().default(""),
durationMs: z.number().default(0),
});
export type CheckResult = z.infer<typeof CheckResult>;
export const ValidationResult = z.object({
contractId: z.string(),
verdict: z.enum(["PASS", "FAIL", "ABORT_PRECHECK"]),
startedAt: z.string().datetime(),
completedAt: z.string().datetime(),
prerequisiteResults: z.array(
z.object({
name: z.string(),
passed: z.boolean(),
message: z.string().default(""),
}),
),
checkResults: z.array(CheckResult),
runtimeCommandResults: z.array(
z.object({
name: z.string(),
passed: z.boolean(),
exitCode: z.number(),
stdout: z.string().default(""),
stderr: z.string().default(""),
durationMs: z.number(),
}),
),
summary: z.object({
total: z.number(),
passed: z.number(),
failed: z.number(),
blockingFailed: z.number(),
}),
});
export type ValidationResult = z.infer<typeof ValidationResult>;

114
src/contract/store.ts Normal file
View File

@@ -0,0 +1,114 @@
import { writeFile, readFile, mkdir, chmod } from "node:fs/promises";
import { dirname, join } from "node:path";
import { SprintContract } from "./schema.js";
import { getPrisma } from "../orchestrator/persist.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "contract-store" });
const CONTRACTS_DIR = ".rails/contracts";
export function contractFilePath(railsDir: string, contractId: string): string {
return join(railsDir, CONTRACTS_DIR, `${contractId}.sprint-contract.json`);
}
/**
* Save a draft contract to file + DB.
* The contract is mutable until `freezeContract()` is called.
*/
export async function saveDraftContract(
railsDir: string,
contract: SprintContract,
pipelineId?: string,
): Promise<string> {
const filePath = contractFilePath(railsDir, contract.id);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
if (pipelineId) {
const prisma = getPrisma();
await prisma.contract.create({
data: {
id: contract.id,
pipelineId,
sprintId: contract.sprintId,
version: contract.version,
bodyJson: JSON.stringify(contract),
},
});
}
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Draft contract saved");
return filePath;
}
/**
* Load a contract from file (file is source of truth for validation).
*/
export async function loadContract(
railsDir: string,
contractId: string,
): Promise<SprintContract> {
const filePath = contractFilePath(railsDir, contractId);
const raw = await readFile(filePath, "utf8");
return SprintContract.parse(JSON.parse(raw));
}
/**
* Freeze a contract: mark as immutable in DB and make file read-only.
*/
export async function freezeContract(
railsDir: string,
contractId: string,
): Promise<void> {
const filePath = contractFilePath(railsDir, contractId);
// Make file read-only
await chmod(filePath, 0o444);
// Update DB if contract exists there
try {
const prisma = getPrisma();
await prisma.contract.update({
where: { id: contractId },
data: { frozenAt: new Date() },
});
} catch {
// DB entry may not exist for local-only contracts
}
log.info({ contractId }, "Contract frozen");
}
/**
* Update a draft contract (allowed only if not frozen).
*/
export async function updateDraftContract(
railsDir: string,
contract: SprintContract,
): Promise<void> {
const filePath = contractFilePath(railsDir, contract.id);
// Check frozen status in DB
try {
const prisma = getPrisma();
const existing = await prisma.contract.findUnique({
where: { id: contract.id },
});
if (existing?.frozenAt) {
throw new Error(
`Contract ${contract.id} is frozen since ${existing.frozenAt.toISOString()} and cannot be modified.`,
);
}
await prisma.contract.update({
where: { id: contract.id },
data: { bodyJson: JSON.stringify(contract) },
});
} catch (err) {
if (err instanceof Error && err.message.includes("frozen")) throw err;
// Ignore other DB errors for local-only contracts
}
// Write file (will fail if file was chmod 0444, which means already frozen)
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
}

157
src/contract/validator.ts Normal file
View File

@@ -0,0 +1,157 @@
import {
SprintContract,
type ValidationResult,
type CheckResult,
} from "./schema.js";
import { CHECK_HANDLERS, execCommand } from "./checks/index.js";
import { checkPrerequisite } from "./prerequisite.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "contract-validator" });
export interface ValidateOptions {
workdir: string;
env?: Record<string, string>;
}
/**
* Run the full validation pipeline for a sprint contract:
* 1. Environment prerequisites (any failure → ABORT_PRECHECK)
* 2. Runtime validation commands
* 3. DoD checks
*
* Returns a ValidationResult with verdict PASS / FAIL / ABORT_PRECHECK.
*/
export async function validateContract(
contractJson: unknown,
opts: ValidateOptions,
): Promise<ValidationResult> {
const startedAt = new Date().toISOString();
const contract = SprintContract.parse(contractJson);
const env = opts.env ?? (process.env as Record<string, string>);
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Validation started");
// ── Step 1. Environment prerequisites ──
const prerequisiteResults = [];
for (const prereq of contract.environmentPrerequisites) {
const r = await checkPrerequisite(prereq, opts.workdir);
prerequisiteResults.push(r);
if (!r.passed) {
log.warn({ prereq: prereq.name, message: r.message }, "Prereq failed");
return {
contractId: contract.id,
verdict: "ABORT_PRECHECK",
startedAt,
completedAt: new Date().toISOString(),
prerequisiteResults,
checkResults: [],
runtimeCommandResults: [],
summary: { total: 0, passed: 0, failed: 0, blockingFailed: 1 },
};
}
}
// ── Step 2. Runtime validation commands ──
const runtimeCommandResults: ValidationResult["runtimeCommandResults"] = [];
for (const cmd of contract.runtimeValidation.commands) {
const cmdStart = Date.now();
const result = await execCommand(cmd.command, {
cwd: cmd.cwd ?? opts.workdir,
env: { ...env, ...(cmd.env ?? {}) },
timeoutMs: cmd.timeoutMs,
});
runtimeCommandResults.push({
name: cmd.name,
passed: !result.timedOut && result.exitCode === cmd.expectExitCode,
exitCode: result.exitCode,
stdout: result.stdout.slice(0, 2000),
stderr: result.stderr.slice(0, 2000),
durationMs: Date.now() - cmdStart,
});
}
// ── Step 3. DoD checks ──
const checkResults: CheckResult[] = [];
for (const check of contract.dod.checks) {
const handler = CHECK_HANDLERS[check.kind];
if (!handler) {
checkResults.push({
id: check.id,
kind: check.kind,
passed: false,
blocking: check.blocking,
severity: check.severity,
evidence: "",
errorMessage: `No handler registered for kind: ${check.kind}`,
durationMs: 0,
});
continue;
}
try {
const outcome = await handler(check, { workdir: opts.workdir, env });
checkResults.push({
id: check.id,
kind: check.kind,
passed: outcome.passed,
blocking: check.blocking,
severity: check.severity,
evidence: outcome.evidence,
errorMessage: outcome.errorMessage,
durationMs: outcome.durationMs,
});
} catch (err) {
checkResults.push({
id: check.id,
kind: check.kind,
passed: false,
blocking: check.blocking,
severity: check.severity,
evidence: "",
errorMessage: `Handler threw: ${err instanceof Error ? err.message : String(err)}`,
durationMs: 0,
});
}
}
// ── Aggregate ──
const allResults = [
...checkResults,
...runtimeCommandResults.map((r) => ({
id: `runtime:${r.name}`,
kind: "command_success" as const,
passed: r.passed,
blocking: true,
severity: "major" as const,
evidence: r.passed ? `${r.name} exit ${r.exitCode}` : "",
errorMessage: r.passed ? "" : `${r.name} failed: ${r.stderr.slice(0, 200)}`,
durationMs: r.durationMs,
})),
];
const total = allResults.length;
const passed = allResults.filter((r) => r.passed).length;
const failed = total - passed;
const blockingFailed = allResults.filter(
(r) => !r.passed && r.blocking,
).length;
const verdict = blockingFailed === 0 ? "PASS" : "FAIL";
log.info(
{ contractId: contract.id, verdict, total, passed, failed, blockingFailed },
"Validation complete",
);
return {
contractId: contract.id,
verdict,
startedAt,
completedAt: new Date().toISOString(),
prerequisiteResults,
checkResults,
runtimeCommandResults,
summary: { total, passed, failed, blockingFailed },
};
}

447
tests/contract.test.ts Normal file
View File

@@ -0,0 +1,447 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createServer, type Server } from "node:http";
import { validateContract } from "../src/contract/validator.js";
import { fileExistsCheck } from "../src/contract/checks/file-exists.js";
import { commandSuccessCheck } from "../src/contract/checks/command-success.js";
import {
regexInFileCheck,
regexAbsentCheck,
} from "../src/contract/checks/regex-in-file.js";
import { httpStatusCheck } from "../src/contract/checks/http-status.js";
import { artifactSchemaCheck } from "../src/contract/checks/artifact-schema.js";
import { manualCheck } from "../src/contract/checks/manual.js";
import { generateDraftContract } from "../src/contract/generator.js";
import { saveDraftContract, loadContract } from "../src/contract/store.js";
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-contract-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("file_exists check", () => {
it("passes when file exists", async () => {
await writeFile(join(testDir, "README.md"), "# test");
const result = await fileExistsCheck(
{
id: "readme",
description: "",
kind: "file_exists",
spec: { path: "README.md" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
expect(result.evidence).toContain("exists");
});
it("fails when file missing", async () => {
const result = await fileExistsCheck(
{
id: "nope",
description: "",
kind: "file_exists",
spec: { path: "missing.txt" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(false);
expect(result.errorMessage).toContain("not found");
});
});
describe("command_success check", () => {
it("passes on exit 0", async () => {
const result = await commandSuccessCheck(
{
id: "true",
description: "",
kind: "command_success",
spec: { command: "true", timeoutMs: 5000, expectExitCode: 0 },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: process.env as Record<string, string> },
);
expect(result.passed).toBe(true);
});
it("fails on non-zero exit", async () => {
const result = await commandSuccessCheck(
{
id: "false",
description: "",
kind: "command_success",
spec: { command: "false", timeoutMs: 5000, expectExitCode: 0 },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: process.env as Record<string, string> },
);
expect(result.passed).toBe(false);
});
it("fails on timeout", async () => {
const result = await commandSuccessCheck(
{
id: "sleep",
description: "",
kind: "command_success",
spec: { command: "sleep 5", timeoutMs: 300, expectExitCode: 0 },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: process.env as Record<string, string> },
);
expect(result.passed).toBe(false);
expect(result.errorMessage).toContain("timed out");
});
});
describe("regex_in_file check", () => {
it("matches pattern", async () => {
await writeFile(join(testDir, "config.json"), '{"strict": true}');
const result = await regexInFileCheck(
{
id: "strict",
description: "",
kind: "regex_in_file",
spec: { path: "config.json", pattern: '"strict"\\s*:\\s*true' },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
});
it("fails when pattern absent", async () => {
await writeFile(join(testDir, "config.json"), "{}");
const result = await regexInFileCheck(
{
id: "strict",
description: "",
kind: "regex_in_file",
spec: { path: "config.json", pattern: "strict" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(false);
});
});
describe("regex_absent check", () => {
it("passes when pattern absent", async () => {
await writeFile(join(testDir, "code.ts"), "const x = 1");
const result = await regexAbsentCheck(
{
id: "no-console",
description: "",
kind: "regex_absent",
spec: { path: "code.ts", pattern: "console\\." },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
});
it("fails when forbidden pattern found", async () => {
await writeFile(join(testDir, "code.ts"), "console.log(42)");
const result = await regexAbsentCheck(
{
id: "no-console",
description: "",
kind: "regex_absent",
spec: { path: "code.ts", pattern: "console\\." },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(false);
});
});
describe("http_status check", () => {
let server: Server;
let port: number;
beforeEach(async () => {
server = createServer((req, res) => {
if (req.url === "/ok") {
res.writeHead(200);
res.end("ok");
} else if (req.url === "/notfound") {
res.writeHead(404);
res.end();
} else {
res.writeHead(500);
res.end();
}
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const addr = server.address();
if (typeof addr === "object" && addr) {
port = addr.port;
} else {
throw new Error("Cannot get server port");
}
});
afterEach(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
it("passes on matching status", async () => {
const result = await httpStatusCheck(
{
id: "health",
description: "",
kind: "http_status",
spec: {
url: `http://127.0.0.1:${port}/ok`,
expectStatus: 200,
},
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
});
it("fails on status mismatch", async () => {
const result = await httpStatusCheck(
{
id: "health",
description: "",
kind: "http_status",
spec: {
url: `http://127.0.0.1:${port}/notfound`,
expectStatus: 200,
},
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(false);
});
});
describe("artifact_schema check", () => {
it("passes when JSON matches registered schema", async () => {
const valid = {
id: "c1",
kind: "file_exists",
passed: true,
blocking: true,
severity: "major",
evidence: "found",
errorMessage: "",
durationMs: 5,
};
await writeFile(join(testDir, "result.json"), JSON.stringify(valid));
const result = await artifactSchemaCheck(
{
id: "schema",
description: "",
kind: "artifact_schema",
spec: { artifactPath: "result.json", schemaName: "CheckResult" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
});
it("fails when schema name is unknown", async () => {
const result = await artifactSchemaCheck(
{
id: "schema",
description: "",
kind: "artifact_schema",
spec: { artifactPath: "nope.json", schemaName: "NonExistent" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(false);
expect(result.errorMessage).toContain("Unknown schema");
});
});
describe("manual check (stub)", () => {
it("is always SKIP (passed=true) in Sprint 003", async () => {
const result = await manualCheck(
{
id: "review",
description: "",
kind: "manual",
spec: { question: "Is the code clean?" },
blocking: true,
severity: "major",
},
{ workdir: testDir, env: {} },
);
expect(result.passed).toBe(true);
expect(result.evidence).toContain("SKIPPED");
});
});
describe("validator integration", () => {
it("PASS when all checks pass", async () => {
await writeFile(join(testDir, "README.md"), "# ok");
const contract = {
version: "v1" as const,
id: "c1",
sprintId: "S1",
createdAt: new Date().toISOString(),
type: "feature" as const,
dod: {
checks: [
{
id: "readme",
description: "",
kind: "file_exists" as const,
spec: { path: "README.md" },
blocking: true,
severity: "major" as const,
},
],
},
environmentPrerequisites: [],
nonGoals: [],
runtimeValidation: { commands: [] },
riskFlags: [],
reviewerProfile: "static" as const,
approvalGates: { impl: true, review: true, deploy: true },
};
const result = await validateContract(contract, { workdir: testDir });
expect(result.verdict).toBe("PASS");
expect(result.summary.passed).toBe(1);
});
it("FAIL when a blocking check fails", async () => {
const contract = {
version: "v1" as const,
id: "c2",
sprintId: "S1",
createdAt: new Date().toISOString(),
type: "feature" as const,
dod: {
checks: [
{
id: "missing",
description: "",
kind: "file_exists" as const,
spec: { path: "does-not-exist.txt" },
blocking: true,
severity: "major" as const,
},
],
},
environmentPrerequisites: [],
nonGoals: [],
runtimeValidation: { commands: [] },
riskFlags: [],
reviewerProfile: "static" as const,
approvalGates: { impl: true, review: true, deploy: true },
};
const result = await validateContract(contract, { workdir: testDir });
expect(result.verdict).toBe("FAIL");
expect(result.summary.blockingFailed).toBe(1);
});
it("ABORT_PRECHECK when prerequisite missing", async () => {
const contract = {
version: "v1" as const,
id: "c3",
sprintId: "S1",
createdAt: new Date().toISOString(),
type: "feature" as const,
dod: { checks: [] },
environmentPrerequisites: [
{
name: "nonexistent-cmd",
check: "command_exists" as const,
spec: { command: "definitely-not-a-real-command-xyz-42" },
reason: "need it",
},
],
nonGoals: [],
runtimeValidation: { commands: [] },
riskFlags: [],
reviewerProfile: "static" as const,
approvalGates: { impl: true, review: true, deploy: true },
};
const result = await validateContract(contract, { workdir: testDir });
expect(result.verdict).toBe("ABORT_PRECHECK");
});
});
describe("generator + store", () => {
it("generates draft contract from sprint markdown", async () => {
const mdPath = join(testDir, "SPRINT-001.md");
await writeFile(
mdPath,
`# SPRINT-001 — Test Sprint\n\n## Type\n\`scaffold\`\n\n## Non-Goals\n\n- Skip XYZ\n- Do not do ABC\n`,
);
const draft = await generateDraftContract(mdPath, "SPRINT-001");
expect(draft.version).toBe("v1");
expect(draft.type).toBe("scaffold");
expect(draft.sprintId).toBe("SPRINT-001");
expect(draft.nonGoals).toEqual(["Skip XYZ", "Do not do ABC"]);
expect(draft.dod.checks.length).toBeGreaterThan(0);
});
it("saves and loads a contract round-trip", async () => {
await mkdir(join(testDir, ".rails", "contracts"), { recursive: true });
const draft = {
version: "v1" as const,
id: "test-01",
sprintId: "S1",
createdAt: new Date().toISOString(),
type: "scaffold" as const,
dod: {
checks: [
{
id: "c1",
description: "",
kind: "file_exists" as const,
spec: { path: "README.md" },
blocking: true,
severity: "major" as const,
},
],
},
environmentPrerequisites: [],
nonGoals: [],
runtimeValidation: { commands: [] },
riskFlags: [],
reviewerProfile: "static" as const,
approvalGates: { impl: true, review: true, deploy: true },
};
await saveDraftContract(testDir, draft);
const loaded = await loadContract(testDir, "test-01");
expect(loaded.id).toBe("test-01");
expect(loaded.dod.checks[0]?.kind).toBe("file_exists");
});
});