Sprint 006 전체 구현 — F3/F2 의 QA 측면 완성:
Schema:
- src/qa/schema.ts — QaTemplate, QaChecklistResult, QaArtifact Zod 스키마
- Zod + DodCheck 재사용
Templates (6종 YAML):
- qa-templates/scaffold-v1.yaml — README/LICENSE/gitignore/lockfile/strict
- qa-templates/feature-v1.yaml — tests/typecheck/no-console/no-any/tests-added
- qa-templates/bugfix-v1.yaml — regression-test/root-cause/no-scope-creep
- qa-templates/refactor-v1.yaml — tests/typecheck/no-behavior-change
- qa-templates/migration-v1.yaml — rollback/dry-run/data-loss/backup (critical)
- qa-templates/infra-v1.yaml — config-validated/secrets/rollback
Core:
- src/qa/template.ts — YAML loader, extends 체인 resolution, 프로젝트별 extras
- src/qa/verdict.ts — verdict 규칙 (critical/major → REQUEST_CHANGES,
minor/recommendation 만 → APPROVE_WITH_NITS, 절대 REQUEST_CHANGES 안 됨)
- src/qa/runtime.ts — runQaTemplate: Contract check handlers 재사용
manual 체크는 resolver 주입 가능 (없으면 SKIP 기본값)
CLI:
- rails qa run <type> [-s sprint-id] [-w workdir]
- rails qa show <artifact-id>
- rails qa templates (목록)
Tests (19 신규, 101 total pass):
- computeVerdict 7가지 시나리오 (APPROVE / APPROVE_WITH_NITS / REQUEST_CHANGES / ABORT)
- minor-only 는 절대 REQUEST_CHANGES 안 된다는 rule 명시 테스트
- Template loader + listTemplates + extends merge
- runtime: file_exists pass/fail + manual resolver 주입 + artifact 저장
- scaffold-v1 실파일 로드 확인
검증: tsc --noEmit ✓ | vitest 101/101 ✓ | build ✓
rails qa templates → 6개 전부 출력 ✓
사용자 메모리 feedback_qa_thorough.md 준수:
- 체크 항목 수 제한 없음
- 각 템플릿이 타입별로 세분화됨
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
323 lines
8.5 KiB
TypeScript
323 lines
8.5 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 { computeVerdict, summarize } from "../src/qa/verdict.js";
|
|
import {
|
|
loadTemplate,
|
|
loadTemplateForType,
|
|
listTemplates,
|
|
mergeTemplates,
|
|
} from "../src/qa/template.js";
|
|
import { runQaTemplate, saveQaArtifact } from "../src/qa/runtime.js";
|
|
import type { QaTemplate, QaChecklistResult } from "../src/qa/schema.js";
|
|
|
|
const PROJECT_TEMPLATES = join(process.cwd(), "qa-templates");
|
|
|
|
let workDir: string;
|
|
|
|
beforeEach(async () => {
|
|
workDir = await mkdtemp(join(tmpdir(), "rails-qa-test-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("computeVerdict", () => {
|
|
const makeCheck = (
|
|
passed: boolean,
|
|
severity: QaChecklistResult["severity"],
|
|
): QaChecklistResult => ({
|
|
id: "test",
|
|
kind: "manual",
|
|
passed,
|
|
severity,
|
|
evidence: "",
|
|
errorMessage: "",
|
|
reviewerNote: "",
|
|
durationMs: 0,
|
|
});
|
|
|
|
it("APPROVE when all checks pass", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(true, "major"), makeCheck(true, "minor")],
|
|
prerequisitesPassed: true,
|
|
}),
|
|
).toBe("APPROVE");
|
|
});
|
|
|
|
it("REQUEST_CHANGES on any major failure", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(true, "major"), makeCheck(false, "major")],
|
|
prerequisitesPassed: true,
|
|
}),
|
|
).toBe("REQUEST_CHANGES");
|
|
});
|
|
|
|
it("REQUEST_CHANGES on any critical failure", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(false, "critical")],
|
|
prerequisitesPassed: true,
|
|
}),
|
|
).toBe("REQUEST_CHANGES");
|
|
});
|
|
|
|
it("APPROVE_WITH_NITS when only minor issues fail", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(true, "major"), makeCheck(false, "minor")],
|
|
prerequisitesPassed: true,
|
|
}),
|
|
).toBe("APPROVE_WITH_NITS");
|
|
});
|
|
|
|
it("APPROVE_WITH_NITS on recommendation only", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(false, "recommendation")],
|
|
prerequisitesPassed: true,
|
|
}),
|
|
).toBe("APPROVE_WITH_NITS");
|
|
});
|
|
|
|
it("ABORT on prerequisite failure", () => {
|
|
expect(
|
|
computeVerdict({
|
|
checks: [makeCheck(true, "major")],
|
|
prerequisitesPassed: false,
|
|
}),
|
|
).toBe("ABORT");
|
|
});
|
|
|
|
it("NEVER REQUEST_CHANGES for minor-only failures (rule)", () => {
|
|
const v = computeVerdict({
|
|
checks: [
|
|
makeCheck(false, "minor"),
|
|
makeCheck(false, "minor"),
|
|
makeCheck(false, "recommendation"),
|
|
],
|
|
prerequisitesPassed: true,
|
|
});
|
|
expect(v).not.toBe("REQUEST_CHANGES");
|
|
});
|
|
|
|
it("summarize counts blocking failures correctly", () => {
|
|
const s = summarize([
|
|
makeCheck(true, "major"),
|
|
makeCheck(false, "major"),
|
|
makeCheck(false, "minor"),
|
|
makeCheck(false, "critical"),
|
|
]);
|
|
expect(s.total).toBe(4);
|
|
expect(s.passed).toBe(1);
|
|
expect(s.failed).toBe(3);
|
|
expect(s.blockingFailed).toBe(2); // major + critical
|
|
});
|
|
});
|
|
|
|
describe("template loader", () => {
|
|
it("lists shipped templates", async () => {
|
|
const names = await listTemplates(PROJECT_TEMPLATES);
|
|
expect(names).toContain("scaffold-v1");
|
|
expect(names).toContain("feature-v1");
|
|
expect(names).toContain("bugfix-v1");
|
|
expect(names).toContain("migration-v1");
|
|
expect(names).toContain("refactor-v1");
|
|
expect(names).toContain("infra-v1");
|
|
});
|
|
|
|
it("loads scaffold-v1 template", async () => {
|
|
const t = await loadTemplate("scaffold-v1", PROJECT_TEMPLATES);
|
|
expect(t.template).toBe("scaffold-v1");
|
|
expect(t.appliesTo).toContain("scaffold");
|
|
expect(t.requiredChecks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("loadTemplateForType maps type → template", async () => {
|
|
const t = await loadTemplateForType("feature", PROJECT_TEMPLATES);
|
|
expect(t.template).toBe("feature-v1");
|
|
});
|
|
|
|
it("throws on unknown template", async () => {
|
|
await expect(
|
|
loadTemplate("nonexistent", PROJECT_TEMPLATES),
|
|
).rejects.toThrow(/not found/);
|
|
});
|
|
|
|
it("merges templates", async () => {
|
|
const base: QaTemplate = {
|
|
template: "base-v1",
|
|
version: "v1",
|
|
appliesTo: ["feature"],
|
|
requiredChecks: [
|
|
{
|
|
id: "c1",
|
|
description: "",
|
|
kind: "file_exists",
|
|
spec: { path: "a" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const extra: QaTemplate = {
|
|
template: "extra-v1",
|
|
version: "v1",
|
|
appliesTo: [],
|
|
requiredChecks: [
|
|
{
|
|
id: "c2",
|
|
description: "",
|
|
kind: "file_exists",
|
|
spec: { path: "b" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const merged = mergeTemplates(base, extra);
|
|
expect(merged.requiredChecks).toHaveLength(2);
|
|
expect(merged.template).toBe("base-v1+extra-v1");
|
|
});
|
|
});
|
|
|
|
describe("runtime", () => {
|
|
it("passes a file_exists check when file present", async () => {
|
|
await writeFile(join(workDir, "README.md"), "# test");
|
|
const template: QaTemplate = {
|
|
template: "test-v1",
|
|
version: "v1",
|
|
appliesTo: ["scaffold"],
|
|
requiredChecks: [
|
|
{
|
|
id: "readme",
|
|
description: "",
|
|
kind: "file_exists",
|
|
spec: { path: "README.md" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const artifact = await runQaTemplate({
|
|
template,
|
|
workdir: workDir,
|
|
sprintId: "S1",
|
|
});
|
|
expect(artifact.verdict).toBe("APPROVE");
|
|
expect(artifact.summary.passed).toBe(1);
|
|
});
|
|
|
|
it("fails on missing file", async () => {
|
|
const template: QaTemplate = {
|
|
template: "test-v1",
|
|
version: "v1",
|
|
appliesTo: ["scaffold"],
|
|
requiredChecks: [
|
|
{
|
|
id: "missing",
|
|
description: "",
|
|
kind: "file_exists",
|
|
spec: { path: "never.txt" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const artifact = await runQaTemplate({
|
|
template,
|
|
workdir: workDir,
|
|
sprintId: "S1",
|
|
});
|
|
expect(artifact.verdict).toBe("REQUEST_CHANGES");
|
|
expect(artifact.summary.blockingFailed).toBe(1);
|
|
});
|
|
|
|
it("manual checks are SKIPPED by default (no resolver)", async () => {
|
|
const template: QaTemplate = {
|
|
template: "test-v1",
|
|
version: "v1",
|
|
appliesTo: ["feature"],
|
|
requiredChecks: [
|
|
{
|
|
id: "review",
|
|
description: "",
|
|
kind: "manual",
|
|
spec: { question: "Is it good?" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const artifact = await runQaTemplate({
|
|
template,
|
|
workdir: workDir,
|
|
sprintId: "S1",
|
|
});
|
|
expect(artifact.verdict).toBe("APPROVE");
|
|
expect(artifact.checks[0]!.evidence).toContain("SKIPPED");
|
|
});
|
|
|
|
it("manual checks use resolver when provided", async () => {
|
|
const template: QaTemplate = {
|
|
template: "test-v1",
|
|
version: "v1",
|
|
appliesTo: ["feature"],
|
|
requiredChecks: [
|
|
{
|
|
id: "review",
|
|
description: "",
|
|
kind: "manual",
|
|
spec: { question: "Is it clean?" },
|
|
blocking: true,
|
|
severity: "major",
|
|
},
|
|
],
|
|
additionalChecks: [],
|
|
};
|
|
const artifact = await runQaTemplate({
|
|
template,
|
|
workdir: workDir,
|
|
sprintId: "S1",
|
|
manualResolver: async () => ({ passed: false, note: "found a TODO" }),
|
|
});
|
|
expect(artifact.verdict).toBe("REQUEST_CHANGES");
|
|
expect(artifact.checks[0]!.errorMessage).toBe("found a TODO");
|
|
});
|
|
|
|
it("saves artifact to disk", async () => {
|
|
const template: QaTemplate = {
|
|
template: "test-v1",
|
|
version: "v1",
|
|
appliesTo: ["feature"],
|
|
requiredChecks: [],
|
|
additionalChecks: [],
|
|
};
|
|
const artifact = await runQaTemplate({
|
|
template,
|
|
workdir: workDir,
|
|
sprintId: "S1",
|
|
});
|
|
const path = await saveQaArtifact(workDir, artifact);
|
|
expect(path).toContain(artifact.artifactId);
|
|
});
|
|
});
|
|
|
|
describe("scaffold-v1 on real project", () => {
|
|
it("loads without error and has expected checks", async () => {
|
|
const t = await loadTemplate("scaffold-v1", PROJECT_TEMPLATES);
|
|
const ids = t.requiredChecks.map((c) => c.id);
|
|
expect(ids).toContain("readme-exists");
|
|
expect(ids).toContain("tsconfig-strict");
|
|
});
|
|
});
|