Files
hanarang-rails/tests/contract.test.ts
이랑이 58d6c262d5 feat(sprint-003): Sprint Contract + DoD Validator
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>
2026-04-10 15:21:35 +09:00

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");
});
});