diff --git a/packages/web/qa/README.md b/packages/web/qa/README.md index cb2ca7c4..a6a2b84f 100644 --- a/packages/web/qa/README.md +++ b/packages/web/qa/README.md @@ -24,17 +24,22 @@ PRD #908: 각 실행 레인이 하나의 seam. F-01 이 만든 것은 **prod 레 qa/ ├── contract/ │ ├── verdict.ts # 통일 verdict 계약(정본): 타입 + KO→EN 매핑 + makeVerdict + computeGate -│ └── verdict.test.ts # 계약 유닛(vitest, 네트워크 없음): 게이트 규칙 + P0⟹deterministic 불변식 +│ └── verdict.test.ts # 계약 유닛(vitest): 게이트 규칙 + 공허 방어 + P0 불변식(deterministic·pass/fail) ├── report/ │ ├── report.ts # RunReport 봉투 + JSON·마크다운 이중 출력(순수 함수) -│ └── write.ts # 파일 쓰기 + git sha (유일한 node fs 사용부) +│ ├── write.ts # 파일 쓰기 + git sha +│ ├── ledger.ts # append-only NDJSON 원장 + finalizeFromLedger(크로스-스펙 집계 seam) +│ └── ledger.test.ts # 원장 round-trip + finalize 공허 바닥(floor) 유닛(vitest) ├── prod/ │ ├── readonly-guard.ts # P0 안전: 뮤테이션 차단 라우트(4분기 정책) │ ├── readonly-guard.test.ts # host 매칭 유닛(vitest) │ ├── readonly-guard.contract.spec.ts # 가드가 실제로 차단·기록하는지 e2e 증명(prod 안 침) -│ ├── fixtures.ts # Playwright 픽스처: guard(auto) + qa 수집기 + finalizeRun +│ ├── paths.ts # LEDGER_PATH·REPORT_DIR 단일 소스(setup·teardown·fixtures 공유) +│ ├── fixtures.ts # Playwright 픽스처: guard(auto) + qa 수집기(원장에 append) +│ ├── global-setup.ts # run 시작: 원장 초기화(workers>1 안전한 결정적 리셋 지점) +│ ├── global-teardown.ts# run 종료 1회: 원장 집계 → 리포트 emit → 게이트 판정(blocked면 throw) │ └── home.prod.spec.ts # walking skeleton: 홈 저니 1개, 결정적(C1/P0) -└── playwright.prod.config.ts # prod config: baseURL=decoded.style, webServer 없음, retries=0 +└── playwright.prod.config.ts # prod config: baseURL=decoded.style, webServer 없음, retries=0, global setup/teardown ``` ## 통일 verdict 계약 @@ -53,25 +58,41 @@ qa/ | 근거 | `evidence` | 사람이 읽는 판정 이유 | | 재현성 | `reproducibility` | deterministic · advisory | -**게이트 규칙(단일)**: `blocked = any(tier==="P0" && outcome==="fail")`. P1/thin/gated 는 -절대 차단하지 않는다(advisory). `severity` 는 게이트를 좌우하지 않는다. +**게이트 규칙**: -**계약 불변식**: `P0 ⟹ reproducibility==="deterministic"` — 차단 게이트는 재현 가능해야 -한다. `makeVerdict` 가 위반 시 throw. +- `blocked = any(tier==="P0" && outcome==="fail")`. P1/thin/gated 는 절대 차단하지 + 않는다(advisory). `severity` 는 게이트를 좌우하지 않는다. +- **공허(vacuous) 방어**: verdict 가 하나도 없으면(빈 집합) 통과가 아니라 **차단**이다. + 저니가 조용히 누락(qa.record 망각·조기 return)되면 0 verdict → false-green 이 되는 + "침묵=성공" 함정을 계약 레벨에서 막는다(PRD #968: empty check set must NOT pass). + 리포트는 이를 `NO CHECKS RAN` 으로 P0 fail 과 구분해 렌더한다. -출력: 실행마다 JSON + 마크다운을 `qa/.artifacts/reports/` 에 쓴다(gitignored). +**계약 불변식**(`makeVerdict`/`assertContractInvariants` 가 위반 시 throw): + +- `P0 ⟹ reproducibility==="deterministic"` — 차단 게이트는 재현 가능해야 한다. +- `P0 ⟹ outcome ∈ {pass, fail}` — P0(차단) 축은 `warn`/`skip` 으로 게이트를 우회할 수 + 없다(advisory-in-disguise 금지). 뷰포트 등으로 정말 조건부면 P0 축(C1/C2)에서 빼거나 + 실행 조건을 결정적으로 고정(예: config 가 Desktop Chrome 고정)해 정직한 pass/fail 로 만든다. + +출력: 실행마다 JSON + 마크다운을 `qa/.artifacts/reports/` 에 쓴다(gitignored). 크로스-스펙 +집계는 append-only 원장 `qa/.artifacts/qa-ledger.ndjson`(gitignored)을 거친다. ## 체크 추가하는 법 (#972+) 1. 결정적 prod read-only 체크면 `qa/prod/.prod.spec.ts` 를 만든다. - `import { test, expect, finalizeRun } from "./fixtures"` — guard 는 자동 설치된다. + `import { test, expect } from "./fixtures"` — guard 는 자동 설치된다. 2. 관측 → `qa.record(makeVerdict({ surface, axis, technique, outcome, severity, reproducibility, evidence }))`. tier 는 axis 에서 파생되므로 넘기지 않는다. 3. **prod-안정 셀렉터만** 쓴다(아래 fidelity 참조). 콘텐츠가 아니라 **데이터 불변 구조**를 assert 한다(카드 개수·특정 텍스트 금지 — prod 데이터는 변한다). -4. `test.afterAll` 에서 `finalizeRun()` 호출(스켈레톤 패턴 참조). #972+ 가 여러 스펙을 - 쓰면 수집기 공유 방식은 파일 append ledger(RunLedger seam)로 확장한다. -5. advisory(P1/thin) 체크는 별도 레인(탐색 스윕)으로 — 이 결정적 러너에 섞지 않는다. +4. **finalize 는 절대 스펙에서 하지 않는다.** `qa.record(...)` 만 하면 된다 — 리포트 emit· + 게이트 판정은 `globalTeardown` 이 **모든 스펙 종료 후 단 한 번** 원장을 집계해서 한다. + 스펙마다 `afterAll→finalizeRun` 을 복사하면 같은 리포트 경로를 last-write-wins 로 + 클로버링하고 `workers>1` 에서 깨진다 — 그래서 이 패턴은 금지다. `qa.record` 는 결과를 + append-only 원장(`ledger.ts`)에 남기고, globalTeardown 이 그걸 읽어 finalize 한다. +5. verdict 를 하나도 기록하지 않으면(조기 return·조건 누락) 그 run 은 **공허 실행**으로 + 게이트가 **실패**한다(false-green 금지). 최소 1개 P0 체크는 반드시 emit 되게 짠다. +6. advisory(P1/thin) 체크는 별도 레인(탐색 스윕)으로 — 이 결정적 러너에 섞지 않는다. ## Fidelity 스파이크 (F-01 실측 — prod `https://decoded.style/en`, 2런 재현 확인) @@ -119,9 +140,19 @@ bun run vitest run qa/ 정책을 확장하거나 우회하는 별도 fixture 가 필요하다. - repo=PUBLIC: 악용상세·엔드포인트 auth/비용 수치·file:line 을 이 코드/문서에 넣지 않는다. -## ⚠️ 게이트 known-limitation (#972+ 유의) +## 실행 seam — finalize·게이트 (#971 하드닝) + +fan-out(#972~#984)이 안전하게 쌓이도록 실행 마무리 seam 을 하드닝했다: + +- **finalize = globalTeardown 1회**: 각 스펙은 `qa.record(...)` 로 verdict 를 append-only + 원장(`qa/.artifacts/qa-ledger.ndjson`)에 남기고, `global-setup.ts` 가 run 시작에 원장을 + 리셋(workers>1 안전한 결정적 지점), `global-teardown.ts` 가 모든 스펙 종료 후 단 한 번 + 집계→리포트→게이트를 한다. globalTeardown 은 워커와 다른 프로세스라 모듈 전역이 아니라 + 디스크 원장으로 verdict 를 넘겨받는다. 게이트가 blocked 면 throw → 러너 비정상 종료. +- **공허 통과 차단**: verdict 0 → 게이트 실패(`NO CHECKS RAN`). 저니 조용한 누락을 잡는다. +- **P0 warn 우회 차단**: `P0 ⟹ outcome ∈ {pass, fail}` 불변식으로 P0 축이 warn 으로 + advisory 화하는 것을 makeVerdict 가 거부한다. -`computeGate([])` → `blocked:false` — **빈 verdict 는 vacuously 통과**한다. 지금은 러너 크래시를 -Playwright 테스트 실패가 잡지만, 게이트 계약 자체는 "돌아서 통과"와 "안 돌았음"을 구분 못 한다 -(반복 에이전틱 실행의 "침묵=성공" 함정). run ledger 를 도입할 때(#972+) **기대 체크 floor**(또는 -원장 기록 expected count) 대비 실제 emit 수를 검증해, 저니가 조용히 누락되면 fail 로 잡아야 한다. +> **주의(fan-out 저자)**: verdict 를 하나도 emit 하지 않는 스펙(예: guard 계약 스펙만 단독 +> 실행)을 이 config 로 돌리면 공허 바닥에 걸려 게이트가 실패한다 — 의도된 동작이다. 정상 +> 실행은 저니 스펙(들)을 함께 돌려 최소 1개 P0 verdict 가 기록되게 한다. diff --git a/packages/web/qa/contract/verdict.test.ts b/packages/web/qa/contract/verdict.test.ts index 949c3394..56a388fe 100644 --- a/packages/web/qa/contract/verdict.test.ts +++ b/packages/web/qa/contract/verdict.test.ts @@ -74,6 +74,19 @@ describe("makeVerdict — tier 파생 + 불변식", () => { ).toThrow(/must be deterministic/); }); + it("P0 는 warn 판정을 거부한다(P0 ⟹ outcome ∈ {pass, fail})", () => { + // P0(차단) 축이 warn 으로 게이트를 우회(advisory-in-disguise)하는 것을 막는다. + expect(() => + makeVerdict({ + ...base, + check: "p0-warn", + axis: "C1", + outcome: "warn", + reproducibility: "deterministic", + }) + ).toThrow(/pass\|fail/); + }); + it("P1/thin 은 advisory 재현성을 허용한다", () => { expect(() => makeVerdict({ @@ -138,13 +151,28 @@ describe("computeGate — 단일 규칙", () => { }); it("P0 fail 이 없으면 통과", () => { + // P0 는 warn 을 emit 할 수 없으므로(P0 ⟹ pass|fail), warn 신호는 advisory 축(C3)으로. const gate = computeGate([ v({ check: "a", outcome: "pass" }), - v({ check: "b", outcome: "warn", axis: "C1" }), + makeVerdict({ + ...base, + check: "b", + axis: "C3", + technique: "a11y-scan", + outcome: "warn", + reproducibility: "advisory", + }), ]); expect(gate.blocked).toBe(false); }); + it("빈/공허(vacuous) verdict 집합은 통과가 아니다(empty ⟹ NOT pass)", () => { + // 저니가 조용히 누락(qa.record 망각·조기 return)되면 0 verdict → false-green 금지. + const gate = computeGate([]); + expect(gate.blocked).toBe(true); + expect(gate.blockingChecks).toEqual([]); + }); + it("P1/thin/gated fail 은 절대 차단하지 않는다(advisory)", () => { const p1 = makeVerdict({ ...base, diff --git a/packages/web/qa/contract/verdict.ts b/packages/web/qa/contract/verdict.ts index ca2376eb..9290ae43 100644 --- a/packages/web/qa/contract/verdict.ts +++ b/packages/web/qa/contract/verdict.ts @@ -124,8 +124,12 @@ export interface Verdict { export type VerdictInput = Omit; /** - * 계약 불변식(invariant) — P0 ⟹ deterministic. - * 차단(P0) 판정은 재현 가능해야만 한다. advisory 재현성으로는 배포를 막을 수 없다. + * 계약 불변식(invariant): + * - P0 ⟹ reproducibility==="deterministic": 차단 판정은 재현 가능해야만 한다 + * (flaky/advisory 로는 배포를 막을 수 없다). + * - P0 ⟹ outcome ∈ {pass, fail}: 차단 축은 warn/skip 으로 게이트를 우회할 수 없다. + * P0 warn 은 "차단도 통과도 아님"(advisory-in-disguise)이 되어 결정적 레인의 정직성을 + * 깬다 → 정직한 pass/fail 로 강제한다. * 위반 시 throw — 슬라이스가 잘못된 조합을 emit 하는 순간 실패하게 만든다. */ export function assertContractInvariants(v: Verdict): void { @@ -135,6 +139,12 @@ export function assertContractInvariants(v: Verdict): void { `(got reproducibility="${v.reproducibility}"). 차단 게이트는 재현 가능해야 한다.` ); } + if (v.tier === "P0" && v.outcome !== "pass" && v.outcome !== "fail") { + throw new Error( + `[verdict-contract] P0 verdict "${v.check}" must resolve to pass|fail ` + + `(got outcome="${v.outcome}"). P0(차단) 축은 warn/skip 으로 게이트를 우회할 수 없다.` + ); + } if (AXIS_TIER[v.axis] !== v.tier) { throw new Error( `[verdict-contract] verdict "${v.check}" tier="${v.tier}" != AXIS_TIER["${v.axis}"]="${AXIS_TIER[v.axis]}". tier 는 축에서 파생 고정이다.` @@ -162,10 +172,19 @@ export interface Gate { } /** - * 단일 게이트 규칙: blocked = any(tier==="P0" && outcome==="fail"). - * P1/thin/gated 는 절대 차단하지 않는다(advisory). severity 도 게이트를 좌우하지 않는다. + * 게이트 규칙: + * - blocked = any(tier==="P0" && outcome==="fail"). P1/thin/gated 는 절대 차단하지 + * 않는다(advisory). severity 도 게이트를 좌우하지 않는다. + * - **공허(vacuous) 방어**: verdict 가 하나도 없으면(빈 집합) 통과가 아니라 차단이다. + * 저니가 조용히 누락(qa.record 망각·조기 return)되면 0 verdict → false-green 이 되는 + * "침묵=성공" 함정을 계약 레벨에서 막는다(PRD #968: empty check set must NOT pass). + * "돌아서 통과"와 "안 돌았음"을 게이트가 구분한다. blockingChecks 는 비어 있으므로 + * 리포트는 verdicts.length===0 으로 "NO CHECKS RAN" 을 P0 fail 과 구분해 렌더한다. */ export function computeGate(verdicts: Verdict[]): Gate { + if (verdicts.length === 0) { + return { blocked: true, blockingChecks: [] }; + } const blocking = verdicts.filter( (v) => v.tier === "P0" && v.outcome === "fail" ); diff --git a/packages/web/qa/mutation/accounts.test.ts b/packages/web/qa/mutation/accounts.test.ts new file mode 100644 index 00000000..e10b352d --- /dev/null +++ b/packages/web/qa/mutation/accounts.test.ts @@ -0,0 +1,75 @@ +/** + * 계정 provisioning + commissioning 유닛 — signup/reset 분리(#1) + ≥2 commissioned fail-closed. + */ +import { describe, expect, it } from "vitest"; +import { + MIN_POOL_SIZE, + assertPoolCommissioned, + assertRepeatable, + classifyOp, + commission, + type CommissioningCheck, + type QaAccount, +} from "./accounts"; + +const acct = (over: Partial = {}): QaAccount => ({ + id: "qa-1", + credentialEnvVar: "QA_ACCOUNT_1", + commissioned: true, + ...over, +}); + +describe("signup/reset 분리 (acceptance #1)", () => { + it("lifecycle op(signup/reset/delete)은 반복 뮤테이션 경로에서 거부된다", () => { + expect(() => assertRepeatable("signup")).toThrow(/lifecycle/); + expect(() => assertRepeatable("password-reset")).toThrow(/lifecycle/); + expect(() => assertRepeatable("delete-account")).toThrow(/lifecycle/); + }); + + it("repeatable op(like/save/decode 등)은 통과한다", () => { + expect(() => assertRepeatable("like")).not.toThrow(); + expect(() => assertRepeatable("save")).not.toThrow(); + }); + + it("classifyOp 가 lifecycle / repeatable 을 구분한다", () => { + expect(classifyOp("signup")).toBe("lifecycle"); + expect(classifyOp("like")).toBe("repeatable"); + }); +}); + +describe("commissioning 체크리스트", () => { + const checks: CommissioningCheck[] = [ + { name: "has-credential-ref", check: (a) => a.credentialEnvVar.length > 0 }, + { name: "commissioned-flag", check: (a) => a.commissioned }, + ]; + + it("모든 체크 통과 → passed", () => { + const r = commission(acct(), checks); + expect(r.passed).toBe(true); + expect(r.failures).toEqual([]); + }); + + it("실패 체크 이름을 돌려준다", () => { + const r = commission(acct({ commissioned: false }), checks); + expect(r.passed).toBe(false); + expect(r.failures).toEqual(["commissioned-flag"]); + }); +}); + +describe("assertPoolCommissioned — fail-closed 풀 검증", () => { + it(`계정 < ${MIN_POOL_SIZE} 이면 throw(투입 금지)`, () => { + expect(() => assertPoolCommissioned([acct()])).toThrow(/최소 2개/); + }); + + it("하나라도 미commissioned 이면 throw", () => { + expect(() => + assertPoolCommissioned([acct({ id: "a" }), acct({ id: "b", commissioned: false })]) + ).toThrow(/미commissioned/); + }); + + it("≥2 이고 전부 commissioned 이면 통과", () => { + expect(() => + assertPoolCommissioned([acct({ id: "a" }), acct({ id: "b" })]) + ).not.toThrow(); + }); +}); diff --git a/packages/web/qa/mutation/accounts.ts b/packages/web/qa/mutation/accounts.ts new file mode 100644 index 00000000..d9783077 --- /dev/null +++ b/packages/web/qa/mutation/accounts.ts @@ -0,0 +1,120 @@ +/** + * QA 뮤테이션 계정 provisioning + commissioning 체크리스트 (#976 F-06, 게이트 슬라이스). + * + * 이 모듈은 **메커니즘만** 담는다 — 실제 자격증명·엔드포인트·auth 흐름은 코드에 없다. + * 계정은 자격증명을 담은 **env 변수 이름**으로만 참조하고(값이 아니라 이름), 런타임에 + * 해석한다(repo=PUBLIC → 비밀·엔드포인트·비용 수치를 코드/주석에 넣지 않는다). + * + * 두 가지를 코드로 강제한다(문서 주석이 아니라 실패 가능한 teeth): + * 1. **signup/reset 분리**(acceptance #1): 계정 lifecycle 뮤테이션(가입/리셋/삭제)은 + * 반복 뮤테이션 경로에서 실행될 수 없다 — `assertRepeatable` 이 lifecycle op 를 + * 거부한다. lifecycle 은 out-of-band commissioning 에서 딱 한 번, 반복 뮤테이션과 격리. + * 2. **commissioning 체크리스트 + ≥2 계정**: 풀은 최소 2개, 전부 commissioned 여야 + * 뮤테이션 레인에 투입된다 — `assertPoolCommissioned` 가 fail-closed 로 검증(<2 이거나 + * 하나라도 미검증이면 throw). 미검증 풀로는 prod 를 건드리지 않는다. + */ + +/** 뮤테이션 op 분류. lifecycle 은 반복 뮤테이션 경로에서 금지(신원 부작용·레이트리밋). */ +export type MutationOpKind = "lifecycle" | "repeatable"; + +/** + * 계정 lifecycle op 이름. 반복 뮤테이션에서 분리 강제되는 신원 변경 연산이다 — + * 이 이름들은 out-of-band commissioning 에서만 쓰이고, 반복 뮤테이션엔 절대 안 온다. + */ +export const LIFECYCLE_OPS = [ + "signup", + "password-reset", + "delete-account", +] as const; +export type LifecycleOp = (typeof LIFECYCLE_OPS)[number]; + +/** op 를 lifecycle / repeatable 로 분류. lifecycle 목록에 없으면 repeatable. */ +export function classifyOp(op: string): MutationOpKind { + return (LIFECYCLE_OPS as readonly string[]).includes(op) + ? "lifecycle" + : "repeatable"; +} + +/** + * 반복 뮤테이션 경로에서 op 를 실행하기 **전** 호출. lifecycle op(가입/리셋/삭제)이면 + * throw — 반복 실행에서 신원 부작용을 내지 않도록 원천 차단한다(acceptance #1 teeth). + * repeatable op 만 통과한다. + */ +export function assertRepeatable(op: string): void { + if (classifyOp(op) === "lifecycle") { + throw new Error( + `[qa-mutation] lifecycle op "${op}" 는 반복 뮤테이션 경로에서 실행할 수 없다 ` + + `(signup/reset/delete 은 commissioning 에서 out-of-band 로만 — 반복 뮤테이션과 분리). ` + + `LIFECYCLE_OPS 참조.` + ); + } +} + +/** + * QA 계정 — shape 만. 자격증명은 코드에 없고 env 변수 **이름**으로 참조한다. + * 뮤테이션 레인은 런타임에 `credentialEnvVar` 를 읽어 세션을 만든다(이 모듈은 값을 모른다). + */ +export interface QaAccount { + /** 안정적 계정 식별자(로그·원장용, 비밀 아님). */ + id: string; + /** 자격증명이 담긴 env 변수 **이름**(값이 아니라 이름). 런타임에 해석. */ + credentialEnvVar: string; + /** commissioning 통과 여부. false 면 뮤테이션 레인 투입 금지. */ + commissioned: boolean; +} + +/** commissioning 체크리스트 항목. */ +export interface CommissioningCheck { + /** 체크 이름(리포트·실패 사유). */ + name: string; + /** + * 계정이 이 전제를 만족하는가. 순수·동기 술어 — 실제 네트워크/DB 검사는 주입한다 + * (여기 로직은 결정적으로 유닛 검증 가능해야 한다). + */ + check: (account: QaAccount) => boolean; +} + +/** 계정 하나의 commissioning 결과. */ +export interface CommissioningResult { + account: QaAccount; + passed: boolean; + /** 실패한 체크 이름들(passed=false 일 때 비어있지 않음). */ + failures: string[]; +} + +/** 뮤테이션 레인 최소 계정 수(≥2 — race 회피 + lease 직렬화 여유). */ +export const MIN_POOL_SIZE = 2; + +/** + * 계정 하나를 체크리스트로 commission 한다. 하나라도 실패하면 passed=false 이고 + * 실패 체크 이름을 돌려준다. + */ +export function commission( + account: QaAccount, + checks: CommissioningCheck[] +): CommissioningResult { + const failures = checks.filter((c) => !c.check(account)).map((c) => c.name); + return { account, passed: failures.length === 0, failures }; +} + +/** + * 풀 전체가 뮤테이션 레인에 투입 가능한지 **fail-closed** 로 검증한다. 아래 중 하나라도 + * 어기면 throw — 미검증/부족 풀로 prod 뮤테이션을 시작하지 않는다: + * - 계정 수 < MIN_POOL_SIZE(2). + * - 하나라도 `commissioned=false`. + */ +export function assertPoolCommissioned(accounts: QaAccount[]): void { + if (accounts.length < MIN_POOL_SIZE) { + throw new Error( + `[qa-mutation] 계정 풀은 최소 ${MIN_POOL_SIZE}개 필요하다 (got ${accounts.length}). ` + + `≥2 여야 lease 직렬화로 계정당 race 를 피한다.` + ); + } + const uncommissioned = accounts.filter((a) => !a.commissioned).map((a) => a.id); + if (uncommissioned.length > 0) { + throw new Error( + `[qa-mutation] 미commissioned 계정: ${uncommissioned.join(", ")}. ` + + `전부 commissioning 통과해야 뮤테이션 레인 투입.` + ); + } +} diff --git a/packages/web/qa/mutation/budget.test.ts b/packages/web/qa/mutation/budget.test.ts new file mode 100644 index 00000000..d50313bb --- /dev/null +++ b/packages/web/qa/mutation/budget.test.ts @@ -0,0 +1,50 @@ +/** + * 예산 하드 캡 유닛 — **fail-closed 기본**(크라운 주얼) + 건수/비용 캡 초과 거부. + */ +import { describe, expect, it } from "vitest"; +import { Budget, CLOSED_BUDGET } from "./budget"; + +describe("fail-closed 기본 (크라운 주얼 — 안 열면 아무 것도 안 나간다)", () => { + it("기본 예산(CLOSED_BUDGET, 0/0)은 첫 뮤테이션부터 거부한다", () => { + const b = new Budget(); // = CLOSED_BUDGET + const d = b.reserve(0); + expect(d.allowed).toBe(false); + expect(d.reason).toBe("count-cap"); + expect(b.spentCount()).toBe(0); // 거부는 카운터를 올리지 않는다. + }); + + it("CLOSED_BUDGET 은 명시적으로 0/0 이다", () => { + expect(CLOSED_BUDGET).toEqual({ maxMutations: 0, maxCostUsd: 0 }); + }); +}); + +describe("건수 캡", () => { + it("maxMutations 만큼만 통과, 초과는 count-cap 으로 거부", () => { + const b = new Budget({ maxMutations: 2, maxCostUsd: 100 }); + expect(b.reserve(0).allowed).toBe(true); + expect(b.reserve(0).allowed).toBe(true); + const third = b.reserve(0); + expect(third.allowed).toBe(false); + expect(third.reason).toBe("count-cap"); + expect(b.spentCount()).toBe(2); + expect(b.remainingCount()).toBe(0); + }); +}); + +describe("비용 캡", () => { + it("누적 비용이 maxCostUsd 를 넘으면 cost-cap 으로 거부(카운터 불변)", () => { + const b = new Budget({ maxMutations: 10, maxCostUsd: 1 }); + expect(b.reserve(0.6).allowed).toBe(true); // 누적 0.6 + const over = b.reserve(0.6); // 누적 1.2 > 1 → 거부 + expect(over.allowed).toBe(false); + expect(over.reason).toBe("cost-cap"); + expect(b.spentCostUsd()).toBeCloseTo(0.6); + }); + + it("건수 여유가 있어도 비용 캡이 먼저 걸리면 거부", () => { + const b = new Budget({ maxMutations: 100, maxCostUsd: 0.5 }); + const d = b.reserve(1); + expect(d.allowed).toBe(false); + expect(d.reason).toBe("cost-cap"); + }); +}); diff --git a/packages/web/qa/mutation/budget.ts b/packages/web/qa/mutation/budget.ts new file mode 100644 index 00000000..dee98846 --- /dev/null +++ b/packages/web/qa/mutation/budget.ts @@ -0,0 +1,66 @@ +/** + * Fail-closed 예산 하드 캡 (#976 F-06) — 뮤테이션·실-AI 표면(돈·비가역)의 #1 안전장치. + * + * **기본값이 닫힘**: `CLOSED_BUDGET` = {maxMutations:0, maxCostUsd:0} → 아무 것도 통과 못 + * 한다. 뮤테이션 레인은 캡을 **명시적으로** 열어야 실행할 수 있고, 캡을 넘는 요청은 + * 거부된다(deny) — 초과 실행이 원천적으로 불가능하다. reserve() 가 곧 소비이므로 + * "예약만 하고 안 쓴" 누수도 없다(fail-closed). + * + * repo=PUBLIC: 실제 캡 수치(건수·비용)는 코드에 하드코딩하지 않는다 — 런타임에 주입한다. + */ + +/** 예산 한도. 기본은 닫힘(0/0). */ +export interface BudgetLimits { + /** 허용 뮤테이션 최대 건수. */ + maxMutations: number; + /** 허용 누적 비용 상한(USD). */ + maxCostUsd: number; +} + +/** 완전히 닫힌 기본 예산 — 명시적으로 열기 전엔 어떤 뮤테이션도 통과 못 한다. */ +export const CLOSED_BUDGET: BudgetLimits = { maxMutations: 0, maxCostUsd: 0 }; + +/** reserve 판정. 거부 시 reason 으로 어느 캡에 걸렸는지 구분. */ +export interface BudgetDecision { + allowed: boolean; + reason?: "count-cap" | "cost-cap"; +} + +export class Budget { + private mutations = 0; + private costUsd = 0; + + constructor(private readonly limits: BudgetLimits = CLOSED_BUDGET) {} + + /** + * 다음 뮤테이션(예상 비용 `costUsd`)을 예약한다. 건수 캡 또는 비용 캡을 넘으면 + * allowed=false 로 거부하고 카운터를 **올리지 않는다**(fail-closed — 초과 실행 없음). + * 넘지 않으면 카운터를 올리고 allowed=true. 예약이 곧 소비다. + */ + reserve(costUsd = 0): BudgetDecision { + if (this.mutations + 1 > this.limits.maxMutations) { + return { allowed: false, reason: "count-cap" }; + } + if (this.costUsd + costUsd > this.limits.maxCostUsd) { + return { allowed: false, reason: "cost-cap" }; + } + this.mutations += 1; + this.costUsd += costUsd; + return { allowed: true }; + } + + /** 지금까지 소비한 뮤테이션 건수. */ + spentCount(): number { + return this.mutations; + } + + /** 지금까지 소비한 누적 비용(USD). */ + spentCostUsd(): number { + return this.costUsd; + } + + /** 남은 건수(0 이하로 안 내려감). */ + remainingCount(): number { + return Math.max(0, this.limits.maxMutations - this.mutations); + } +} diff --git a/packages/web/qa/mutation/cleanup.test.ts b/packages/web/qa/mutation/cleanup.test.ts new file mode 100644 index 00000000..9958d8b7 --- /dev/null +++ b/packages/web/qa/mutation/cleanup.test.ts @@ -0,0 +1,67 @@ +/** + * 다신호 cleanup 유닛 — 3신호 전부 필요(#4) + TTL 백스톱 신호 독립. + */ +import { describe, expect, it } from "vitest"; +import { + evaluateCleanup, + isExpired, + shouldReclaim, + type CleanupSignals, +} from "./cleanup"; + +const all: CleanupSignals = { + dbAbsent: true, + feedProfileAbsent: true, + indexTaskComplete: true, +}; + +describe("evaluateCleanup — 3신호 전부 필요", () => { + it("3신호 전부 true 여야 cleaned", () => { + expect(evaluateCleanup(all)).toEqual({ cleaned: true, missing: [] }); + }); + + it("2-of-3 → NOT cleaned, 누락 신호 표면화", () => { + const v = evaluateCleanup({ ...all, indexTaskComplete: false }); + expect(v.cleaned).toBe(false); + expect(v.missing).toEqual(["indexTaskComplete"]); + }); + + it("어느 한 신호만 빠져도 cleaned 아님(거짓 cleaned 방어)", () => { + expect(evaluateCleanup({ ...all, dbAbsent: false }).cleaned).toBe(false); + expect(evaluateCleanup({ ...all, feedProfileAbsent: false }).cleaned).toBe(false); + expect(evaluateCleanup({ dbAbsent: false, feedProfileAbsent: false, indexTaskComplete: false }).missing).toHaveLength(3); + }); +}); + +describe("isExpired — 주입된 시계로 결정적", () => { + it("now - committedAt > ttl 이면 만료", () => { + expect(isExpired(1000, 1000 + 61, 60)).toBe(true); + expect(isExpired(1000, 1000 + 30, 60)).toBe(false); + }); +}); + +describe("shouldReclaim — 신호 정상 vs TTL 백스톱(독립)", () => { + const t0 = 1000; + const ttl = 60_000; + + it("3신호 통과 → 정상 회수(via=signals)", () => { + expect(shouldReclaim(all, t0, t0 + 10, ttl)).toEqual({ reclaim: true, via: "signals", missing: [] }); + }); + + it("신호 미완이지만 TTL 초과 → 백스톱 회수(신호와 독립, 조사 필요)", () => { + const partial = { ...all, indexTaskComplete: false }; + const v = shouldReclaim(partial, t0, t0 + ttl + 1, ttl); + expect(v.reclaim).toBe(true); + expect(v.via).toBe("ttl-backstop"); + expect(v.missing).toEqual(["indexTaskComplete"]); + }); + + it("신호 미완 & TTL 이내 → 회수 안 함", () => { + const partial = { ...all, dbAbsent: false }; + expect(shouldReclaim(partial, t0, t0 + 10, ttl)).toEqual({ + reclaim: false, + via: "none", + missing: ["dbAbsent"], + }); + }); +}); diff --git a/packages/web/qa/mutation/cleanup.ts b/packages/web/qa/mutation/cleanup.ts new file mode 100644 index 00000000..674d6448 --- /dev/null +++ b/packages/web/qa/mutation/cleanup.ts @@ -0,0 +1,83 @@ +/** + * 다신호 cleanup 판정 (#976 F-06, acceptance #4). + * + * 뮤테이션 산출물이 **정말** 제거됐는지 3개 독립 신호가 **전부** 확인돼야 cleaned 로 본다. + * 하나만 봐서는 거짓 "cleaned"(예: DB 에선 지웠지만 검색 인덱스에 잔존)를 놓친다: + * 1. `dbAbsent` — 레코드가 DB 에서 사라짐. + * 2. `feedProfileAbsent` — 산출물이 피드/프로필에 더는 안 보임. + * 3. `indexTaskComplete` — 검색 인덱스 재색인 task 종료(검색에 잔존 안 함). + * + * **TTL 백스톱은 신호와 독립**이다: 산출물이 TTL 을 넘겼는데 미cleaned 면, 신호 상태와 + * 무관하게 회수 대상으로 삼는다(크래시·신호 수집 실패의 안전망). via 로 정상 경로("signals")와 + * 백스톱 경로("ttl-backstop", 조사 필요)를 구분한다. + * + * 순수 함수(주입된 신호·시각) → 결정적 유닛 검증. 실제 신호 수집(DB 쿼리·피드 조회·인덱스 + * 상태)은 이 위에 얹히고, 여기 로직은 "무엇이 회수를 정당화하는가"만 판정한다. + */ + +/** 정리 확인 신호 3종. */ +export interface CleanupSignals { + dbAbsent: boolean; + feedProfileAbsent: boolean; + indexTaskComplete: boolean; +} + +/** cleaned 로 인정하려면 전부 true 여야 하는 신호 이름들. */ +export const REQUIRED_SIGNALS = [ + "dbAbsent", + "feedProfileAbsent", + "indexTaskComplete", +] as const; + +/** cleanup 판정 + 누락 신호. */ +export interface CleanupVerdict { + cleaned: boolean; + /** 아직 확인 안 된 신호 이름들(cleaned=true 면 빈 배열). */ + missing: string[]; +} + +/** 3신호 전부 true 여야 cleaned. 누락 신호도 함께 돌려준다(관측·트리아지). */ +export function evaluateCleanup(signals: CleanupSignals): CleanupVerdict { + const missing = REQUIRED_SIGNALS.filter((s) => !signals[s]); + return { cleaned: missing.length === 0, missing: [...missing] }; +} + +/** TTL 백스톱: committedAt 이 `now - ttlMs` 보다 오래면 신호 무관 회수 대상(true). */ +export function isExpired( + committedAtMs: number, + now: number, + ttlMs: number +): boolean { + return now - committedAtMs > ttlMs; +} + +/** 최종 회수 판정. */ +export interface ReclaimVerdict { + reclaim: boolean; + /** 회수 근거 경로. "signals"=정상 다신호 통과, "ttl-backstop"=TTL 안전망(조사 필요). */ + via: "signals" | "ttl-backstop" | "none"; + /** 미확인 신호(정상 통과가 아닐 때 무엇이 빠졌는지). */ + missing: string[]; +} + +/** + * 산출물을 회수/종결할지 판정한다: + * - 3신호 전부 확인 → 정상 회수(via="signals"). + * - 아니지만 TTL 초과 → 백스톱 회수(via="ttl-backstop", missing 으로 무엇이 안 왔는지 표면화). + * - 둘 다 아님 → 회수 안 함(via="none"). + */ +export function shouldReclaim( + signals: CleanupSignals, + committedAtMs: number, + now: number, + ttlMs: number +): ReclaimVerdict { + const c = evaluateCleanup(signals); + if (c.cleaned) { + return { reclaim: true, via: "signals", missing: [] }; + } + if (isExpired(committedAtMs, now, ttlMs)) { + return { reclaim: true, via: "ttl-backstop", missing: c.missing }; + } + return { reclaim: false, via: "none", missing: c.missing }; +} diff --git a/packages/web/qa/mutation/lease-pool.test.ts b/packages/web/qa/mutation/lease-pool.test.ts new file mode 100644 index 00000000..ad540a6a --- /dev/null +++ b/packages/web/qa/mutation/lease-pool.test.ts @@ -0,0 +1,58 @@ +/** + * lease 풀 유닛 — 계정당 직렬(#2) + read-only lease 미소비(#5) + 더블-릴리스 방어. + */ +import { describe, expect, it } from "vitest"; +import { LeasePool, type MutationLease } from "./lease-pool"; + +describe("계정당 직렬 뮤테이션 lease (acceptance #2)", () => { + it("같은 계정을 동시에 두 번 못 빌린다(release 전까지)", () => { + const pool = new LeasePool(["a"]); + const first = pool.acquireMutation(); + expect(first?.accountId).toBe("a"); + // 유일 계정이 점유 중 → 다음 획득은 null(대기). + expect(pool.acquireMutation()).toBeNull(); + pool.release(first as MutationLease); + // 반납 후 다시 가능. + expect(pool.acquireMutation()?.accountId).toBe("a"); + }); + + it("서로 다른 계정은 병렬 뮤테이션 lease 가능(풀 크기까지)", () => { + const pool = new LeasePool(["a", "b"]); + const l1 = pool.acquireMutation(); + const l2 = pool.acquireMutation(); + expect([l1?.accountId, l2?.accountId].sort()).toEqual(["a", "b"]); + expect(pool.activeMutationLeases()).toBe(2); + // 풀 소진 → 세 번째는 null. + expect(pool.acquireMutation()).toBeNull(); + }); +}); + +describe("read-only lease 미소비 (acceptance #5)", () => { + it("read-only lease 는 계정 slot 을 잠그지 않는다(무제한 fanout)", () => { + const pool = new LeasePool(["a", "b"]); + // read-only 여러 개 획득해도 뮤테이션 lease 가용성 불변. + pool.acquireReadOnly(); + pool.acquireReadOnly(); + pool.acquireReadOnly(); + expect(pool.activeMutationLeases()).toBe(0); + // 뮤테이션 lease 는 여전히 풀 크기만큼 전부 가능. + expect(pool.acquireMutation()?.kind).toBe("mutation"); + expect(pool.acquireMutation()?.kind).toBe("mutation"); + expect(pool.activeMutationLeases()).toBe(2); + }); +}); + +describe("release 소유권 방어", () => { + it("소유하지 않은/이미 반납한 lease 반납은 throw(교차·더블 릴리스 차단)", () => { + const pool = new LeasePool(["a"]); + const lease = pool.acquireMutation() as MutationLease; + pool.release(lease); + expect(() => pool.release(lease)).toThrow(/잘못된 lease 반납/); + const forged: MutationLease = { kind: "mutation", accountId: "a", leaseId: "lease-999" }; + expect(() => pool.release(forged)).toThrow(/잘못된 lease 반납/); + }); + + it("중복 계정 id 는 풀 생성 시 throw", () => { + expect(() => new LeasePool(["a", "a"])).toThrow(/중복/); + }); +}); diff --git a/packages/web/qa/mutation/lease-pool.ts b/packages/web/qa/mutation/lease-pool.ts new file mode 100644 index 00000000..507dcbd1 --- /dev/null +++ b/packages/web/qa/mutation/lease-pool.ts @@ -0,0 +1,89 @@ +/** + * 계정 lease 풀 — 계정당 **직렬** 보장으로 뮤테이션 race 를 원천 차단(#976 F-06, acceptance #2/#5). + * + * - **뮤테이션 lease**: 계정 하나당 동시에 하나만. 이미 leased 인 계정은 release 전까지 + * 다시 못 빌린다 → 같은 계정에 대한 동시 뮤테이션이 불가능하다(계정당 직렬). + * - **read-only lease**: slot 을 소비하지 않는다(no-lease sentinel). read-only 병렬 경로는 + * 이 풀을 거치되 계정 slot 을 잠그지 않아 **무제한 fanout** 이 보존된다(acceptance #5). + * + * 순수 in-memory(네트워크 없음) → 결정적 유닛 검증 가능. 실제 계정 세션 획득/반납은 이 + * 위에 얹히고, 이 모듈은 "누가 어느 계정을 직렬로 점유하는가"만 관장한다. + */ + +/** 뮤테이션 lease — 계정 slot 을 직렬 점유한다. */ +export interface MutationLease { + kind: "mutation"; + accountId: string; + /** 이 점유의 고유 토큰. release 시 소유권 검증(더블-릴리스·교차-릴리스 방어). */ + leaseId: string; +} + +/** read-only lease — slot 미소비 sentinel(무제한 fanout). */ +export interface ReadOnlyLease { + kind: "read-only"; +} + +export type Lease = MutationLease | ReadOnlyLease; + +export class LeasePool { + private readonly accountIds: string[]; + /** accountId → 현재 활성 leaseId(존재하면 그 계정은 뮤테이션 점유 중). */ + private readonly leased = new Map(); + private seq = 0; + + constructor(accountIds: string[]) { + if (new Set(accountIds).size !== accountIds.length) { + throw new Error("[qa-mutation] lease 풀 계정 id 중복 금지."); + } + if (accountIds.length === 0) { + throw new Error("[qa-mutation] lease 풀에 계정이 최소 1개 필요."); + } + this.accountIds = [...accountIds]; + } + + /** + * 뮤테이션 lease 획득. 비어 있는(미점유) 계정을 하나 골라 직렬 점유한다. 모든 계정이 + * 점유 중이면 `null`(호출자가 대기·재시도) — 계정 수를 넘는 동시 뮤테이션은 불가능. + */ + acquireMutation(): MutationLease | null { + const free = this.accountIds.find((id) => !this.leased.has(id)); + if (free === undefined) return null; + const leaseId = `lease-${++this.seq}`; + this.leased.set(free, leaseId); + return { kind: "mutation", accountId: free, leaseId }; + } + + /** + * read-only lease 획득 — slot 을 소비하지 않는다. 항상 성공하며 뮤테이션 lease 가용성에 + * 영향을 주지 않는다(무제한 read-only fanout 보존). + */ + acquireReadOnly(): ReadOnlyLease { + return { kind: "read-only" }; + } + + /** + * 뮤테이션 lease 반납. 자신이 소유한(leaseId 일치) 계정만 반납할 수 있다 — 소유하지 않은 + * 계정/이미 반납된 lease 를 반납하려 하면 throw(더블-릴리스·교차-릴리스로 남의 점유를 + * 여는 사고 방지). read-only lease 는 반납할 것이 없다(no-op 아님, 애초에 slot 무소비). + */ + release(lease: MutationLease): void { + const current = this.leased.get(lease.accountId); + if (current !== lease.leaseId) { + throw new Error( + `[qa-mutation] 잘못된 lease 반납: account=${lease.accountId} ` + + `lease=${lease.leaseId} (현재 점유=${current ?? "없음"}). 더블/교차 릴리스 방어.` + ); + } + this.leased.delete(lease.accountId); + } + + /** 현재 뮤테이션 점유 계정 수(테스트·관측용). read-only 는 세지 않는다. */ + activeMutationLeases(): number { + return this.leased.size; + } + + /** 특정 계정이 뮤테이션 점유 중인가. */ + isLeased(accountId: string): boolean { + return this.leased.has(accountId); + } +} diff --git a/packages/web/qa/mutation/paths.ts b/packages/web/qa/mutation/paths.ts new file mode 100644 index 00000000..2b6bcdd6 --- /dev/null +++ b/packages/web/qa/mutation/paths.ts @@ -0,0 +1,13 @@ +/** + * 뮤테이션 레인 런타임 경로(정본) — 내구 run ledger 위치의 단일 소스(#976 F-06). + * `__dirname` 기반이라 cwd 와 무관. vitest 유닛은 이 상수를 import 하지 않고 명시적 temp + * 경로를 쓴다(run-ledger.test.ts). read-only 리포트 원장(report/ledger.ts)과 **별개** — + * 이건 뮤테이션 부작용·정리 상태를 추적하는 크래시-복구용 원장이다. + */ +import path from "node:path"; + +/** 뮤테이션 부작용 내구 append-only 원장(gitignored: qa/.artifacts/). */ +export const MUTATION_LEDGER_PATH = path.resolve( + __dirname, + "../.artifacts/mutation-ledger.ndjson" +); diff --git a/packages/web/qa/mutation/run-ledger.test.ts b/packages/web/qa/mutation/run-ledger.test.ts new file mode 100644 index 00000000..f918fcc9 --- /dev/null +++ b/packages/web/qa/mutation/run-ledger.test.ts @@ -0,0 +1,123 @@ +/** + * 뮤테이션 원장 유닛 — 검증된 inverse(fail-closed) + 크래시 후 오펀 식별 + TTL age. + * 시계는 주입(now: number)해 결정적으로 검증한다. + */ +import { appendFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + findExpired, + findOrphans, + findUnverifiedInverse, + isValidInverse, + readMutationLedger, + recordCleaned, + recordCommitted, + recordIntent, + type Inverse, +} from "./run-ledger"; + +const T0 = Date.parse("2026-07-16T00:00:00Z"); +const inv: Inverse = { op: "undo-like", targetRef: "art-1" }; + +let dir: string; +let ledger: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "qa-mut-ledger-")); + ledger = join(dir, "mutation-ledger.ndjson"); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe("검증된 inverse — fail-closed intent 게이트", () => { + it("inverse 없이는 intent 를 기록할 수 없다(뮤테이션 금지)", () => { + expect(() => + recordIntent(ledger, { + mutationId: "m1", + op: "like", + inverse: { op: "", targetRef: "" } as Inverse, + now: T0, + }) + ).toThrow(/검증된 inverse 필요/); + }); + + it("lifecycle op 은 원장 intent 에서도 거부된다(signup/reset 분리)", () => { + expect(() => + recordIntent(ledger, { mutationId: "m1", op: "signup", inverse: inv, now: T0 }) + ).toThrow(/lifecycle/); + }); + + it("isValidInverse: op·targetRef 둘 다 있어야 유효", () => { + expect(isValidInverse(inv)).toBe(true); + expect(isValidInverse({ op: "x", targetRef: "" })).toBe(false); + expect(isValidInverse(undefined)).toBe(false); + }); + + it("inverse.targetRef 가 committed 산출물과 일치하면 verified", () => { + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: inv, now: T0 }); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 + 1 }); + const [r] = readMutationLedger(ledger); + expect(r.inverseVerified).toBe(true); + }); + + it("inverse 가 엉뚱한 산출물을 가리키면 verified=false → findUnverifiedInverse 로 표면화", () => { + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: { op: "undo", targetRef: "art-WRONG" }, now: T0 }); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 + 1 }); + const records = readMutationLedger(ledger); + expect(records[0].inverseVerified).toBe(false); + expect(findUnverifiedInverse(records).map((r) => r.mutationId)).toEqual(["m1"]); + }); +}); + +describe("크래시 후 오펀 식별 (acceptance #3)", () => { + it("committed·미cleaned = 오펀; cleaned 는 오펀 아님", () => { + // m1: 정리됨. m2: committed 후 크래시(cleaned 없음). + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: inv, now: T0 }); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 + 1 }); + recordCleaned(ledger, { mutationId: "m1", now: T0 + 2 }); + recordIntent(ledger, { mutationId: "m2", op: "like", inverse: { op: "undo", targetRef: "art-2" }, now: T0 }); + recordCommitted(ledger, { mutationId: "m2", artifactId: "art-2", costUsd: 0, now: T0 + 1 }); + + const orphans = findOrphans(readMutationLedger(ledger)); + expect(orphans.map((r) => r.mutationId)).toEqual(["m2"]); + // 오펀은 inverse 를 지녀 undo 가능. + expect(orphans[0].inverse?.targetRef).toBe("art-2"); + }); + + it("intent 만 있고 committed 없이 크래시해도 보수적으로 오펀(회수 대상)", () => { + recordIntent(ledger, { mutationId: "m3", op: "save", inverse: { op: "undo", targetRef: "art-3" }, now: T0 }); + expect(findOrphans(readMutationLedger(ledger)).map((r) => r.mutationId)).toEqual(["m3"]); + }); + + it("깨진 줄이 섞여도 유효 레코드는 살린다(부분 write 내구성)", () => { + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: inv, now: T0 }); + appendFileSync(ledger, "{not json\n", "utf-8"); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 + 1 }); + expect(readMutationLedger(ledger)).toHaveLength(1); + }); + + it("없는 원장 파일 → 빈 배열(크래시 아님)", () => { + expect(readMutationLedger(join(dir, "nope.ndjson"))).toEqual([]); + }); +}); + +describe("TTL 백스톱 age (acceptance #4)", () => { + it("committed·미cleaned 이고 TTL 초과면 findExpired 가 잡는다", () => { + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: inv, now: T0 }); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 }); + const records = readMutationLedger(ledger); + const ttl = 60_000; + // TTL 이내 → 안 걸림. + expect(findExpired(records, T0 + 30_000, ttl)).toEqual([]); + // TTL 초과 → 걸림. + expect(findExpired(records, T0 + 61_000, ttl).map((r) => r.mutationId)).toEqual(["m1"]); + }); + + it("cleaned 레코드는 TTL 초과여도 회수 대상 아님", () => { + recordIntent(ledger, { mutationId: "m1", op: "like", inverse: inv, now: T0 }); + recordCommitted(ledger, { mutationId: "m1", artifactId: "art-1", costUsd: 0, now: T0 }); + recordCleaned(ledger, { mutationId: "m1", now: T0 + 1 }); + expect(findExpired(readMutationLedger(ledger), T0 + 10 ** 9, 60_000)).toEqual([]); + }); +}); diff --git a/packages/web/qa/mutation/run-ledger.ts b/packages/web/qa/mutation/run-ledger.ts new file mode 100644 index 00000000..1e369299 --- /dev/null +++ b/packages/web/qa/mutation/run-ledger.ts @@ -0,0 +1,220 @@ +/** + * 뮤테이션 부작용 내구 원장(append-only NDJSON) — 크래시-복구·정리 추적 seam(#976 F-06, acceptance #3). + * + * read-only 리포트 원장(report/ledger.ts, verdict 집계)과 **별개**다. 이건 실제 뮤테이션이 + * 낸 **부작용**(생성 산출물·비용·정리 상태)을 내구적으로 기록해, 러너가 크래시해도 재시작 시 + * 원장을 읽어 **오펀(정리 안 된 산출물)을 식별**하게 한다. + * + * 세 phase 를 append 한다(항상 이 순서): + * 1. `intent` — 실행 **전**. 되돌릴 **inverse**(검증된 undo)를 반드시 함께 적는다. + * inverse 없으면 recordIntent 가 throw(fail-closed — undo 없이 뮤테이트 금지). + * 2. `committed`— 실행 **후**. 생성 산출물 id·비용·시각. 이때 inverse 가 그 산출물을 + * 가리키는지 검증한다(inverseVerified). + * 3. `cleaned` — 정리 확인 **후**(다신호 cleanup — cleanup.ts). + * + * 크래시 복구: `committed`(또는 `intent`)됐지만 `cleaned` 안 된 = 오펀 → inverse 로 undo. + * append 는 O_APPEND(appendFileSync) 라 workers>1 다중 프로세스에서도 줄 단위 안전. + * 시계는 `now: number` 로 **주입**한다(TTL·age 판정을 결정적으로 — Date.now() 내부 호출 금지). + * 순수 fs 모듈(네트워크·Playwright 없음) → 명시적 경로/시각 인자로 vitest 검증 가능. + */ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { assertRepeatable } from "./accounts"; + +/** 되돌릴 undo 서술 — op(연산 이름, 추상) + targetRef(대상 참조). 비밀·엔드포인트 아님. */ +export interface Inverse { + /** undo 연산 이름(추상 라벨, 비밀 아님). */ + op: string; + /** undo 대상 참조. committed artifactId 와 일치해야 "verified"(이 산출물을 실제로 undo). */ + targetRef: string; +} + +/** 원장 한 줄(NDJSON). */ +export type MutationLedgerEntry = + | { kind: "intent"; mutationId: string; op: string; inverse: Inverse; at: string } + | { + kind: "committed"; + mutationId: string; + artifactId: string; + costUsd: number; + at: string; + } + | { kind: "cleaned"; mutationId: string; at: string }; + +/** mutationId 별로 접은 레코드(readMutationLedger 산출). */ +export interface MutationRecord { + mutationId: string; + op?: string; + inverse?: Inverse; + artifactId?: string; + /** 누적 비용(USD). */ + costUsd: number; + /** committed 시각(ISO). 없으면 미실행/미커밋. */ + committedAt?: string; + cleaned: boolean; + /** + * inverse 가 committed 산출물을 실제로 가리키는가(inverse.targetRef === artifactId). + * committed 없거나 inverse 없으면 false. false 인 committed 는 자동 undo 불가 → 백스톱 대상. + */ + inverseVerified: boolean; +} + +/** + * inverse 가 well-formed 인가(intent 의 fail-closed 게이트). op·targetRef 둘 다 비어있지 + * 않은 문자열이어야 한다 — 이게 아니면 나중에 이 산출물을 되돌릴 수 없다. + */ +export function isValidInverse(inv: Inverse | undefined): inv is Inverse { + return ( + !!inv && + typeof inv.op === "string" && + inv.op.length > 0 && + typeof inv.targetRef === "string" && + inv.targetRef.length > 0 + ); +} + +function iso(now: number): string { + return new Date(now).toISOString(); +} + +function append(ledgerPath: string, entry: MutationLedgerEntry): void { + mkdirSync(dirname(ledgerPath), { recursive: true }); + appendFileSync(ledgerPath, `${JSON.stringify(entry)}\n`, "utf-8"); +} + +/** + * 실행 **전** intent 를 기록한다. **fail-closed 이중 게이트**: + * - `assertRepeatable(op)` — lifecycle op(signup/reset/delete)는 원장에도 못 남긴다. + * - `isValidInverse(inverse)` — 검증된 inverse 없으면 throw(undo 없이 뮤테이트 금지 → + * 정리 불가 산출물이 생기는 것을 원천 차단). + */ +export function recordIntent( + ledgerPath: string, + args: { mutationId: string; op: string; inverse: Inverse; now: number } +): void { + assertRepeatable(args.op); + if (!isValidInverse(args.inverse)) { + throw new Error( + `[qa-mutation] intent "${args.mutationId}" 에 검증된 inverse 필요 — ` + + `inverse(op+targetRef) 없이는 뮤테이션 금지(정리 불가 산출물 방지).` + ); + } + append(ledgerPath, { + kind: "intent", + mutationId: args.mutationId, + op: args.op, + inverse: args.inverse, + at: iso(args.now), + }); +} + +/** 실행 **후** 생성 산출물·비용을 기록한다(inverse 검증은 집계 시점에 판정). */ +export function recordCommitted( + ledgerPath: string, + args: { mutationId: string; artifactId: string; costUsd: number; now: number } +): void { + append(ledgerPath, { + kind: "committed", + mutationId: args.mutationId, + artifactId: args.artifactId, + costUsd: args.costUsd, + at: iso(args.now), + }); +} + +/** 정리 확인 **후** cleaned 를 기록한다(다신호 cleanup 통과 시). */ +export function recordCleaned( + ledgerPath: string, + args: { mutationId: string; now: number } +): void { + append(ledgerPath, { + kind: "cleaned", + mutationId: args.mutationId, + at: iso(args.now), + }); +} + +/** + * 원장 전체를 읽어 mutationId 별 레코드로 접는다. 파일 없으면 빈 배열(크래시 아님). 깨진 + * 줄(부분 write)은 건너뛴다. inverseVerified 는 순서 무관 최종 패스로 판정한다. + */ +export function readMutationLedger(ledgerPath: string): MutationRecord[] { + if (!existsSync(ledgerPath)) return []; + const byId = new Map(); + const order: string[] = []; + const get = (id: string): MutationRecord => { + let r = byId.get(id); + if (!r) { + r = { mutationId: id, costUsd: 0, cleaned: false, inverseVerified: false }; + byId.set(id, r); + order.push(id); + } + return r; + }; + + for (const line of readFileSync(ledgerPath, "utf-8").split("\n")) { + if (!line.trim()) continue; + let entry: MutationLedgerEntry; + try { + entry = JSON.parse(line) as MutationLedgerEntry; + } catch { + continue; // 깨진/부분 줄 → 건너뜀. + } + const r = get(entry.mutationId); + if (entry.kind === "intent") { + r.op = entry.op; + r.inverse = entry.inverse; + } else if (entry.kind === "committed") { + r.artifactId = entry.artifactId; + r.costUsd += entry.costUsd; + r.committedAt = entry.at; + } else if (entry.kind === "cleaned") { + r.cleaned = true; + } + } + + const records = order.map((id) => byId.get(id) as MutationRecord); + for (const r of records) { + r.inverseVerified = + !!r.inverse && !!r.artifactId && r.inverse.targetRef === r.artifactId; + } + return records; +} + +/** + * 오펀 식별(크래시 복구): `cleaned` 안 됐고 이미 뮤테이션이 일어났거나(committed) 일어났을 + * 수 있는(intent 만 있고 cleaned 없음) 레코드. 보수적으로 intent-only 도 포함한다 — + * intent 후 크래시면 뮤테이션이 실제로 났는지 알 수 없으니 회수 대상으로 본다(fail-safe). + */ +export function findOrphans(records: MutationRecord[]): MutationRecord[] { + return records.filter( + (r) => !r.cleaned && (r.committedAt !== undefined || r.inverse !== undefined) + ); +} + +/** + * TTL 백스톱 회수 대상: committed 됐고 미cleaned 이며 committedAt 이 `now - ttlMs` 보다 + * 오래된 레코드. **신호 기반 cleanup 과 독립** — 크래시·신호 수집 실패 경로의 안전망이다. + */ +export function findExpired( + records: MutationRecord[], + now: number, + ttlMs: number +): MutationRecord[] { + return records.filter((r) => { + if (r.cleaned || !r.committedAt) return false; + return now - Date.parse(r.committedAt) > ttlMs; + }); +} + +/** + * inverse 검증 실패 레코드: committed 됐지만 inverse 가 산출물을 안 가리킴 → 자동 undo 불가. + * 정상 신호 cleanup 으로 안전히 되돌릴 수 없으니 조사/TTL 백스톱 대상으로 표면화한다. + */ +export function findUnverifiedInverse( + records: MutationRecord[] +): MutationRecord[] { + return records.filter( + (r) => r.committedAt !== undefined && !r.inverseVerified + ); +} diff --git a/packages/web/qa/playwright.prod.config.ts b/packages/web/qa/playwright.prod.config.ts index 57484090..07ef57ab 100644 --- a/packages/web/qa/playwright.prod.config.ts +++ b/packages/web/qa/playwright.prod.config.ts @@ -19,6 +19,11 @@ export default defineConfig({ testDir: "./prod", // qa/prod 의 모든 `*.spec.ts` = Playwright 러너. vitest 유닛(`*.test.ts`)과 분리. testMatch: /\.spec\.ts$/, + // 원장 초기화(run 시작) → verdict append(스펙) → 집계·finalize·게이트(run 종료). + // finalize 를 스펙 afterAll 이 아니라 여기로 옮겨야 fan-out(#972~#984)에서 리포트 경로 + // 클로버링·workers>1 붕괴가 없다. globalTeardown 이 blocked 면 throw → 비정상 종료. + globalSetup: path.resolve(__dirname, "prod/global-setup.ts"), + globalTeardown: path.resolve(__dirname, "prod/global-teardown.ts"), fullyParallel: false, // 스켈레톤 단일 저니; 순서/리포트 예측성 우선. forbidOnly: !!process.env.CI, retries: 0, // 정직한 게이트 — P0 는 결정적이어야 하므로 재시도로 숨기지 않는다. diff --git a/packages/web/qa/prod/fixtures.ts b/packages/web/qa/prod/fixtures.ts index b943c74b..28db564e 100644 --- a/packages/web/qa/prod/fixtures.ts +++ b/packages/web/qa/prod/fixtures.ts @@ -1,28 +1,31 @@ /** - * Playwright 픽스처 — prod read-only 러너 seam. + * Playwright 픽스처 — prod read-only 러너 seam(#971 하드닝). * * 제공: - * - `guard`(auto): 각 테스트 context 에 read-only 가드 설치, 테스트 종료 시 대상 - * origin 뮤테이션 위반이 없음을 hard-assert(P0 안전). 차단 비콘 수는 원장에 누적. - * - `qa`: verdict 수집기. 체크가 `qa.record(makeVerdict(...))` 로 결과를 남긴다. + * - `guard`(auto): 각 테스트 context 에 read-only 가드 설치, 테스트 종료 시 대상 origin + * 뮤테이션 위반이 없음을 hard-assert(P0 안전). 차단 비콘/anon-write 수는 **원장에 append**. + * - `qa`: verdict 수집기. 체크가 `qa.record(makeVerdict(...))` 로 결과를 남기면 **디스크 + * 원장에 append** 된다(크로스-스펙 집계의 정본). * - * 실행 마무리(리포트 emit + 게이트 판정)는 스펙의 `test.afterAll` 에서 finalizeRun() - * 을 호출한다. #972+ 는 같은 수집기에 verdict 를 추가하기만 하면 된다(계약 유지). + * **finalize 는 여기서 하지 않는다.** 스펙은 `qa.record(...)` 만 하고, 리포트 emit·게이트 + * 판정은 `playwright.prod.config.ts` 의 `globalTeardown`(→ ledger.finalizeFromLedger)이 모든 + * 스펙 종료 후 단 한 번 한다. 이렇게 해야 fan-out(#972~#984)에서 스펙마다 finalize 를 복사해 + * 같은 리포트 경로를 클로버링하거나 `workers>1` 에서 깨지지 않는다. */ import { test as base, expect } from "@playwright/test"; -import { type Verdict, computeGate } from "../contract/verdict"; -import { buildReport } from "../report/report"; -import { resolveGitSha, writeReport } from "../report/write"; +import { type Verdict } from "../contract/verdict"; +import { appendLedger } from "../report/ledger"; import { installReadOnlyGuard, type ReadOnlyGuard } from "./readonly-guard"; - -/** 러너 전역 상태(단일 스펙 프로세스 내 공유). */ -const collectedVerdicts: Verdict[] = []; -let blockedBeaconsTotal = 0; -let allowedAnonWritesTotal = 0; -const runStartedAt = new Date().toISOString(); +import { LEDGER_PATH } from "./paths"; export interface QaCollector { + /** verdict 를 원장에 기록한다(크로스-스펙 집계는 globalTeardown 이 한다). */ record(verdict: Verdict): void; + /** + * **이 테스트에서** 기록한 verdict 만 돌려준다(로컬 어서션용, spec-wide 아님). 크로스-스펙· + * 크로스-테스트 집계의 정본은 디스크 원장이며 globalTeardown 이 읽는다 — 게이트 판정에 + * 이 배열을 쓰지 말 것. + */ verdicts(): readonly Verdict[]; } @@ -31,9 +34,13 @@ export const test = base.extend<{ guard: ReadOnlyGuard; qa: QaCollector }>({ async ({ context }, use) => { const guard = await installReadOnlyGuard(context); await use(guard); - // 테스트 종료: 대상 origin 뮤테이션 시도는 P0 안전 위반 → 즉시 실패. - blockedBeaconsTotal += guard.blockedBeacons; - allowedAnonWritesTotal += guard.allowedAnonWrites; + // 테스트 종료: guard 신호를 원장에 append(globalTeardown 이 집계). 그리고 대상 origin + // 뮤테이션 시도는 P0 안전 위반 → 즉시 실패. + appendLedger(LEDGER_PATH, { + kind: "guard", + blockedBeacons: guard.blockedBeacons, + allowedAnonWrites: guard.allowedAnonWrites, + }); expect( guard.violations, `read-only 위반: 대상 origin 으로 비안전 요청이 시도됨(P0 안전 breach)` @@ -46,34 +53,17 @@ export const test = base.extend<{ guard: ReadOnlyGuard; qa: QaCollector }>({ // qa 는 의존 픽스처가 없어 빈 패턴을 쓴다(no-empty-pattern 은 여기서만 예외). // eslint-disable-next-line no-empty-pattern qa: async ({}, use) => { + const local: Verdict[] = []; const collector: QaCollector = { - record: (v) => collectedVerdicts.push(v), - verdicts: () => collectedVerdicts, + record: (v) => { + local.push(v); + // 크로스-스펙(크로스-프로세스) 집계의 정본 = append-only 디스크 원장. + appendLedger(LEDGER_PATH, { kind: "verdict", verdict: v }); + }, + verdicts: () => local, }; await use(collector); }, }); export { expect }; - -/** - * 실행 마무리 — 수집된 verdict 로 리포트를 만들고 JSON·마크다운을 쓴 뒤 게이트를 - * 판정한다. 반환값의 blocked 여부로 스펙이 프로세스 종료 코드를 정직하게 만든다. - */ -export function finalizeRun(baseUrl: string, outDir: string) { - const report = buildReport({ - runId: `qa-prod-${runStartedAt.replace(/[:.]/g, "-")}`, - baseUrl, - startedAt: runStartedAt, - finishedAt: new Date().toISOString(), - gitSha: resolveGitSha(), - verdicts: collectedVerdicts, - ledger: { - blockedBeacons: blockedBeaconsTotal, - allowedAnonWrites: allowedAnonWritesTotal, - }, - }); - const written = writeReport(report, outDir); - const gate = computeGate(collectedVerdicts); - return { report, written, gate }; -} diff --git a/packages/web/qa/prod/global-setup.ts b/packages/web/qa/prod/global-setup.ts new file mode 100644 index 00000000..d55cf171 --- /dev/null +++ b/packages/web/qa/prod/global-setup.ts @@ -0,0 +1,13 @@ +/** + * Playwright globalSetup — run 시작 시 원장을 초기화한다(#971 하드닝). + * + * workers>1 에서 워커 프로세스들이 verdict 를 append 하기 **전에** 단 한 번 돌아 원장을 + * 리셋하는 결정적 지점. 지난 run 의 잔여 verdict 가 누적돼 이번 run 리포트를 오염시키는 것을 + * 막고, run-start 마커로 리포트 window 시작 시각을 박는다. globalTeardown 이 짝을 이룬다. + */ +import { resetLedger } from "../report/ledger"; +import { LEDGER_PATH } from "./paths"; + +export default function globalSetup(): void { + resetLedger(LEDGER_PATH); +} diff --git a/packages/web/qa/prod/global-teardown.ts b/packages/web/qa/prod/global-teardown.ts new file mode 100644 index 00000000..80ae0aea --- /dev/null +++ b/packages/web/qa/prod/global-teardown.ts @@ -0,0 +1,37 @@ +/** + * Playwright globalTeardown — 모든 스펙이 끝난 뒤 **단 한 번** 실행되는 finalize seam(#971). + * + * 왜 여기인가(#971 하드닝): 이전엔 스펙의 `afterAll` 에서 finalizeRun 을 불렀다. fan-out + * (#972~#984)에서 스펙마다 그 패턴을 복사하면 (1) 같은 runId 리포트 경로에 last-write-wins + * 클로버링, (2) `workers>1` 붕괴가 생긴다. globalTeardown 은 프로세스당 한 번만 돌므로, + * 디스크 원장(각 스펙이 append)을 read→집계→finalize 하는 유일한 지점이 된다. + * + * 게이트: report.gate.blocked(P0 fail **또는** 공허 실행=verdict 0)이면 throw → 러너가 + * 비정상 종료 코드를 낸다(정직한 게이트). finalize 순수 코어는 ledger.finalizeFromLedger 에 + * 있고 vitest 로 검증된다 — 여기선 얇은 래퍼 + 종료 판정만 한다. + */ +import { finalizeFromLedger } from "../report/ledger"; +import { LEDGER_PATH, REPORT_DIR } from "./paths"; + +export default function globalTeardown(): void { + const baseUrl = process.env.QA_PROD_BASE_URL ?? "https://decoded.style"; + const { report, written, vacuous } = finalizeFromLedger( + LEDGER_PATH, + baseUrl, + REPORT_DIR + ); + + // 내구 아티팩트 위치 안내. + console.log( + `\n[qa verdict] runner=${report.runner} verdicts=${report.verdicts.length} ` + + `blockedBeacons=${report.ledger.blockedBeacons} blocked=${report.gate.blocked}\n` + + `[qa verdict] json=${written.jsonPath}\n[qa verdict] md=${written.mdPath}` + ); + + if (report.gate.blocked) { + const reason = vacuous + ? "공허 실행 — verdict 가 0건이다(저니 누락·qa.record 망각·조기 return). '침묵=성공' 금지." + : `P0 차단: ${report.gate.blockingChecks.join(", ")}`; + throw new Error(`[qa gate] 배포 차단 — ${reason}`); + } +} diff --git a/packages/web/qa/prod/home.prod.spec.ts b/packages/web/qa/prod/home.prod.spec.ts index 6fb077ef..358e6675 100644 --- a/packages/web/qa/prod/home.prod.spec.ts +++ b/packages/web/qa/prod/home.prod.spec.ts @@ -15,13 +15,11 @@ * - networkidle 대기 금지(third-party 스크립트로 안 끝날 수 있음). assert 대상 * landmark(footer)를 직접 기다린다. */ -import path from "node:path"; import { makeVerdict } from "../contract/verdict"; -import { expect, finalizeRun, test } from "./fixtures"; +import { expect, test } from "./fixtures"; const HOME_PATH = "/en"; // localePrefix: "always" + RENDERED_LOCALE="en" const SURFACE = "home"; // JA-home -const REPORT_DIR = path.resolve(__dirname, "../.artifacts/reports"); test.describe("[prod read-only] home journey — walking skeleton (C1)", () => { test("홈 /en 이 결정적 shell + 히어로를 렌더한다", async ({ page, qa }) => { @@ -122,7 +120,8 @@ test.describe("[prod read-only] home journey — walking skeleton (C1)", () => { ); expect.soft(footerOk, "footer[role=contentinfo] 부재").toBe(true); - // 6) 앱 셸 — 데스크톱 primary nav. + // 6) 앱 셸 — primary nav. config 가 Desktop Chrome 뷰포트를 고정하므로 결정적 P0 + // pass/fail 이다(P0 는 warn 으로 게이트를 우회할 수 없다 — verdict 계약 불변식). const navCount = await page.locator('nav[aria-label="Primary"]').count(); const navOk = navCount > 0; qa.record( @@ -131,27 +130,18 @@ test.describe("[prod read-only] home journey — walking skeleton (C1)", () => { surface: SURFACE, axis: "C1", technique: "deterministic-e2e", - outcome: navOk ? "pass" : "warn", // 뷰포트 의존(모바일엔 없음) → warn - severity: "minor", + outcome: navOk ? "pass" : "fail", + severity: navOk ? "info" : "major", reproducibility: "deterministic", - evidence: `nav[aria-label=Primary] count=${navCount} (데스크톱 뷰포트 기대 ≥1)`, + evidence: `nav[aria-label=Primary] count=${navCount} (Desktop Chrome 뷰포트 기대 ≥1)`, observed: { navCount }, }) ); + expect + .soft(navOk, `nav[aria-label=Primary] 부재 (count=${navCount})`) + .toBe(true); }); - test.afterAll(async () => { - const baseUrl = process.env.QA_PROD_BASE_URL ?? "https://decoded.style"; - const { report, written, gate } = finalizeRun(baseUrl, REPORT_DIR); - // 리포트 위치 안내(내구 아티팩트). - console.log( - `\n[qa verdict] runner=${report.runner} verdicts=${report.verdicts.length} ` + - `blockedBeacons=${report.ledger.blockedBeacons} blocked=${gate.blocked}\n` + - `[qa verdict] json=${written.jsonPath}\n[qa verdict] md=${written.mdPath}` - ); - // 게이트: P0 fail 이 하나라도 있으면 러너를 정직하게 실패시킨다. - expect(gate.blocked, `P0 차단: ${gate.blockingChecks.join(", ")}`).toBe( - false - ); - }); + // finalize 는 스펙에서 하지 않는다 — globalTeardown(playwright.prod.config.ts)이 모든 + // 스펙 종료 후 원장을 집계해 단 한 번 리포트·게이트를 판정한다. #972+ 는 qa.record 만 한다. }); diff --git a/packages/web/qa/prod/paths.ts b/packages/web/qa/prod/paths.ts new file mode 100644 index 00000000..d846e94a --- /dev/null +++ b/packages/web/qa/prod/paths.ts @@ -0,0 +1,15 @@ +/** + * 러너 런타임 경로(정본) — Playwright globalSetup/globalTeardown/fixtures 가 **같은** 원장· + * 리포트 위치를 가리키게 하는 단일 소스. `__dirname` 기반이라 cwd 와 무관하게 안정적이다. + * (vitest 유닛은 이 상수를 import 하지 않고 명시적 temp 경로를 쓴다 — ledger.test.ts 참조.) + */ +import path from "node:path"; + +/** 크로스-스펙 append-only 원장(gitignored: qa/.artifacts/). */ +export const LEDGER_PATH = path.resolve( + __dirname, + "../.artifacts/qa-ledger.ndjson" +); + +/** JSON·마크다운 리포트 출력 디렉터리(gitignored: qa/.artifacts/). */ +export const REPORT_DIR = path.resolve(__dirname, "../.artifacts/reports"); diff --git a/packages/web/qa/prod/readonly-guard.mutation.test.ts b/packages/web/qa/prod/readonly-guard.mutation.test.ts new file mode 100644 index 00000000..553154b8 --- /dev/null +++ b/packages/web/qa/prod/readonly-guard.mutation.test.ts @@ -0,0 +1,141 @@ +/** + * 뮤테이션 레인 가드 확장 유닛(#976 F-06) — **순수 결정 함수만** 검증한다. 브라우저· + * route.continue 를 절대 쓰지 않는다(continue 는 실 prod 에 닿는다 → 게이트 슬라이스 금지). + * 여기선 "무엇을 통과시키고 무엇을 막는가"의 결정 로직만 네트워크 없이 증명한다. + * + * ⚠️ 이 파일은 `.test.ts`(vitest) 다 — `.spec.ts` 였다면 playwright prod 러너가 주워 + * 브라우저에서 실행했을 것이다. 게이트 슬라이스는 그런 실행 표면을 만들지 않는다. + */ +import type { BrowserContext, Route } from "@playwright/test"; +import { describe, expect, it, vi } from "vitest"; +import { + installReadOnlyGuard, + matchesMutationAllowlist, + mutationPermit, +} from "./readonly-guard"; + +describe("fail-closed 기본 (크라운 주얼 — 안 열면 대상 origin 뮤테이션 불가)", () => { + it("빈 allowlist → 어떤 write 도 뮤테이션 경로에 매칭 안 됨(위반 분기로 감)", () => { + expect(matchesMutationAllowlist("https://decoded.style/api/v1/posts/x/likes", [])).toBe(false); + }); + + it("캡 기본값(0, 0) → 통과 불가", () => { + // 설령 allowlist 가 매칭돼도 캡이 0 이면 아무 것도 못 나간다(이중 fail-closed). + expect(mutationPermit(0, 0)).toBe(false); + }); +}); + +describe("matchesMutationAllowlist", () => { + it("등록된 패턴에 매칭되는 대상 origin write 만 뮤테이션 후보", () => { + const list = [/\/api\/v1\/posts\/[^/]+\/likes\b/]; + expect(matchesMutationAllowlist("https://decoded.style/api/v1/posts/abc/likes", list)).toBe(true); + // 등록 안 된 엔드포인트는 후보 아님 → 위반 분기. + expect(matchesMutationAllowlist("https://decoded.style/api/v1/users/abc/delete", list)).toBe(false); + }); +}); + +describe("mutationPermit — fail-closed 하드 캡", () => { + it("지금까지 통과 수 < 최대면 허용", () => { + expect(mutationPermit(0, 1)).toBe(true); + expect(mutationPermit(1, 3)).toBe(true); + }); + + it("캡 도달·초과 → 거부(초과 실행 없음)", () => { + expect(mutationPermit(1, 1)).toBe(false); + expect(mutationPermit(5, 3)).toBe(false); + }); +}); + +/** + * installReadOnlyGuard 뮤테이션 분기 **통합** 검증 — mock route 로 handler 를 직접 호출한다 + * (네트워크 없음, route.continue 는 vi.fn 이라 실 prod 에 안 닿는다). 이 슬라이스의 존재 + * 이유인 "유일한 문"의 배선(continue vs abort·카운터 증가·기본 닫힘)을 코드 판독이 아니라 + * 실행으로 증명한다. + */ +function fakeScope() { + let handler: ((route: Route) => unknown) | undefined; + const scope = { + route: (_pattern: string, h: (route: Route) => unknown) => { + handler = h; + return Promise.resolve(); + }, + } as unknown as BrowserContext; + return { + scope, + invoke: (route: Route) => (handler as (r: Route) => unknown)(route), + }; +} + +function fakeRoute(method: string, url: string) { + const cont = vi.fn(); + const abort = vi.fn(); + const route = { + request: () => ({ method: () => method, url: () => url }), + continue: cont, + abort, + } as unknown as Route; + return { route, cont, abort }; +} + +describe("installReadOnlyGuard 뮤테이션 분기 통합(mock-route, 네트워크 없음)", () => { + const TARGET_POST = "https://decoded.style/api/v1/posts/x/likes"; + + it("기본 옵션(닫힘) → 대상 origin write 는 위반 기록 + abort(통과 없음)", async () => { + const { scope, invoke } = fakeScope(); + const guard = await installReadOnlyGuard(scope); + const { route, cont, abort } = fakeRoute("POST", TARGET_POST); + invoke(route); + expect(cont).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledTimes(1); + expect(guard.violations).toHaveLength(1); + expect(guard.allowedMutations).toBe(0); + }); + + it("allowlist 매칭 + 캡 안 → continue 통과·allowedMutations 증가; 초과분 → abort·deniedMutations", async () => { + const { scope, invoke } = fakeScope(); + const guard = await installReadOnlyGuard(scope, { + mutationAllowlist: [/\/posts\/[^/]+\/likes\b/], + mutationBudget: { maxMutations: 1 }, + }); + + const first = fakeRoute("POST", TARGET_POST); + invoke(first.route); + expect(first.cont).toHaveBeenCalledTimes(1); + expect(first.abort).not.toHaveBeenCalled(); + expect(guard.allowedMutations).toBe(1); + expect(guard.violations).toEqual([]); + + // 2번째 = 캡 초과 → fail-closed abort(prod 에 절대 안 닿음). + const second = fakeRoute("POST", TARGET_POST); + invoke(second.route); + expect(second.cont).not.toHaveBeenCalled(); + expect(second.abort).toHaveBeenCalledTimes(1); + expect(guard.allowedMutations).toBe(1); + expect(guard.deniedMutations).toBe(1); + }); + + it("allowlist 열려도 캡 0(미지정) → 통과 없음(이중 fail-closed)", async () => { + const { scope, invoke } = fakeScope(); + const guard = await installReadOnlyGuard(scope, { + mutationAllowlist: [/\/likes\b/], + }); + const { route, cont, abort } = fakeRoute("POST", TARGET_POST); + invoke(route); + expect(cont).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledTimes(1); + expect(guard.allowedMutations).toBe(0); + expect(guard.deniedMutations).toBe(1); + }); + + it("GET(안전 메서드)은 뮤테이션 옵션과 무관하게 continue·뮤테이션으로 안 셈", async () => { + const { scope, invoke } = fakeScope(); + const guard = await installReadOnlyGuard(scope, { + mutationAllowlist: [/\/likes\b/], + mutationBudget: { maxMutations: 5 }, + }); + const { route, cont } = fakeRoute("GET", TARGET_POST); + invoke(route); + expect(cont).toHaveBeenCalledTimes(1); + expect(guard.allowedMutations).toBe(0); + }); +}); diff --git a/packages/web/qa/prod/readonly-guard.ts b/packages/web/qa/prod/readonly-guard.ts index 30fa14d6..fe5640aa 100644 --- a/packages/web/qa/prod/readonly-guard.ts +++ b/packages/web/qa/prod/readonly-guard.ts @@ -18,6 +18,12 @@ * → **abort(silent)**. 실패시키지 않는다. * PRD 스토리 28-29: 분석 노이즈=문서화된 known-risk. * + * 뮤테이션 레인 seam(#976 F-06, additive): 대상 origin write 가 `mutationAllowlist` 에 + * 매칭되면(2·3분기보다 뒤, 위반 판정 전) fail-closed `mutationBudget` 하드 캡 안에서만 + * **실제 통과**(continue)시킨다. read-only 레인은 이 옵션을 안 넘겨(기본 []·캡 0) 이 분기에 + * 들지 않으므로 위 4분기 동작이 그대로다 — 순수 additive. 이 경로가 QA harness 가 대상 + * origin 을 뮤테이트할 수 있는 유일한 문이며, 기본은 완전히 닫혀 있다(이중 fail-closed). + * * 대상 host 판정은 정확 매칭 + 서브도메인 suffix(`*.decoded.style`)로 한다. * * ⚠️ 계약 전제: 이 위반 규칙은 **passive same-origin write 가 0** 임을 가정한다. 홈 저니는 @@ -41,6 +47,16 @@ export interface ReadOnlyGuard { blockedBeacons: number; /** allowlist 매칭으로 차단한 passive same-origin anon write 수(원장 신호, 실패 아님). */ allowedAnonWrites: number; + /** + * 뮤테이션 레인(#976 F-06)이 **의도적으로 통과**시킨 대상 origin write 수. read-only + * 레인은 항상 0(mutationAllowlist 비어 있음 → 이 경로 미진입). fail-closed 캡 안에서만 증가. + */ + allowedMutations: number; + /** + * mutationAllowlist 에 매칭됐지만 fail-closed 예산 캡을 넘어 **거부**한 write 수(#976). + * 초과 뮤테이션은 prod 에 절대 닿지 않는다(abort). 0 이 아니면 캡 소진 신호. + */ + deniedMutations: number; } export interface ReadOnlyGuardOptions { @@ -52,6 +68,38 @@ export interface ReadOnlyGuardOptions { * write 0). 슬라이스가 실측한 anon telemetry 엔드포인트를 여기에 등록한다. */ anonWriteAllowlist?: RegExp[]; + /** + * **뮤테이션 레인 seam(#976 F-06, additive)**. 대상 origin write 중 이 패턴에 매칭되는 + * 것만 **실제로 통과**(route.continue)시킨다 — 오직 `mutationBudget` 하드 캡 안에서만. + * read-only 레인은 이 필드를 안 넘긴다(기본 [] → 이 경로 미진입 → 기존 위반 로직 그대로). + * ⚠️ 이 경로만이 QA harness 가 대상 origin 을 뮤테이트할 수 있는 유일한 문이다. + */ + mutationAllowlist?: RegExp[]; + /** + * 뮤테이션 fail-closed 하드 캡(#976). `maxMutations` 만큼만 통과, 초과는 거부. + * **기본은 닫힘**(미지정 → maxMutations 0) → mutationAllowlist 를 열어도 캡이 0 이면 + * 아무 것도 통과 못 한다(이중 fail-closed). 실 수치는 런타임 주입(repo=PUBLIC). + */ + mutationBudget?: { maxMutations: number }; +} + +/** url 이 뮤테이션 레인 allowlist 에 매칭되는가(순수·네트워크 없음, 유닛 검증용). */ +export function matchesMutationAllowlist( + url: string, + allowlist: RegExp[] +): boolean { + return allowlist.some((re) => re.test(url)); +} + +/** + * Fail-closed 뮤테이션 캡 판정(순수). 지금까지 통과 수 < 최대면 허용. 기본 인자 조합 + * (0, 0) → false(닫힘) — 이게 안전의 **크라운 주얼**이다: 아무 것도 안 열면 아무 것도 못 나간다. + */ +export function mutationPermit( + allowedSoFar: number, + maxMutations: number +): boolean { + return allowedSoFar < maxMutations; } /** host 가 대상 도메인(정확 or `*.decoded.style`)인지. */ @@ -64,7 +112,8 @@ export function isTargetHost(hostname: string, targetHost: string): boolean { /** * page(또는 context)에 read-only 가드 라우트를 설치한다. * @param scope Playwright Page 또는 BrowserContext. - * @param options targetHost(기본 "decoded.style", 서브도메인 포함) + anonWriteAllowlist. + * @param options targetHost(기본 "decoded.style", 서브도메인 포함) + anonWriteAllowlist + + * 뮤테이션 레인 seam(mutationAllowlist·mutationBudget, 기본 닫힘). */ export async function installReadOnlyGuard( scope: Page | BrowserContext, @@ -72,10 +121,15 @@ export async function installReadOnlyGuard( ): Promise { const targetHost = options.targetHost ?? "decoded.style"; const anonWriteAllowlist = options.anonWriteAllowlist ?? []; + // 뮤테이션 레인 seam(#976): 기본 닫힘 — allowlist 비어 있고 캡 0 이면 뮤테이션 불가. + const mutationAllowlist = options.mutationAllowlist ?? []; + const maxMutations = options.mutationBudget?.maxMutations ?? 0; const guard: ReadOnlyGuard = { violations: [], blockedBeacons: 0, allowedAnonWrites: 0, + allowedMutations: 0, + deniedMutations: 0, }; await scope.route("**/*", (route: Route) => { @@ -101,6 +155,17 @@ export async function installReadOnlyGuard( guard.allowedAnonWrites += 1; return route.abort(); } + // 뮤테이션 레인 seam(#976 F-06): allowlist 매칭 write 만 **실제 통과**(continue) — + // 오직 fail-closed 캡 안에서만. read-only 레인은 allowlist 가 비어 이 분기에 안 든다. + if (matchesMutationAllowlist(url, mutationAllowlist)) { + if (mutationPermit(guard.allowedMutations, maxMutations)) { + guard.allowedMutations += 1; + return route.continue(); // 대상 origin 뮤테이션 통과(캡 안, 명시적으로 연 경우만). + } + // 캡 초과 → fail-closed 거부: 초과 뮤테이션은 prod 에 절대 닿지 않는다. + guard.deniedMutations += 1; + return route.abort(); + } // 그 외 대상 origin 뮤테이션 = P0 안전 위반. 절대 prod 에 닿지 않게 abort + 기록. guard.violations.push({ method, url }); return route.abort(); diff --git a/packages/web/qa/report/ledger.test.ts b/packages/web/qa/report/ledger.test.ts new file mode 100644 index 00000000..9f2da659 --- /dev/null +++ b/packages/web/qa/report/ledger.test.ts @@ -0,0 +1,181 @@ +/** + * 러너 원장 유닛 테스트 — 크로스-스펙(크로스-프로세스) 집계 seam 을 결정적으로 증명한다. + * globalTeardown 은 워커와 다른 프로세스에서 돌기 때문에 모듈 전역이 아니라 **디스크 + * append-only 원장**으로 verdict 를 넘겨받는다. 여기서 그 원장 round-trip 과 + * finalizeFromLedger(공허 바닥 포함)를 네트워크·브라우저 없이 검증한다. + */ +import { + appendFileSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { makeVerdict } from "../contract/verdict"; +import { + appendLedger, + finalizeFromLedger, + readLedger, + resetLedger, +} from "./ledger"; + +const base = { + surface: "home", + technique: "deterministic-e2e" as const, + severity: "info" as const, + evidence: "test", +}; + +let dir: string; +let ledgerPath: string; +let outDir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "qa-ledger-")); + ledgerPath = join(dir, "qa-ledger.ndjson"); + outDir = join(dir, "reports"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("ledger round-trip — 크로스-스펙 집계", () => { + it("resetLedger 는 run-start 마커를 쓰고 시작 시각을 돌려준다", () => { + const startedAt = resetLedger(ledgerPath); + expect(existsSync(ledgerPath)).toBe(true); + const agg = readLedger(ledgerPath); + expect(agg.startedAt).toBe(startedAt); + expect(agg.verdicts).toEqual([]); + }); + + it("여러 스펙의 verdict + guard counts 를 append→read 로 집계한다", () => { + resetLedger(ledgerPath); + // 스펙 A + appendLedger(ledgerPath, { + kind: "verdict", + verdict: makeVerdict({ + ...base, + check: "a.reachable", + axis: "C1", + outcome: "pass", + reproducibility: "deterministic", + }), + }); + appendLedger(ledgerPath, { + kind: "guard", + blockedBeacons: 2, + allowedAnonWrites: 1, + }); + // 스펙 B + appendLedger(ledgerPath, { + kind: "verdict", + verdict: makeVerdict({ + ...base, + check: "b.a11y", + axis: "C3", + technique: "a11y-scan", + outcome: "warn", + reproducibility: "advisory", + }), + }); + appendLedger(ledgerPath, { + kind: "guard", + blockedBeacons: 3, + allowedAnonWrites: 0, + }); + + const agg = readLedger(ledgerPath); + expect(agg.verdicts.map((v) => v.check)).toEqual(["a.reachable", "b.a11y"]); + expect(agg.blockedBeacons).toBe(5); + expect(agg.allowedAnonWrites).toBe(1); + }); + + it("없는 원장 파일은 빈 집계를 돌려준다(크래시 아님)", () => { + const agg = readLedger(join(dir, "does-not-exist.ndjson")); + expect(agg.verdicts).toEqual([]); + expect(agg.blockedBeacons).toBe(0); + expect(agg.allowedAnonWrites).toBe(0); + }); + + it("깨진 줄은 조용히 건너뛴다(부분 write 내구성)", () => { + resetLedger(ledgerPath); + // 원장에 손상된 줄이 섞여도 유효 verdict 는 살린다. + appendFileSync(ledgerPath, "{not json\n", "utf-8"); + appendLedger(ledgerPath, { + kind: "verdict", + verdict: makeVerdict({ + ...base, + check: "survives", + axis: "C1", + outcome: "pass", + reproducibility: "deterministic", + }), + }); + const agg = readLedger(ledgerPath); + expect(agg.verdicts.map((v) => v.check)).toEqual(["survives"]); + }); +}); + +describe("finalizeFromLedger — 리포트 + 공허 바닥(floor)", () => { + it("verdict 가 있으면 리포트를 쓰고 게이트를 계산한다", () => { + resetLedger(ledgerPath); + appendLedger(ledgerPath, { + kind: "verdict", + verdict: makeVerdict({ + ...base, + check: "home.reachable", + axis: "C1", + outcome: "pass", + reproducibility: "deterministic", + }), + }); + const { report, written, vacuous } = finalizeFromLedger( + ledgerPath, + "https://decoded.style", + outDir + ); + expect(vacuous).toBe(false); + expect(report.gate.blocked).toBe(false); + expect(report.verdicts).toHaveLength(1); + expect(existsSync(written.jsonPath)).toBe(true); + expect(existsSync(written.mdPath)).toBe(true); + expect(readFileSync(written.mdPath, "utf-8")).toContain("PASS"); + }); + + it("빈 원장(verdict 0)은 공허 실행 — 게이트가 실패한다(false-green 금지)", () => { + resetLedger(ledgerPath); // run-start 만, verdict 없음 + const { report, vacuous } = finalizeFromLedger( + ledgerPath, + "https://decoded.style", + outDir + ); + expect(vacuous).toBe(true); + expect(report.gate.blocked).toBe(true); + }); + + it("P0 fail 이 원장에 있으면 게이트가 차단한다", () => { + resetLedger(ledgerPath); + appendLedger(ledgerPath, { + kind: "verdict", + verdict: makeVerdict({ + ...base, + check: "home.hero.h1", + axis: "C1", + outcome: "fail", + reproducibility: "deterministic", + }), + }); + const { report, vacuous } = finalizeFromLedger( + ledgerPath, + "https://decoded.style", + outDir + ); + expect(vacuous).toBe(false); + expect(report.gate.blocked).toBe(true); + expect(report.gate.blockingChecks).toEqual(["home.hero.h1"]); + }); +}); diff --git a/packages/web/qa/report/ledger.ts b/packages/web/qa/report/ledger.ts new file mode 100644 index 00000000..f1c575a9 --- /dev/null +++ b/packages/web/qa/report/ledger.ts @@ -0,0 +1,137 @@ +/** + * 러너 원장(append-only NDJSON) — 크로스-스펙 verdict 집계 seam(#908, #971 하드닝). + * + * **왜 디스크 원장인가:** finalize 를 Playwright `globalTeardown` 으로 옮기면, teardown 은 + * 워커와 **다른 프로세스**에서 돈다 → 모듈 전역(collectedVerdicts 등)이 넘어오지 않는다. + * 또 fan-out(#972~#984)에서 스펙마다 `afterAll→finalizeRun` 을 복사하면 같은 runId 리포트 + * 경로에 last-write-wins 클로버링이 생기고 `workers>1` 에서 깨진다. 해법 = 각 스펙이 결과를 + * 이 원장에 **append** 하고, globalTeardown 이 단 한 번 read→집계→finalize 한다. + * + * append 는 `O_APPEND`(appendFileSync) 라 workers>1 다중 프로세스에서도 줄 단위로 안전하다 + * (한 줄=한 write, 상호 인터리브 없음 — evidence 는 간결히 유지). + * + * 순수 fs 모듈(네트워크·Playwright 의존 없음) → 명시적 경로 인자로 vitest 검증 가능. + */ +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { type Verdict } from "../contract/verdict"; +import { type RunReport, buildReport } from "./report"; +import { type WrittenReport, resolveGitSha, writeReport } from "./write"; + +/** + * 원장 엔트리(NDJSON 한 줄). globalSetup 이 run-start 를, 각 스펙이 verdict 를, 각 테스트의 + * read-only 가드가 guard counts 를 append 한다. globalTeardown 이 읽어 집계한다. + */ +export type LedgerEntry = + | { kind: "run-start"; startedAt: string } + | { kind: "verdict"; verdict: Verdict } + | { kind: "guard"; blockedBeacons: number; allowedAnonWrites: number }; + +/** 원장 집계 결과. */ +export interface AggregatedLedger { + /** run-start 마커의 시작 시각(리포트 window 시작). */ + startedAt?: string; + /** 모든 스펙이 emit 한 verdict(append 순서 보존). */ + verdicts: Verdict[]; + /** guard 가 차단한 third-party 비콘 누계. */ + blockedBeacons: number; + /** allowlist 로 차단한 passive same-origin anon write 누계(#907). */ + allowedAnonWrites: number; +} + +function ndjson(entry: LedgerEntry): string { + return `${JSON.stringify(entry)}\n`; +} + +/** + * run 시작 시 원장을 초기화하고 run-start 마커를 쓴다(globalSetup). workers>1 에서 워커가 + * append 하기 전에 단 한 번 리셋되는 결정적 지점 — 지난 run 의 잔여 verdict 누적을 막는다. + * @returns run 시작 ISO 시각. + */ +export function resetLedger(ledgerPath: string): string { + mkdirSync(dirname(ledgerPath), { recursive: true }); + const startedAt = new Date().toISOString(); + writeFileSync(ledgerPath, ndjson({ kind: "run-start", startedAt }), "utf-8"); + return startedAt; +} + +/** 원장에 한 줄 append(append-only, O_APPEND 로 다중 프로세스 안전). */ +export function appendLedger(ledgerPath: string, entry: LedgerEntry): void { + mkdirSync(dirname(ledgerPath), { recursive: true }); + appendFileSync(ledgerPath, ndjson(entry), "utf-8"); +} + +/** + * 원장 전체를 읽어 verdict + guard counts 를 집계한다(globalTeardown). 파일이 없으면 빈 + * 집계(크래시 아님). 깨진 줄(부분 write)은 조용히 건너뛴다 — 유효 verdict 는 살린다. + */ +export function readLedger(ledgerPath: string): AggregatedLedger { + const agg: AggregatedLedger = { + verdicts: [], + blockedBeacons: 0, + allowedAnonWrites: 0, + }; + if (!existsSync(ledgerPath)) return agg; + for (const line of readFileSync(ledgerPath, "utf-8").split("\n")) { + if (!line.trim()) continue; + let entry: LedgerEntry; + try { + entry = JSON.parse(line) as LedgerEntry; + } catch { + continue; // 깨진/부분 줄 → 건너뜀. + } + if (entry.kind === "run-start") { + agg.startedAt = entry.startedAt; + } else if (entry.kind === "verdict") { + agg.verdicts.push(entry.verdict); + } else if (entry.kind === "guard") { + agg.blockedBeacons += entry.blockedBeacons; + agg.allowedAnonWrites += entry.allowedAnonWrites; + } + } + return agg; +} + +/** finalize 결과. `vacuous`=verdict 0(공허 실행) → 게이트 실패로 취급. */ +export interface FinalizeResult { + report: RunReport; + written: WrittenReport; + vacuous: boolean; +} + +/** + * 원장을 집계해 RunReport 를 만들고 JSON·마크다운을 outDir 에 쓴다. globalTeardown 의 + * **순수 코어**(유닛 테스트 대상) — Playwright 없이 finalize + 공허 바닥(floor)을 검증한다. + * + * 공허 방어는 computeGate 가 담당한다(verdict 0 → gate.blocked=true). 호출자(globalTeardown) + * 는 report.gate.blocked 로 프로세스 종료 코드를 정직하게 만든다. `vacuous` 는 "P0 fail 차단" + * 과 "안 돌았음"을 메시지에서 구분하기 위한 신호일 뿐이다. + */ +export function finalizeFromLedger( + ledgerPath: string, + baseUrl: string, + outDir: string +): FinalizeResult { + const agg = readLedger(ledgerPath); + const startedAt = agg.startedAt ?? new Date().toISOString(); + const report = buildReport({ + runId: `qa-prod-${startedAt.replace(/[:.]/g, "-")}`, + baseUrl, + startedAt, + finishedAt: new Date().toISOString(), + gitSha: resolveGitSha(), + verdicts: agg.verdicts, + ledger: { + blockedBeacons: agg.blockedBeacons, + allowedAnonWrites: agg.allowedAnonWrites, + }, + }); + const written = writeReport(report, outDir); + return { report, written, vacuous: agg.verdicts.length === 0 }; +} diff --git a/packages/web/qa/report/report.ts b/packages/web/qa/report/report.ts index ead4794a..44742cdd 100644 --- a/packages/web/qa/report/report.ts +++ b/packages/web/qa/report/report.ts @@ -93,9 +93,13 @@ const OUTCOME_ICON: Record = { /** 마크다운 요약(사람 판독용). 게이트 headline + verdict 표. */ export function toMarkdown(report: RunReport): string { const { gate } = report; - const headline = gate.blocked - ? `## ❌ BLOCKED — P0 실패 ${gate.blockingChecks.length}건` - : `## ✅ PASS — P0 차단 없음`; + // 공허(빈 verdict) 실행은 "돌지 않았음" — P0 fail(blockingChecks 존재)과 구분해 렌더한다. + const headline = + report.verdicts.length === 0 + ? `## ❌ NO CHECKS RAN — 게이트 실패(공허 통과 차단, 저니 누락)` + : gate.blocked + ? `## ❌ BLOCKED — P0 실패 ${gate.blockingChecks.length}건` + : `## ✅ PASS — P0 차단 없음`; const counts = report.verdicts.reduce>((acc, v) => { acc[v.outcome] = (acc[v.outcome] ?? 0) + 1;