commitgate 0.9.6 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,813 @@
1
+ /**
2
+ * 승인 증거(evidence) 정본 — `approvals.jsonl` 매니페스트 모델·검증과 그 보조 술어의 **단일 지점** (REQ-2026-048 DEC-1).
3
+ *
4
+ * **왜 별도 모듈인가**: design 승인 경로(`review-codex`)와 커밋 경로(`req-commit`)가 **같은 증거 내구화 로직**을
5
+ * 써야 하는데, 기존엔 그 로직이 `req-commit`에 있고 `req-commit → review-codex` 방향 import가 이미 있었다.
6
+ * `review-codex`가 `req-commit`을 부르면 **런타임 순환**이 된다. 그래서 공통 부분을 leaf로 내린다.
7
+ *
8
+ * 🔴 **이 파일은 leaf여야 한다.** 런타임 import는 `./scratch`(그 자신도 leaf) 하나뿐이고, `review-codex`에서는
9
+ * **타입만**(`import type` — 컴파일 시 소거) 가져온다. 여기에 `../review-codex`·`../req-doctor`·`../req-commit`의
10
+ * **값(런타임) import를 추가하는 순간 순환이 되살아난다** — `tests/unit/evidence-module.test.ts`가 이를 고정한다.
11
+ *
12
+ * ⚠️ 원래 위치에서 **이동**해 온 것들이며 동작은 바뀌지 않았다. 기존 호출부·테스트가 깨지지 않도록
13
+ * `review-codex`(`archiveBaseName`·`isValidIsoInstant`)·`req-doctor`(`isConfinedArchivePath`)·
14
+ * `req-commit`(나머지)이 각각 **re-export**한다.
15
+ */
16
+ import { isArchiveFileName } from './scratch'
17
+ import type { ApprovalEvidence, ReviewKind } from '../review-codex'
18
+
19
+ // ─────────────────────────────────────────────────────── 공통 형식 술어 ──
20
+
21
+ const SHA256_RE = /^[0-9a-f]{64}$/i
22
+ const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
23
+ const REVIEW_ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/
24
+
25
+ function escapeRegExp(s: string): string {
26
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27
+ }
28
+
29
+ /**
30
+ * ISO instant 유효성(REQ-2026-028 D2). **형식 + 달력 유효성 둘 다**(design-r02·r03).
31
+ * `REVIEW_ISO_RE`만으론 `2026-99-99T99:99:99Z`가 통과하므로, 재파싱해 성분(연·월·일·시·분·초)이 보존되는지 확인.
32
+ * 밀리초 표기 차(`08Z` vs `08.000Z`)는 비교에서 무시 — 성분이 맞으면 유효.
33
+ */
34
+ export function isValidIsoInstant(s: unknown): boolean {
35
+ if (typeof s !== 'string' || !REVIEW_ISO_RE.test(s)) return false
36
+ const d = new Date(s)
37
+ if (Number.isNaN(d.getTime())) return false
38
+ // 재직렬화 후 초까지 성분 비교(밀리초 절단). `2026-99-99...`는 여기서 불일치로 걸린다.
39
+ const canon = (x: string): string => x.replace(/\.\d+Z$/, 'Z').replace(/Z$/, '')
40
+ return canon(d.toISOString()) === canon(s)
41
+ }
42
+
43
+ /** 아카이브 base(round namespace): design은 'design'(phaseId 무시), phase는 phaseId(없으면 'phase'=레거시). */
44
+ export function archiveBaseName(kind: ReviewKind, phaseId: string | null): string {
45
+ return kind === 'design' ? 'design' : phaseId && phaseId.length > 0 ? phaseId : 'phase'
46
+ }
47
+
48
+ /**
49
+ * evidence `response_path`가 **현재 티켓 `responses/` 직계 아카이브**인지(D-016 confinement).
50
+ * 절대경로·`..`·다른 티켓·중첩경로·`approvals.jsonl` 등 비아카이브는 거부. ticketRel 미지정 시 false(fail-closed).
51
+ */
52
+ export function isConfinedArchivePath(p: string, ticketRel: string | undefined): boolean {
53
+ if (!ticketRel || typeof p !== 'string' || !p) return false
54
+ const norm = p.replace(/\\/g, '/')
55
+ if (norm.includes('..') || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm)) return false
56
+ const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
57
+ if (!norm.startsWith(prefix)) return false
58
+ const name = norm.slice(prefix.length)
59
+ if (!name || name.includes('/')) return false
60
+ return isArchiveFileName(name)
61
+ }
62
+
63
+ // ───────────────────────────────── approvals.jsonl 매니페스트 모델 (B1) ──
64
+
65
+ /** HIGH 사람확인 감사 기록(암호학적 증명 아님 — D-016-8). */
66
+ export interface UserCommitConfirmed {
67
+ confirmed: boolean
68
+ method: string
69
+ confirmed_at: string
70
+ note?: string
71
+ }
72
+
73
+ /**
74
+ * 이 승인에 이르는 라운드 아카이브 1건(REQ-2026-048 DEC-2).
75
+ *
76
+ * **왜 파일명 sweep이 아니라 목록인가**: 디스크 스캔은 실행 시점 디렉터리 상태에 의존해 **재현 불가**다
77
+ * (나중에 파일이 늘거나 지워지면 결과가 달라진다). 경로+sha로 매니페스트에 박으면 사후 감사에서
78
+ * **재검증**할 수 있고, DONE 게이트가 이 목록을 그대로 오라클로 쓴다.
79
+ */
80
+ export interface ArchiveInventoryItem {
81
+ response_path: string
82
+ sha256: string
83
+ }
84
+
85
+ /** approvals.jsonl 한 줄(D-016-3b). kind 격리: phase=approved_tree, design=design_hash. */
86
+ export interface ManifestEntry {
87
+ kind: ReviewKind
88
+ phase_id: string | null
89
+ response_path: string
90
+ response_sha256: string
91
+ review_base_sha: string
92
+ approved_tree?: string
93
+ design_hash?: string
94
+ /**
95
+ * 🔴 REQ-2026-052 DEC-B5(phase-3a2): **phase 전용** — 이 phase가 승인 시점에 결속된 committed design 참조
96
+ * (= `captureDesignBinding().designHash`, design 행 `design_hash`와 동일 계산·값). dev-complete 완전성이
97
+ * 이 값 == 현재 committed design_ref인 phase 행만 산입한다(design-blind면 D1 검토분이 D2 완료에 샌다).
98
+ *
99
+ * **선택 필드**다: 부재(레거시·이 보정 이전 커밋분)해도 매니페스트 검증은 통과한다(무회귀). 단 durable 완료
100
+ * 판정에서는 부재를 **불산입(fail-closed)** — 검증의 관대함과 완료의 엄격함을 분리(`archive_inventory`와 동형).
101
+ * design 행에는 **금지**(kind 격리). 미지정 시 키 자체를 넣지 않아 기존 phase 행과 바이트 동일.
102
+ */
103
+ phase_design_ref?: string
104
+ approved_at: string
105
+ consumed_at: string
106
+ consumed_by_commit_sha: string
107
+ user_commit_confirmed: UserCommitConfirmed | null
108
+ /**
109
+ * REQ-2026-048 DEC-2 — 이 승인 시점의 라운드 아카이브 전부(needs-fix 포함, 승인본 자기 자신 포함).
110
+ *
111
+ * **선택 필드**다: 부재해도 매니페스트 검증은 통과한다(기존 행 무회귀). 단 내구성 marker가 켜진 신규
112
+ * 티켓에서는 DONE 게이트가 design 행의 이 필드 부재를 **BLOCKED**로 본다 — 검증의 관대함(legacy 호환)과
113
+ * 완료 판정의 엄격함(신규)을 분리한다.
114
+ *
115
+ * 현재 **design 행만 채운다**. phase 경로는 `expectedArchivePaths`가 이미 needs-fix까지 stage하므로
116
+ * 인벤토리가 필요 없다. 다만 phase 행에서 이 필드를 *금지*하지는 않는다 — 새 금지 규칙은 설계 범위 밖이고,
117
+ * 형식 검증은 kind와 무관하게 동일하게 적용된다.
118
+ */
119
+ archive_inventory?: ArchiveInventoryItem[]
120
+ }
121
+
122
+ /** approvals.jsonl 엔트리 허용 top-level 키(이 외 = 주입/오염 → fail). */
123
+ const MANIFEST_KEYS = new Set([
124
+ 'kind',
125
+ 'phase_id',
126
+ 'response_path',
127
+ 'response_sha256',
128
+ 'review_base_sha',
129
+ 'approved_tree',
130
+ 'design_hash',
131
+ 'phase_design_ref', // REQ-2026-052 DEC-B5(선택 — phase 전용·부재해도 유효)
132
+ 'approved_at',
133
+ 'consumed_at',
134
+ 'consumed_by_commit_sha',
135
+ 'user_commit_confirmed',
136
+ 'archive_inventory', // REQ-2026-048 DEC-2(선택 — 부재해도 유효)
137
+ ])
138
+
139
+ /**
140
+ * 이 승인의 아카이브 인벤토리 산출(순수 — sha 계산은 주입).
141
+ *
142
+ * **수집 범위(결정적 정의)**: 현재 티켓 `responses/` **직계**의 `kind` 아카이브 **전부**
143
+ * (`archiveBaseName(kind, phaseId)` 매처 — `-approved`·`-needs-fix` 모두). round(rNN) **오름차순**으로 정렬해
144
+ * 디렉터리 읽기 순서에 비의존하게 만든다(`expectedArchivePaths`와 동일 기법).
145
+ *
146
+ * **재승인 시**: 그 시점의 전부를 다시 담으므로 이전 라운드를 포함한다. 각 행이 "그 승인 시점의 완전한 상태"라는
147
+ * 의미로 일관되고, DONE 게이트는 **가장 마지막 design 행**을 본다. stale 아카이브를 골라내는 휴리스틱은 두지 않는다
148
+ * (재현 불가능해진다).
149
+ *
150
+ * @param shaOf repo-상대 경로 → sha256(hex). 호출부가 파일을 읽어 주입(이 모듈은 fs를 모른다).
151
+ */
152
+ export function buildArchiveInventory(
153
+ archiveNames: string[],
154
+ kind: ReviewKind,
155
+ phaseId: string | null,
156
+ ticketRel: string,
157
+ shaOf: (repoRelPath: string) => string,
158
+ ): ArchiveInventoryItem[] {
159
+ return expectedArchivePaths(archiveNames, kind, phaseId, ticketRel).map((p) => ({
160
+ response_path: p,
161
+ sha256: shaOf(p),
162
+ }))
163
+ }
164
+
165
+ /**
166
+ * design evidence 커밋에 stage할 repo-상대 경로(순수, 결정적).
167
+ *
168
+ * = **인벤토리 전량**(needs-fix 포함) + 승인본 + `approvals.jsonl`. 중복은 제거하고 순서는 입력 순서를 따른다.
169
+ * 승인본은 정상 경로에서 인벤토리에 이미 들어 있지만, 인벤토리가 비는 이례적 상황(디렉터리 조회 실패 등)에서도
170
+ * **최소한 승인 증거는 남도록** 명시적으로 합류시킨다.
171
+ *
172
+ * 🔴 `approvals.jsonl` 외에는 **티켓 `responses/` 밖 경로가 절대 섞이지 않는다** — 호출부의 leak 가드
173
+ * (`responses/` 외 staged 금지)와 이중으로, 무관한 index 변경이 evidence 커밋에 딸려 들어가지 못하게 한다.
174
+ */
175
+ export function designEvidenceStagePaths(
176
+ inventory: readonly ArchiveInventoryItem[],
177
+ responsePath: string,
178
+ ticketRel: string,
179
+ /**
180
+ * 리뷰 원장이 디스크에 **존재하는가**(REQ-2026-051 D7). 존재할 때만 pathspec에 합류시킨다 —
181
+ * 없는 경로를 넣으면 `commitPaths`가 실패해 승인 증거 커밋 **전체**가 무산된다(원장 때문에 승인
182
+ * 증거를 잃는 것은 본말전도다).
183
+ * 생략 가능: 기존 3-arg 호출부를 깨지 않는다.
184
+ */
185
+ ledgerExists = false,
186
+ ): string[] {
187
+ const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
188
+ const archives = [...inventory.map((i) => i.response_path), responsePath].filter(
189
+ (p) => typeof p === 'string' && p.length > 0 && isConfinedArchivePath(p, ticketRel),
190
+ )
191
+ const tail = ledgerExists ? [`${dir}/approvals.jsonl`, `${dir}/review-ledger.jsonl`] : [`${dir}/approvals.jsonl`]
192
+ return [...new Set([...archives, ...tail])]
193
+ }
194
+
195
+ /** 인벤토리 항목의 형식 문제 목록(순수). 빈 배열 = 정상. `line N: ` 접두는 호출부가 붙인다. */
196
+ function archiveInventoryProblems(inv: unknown, ticketRel: string): string[] {
197
+ const out: string[] = []
198
+ if (!Array.isArray(inv)) return ['archive_inventory가 배열 아님']
199
+ const seen = new Set<string>()
200
+ for (let i = 0; i < inv.length; i++) {
201
+ const it = inv[i] as { response_path?: unknown; sha256?: unknown } | null
202
+ const at = `archive_inventory[${i}]`
203
+ if (!it || typeof it !== 'object' || Array.isArray(it)) {
204
+ out.push(`${at}: object 아님`)
205
+ continue
206
+ }
207
+ for (const k of Object.keys(it)) if (k !== 'response_path' && k !== 'sha256') out.push(`${at}: 예상 외 필드: ${k}`)
208
+ const p = typeof it.response_path === 'string' ? it.response_path : ''
209
+ // ⚠️ 인벤토리는 **needs-fix 이름을 허용**한다(라운드 전체 보존이 목적). 행 최상위 `response_path`의
210
+ // "-approved.json만" 규칙과는 의미가 다르다 — 그 규칙은 여기 적용하지 않는다.
211
+ if (!isConfinedArchivePath(p, ticketRel)) out.push(`${at}: response_path 비confined: ${p}`)
212
+ else if (seen.has(p)) out.push(`${at}: 중복 response_path: ${p}`)
213
+ else seen.add(p)
214
+ if (typeof it.sha256 !== 'string' || !SHA256_RE.test(it.sha256)) out.push(`${at}: sha256 형식 오류(64hex)`)
215
+ }
216
+ return out
217
+ }
218
+
219
+ /**
220
+ * user_commit_confirmed 감사 기록 형식 검증(순수). 유효하면 null, 아니면 사유.
221
+ * 요구: confirmed===true · method(공백 아닌 문자열) · confirmed_at(ISO).
222
+ * ⚠️ 위조불가 증명이 아니다 — Claude가 생성 가능한 플래그. 가장 강한 보장 = 사용자가 직접 `req:commit` 실행.
223
+ */
224
+ export function userConfirmProblem(ucc: unknown): string | null {
225
+ if (!ucc || typeof ucc !== 'object') return '기록 없음'
226
+ const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
227
+ if (c.confirmed !== true) return 'confirmed=true 아님'
228
+ if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
229
+ if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
230
+ return null
231
+ }
232
+
233
+ /**
234
+ * 승인 증거(state pin)와 소비 정보로 매니페스트 엔트리 생성(순수, 고정 필드·키 순서).
235
+ * kind 격리: phase→approved_tree만, design→design_hash만(반대 kind 필드 미포함).
236
+ */
237
+ export function buildManifestEntry(
238
+ ev: ApprovalEvidence,
239
+ consume: {
240
+ consumedAt: string
241
+ consumedByCommitSha: string
242
+ userCommitConfirmed: UserCommitConfirmed | null
243
+ /** REQ-2026-048 DEC-2. 지정 시에만 행에 포함(미지정 = 필드 자체 부재 → 기존 행과 바이트 동일). */
244
+ archiveInventory?: ArchiveInventoryItem[]
245
+ },
246
+ ): ManifestEntry {
247
+ const base: ManifestEntry = {
248
+ kind: ev.review_kind,
249
+ phase_id: ev.phase_id ?? null,
250
+ response_path: ev.response_path,
251
+ response_sha256: ev.response_sha256,
252
+ review_base_sha: ev.review_base_sha,
253
+ approved_at: ev.approved_at,
254
+ consumed_at: consume.consumedAt,
255
+ consumed_by_commit_sha: consume.consumedByCommitSha,
256
+ user_commit_confirmed: consume.userCommitConfirmed,
257
+ }
258
+ // 미지정이면 키 자체를 넣지 않는다 — 기존 행과 바이트 동일(하위호환).
259
+ const inv = consume.archiveInventory ? { archive_inventory: consume.archiveInventory } : {}
260
+ // fail-fast: kind별 필수 바인딩 필드(phase=approved_tree, design=design_hash). 반대 kind 필드는 미포함.
261
+ if (ev.review_kind === 'design') {
262
+ const designHash = ev.design_hash
263
+ if (typeof designHash !== 'string' || !designHash)
264
+ throw new Error('buildManifestEntry: design evidence에 design_hash 누락(fail-fast)')
265
+ return { ...base, phase_id: null, design_hash: designHash, ...inv }
266
+ }
267
+ const approvedTree = ev.approved_tree
268
+ if (typeof approvedTree !== 'string' || !approvedTree)
269
+ throw new Error('buildManifestEntry: phase evidence에 approved_tree 누락(fail-fast)')
270
+ // REQ-2026-052 DEC-B5: phase_design_ref(승인 시점 design 결속)가 있으면 phase 행에 포함. 미지정이면
271
+ // 키 자체를 넣지 않는다 — 레거시 phase 행과 바이트 동일(무회귀). durable 티켓은 designValid 게이트가
272
+ // 승인 전제라 정상 경로에서 항상 채워지고, 완료 판정이 이 값으로 design-bound 필터한다.
273
+ const pdr = typeof ev.phase_design_ref === 'string' && ev.phase_design_ref ? { phase_design_ref: ev.phase_design_ref } : {}
274
+ return { ...base, approved_tree: approvedTree, ...pdr, ...inv }
275
+ }
276
+
277
+ /** 매니페스트 한 줄 직렬화(JSONL): JSON + 끝 개행. 고정 키 순서라 deterministic. */
278
+ export function serializeManifestLine(entry: ManifestEntry): string {
279
+ return `${JSON.stringify(entry)}\n`
280
+ }
281
+
282
+ /**
283
+ * approvals.jsonl 내용 검증(순수, fail-closed). 문제 목록 반환(빈 배열=정상).
284
+ * 검사: malformed JSONL · response_path confinement(현재 티켓 responses/ 직계) · SHA-256 형식 ·
285
+ * phase kind의 phase_id 유효성 · (kind,phase_id,sha) 중복/주입.
286
+ */
287
+ export function validateManifest(content: string, opts: { ticketRel: string; validPhaseIds: string[] }): string[] {
288
+ const problems: string[] = []
289
+ const seenKey = new Set<string>()
290
+ const seenPath = new Set<string>()
291
+ const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
292
+ for (let i = 0; i < lines.length; i++) {
293
+ const ln = i + 1
294
+ let e: Record<string, unknown>
295
+ try {
296
+ e = JSON.parse(lines[i] as string) as Record<string, unknown>
297
+ } catch {
298
+ problems.push(`line ${ln}: malformed JSON`)
299
+ continue
300
+ }
301
+ if (!e || typeof e !== 'object' || Array.isArray(e)) {
302
+ problems.push(`line ${ln}: object 아님`)
303
+ continue
304
+ }
305
+ // 예상 외 extra field 금지(주입 차단).
306
+ for (const k of Object.keys(e)) if (!MANIFEST_KEYS.has(k)) problems.push(`line ${ln}: 예상 외 필드: ${k}`)
307
+ const kind = e.kind
308
+ if (kind !== 'phase' && kind !== 'design') problems.push(`line ${ln}: kind 비유효: ${String(kind)}`)
309
+ // 공통: 경로 confinement, sha/OID/ISO 형식.
310
+ const respPath = typeof e.response_path === 'string' ? e.response_path : ''
311
+ if (!isConfinedArchivePath(respPath, opts.ticketRel)) problems.push(`line ${ln}: response_path 비confined: ${respPath}`)
312
+ // B1-P2-1: manifest 행(=소비된 승인)의 response_path basename은 그 행의 kind/phase_id **승인본**(-approved)이어야.
313
+ // (design→phase 아카이브·타 phase·needs-fix 가리키는 주입/변조 차단. expectedArchivePaths[chore 대상]는 needs-fix 포함 별개.)
314
+ if (kind === 'phase' || kind === 'design') {
315
+ const expBase = archiveBaseName(kind, kind === 'phase' && typeof e.phase_id === 'string' ? e.phase_id : null)
316
+ const name = respPath.split('/').pop() ?? ''
317
+ if (!new RegExp(`^${escapeRegExp(expBase)}-r\\d{2,}-approved\\.json$`).test(name))
318
+ problems.push(`line ${ln}: response_path가 ${expBase}-rNN-approved.json 아님: ${name}`)
319
+ }
320
+ if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
321
+ if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
322
+ if (typeof e.consumed_by_commit_sha !== 'string' || !GIT_OID_RE.test(e.consumed_by_commit_sha)) problems.push(`line ${ln}: consumed_by_commit_sha 비-OID`)
323
+ if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
324
+ if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
325
+ // kind별 strict 바인딩(반대 kind 필드 금지).
326
+ if (kind === 'phase') {
327
+ if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
328
+ problems.push(`line ${ln}: phase_id 비유효: ${String(e.phase_id)}`)
329
+ if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree 비-OID`)
330
+ if ('design_hash' in e) problems.push(`line ${ln}: phase entry에 design_hash 금지`)
331
+ // REQ-2026-052 DEC-B5: phase_design_ref는 **선택**(부재=레거시 무회귀). 있으면 64hex(design_hash와 동형).
332
+ if ('phase_design_ref' in e && (typeof e.phase_design_ref !== 'string' || !SHA256_RE.test(e.phase_design_ref)))
333
+ problems.push(`line ${ln}: phase_design_ref 비-64hex`)
334
+ } else if (kind === 'design') {
335
+ if (e.phase_id !== null) problems.push(`line ${ln}: design entry는 phase_id=null이어야`)
336
+ if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash 비-64hex`)
337
+ if ('approved_tree' in e) problems.push(`line ${ln}: design entry에 approved_tree 금지`)
338
+ // REQ-2026-052 DEC-B5: kind 격리 — design 행에는 phase_design_ref 금지.
339
+ if ('phase_design_ref' in e) problems.push(`line ${ln}: design entry에 phase_design_ref 금지`)
340
+ }
341
+ // archive_inventory(REQ-2026-048 DEC-2): **선택** — 부재는 정상(기존 행 무회귀). 있으면 형식 검증.
342
+ if ('archive_inventory' in e) {
343
+ for (const p of archiveInventoryProblems(e.archive_inventory, opts.ticketRel)) problems.push(`line ${ln}: ${p}`)
344
+ }
345
+ // user_commit_confirmed: null 또는 유효 감사 기록(confirmed=true·method·ISO confirmed_at)만. (B2-block3)
346
+ const ucc = e.user_commit_confirmed
347
+ if (ucc !== null) {
348
+ const p = userConfirmProblem(ucc)
349
+ if (p) problems.push(`line ${ln}: user_commit_confirmed ${p}(null 또는 confirmed=true·method·ISO confirmed_at)`)
350
+ }
351
+ // 중복: (kind/phase/sha) + response_path 두 기준 모두.
352
+ const key = `${String(kind)}:${String(e.phase_id)}:${String(e.response_sha256)}`
353
+ if (seenKey.has(key)) problems.push(`line ${ln}: 중복 entry(kind/phase/sha): ${key}`)
354
+ seenKey.add(key)
355
+ if (respPath) {
356
+ if (seenPath.has(respPath)) problems.push(`line ${ln}: 중복 response_path: ${respPath}`)
357
+ seenPath.add(respPath)
358
+ }
359
+ }
360
+ return problems
361
+ }
362
+
363
+ /**
364
+ * 이번 target(kind/phaseId)의 **예상 응답 아카이브 repo-경로만** 반환(blanket `git add responses/` 금지).
365
+ * archiveNames(responses/ 디렉터리 파일명)에서 해당 base의 rNN 아카이브만 필터 → 현재 티켓 responses/ 경로로.
366
+ * needs-fix + approved 모두 포함(evidence chore는 실패 라운드까지 영속화) — 매니페스트 행 검증(approved만)과 별개.
367
+ */
368
+ export function expectedArchivePaths(
369
+ archiveNames: string[],
370
+ kind: ReviewKind,
371
+ phaseId: string | null,
372
+ ticketRel: string,
373
+ ): string[] {
374
+ const base = archiveBaseName(kind, phaseId)
375
+ const re = new RegExp(`^${escapeRegExp(base)}-r(\\d{2,})-(approved|needs-fix)\\.json$`)
376
+ const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
377
+ // readdir 순서 비의존 — round(rNN) 오름차순 정렬(deterministic).
378
+ return archiveNames
379
+ .map((n) => ({ n, m: isArchiveFileName(n) ? re.exec(n) : null }))
380
+ .filter((x): x is { n: string; m: RegExpExecArray } => x.m !== null)
381
+ .sort((a, b) => Number.parseInt(a.m[1] ?? '0', 10) - Number.parseInt(b.m[1] ?? '0', 10))
382
+ .map((x) => `${dir}/${x.n}`)
383
+ }
384
+
385
+ /** 매니페스트에서 이 evidence identity(kind/phase_id/response_sha256)의 행을 찾는다(순수). 없으면 null. */
386
+ export function findEvidenceRow(
387
+ content: string,
388
+ identity: { kind: ReviewKind; phaseId: string | null; responseSha256: string },
389
+ ): ManifestEntry | null {
390
+ for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
391
+ try {
392
+ const e = JSON.parse(line) as ManifestEntry
393
+ if (e && typeof e === 'object' && e.kind === identity.kind && (e.phase_id ?? null) === identity.phaseId && e.response_sha256 === identity.responseSha256)
394
+ return e
395
+ } catch {
396
+ // malformed 줄 무시(무결성은 validateManifest 담당)
397
+ }
398
+ }
399
+ return null
400
+ }
401
+
402
+ // ─────────────────── 매니페스트 순수 파서 (REQ-2026-052 phase-3b: req-commit에서 이동) ──
403
+ // req-commit(command)과 intake 스캔(leaf)이 같은 파서를 공유하도록 매니페스트 모델의 정본인 여기로 옮겼다.
404
+ // req-commit이 기존 경로로 re-export한다(호출부 호환).
405
+
406
+ /** 매니페스트(JSONL) 본문에서 안전하게 엔트리 배열을 뽑는다(파싱 불가 행은 건너뛴다 — 검증은 별도). */
407
+ export function parseManifestEntries(content: string): Array<Record<string, unknown>> {
408
+ const out: Array<Record<string, unknown>> = []
409
+ for (const line of content.split('\n')) {
410
+ if (line.trim() === '') continue
411
+ try {
412
+ const o = JSON.parse(line)
413
+ if (o && typeof o === 'object' && !Array.isArray(o)) out.push(o as Record<string, unknown>)
414
+ } catch {
415
+ /* skip */
416
+ }
417
+ }
418
+ return out
419
+ }
420
+
421
+ /**
422
+ * 매니페스트에서 커밋된 **phase 증거**가 있는 phase id 집합(consumed phase 엔트리).
423
+ *
424
+ * 🔴 REQ-2026-052 DEC-B5(phase-3a2): `designRef`를 주면 **design-bound** — 그 phase 행의 `phase_design_ref`가
425
+ * `designRef`와 **일치**하는 것만 산입한다. dev-complete 완전성이 이 필터를 써야 D1에서 검토된 phase가 D2
426
+ * 재승인 후 D2 완료 증명에 새어들지 않는다. `phase_design_ref` 부재 행(레거시·보정 이전)은 **불산입**(fail-closed).
427
+ * `designRef` 미지정(하위호환)이면 결속 무관 전량(옛 동작) — 신규 완료 경로는 항상 designRef를 준다.
428
+ */
429
+ export function evidencedPhaseIdsFromManifest(content: string, designRef?: string | null): string[] {
430
+ return parseManifestEntries(content)
431
+ .filter((e) => e.kind === 'phase' && typeof e.phase_id === 'string')
432
+ .filter((e) => (designRef == null ? true : e.phase_design_ref === designRef))
433
+ .map((e) => e.phase_id as string)
434
+ }
435
+
436
+ /** 매니페스트에서 커밋된 design 승인의 design_hash(가장 마지막 design 엔트리). 없으면 null. */
437
+ export function designHashFromManifest(content: string): string | null {
438
+ const designs = parseManifestEntries(content).filter((e) => e.kind === 'design' && typeof e.design_hash === 'string')
439
+ return designs.length ? (designs[designs.length - 1]!.design_hash as string) : null
440
+ }
441
+
442
+ // ───────── phase 승인 archive 무결성 (REQ-2026-052 DEC-B6·phase-3b2) — 공유 leaf 모듈 ──
443
+
444
+ export interface PhaseArchiveProblem {
445
+ phaseId: string
446
+ responsePath: string
447
+ reason: 'missing' | 'sha-mismatch'
448
+ }
449
+
450
+ /**
451
+ * 🔴 phase 승인 **archive blob 무결성** 검증(순수 + `headBlobSha256` 포트, DEC-B6). intake(`scanTicketIntake`)와
452
+ * req:commit 발행 후 verifier(`verifyDevCompleteAtHead`)가 **이 한 모듈을 공유**한다 → 두 경로의 phase archive
453
+ * 규칙이 갈라질 수 없다(요구 #3).
454
+ *
455
+ * 각 phase manifest 행에 대해: `response_path` blob이 **HEAD에 존재**하고(`headBlobSha256` non-null) 그
456
+ * sha256이 `response_sha256`과 **일치**하는지 확인한다. archive를 삭제하면 `missing`, 바이트를 바꾸면
457
+ * `sha-mismatch` — 둘 다 archive 손상(corrupt)이다. manifest 행 형식(`response_path`/`response_sha256`)은 여기서
458
+ * 재검증하지 않는다(그건 `validateManifest` 몫) — 형식이 이미 통과한 행의 **blob 정합**만 본다.
459
+ *
460
+ * 🔴 **강한 정책(요구 #1 우선안)**: `onlyDesignRef`를 주지 않으면 **모든** phase 행을 검증한다(inventory 한정 아님).
461
+ * 감사 내구성상 재승인 이전 라운드 행의 archive까지 온전해야 한다. `onlyDesignRef` 지정 시 그 design_ref에
462
+ * 결속된 phase 행만(부분 검증 — 필요 시).
463
+ *
464
+ * 🔴 on-disk·워킹트리를 절대 읽지 않는다 — `headBlobSha256`(HEAD blob 바이트 sha)만. leaf라 git을 직접 모른다.
465
+ */
466
+ export function verifyPhaseArchives(
467
+ content: string,
468
+ headBlobSha256: (repoRel: string) => string | null,
469
+ onlyDesignRef?: string | null,
470
+ ): PhaseArchiveProblem[] {
471
+ const problems: PhaseArchiveProblem[] = []
472
+ for (const e of parseManifestEntries(content)) {
473
+ if (e.kind !== 'phase' || typeof e.phase_id !== 'string' || e.phase_id === '') continue
474
+ if (onlyDesignRef != null && e.phase_design_ref !== onlyDesignRef) continue
475
+ const responsePath = typeof e.response_path === 'string' ? e.response_path : ''
476
+ const expectedSha = typeof e.response_sha256 === 'string' ? e.response_sha256 : ''
477
+ if (!responsePath || !expectedSha) continue // 형식 문제는 validateManifest가 corrupt로 잡는다(중복 판정 안 함).
478
+ const actual = headBlobSha256(responsePath)
479
+ if (actual === null) problems.push({ phaseId: e.phase_id, responsePath, reason: 'missing' })
480
+ else if (actual !== expectedSha) problems.push({ phaseId: e.phase_id, responsePath, reason: 'sha-mismatch' })
481
+ }
482
+ return problems
483
+ }
484
+
485
+ // ─────────────────────────── design evidence 내구화 (REQ-2026-048 DEC-3) ──
486
+
487
+ /**
488
+ * 이 모듈이 부수효과를 내기 위해 쓰는 **주입 포트**. `lib/evidence`는 fs·git을 직접 모른다
489
+ * (leaf 불변식 유지 + 실패 주입 테스트 가능).
490
+ */
491
+ export interface EvidencePorts {
492
+ /** 온디스크 텍스트(없으면 null). */
493
+ readText(repoRel: string): string | null
494
+ writeText(repoRel: string, content: string): void
495
+ /** 티켓 `responses/` 디렉터리의 아카이브 파일명 목록. */
496
+ listArchiveNames(): string[]
497
+ /** 온디스크 파일 바이트의 sha256(hex). */
498
+ sha256(repoRel: string): string
499
+ /** `HEAD`의 blob 텍스트(없으면 null). JSONL 파싱 전용 — 바이트 정합 비교엔 쓰지 않는다. */
500
+ headText(repoRel: string): string | null
501
+ /**
502
+ * 🔴 `HEAD` blob **바이트**의 sha256(없으면 null).
503
+ * 워킹 파일로 계산하면 `core.autocrlf` 환경에서 CRLF↔LF 차이로 **거짓 불일치**가 난다 —
504
+ * 반드시 blob 바이트로 계산해야 커밋 이력과 기록된 sha가 맞는다.
505
+ */
506
+ headBlobSha256(repoRel: string): string | null
507
+ /**
508
+ * `HEAD`에 존재하는 해당 디렉터리의 아카이브 **repo-상대 경로** 목록(REQ-2026-049 DEC-4).
509
+ *
510
+ * 🔴 **basename이 아니라 전체 경로**를 반환한다 — 인벤토리의 `response_path`와 같은 단위여야 집합 비교가
511
+ * 모호해지지 않는다(하위 디렉터리를 허용하게 되어도 동명 파일 충돌이 생기지 않는다).
512
+ * 🔴 **워킹 디렉터리를 읽지 않는다.** 워킹 트리만 고치고 HEAD는 손상된 경우를 잡는 것이 이 검사의 목적이다.
513
+ */
514
+ headArchivePaths(responsesDirRel: string): string[]
515
+ /** 현재 `HEAD` 커밋 SHA. */
516
+ headCommitSha(): string
517
+ /**
518
+ * 🔴 **지정한 경로만** 커밋한다(pathspec 범위). 나머지 index는 **그대로 보존**된다.
519
+ *
520
+ * 전체 index를 커밋하거나 "staged 전체"를 leak으로 판정하면 안 된다 — design 리뷰는 **index의 설계 문서**를
521
+ * 대상으로 돌 수 있으므로, 설계 문서를 stage한 채 승인하는 것이 정상 경로다. 그 상태에서 index 전체를 보면
522
+ * 자동 내구화가 **항상 실패**하고(호출부가 삼켜 승인만 남음) `--finalize-design`도 같은 가드로 실패해
523
+ * 증거가 영원히 커밋되지 않는다(phase-3 리뷰 P1).
524
+ */
525
+ commitPaths(paths: string[], message: string): void
526
+ }
527
+
528
+ /** 내구화 결과. `already-durable`=진짜 no-op, `committed`=신규 기록, `recommitted`=부분 상태 복구. */
529
+ export type DurableOutcome = 'already-durable' | 'committed' | 'recommitted'
530
+
531
+ /**
532
+ * 승인된 **design evidence를 내구화한다** — 호출자가 아는 것은 이 한 문장뿐이다(DEC-1).
533
+ * 매니페스트 형식·stage 목록·멱등 판정은 전부 이 안에 있고, 정상 승인 경로(`review-codex`)와
534
+ * 복구 경로(`req:commit --finalize-design`)가 **같은 구현**을 부른다 → 동작이 갈라질 수 없다.
535
+ *
536
+ * 🔴 **멱등은 온디스크가 아니라 `HEAD` 기준이다**(DEC-3, design r01 P1-2).
537
+ *
538
+ * | 온디스크 엔트리 | HEAD에 내구화됨 | 동작 |
539
+ * |---|---|---|
540
+ * | 없음 | — | append → stage → commit (`committed`) |
541
+ * | 있음 | 예 | 진짜 no-op (`already-durable`) |
542
+ * | 있음 | 아니오 | **append 없이** stage → commit 재시도 (`recommitted`) |
543
+ *
544
+ * 온디스크 엔트리 존재만으로 skip하면, 매니페스트 append·stage까지 되고 `git commit`만 실패한
545
+ * 부분 상태에서 재시도가 **영구히 skip**되어 HEAD 증거를 결코 복구하지 못한다.
546
+ */
547
+ export function durableDesignEvidence(args: {
548
+ ticketId: string
549
+ ticketRel: string
550
+ evidence: ApprovalEvidence
551
+ validPhaseIds: string[]
552
+ nowIso: string
553
+ ports: EvidencePorts
554
+ }): { outcome: DurableOutcome; stagePaths: string[] } {
555
+ const { ticketRel, evidence: ev, ports } = args
556
+ if (ev.review_kind !== 'design') throw new Error(`durableDesignEvidence: review_kind != design (${String(ev.review_kind)})`)
557
+ const manifestRel = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/approvals.jsonl`
558
+ const opts = { ticketRel, validPhaseIds: args.validPhaseIds }
559
+ const identity = { kind: 'design' as ReviewKind, phaseId: null, responseSha256: ev.response_sha256 }
560
+
561
+ const existing = ports.readText(manifestRel) ?? ''
562
+ // 기존 매니페스트 단독 무결성 먼저(오염 위에 덧쓰기 금지 — fail-closed).
563
+ if (existing.trim()) {
564
+ const p = validateManifest(existing, opts)
565
+ if (p.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${p.join('; ')}`)
566
+ }
567
+ const onDiskRow = findEvidenceRow(existing, identity)
568
+
569
+ // HEAD 내구화 판정: 매니페스트 행이 커밋돼 있고, 그 행의 인벤토리 아카이브가 전부 HEAD에 있으며 sha가 일치.
570
+ const headRow = findEvidenceRow(ports.headText(manifestRel) ?? '', identity)
571
+ const headInventory = headRow?.archive_inventory ?? []
572
+ const headDurable =
573
+ headRow !== null &&
574
+ ports.headBlobSha256(ev.response_path) !== null &&
575
+ headInventory.every((i) => ports.headBlobSha256(i.response_path) === i.sha256)
576
+
577
+ if (onDiskRow && headDurable) return { outcome: 'already-durable', stagePaths: [] }
578
+
579
+ let inventory: ArchiveInventoryItem[]
580
+ if (onDiskRow) {
581
+ // 부분 상태 복구: **append하지 않는다**(중복 행 금지). 기록된 인벤토리를 그대로 stage·commit 재시도.
582
+ inventory = onDiskRow.archive_inventory ?? buildArchiveInventory(ports.listArchiveNames(), 'design', null, ticketRel, ports.sha256)
583
+ } else {
584
+ inventory = buildArchiveInventory(ports.listArchiveNames(), 'design', null, ticketRel, ports.sha256)
585
+ const entry = buildManifestEntry(ev, {
586
+ consumedAt: args.nowIso,
587
+ consumedByCommitSha: ports.headCommitSha(),
588
+ userCommitConfirmed: null,
589
+ archiveInventory: inventory,
590
+ })
591
+ const candidate = existing + serializeManifestLine(entry)
592
+ const problems = validateManifest(candidate, opts)
593
+ if (problems.length) throw new Error(`design evidence 매니페스트 검증 실패: ${problems.join('; ')}`)
594
+ ports.writeText(manifestRel, candidate)
595
+ }
596
+
597
+ // REQ-2026-051 D7: 원장이 있으면 같은 커밋에 싣는다. `readText`로 존재를 확인한다 — 포트 경계를
598
+ // 넘지 않고(fs 직접 접근 금지), 없으면 pathspec에 넣지 않아 커밋이 실패하지 않는다.
599
+ const ledgerRel = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/review-ledger.jsonl`
600
+ const ledgerExists = ports.readText(ledgerRel) !== null
601
+ const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel, ledgerExists)
602
+ // 🔴 가드는 **우리가 커밋할 경로**에만 건다 — index 전체가 아니다(phase-3 리뷰 P1).
603
+ // index 전체를 보면 설계 문서를 stage한 정상 승인 경로에서 항상 실패한다. pathspec 범위 커밋이라
604
+ // 무관한 staged 변경은 애초에 이 커밋에 들어갈 수 없고, 커밋 후에도 index에 그대로 남는다.
605
+ const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
606
+ const outside = stagePaths.filter((p) => !p.replace(/\\/g, '/').startsWith(prefix))
607
+ if (outside.length) throw new Error(`design evidence 커밋 대상이 티켓 responses/ 밖: ${outside.join(', ')}`)
608
+ ports.commitPaths(stagePaths, `chore(${args.ticketId}): design-finalize — design 승인 approvals.jsonl 기록`)
609
+ return { outcome: onDiskRow ? 'recommitted' : 'committed', stagePaths }
610
+ }
611
+
612
+ // ───────────────────── DONE 게이트: 커밋된 증거 검증 (REQ-2026-048 DEC-4) ──
613
+
614
+ /** 내구성 marker 필드명. `req:new`가 스캐폴드 `state.json`에 심고 그 스캐폴드가 커밋된다. */
615
+ export const DURABILITY_MARKER = 'evidence_durability_required'
616
+
617
+ /**
618
+ * 신규 티켓(엄격 검증 대상)인가 — 🔴 **`HEAD`의 `state.json` blob**으로 판정한다.
619
+ *
620
+ * 워킹 `state.json`은 **커밋되지 않는 캐시**다. 거기서 marker를 읽으면 캐시 재생성·브랜치 전환으로
621
+ * marker가 사라진 신규 티켓이 **legacy로 오인**되어 DONE 게이트가 통째로 우회된다(design r01 P1-1).
622
+ *
623
+ * | HEAD blob | 판정 |
624
+ * |---|---|
625
+ * | 읽힘 · marker=true | **신규 → 엄격** |
626
+ * | 읽힘 · marker 부재/false | legacy → 기존 DONE 호환 |
627
+ * | 읽기 불가·파손 | 🔴 **엄격** — 완료 선언은 검증 가능한 상태에서만 한다(티켓 스캐폴드가 커밋돼 있지 않다는 뜻) |
628
+ */
629
+ export function isDurabilityRequired(headStateText: string | null): boolean {
630
+ if (headStateText === null) return true // 스캐폴드가 HEAD에 없다 → 완료 선언 대상 아님(보수적 엄격)
631
+ try {
632
+ const s = JSON.parse(headStateText) as Record<string, unknown>
633
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return true
634
+ return s[DURABILITY_MARKER] === true
635
+ } catch {
636
+ return true // 파손 → 보수적 엄격
637
+ }
638
+ }
639
+
640
+ /**
641
+ * **커밋된** design 증거가 완비됐는지(순수 판정 + 포트 조회). DONE 직전 게이트가 쓴다.
642
+ *
643
+ * 🔴 온디스크가 아니라 **`HEAD` blob**만 본다. D17이 온디스크 아카이브로 통과한다는 사실이 이 갭을
644
+ * 조용하게 만들었다 — 여기서 다시 온디스크를 보면 같은 사각이 재발한다.
645
+ */
646
+ export function verifyCommittedDesignEvidence(args: {
647
+ ticketRel: string
648
+ ports: Pick<EvidencePorts, 'headText' | 'headBlobSha256' | 'headArchivePaths'>
649
+ }): { durable: boolean; reason: string } {
650
+ const { ticketRel, ports } = args
651
+ const tRel = ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')
652
+ const manifestRel = `${tRel}/responses/approvals.jsonl`
653
+ const responsesRel = `${tRel}/responses`
654
+ const no = (reason: string): { durable: boolean; reason: string } => ({ durable: false, reason })
655
+
656
+ // ── 1. HEAD state 해석 가능성 ──
657
+ // 완료 선언은 **해석 가능한 상태**에서만 한다. 파손·부재·`phases` 비배열이면 판단 근거가 없으므로 BLOCKED.
658
+ const headState = ports.headText(`${tRel}/state.json`)
659
+ if (headState === null) return no(`커밋된 ${tRel}/state.json 없음 — 티켓 스캐폴드가 HEAD에 없다`)
660
+ let statePhases: unknown
661
+ try {
662
+ const s = JSON.parse(headState) as Record<string, unknown>
663
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return no('커밋된 state.json이 객체가 아님')
664
+ statePhases = s.phases
665
+ } catch {
666
+ return no('커밋된 state.json 파싱 실패(파손)')
667
+ }
668
+ if (!Array.isArray(statePhases)) return no('커밋된 state.json의 phases가 배열이 아님 — phase 정보를 해석할 수 없다')
669
+
670
+ // ── 2. 매니페스트 전체 검증 ──
671
+ const manifest = ports.headText(manifestRel)
672
+ if (manifest === null) return no(`커밋된 ${manifestRel} 없음`)
673
+ /**
674
+ * ⚠️ `validPhaseIds`는 **매니페스트 자신의 phase 행 id**로 만든다 → phase_id **멤버십 검사만** 무효화된다.
675
+ *
676
+ * 이유: `state.json`은 설계상 스캐폴드 이후 **재커밋되지 않으므로**(evidence 커밋은 pathspec으로 `responses/`만
677
+ * 담는다) HEAD의 `phases`는 항상 `[]`다. 그것으로 검사하면 **정상 증거가 전부 차단**된다(실측 확인).
678
+ * phase 행의 phase_id 바인딩은 커밋 시점에 `evidencePreflight`가 이미 강제한다.
679
+ *
680
+ * 🔴 무효화되는 것은 **이 한 가지뿐**이다. 스키마·경로 confinement·`-approved.json` 파일명·SHA 형식·
681
+ * extra field·중복/주입·design 행 제약(phase_id=null·design_hash·approved_tree 금지)은 전부 그대로 강제된다.
682
+ */
683
+ const manifestPhaseIds: string[] = []
684
+ for (const line of manifest.split('\n').map((l) => l.trim()).filter(Boolean)) {
685
+ try {
686
+ const e = JSON.parse(line) as { kind?: unknown; phase_id?: unknown }
687
+ if (e && e.kind === 'phase' && typeof e.phase_id === 'string') manifestPhaseIds.push(e.phase_id)
688
+ } catch {
689
+ // malformed는 아래 validateManifest가 잡는다
690
+ }
691
+ }
692
+ const problems = validateManifest(manifest, { ticketRel: tRel, validPhaseIds: manifestPhaseIds })
693
+ if (problems.length) return no(`커밋된 approvals.jsonl 무결성 실패: ${problems.join('; ')}`)
694
+
695
+ // ── 3. design 행 선택(재승인이 있으면 마지막이 유효 승인) ──
696
+ let row: ManifestEntry | null = null
697
+ for (const line of manifest.split('\n').map((l) => l.trim()).filter(Boolean)) {
698
+ try {
699
+ const e = JSON.parse(line) as ManifestEntry
700
+ if (e && typeof e === 'object' && e.kind === 'design') row = e
701
+ } catch {
702
+ // 위에서 이미 걸러졌다
703
+ }
704
+ }
705
+ if (!row) return no('커밋된 approvals.jsonl에 design 승인 행이 없음')
706
+
707
+ // ── 4. top-level SHA 대조 ── 🔴 "존재"가 아니라 "일치"를 본다.
708
+ const approvedHeadSha = ports.headBlobSha256(row.response_path)
709
+ if (approvedHeadSha === null) return no(`승인 아카이브가 HEAD에 없음: ${row.response_path}`)
710
+ if (approvedHeadSha !== row.response_sha256)
711
+ return no(`승인 아카이브 SHA 불일치(HEAD ≠ manifest): ${row.response_path}`)
712
+
713
+ // ── 5. inventory 비어있지 않음 ── 🔴 `[]`는 `every()`가 공허 참이라 과거 구현이 통과시켰다.
714
+ const inv = row.archive_inventory
715
+ if (!Array.isArray(inv)) return no('design 행에 archive_inventory 없음(구버전 형식 — 재-finalize 필요)')
716
+ if (inv.length === 0) return no('archive_inventory가 비어 있음 — 라운드 증거가 하나도 기록되지 않았다')
717
+
718
+ // ── 6. 승인본이 정확한 SHA로 인벤토리에 포함 ──
719
+ const self = inv.find((i) => i.response_path === row.response_path)
720
+ if (!self) return no(`archive_inventory에 승인 아카이브가 없음: ${row.response_path}`)
721
+ if (self.sha256 !== row.response_sha256)
722
+ return no(`archive_inventory의 승인 아카이브 SHA가 manifest와 불일치: ${row.response_path}`)
723
+
724
+ // ── 7. HEAD의 design 아카이브 **전체 집합**과 정확히 일치(빠짐·잉여 모두 거부) ──
725
+ // 🔴 부분집합만 보면 needs-fix 라운드를 빼고도 통과한다 — 완전성이 이 게이트의 목적이다.
726
+ const designBase = archiveBaseName('design', null)
727
+ const headDesign = ports
728
+ .headArchivePaths(responsesRel)
729
+ .filter((p) => new RegExp(`^${escapeRegExp(designBase)}-r\\d{2,}-(approved|needs-fix)\\.json$`).test(p.split('/').pop() ?? ''))
730
+ const invPaths = new Set(inv.map((i) => i.response_path))
731
+ const headSet = new Set(headDesign)
732
+ const missing = [...headSet].filter((p) => !invPaths.has(p)).sort()
733
+ const extra = [...invPaths].filter((p) => !headSet.has(p)).sort()
734
+ if (missing.length) return no(`HEAD의 design 아카이브가 archive_inventory에 빠져 있음: ${missing.join(', ')}`)
735
+ if (extra.length) return no(`archive_inventory에 HEAD에 없는 항목이 있음: ${extra.join(', ')}`)
736
+
737
+ // ── 8. 각 인벤토리 항목의 SHA 일치 ──
738
+ for (const item of inv) {
739
+ const actual = ports.headBlobSha256(item.response_path)
740
+ if (actual === null) return no(`인벤토리 아카이브가 HEAD에 없음: ${item.response_path}`)
741
+ if (actual !== item.sha256) return no(`인벤토리 아카이브 SHA 불일치: ${item.response_path}`)
742
+ }
743
+ return { durable: true, reason: 'design 승인 증거가 HEAD에 완비됨' }
744
+ }
745
+
746
+ // ───────── committed 증거 무결성 종합(design+phase) (REQ-2026-052 DEC-B7·phase-3b3) — 공유 deep 모듈 ──
747
+
748
+ /**
749
+ * 🔴 durable 티켓의 **committed 증거 무결성 종합 판정**(design + phase, DEC-B7). intake(`scanTicketIntake`)와
750
+ * req:commit 발행 후 verifier(`verifyDevCompleteAtHead`)가 **이 한 함수를 공유**한다(요구 #3) — 두 경로의
751
+ * 증거 무결성 규칙이 갈라질 수 없다. 호출자는 design·phase 검증 순서·세부 조건을 몰라도 된다(얕은 인터페이스 금지).
752
+ *
753
+ * 내부는 **기존 함수 재사용**(중복 규칙 없음):
754
+ * - **design**: manifest에 design 행이 있으면 **`verifyCommittedDesignEvidence`**(REQ-2026-048 DONE 게이트)로
755
+ * 최신 design 승인 archive 존재·SHA + `archive_inventory` 전체(과거 needs-fix 포함) 존재·SHA + HEAD 집합 정합을
756
+ * 검증. `durable=false`면 무결성/완전성 실패.
757
+ * - **phase**: **`verifyPhaseArchives`**(DEC-B6)로 모든 phase 행 archive 존재·SHA.
758
+ *
759
+ * 🔴 **불완전(incomplete) ≠ 손상(tampered)**: manifest에 **design 행이 없으면** design 무결성 검사 대상이 아니다
760
+ * (대체된 미완 티켓·design 미승인은 손상이 아니라 미완 — 통과 가능, series-terminal). `designEvidenceComplete`은
761
+ * needs-recovery 판정 입력(design 행 없으면 false = 미완).
762
+ *
763
+ * 🔴 **HEAD blob만** — on-disk·워킹트리·state.json을 판정 근거로 쓰지 않는다(포트 경유).
764
+ */
765
+ export function verifyCommittedEvidenceIntegrity(args: {
766
+ ticketRel: string
767
+ manifestText: string | null
768
+ ports: Pick<EvidencePorts, 'headText' | 'headBlobSha256' | 'headArchivePaths'>
769
+ }): { problems: string[]; designEvidenceComplete: boolean } {
770
+ const { ticketRel, manifestText, ports } = args
771
+ const problems: string[] = []
772
+ let designEvidenceComplete = false
773
+ if (manifestText === null) return { problems, designEvidenceComplete }
774
+ // design: design 행이 있을 때만 검사(없으면 손상 아님 — 불완전≠손상).
775
+ if (designHashFromManifest(manifestText) !== null) {
776
+ const d = verifyCommittedDesignEvidence({ ticketRel, ports })
777
+ designEvidenceComplete = d.durable
778
+ if (!d.durable) problems.push(`design 증거 무결성/완전성 실패: ${d.reason}`)
779
+ }
780
+ // phase: 모든 phase 행 archive 무결성.
781
+ for (const p of verifyPhaseArchives(manifestText, ports.headBlobSha256))
782
+ problems.push(`phase archive ${p.reason}: ${p.phaseId} (${p.responsePath})`)
783
+ return { problems, designEvidenceComplete }
784
+ }
785
+
786
+ /**
787
+ * approvals.jsonl에 **이 evidence가** 이미 finalize된 엔트리가 있는지(순수, 멱등 finalize용).
788
+ * ⚠️ B3-R2: consumed_by_commit_sha만으로는 부족 — 같은 source SHA를 쓰는 design-finalize row 등에 오인될 수 있음.
789
+ * sourceSha **+ evidence identity(kind·phase_id·response_sha256)** 전부 일치해야 동일 엔트리로 판정.
790
+ */
791
+ export function manifestHasConsumed(
792
+ content: string,
793
+ sourceSha: string,
794
+ identity: { reviewKind: ReviewKind; phaseId: string | null; responseSha256: string },
795
+ ): boolean {
796
+ for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
797
+ try {
798
+ const e = JSON.parse(line) as { consumed_by_commit_sha?: unknown; kind?: unknown; phase_id?: unknown; response_sha256?: unknown }
799
+ if (
800
+ e &&
801
+ typeof e === 'object' &&
802
+ e.consumed_by_commit_sha === sourceSha &&
803
+ e.kind === identity.reviewKind &&
804
+ (e.phase_id ?? null) === identity.phaseId &&
805
+ e.response_sha256 === identity.responseSha256
806
+ )
807
+ return true
808
+ } catch {
809
+ // malformed 줄은 무시(무결성은 validateManifest 담당)
810
+ }
811
+ }
812
+ return false
813
+ }