Sprint 003 전체 구현 — F2 (DoD 강제 실패) + F6 (환경 검증 누락) 해결: Core: - src/contract/schema.ts — SprintContract Zod schema 전체 (DodCheck, EnvPrereq, ValidationResult, CheckResult) - src/contract/validator.ts — 3단계 검증 파이프라인 1. 환경 prerequisites (실패 시 ABORT_PRECHECK) 2. Runtime validation commands 3. DoD checks → PASS/FAIL 집계 - src/contract/prerequisite.ts — 5가지 prereq kind (command_exists, port_open, env_var, file_exists, http_reachable) - src/contract/generator.ts — 스프린트 md 파싱 → draft contract - src/contract/store.ts — 파일 + Prisma contract 저장, freeze/loadContract Check handlers (9종): - file_exists, command_success, regex_in_file, regex_absent - http_status (native fetch), process_listening (TCP probe) - artifact_schema (Zod registry), db_query (Prisma raw) - manual (Sprint 006 스텁) CLI: - rails contract generate <sprint-md> -s <sprint-id> - rails contract freeze <id> - rails contract validate <id> - rails contract show <id> Tests (19 신규, 41 total pass): - 각 check kind 단위 테스트 - http_status: node http 서버 mock - validator integration: PASS / FAIL / ABORT_PRECHECK - generator + store round-trip 검증: tsc --noEmit ✓ | vitest 41/41 ✓ | build ✓ | CLI help ✓ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|