commitgate 0.9.5 → 0.9.8
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.
- package/CHANGELOG.md +30 -0
- package/README.en.md +6 -0
- package/README.md +6 -0
- package/bin/init.ts +3 -2
- package/bin/sync.ts +143 -18
- package/package.json +1 -1
- package/scripts/req/lib/evidence-ports.ts +116 -0
- package/scripts/req/lib/evidence.ts +658 -0
- package/scripts/req/req-commit.ts +623 -806
- package/scripts/req/req-doctor.ts +76 -15
- package/scripts/req/req-new.ts +255 -252
- package/scripts/req/req-next.ts +813 -775
- package/scripts/req/review-codex.ts +2146 -2065
- package/skills/ATTRIBUTION.md +3 -1
- package/skills/commitgate-quality/SKILL.md +126 -0
- package/templates/CLAUDE.template.md +1 -0
- package/templates/workflow.gitignore +4 -0
|
@@ -0,0 +1,658 @@
|
|
|
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
|
+
approved_at: string
|
|
95
|
+
consumed_at: string
|
|
96
|
+
consumed_by_commit_sha: string
|
|
97
|
+
user_commit_confirmed: UserCommitConfirmed | null
|
|
98
|
+
/**
|
|
99
|
+
* REQ-2026-048 DEC-2 — 이 승인 시점의 라운드 아카이브 전부(needs-fix 포함, 승인본 자기 자신 포함).
|
|
100
|
+
*
|
|
101
|
+
* **선택 필드**다: 부재해도 매니페스트 검증은 통과한다(기존 행 무회귀). 단 내구성 marker가 켜진 신규
|
|
102
|
+
* 티켓에서는 DONE 게이트가 design 행의 이 필드 부재를 **BLOCKED**로 본다 — 검증의 관대함(legacy 호환)과
|
|
103
|
+
* 완료 판정의 엄격함(신규)을 분리한다.
|
|
104
|
+
*
|
|
105
|
+
* 현재 **design 행만 채운다**. phase 경로는 `expectedArchivePaths`가 이미 needs-fix까지 stage하므로
|
|
106
|
+
* 인벤토리가 필요 없다. 다만 phase 행에서 이 필드를 *금지*하지는 않는다 — 새 금지 규칙은 설계 범위 밖이고,
|
|
107
|
+
* 형식 검증은 kind와 무관하게 동일하게 적용된다.
|
|
108
|
+
*/
|
|
109
|
+
archive_inventory?: ArchiveInventoryItem[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** approvals.jsonl 엔트리 허용 top-level 키(이 외 = 주입/오염 → fail). */
|
|
113
|
+
const MANIFEST_KEYS = new Set([
|
|
114
|
+
'kind',
|
|
115
|
+
'phase_id',
|
|
116
|
+
'response_path',
|
|
117
|
+
'response_sha256',
|
|
118
|
+
'review_base_sha',
|
|
119
|
+
'approved_tree',
|
|
120
|
+
'design_hash',
|
|
121
|
+
'approved_at',
|
|
122
|
+
'consumed_at',
|
|
123
|
+
'consumed_by_commit_sha',
|
|
124
|
+
'user_commit_confirmed',
|
|
125
|
+
'archive_inventory', // REQ-2026-048 DEC-2(선택 — 부재해도 유효)
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 이 승인의 아카이브 인벤토리 산출(순수 — sha 계산은 주입).
|
|
130
|
+
*
|
|
131
|
+
* **수집 범위(결정적 정의)**: 현재 티켓 `responses/` **직계**의 `kind` 아카이브 **전부**
|
|
132
|
+
* (`archiveBaseName(kind, phaseId)` 매처 — `-approved`·`-needs-fix` 모두). round(rNN) **오름차순**으로 정렬해
|
|
133
|
+
* 디렉터리 읽기 순서에 비의존하게 만든다(`expectedArchivePaths`와 동일 기법).
|
|
134
|
+
*
|
|
135
|
+
* **재승인 시**: 그 시점의 전부를 다시 담으므로 이전 라운드를 포함한다. 각 행이 "그 승인 시점의 완전한 상태"라는
|
|
136
|
+
* 의미로 일관되고, DONE 게이트는 **가장 마지막 design 행**을 본다. stale 아카이브를 골라내는 휴리스틱은 두지 않는다
|
|
137
|
+
* (재현 불가능해진다).
|
|
138
|
+
*
|
|
139
|
+
* @param shaOf repo-상대 경로 → sha256(hex). 호출부가 파일을 읽어 주입(이 모듈은 fs를 모른다).
|
|
140
|
+
*/
|
|
141
|
+
export function buildArchiveInventory(
|
|
142
|
+
archiveNames: string[],
|
|
143
|
+
kind: ReviewKind,
|
|
144
|
+
phaseId: string | null,
|
|
145
|
+
ticketRel: string,
|
|
146
|
+
shaOf: (repoRelPath: string) => string,
|
|
147
|
+
): ArchiveInventoryItem[] {
|
|
148
|
+
return expectedArchivePaths(archiveNames, kind, phaseId, ticketRel).map((p) => ({
|
|
149
|
+
response_path: p,
|
|
150
|
+
sha256: shaOf(p),
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* design evidence 커밋에 stage할 repo-상대 경로(순수, 결정적).
|
|
156
|
+
*
|
|
157
|
+
* = **인벤토리 전량**(needs-fix 포함) + 승인본 + `approvals.jsonl`. 중복은 제거하고 순서는 입력 순서를 따른다.
|
|
158
|
+
* 승인본은 정상 경로에서 인벤토리에 이미 들어 있지만, 인벤토리가 비는 이례적 상황(디렉터리 조회 실패 등)에서도
|
|
159
|
+
* **최소한 승인 증거는 남도록** 명시적으로 합류시킨다.
|
|
160
|
+
*
|
|
161
|
+
* 🔴 `approvals.jsonl` 외에는 **티켓 `responses/` 밖 경로가 절대 섞이지 않는다** — 호출부의 leak 가드
|
|
162
|
+
* (`responses/` 외 staged 금지)와 이중으로, 무관한 index 변경이 evidence 커밋에 딸려 들어가지 못하게 한다.
|
|
163
|
+
*/
|
|
164
|
+
export function designEvidenceStagePaths(
|
|
165
|
+
inventory: readonly ArchiveInventoryItem[],
|
|
166
|
+
responsePath: string,
|
|
167
|
+
ticketRel: string,
|
|
168
|
+
): string[] {
|
|
169
|
+
const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
|
|
170
|
+
const archives = [...inventory.map((i) => i.response_path), responsePath].filter(
|
|
171
|
+
(p) => typeof p === 'string' && p.length > 0 && isConfinedArchivePath(p, ticketRel),
|
|
172
|
+
)
|
|
173
|
+
return [...new Set([...archives, `${dir}/approvals.jsonl`])]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 인벤토리 항목의 형식 문제 목록(순수). 빈 배열 = 정상. `line N: ` 접두는 호출부가 붙인다. */
|
|
177
|
+
function archiveInventoryProblems(inv: unknown, ticketRel: string): string[] {
|
|
178
|
+
const out: string[] = []
|
|
179
|
+
if (!Array.isArray(inv)) return ['archive_inventory가 배열 아님']
|
|
180
|
+
const seen = new Set<string>()
|
|
181
|
+
for (let i = 0; i < inv.length; i++) {
|
|
182
|
+
const it = inv[i] as { response_path?: unknown; sha256?: unknown } | null
|
|
183
|
+
const at = `archive_inventory[${i}]`
|
|
184
|
+
if (!it || typeof it !== 'object' || Array.isArray(it)) {
|
|
185
|
+
out.push(`${at}: object 아님`)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
for (const k of Object.keys(it)) if (k !== 'response_path' && k !== 'sha256') out.push(`${at}: 예상 외 필드: ${k}`)
|
|
189
|
+
const p = typeof it.response_path === 'string' ? it.response_path : ''
|
|
190
|
+
// ⚠️ 인벤토리는 **needs-fix 이름을 허용**한다(라운드 전체 보존이 목적). 행 최상위 `response_path`의
|
|
191
|
+
// "-approved.json만" 규칙과는 의미가 다르다 — 그 규칙은 여기 적용하지 않는다.
|
|
192
|
+
if (!isConfinedArchivePath(p, ticketRel)) out.push(`${at}: response_path 비confined: ${p}`)
|
|
193
|
+
else if (seen.has(p)) out.push(`${at}: 중복 response_path: ${p}`)
|
|
194
|
+
else seen.add(p)
|
|
195
|
+
if (typeof it.sha256 !== 'string' || !SHA256_RE.test(it.sha256)) out.push(`${at}: sha256 형식 오류(64hex)`)
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* user_commit_confirmed 감사 기록 형식 검증(순수). 유효하면 null, 아니면 사유.
|
|
202
|
+
* 요구: confirmed===true · method(공백 아닌 문자열) · confirmed_at(ISO).
|
|
203
|
+
* ⚠️ 위조불가 증명이 아니다 — Claude가 생성 가능한 플래그. 가장 강한 보장 = 사용자가 직접 `req:commit` 실행.
|
|
204
|
+
*/
|
|
205
|
+
export function userConfirmProblem(ucc: unknown): string | null {
|
|
206
|
+
if (!ucc || typeof ucc !== 'object') return '기록 없음'
|
|
207
|
+
const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
|
|
208
|
+
if (c.confirmed !== true) return 'confirmed=true 아님'
|
|
209
|
+
if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
|
|
210
|
+
if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 승인 증거(state pin)와 소비 정보로 매니페스트 엔트리 생성(순수, 고정 필드·키 순서).
|
|
216
|
+
* kind 격리: phase→approved_tree만, design→design_hash만(반대 kind 필드 미포함).
|
|
217
|
+
*/
|
|
218
|
+
export function buildManifestEntry(
|
|
219
|
+
ev: ApprovalEvidence,
|
|
220
|
+
consume: {
|
|
221
|
+
consumedAt: string
|
|
222
|
+
consumedByCommitSha: string
|
|
223
|
+
userCommitConfirmed: UserCommitConfirmed | null
|
|
224
|
+
/** REQ-2026-048 DEC-2. 지정 시에만 행에 포함(미지정 = 필드 자체 부재 → 기존 행과 바이트 동일). */
|
|
225
|
+
archiveInventory?: ArchiveInventoryItem[]
|
|
226
|
+
},
|
|
227
|
+
): ManifestEntry {
|
|
228
|
+
const base: ManifestEntry = {
|
|
229
|
+
kind: ev.review_kind,
|
|
230
|
+
phase_id: ev.phase_id ?? null,
|
|
231
|
+
response_path: ev.response_path,
|
|
232
|
+
response_sha256: ev.response_sha256,
|
|
233
|
+
review_base_sha: ev.review_base_sha,
|
|
234
|
+
approved_at: ev.approved_at,
|
|
235
|
+
consumed_at: consume.consumedAt,
|
|
236
|
+
consumed_by_commit_sha: consume.consumedByCommitSha,
|
|
237
|
+
user_commit_confirmed: consume.userCommitConfirmed,
|
|
238
|
+
}
|
|
239
|
+
// 미지정이면 키 자체를 넣지 않는다 — 기존 행과 바이트 동일(하위호환).
|
|
240
|
+
const inv = consume.archiveInventory ? { archive_inventory: consume.archiveInventory } : {}
|
|
241
|
+
// fail-fast: kind별 필수 바인딩 필드(phase=approved_tree, design=design_hash). 반대 kind 필드는 미포함.
|
|
242
|
+
if (ev.review_kind === 'design') {
|
|
243
|
+
const designHash = ev.design_hash
|
|
244
|
+
if (typeof designHash !== 'string' || !designHash)
|
|
245
|
+
throw new Error('buildManifestEntry: design evidence에 design_hash 누락(fail-fast)')
|
|
246
|
+
return { ...base, phase_id: null, design_hash: designHash, ...inv }
|
|
247
|
+
}
|
|
248
|
+
const approvedTree = ev.approved_tree
|
|
249
|
+
if (typeof approvedTree !== 'string' || !approvedTree)
|
|
250
|
+
throw new Error('buildManifestEntry: phase evidence에 approved_tree 누락(fail-fast)')
|
|
251
|
+
return { ...base, approved_tree: approvedTree, ...inv }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 매니페스트 한 줄 직렬화(JSONL): JSON + 끝 개행. 고정 키 순서라 deterministic. */
|
|
255
|
+
export function serializeManifestLine(entry: ManifestEntry): string {
|
|
256
|
+
return `${JSON.stringify(entry)}\n`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* approvals.jsonl 내용 검증(순수, fail-closed). 문제 목록 반환(빈 배열=정상).
|
|
261
|
+
* 검사: malformed JSONL · response_path confinement(현재 티켓 responses/ 직계) · SHA-256 형식 ·
|
|
262
|
+
* phase kind의 phase_id 유효성 · (kind,phase_id,sha) 중복/주입.
|
|
263
|
+
*/
|
|
264
|
+
export function validateManifest(content: string, opts: { ticketRel: string; validPhaseIds: string[] }): string[] {
|
|
265
|
+
const problems: string[] = []
|
|
266
|
+
const seenKey = new Set<string>()
|
|
267
|
+
const seenPath = new Set<string>()
|
|
268
|
+
const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
269
|
+
for (let i = 0; i < lines.length; i++) {
|
|
270
|
+
const ln = i + 1
|
|
271
|
+
let e: Record<string, unknown>
|
|
272
|
+
try {
|
|
273
|
+
e = JSON.parse(lines[i] as string) as Record<string, unknown>
|
|
274
|
+
} catch {
|
|
275
|
+
problems.push(`line ${ln}: malformed JSON`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) {
|
|
279
|
+
problems.push(`line ${ln}: object 아님`)
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
// 예상 외 extra field 금지(주입 차단).
|
|
283
|
+
for (const k of Object.keys(e)) if (!MANIFEST_KEYS.has(k)) problems.push(`line ${ln}: 예상 외 필드: ${k}`)
|
|
284
|
+
const kind = e.kind
|
|
285
|
+
if (kind !== 'phase' && kind !== 'design') problems.push(`line ${ln}: kind 비유효: ${String(kind)}`)
|
|
286
|
+
// 공통: 경로 confinement, sha/OID/ISO 형식.
|
|
287
|
+
const respPath = typeof e.response_path === 'string' ? e.response_path : ''
|
|
288
|
+
if (!isConfinedArchivePath(respPath, opts.ticketRel)) problems.push(`line ${ln}: response_path 비confined: ${respPath}`)
|
|
289
|
+
// B1-P2-1: manifest 행(=소비된 승인)의 response_path basename은 그 행의 kind/phase_id **승인본**(-approved)이어야.
|
|
290
|
+
// (design→phase 아카이브·타 phase·needs-fix 가리키는 주입/변조 차단. expectedArchivePaths[chore 대상]는 needs-fix 포함 별개.)
|
|
291
|
+
if (kind === 'phase' || kind === 'design') {
|
|
292
|
+
const expBase = archiveBaseName(kind, kind === 'phase' && typeof e.phase_id === 'string' ? e.phase_id : null)
|
|
293
|
+
const name = respPath.split('/').pop() ?? ''
|
|
294
|
+
if (!new RegExp(`^${escapeRegExp(expBase)}-r\\d{2,}-approved\\.json$`).test(name))
|
|
295
|
+
problems.push(`line ${ln}: response_path가 ${expBase}-rNN-approved.json 아님: ${name}`)
|
|
296
|
+
}
|
|
297
|
+
if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
|
|
298
|
+
if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
|
|
299
|
+
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`)
|
|
300
|
+
if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
|
|
301
|
+
if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
|
|
302
|
+
// kind별 strict 바인딩(반대 kind 필드 금지).
|
|
303
|
+
if (kind === 'phase') {
|
|
304
|
+
if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
|
|
305
|
+
problems.push(`line ${ln}: phase_id 비유효: ${String(e.phase_id)}`)
|
|
306
|
+
if (typeof e.approved_tree !== 'string' || !GIT_OID_RE.test(e.approved_tree)) problems.push(`line ${ln}: approved_tree 비-OID`)
|
|
307
|
+
if ('design_hash' in e) problems.push(`line ${ln}: phase entry에 design_hash 금지`)
|
|
308
|
+
} else if (kind === 'design') {
|
|
309
|
+
if (e.phase_id !== null) problems.push(`line ${ln}: design entry는 phase_id=null이어야`)
|
|
310
|
+
if (typeof e.design_hash !== 'string' || !SHA256_RE.test(e.design_hash)) problems.push(`line ${ln}: design_hash 비-64hex`)
|
|
311
|
+
if ('approved_tree' in e) problems.push(`line ${ln}: design entry에 approved_tree 금지`)
|
|
312
|
+
}
|
|
313
|
+
// archive_inventory(REQ-2026-048 DEC-2): **선택** — 부재는 정상(기존 행 무회귀). 있으면 형식 검증.
|
|
314
|
+
if ('archive_inventory' in e) {
|
|
315
|
+
for (const p of archiveInventoryProblems(e.archive_inventory, opts.ticketRel)) problems.push(`line ${ln}: ${p}`)
|
|
316
|
+
}
|
|
317
|
+
// user_commit_confirmed: null 또는 유효 감사 기록(confirmed=true·method·ISO confirmed_at)만. (B2-block3)
|
|
318
|
+
const ucc = e.user_commit_confirmed
|
|
319
|
+
if (ucc !== null) {
|
|
320
|
+
const p = userConfirmProblem(ucc)
|
|
321
|
+
if (p) problems.push(`line ${ln}: user_commit_confirmed ${p}(null 또는 confirmed=true·method·ISO confirmed_at)`)
|
|
322
|
+
}
|
|
323
|
+
// 중복: (kind/phase/sha) + response_path 두 기준 모두.
|
|
324
|
+
const key = `${String(kind)}:${String(e.phase_id)}:${String(e.response_sha256)}`
|
|
325
|
+
if (seenKey.has(key)) problems.push(`line ${ln}: 중복 entry(kind/phase/sha): ${key}`)
|
|
326
|
+
seenKey.add(key)
|
|
327
|
+
if (respPath) {
|
|
328
|
+
if (seenPath.has(respPath)) problems.push(`line ${ln}: 중복 response_path: ${respPath}`)
|
|
329
|
+
seenPath.add(respPath)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return problems
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 이번 target(kind/phaseId)의 **예상 응답 아카이브 repo-경로만** 반환(blanket `git add responses/` 금지).
|
|
337
|
+
* archiveNames(responses/ 디렉터리 파일명)에서 해당 base의 rNN 아카이브만 필터 → 현재 티켓 responses/ 경로로.
|
|
338
|
+
* needs-fix + approved 모두 포함(evidence chore는 실패 라운드까지 영속화) — 매니페스트 행 검증(approved만)과 별개.
|
|
339
|
+
*/
|
|
340
|
+
export function expectedArchivePaths(
|
|
341
|
+
archiveNames: string[],
|
|
342
|
+
kind: ReviewKind,
|
|
343
|
+
phaseId: string | null,
|
|
344
|
+
ticketRel: string,
|
|
345
|
+
): string[] {
|
|
346
|
+
const base = archiveBaseName(kind, phaseId)
|
|
347
|
+
const re = new RegExp(`^${escapeRegExp(base)}-r(\\d{2,})-(approved|needs-fix)\\.json$`)
|
|
348
|
+
const dir = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses`
|
|
349
|
+
// readdir 순서 비의존 — round(rNN) 오름차순 정렬(deterministic).
|
|
350
|
+
return archiveNames
|
|
351
|
+
.map((n) => ({ n, m: isArchiveFileName(n) ? re.exec(n) : null }))
|
|
352
|
+
.filter((x): x is { n: string; m: RegExpExecArray } => x.m !== null)
|
|
353
|
+
.sort((a, b) => Number.parseInt(a.m[1] ?? '0', 10) - Number.parseInt(b.m[1] ?? '0', 10))
|
|
354
|
+
.map((x) => `${dir}/${x.n}`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 매니페스트에서 이 evidence identity(kind/phase_id/response_sha256)의 행을 찾는다(순수). 없으면 null. */
|
|
358
|
+
export function findEvidenceRow(
|
|
359
|
+
content: string,
|
|
360
|
+
identity: { kind: ReviewKind; phaseId: string | null; responseSha256: string },
|
|
361
|
+
): ManifestEntry | null {
|
|
362
|
+
for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
363
|
+
try {
|
|
364
|
+
const e = JSON.parse(line) as ManifestEntry
|
|
365
|
+
if (e && typeof e === 'object' && e.kind === identity.kind && (e.phase_id ?? null) === identity.phaseId && e.response_sha256 === identity.responseSha256)
|
|
366
|
+
return e
|
|
367
|
+
} catch {
|
|
368
|
+
// malformed 줄 무시(무결성은 validateManifest 담당)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─────────────────────────── design evidence 내구화 (REQ-2026-048 DEC-3) ──
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 이 모듈이 부수효과를 내기 위해 쓰는 **주입 포트**. `lib/evidence`는 fs·git을 직접 모른다
|
|
378
|
+
* (leaf 불변식 유지 + 실패 주입 테스트 가능).
|
|
379
|
+
*/
|
|
380
|
+
export interface EvidencePorts {
|
|
381
|
+
/** 온디스크 텍스트(없으면 null). */
|
|
382
|
+
readText(repoRel: string): string | null
|
|
383
|
+
writeText(repoRel: string, content: string): void
|
|
384
|
+
/** 티켓 `responses/` 디렉터리의 아카이브 파일명 목록. */
|
|
385
|
+
listArchiveNames(): string[]
|
|
386
|
+
/** 온디스크 파일 바이트의 sha256(hex). */
|
|
387
|
+
sha256(repoRel: string): string
|
|
388
|
+
/** `HEAD`의 blob 텍스트(없으면 null). JSONL 파싱 전용 — 바이트 정합 비교엔 쓰지 않는다. */
|
|
389
|
+
headText(repoRel: string): string | null
|
|
390
|
+
/**
|
|
391
|
+
* 🔴 `HEAD` blob **바이트**의 sha256(없으면 null).
|
|
392
|
+
* 워킹 파일로 계산하면 `core.autocrlf` 환경에서 CRLF↔LF 차이로 **거짓 불일치**가 난다 —
|
|
393
|
+
* 반드시 blob 바이트로 계산해야 커밋 이력과 기록된 sha가 맞는다.
|
|
394
|
+
*/
|
|
395
|
+
headBlobSha256(repoRel: string): string | null
|
|
396
|
+
/**
|
|
397
|
+
* `HEAD`에 존재하는 해당 디렉터리의 아카이브 **repo-상대 경로** 목록(REQ-2026-049 DEC-4).
|
|
398
|
+
*
|
|
399
|
+
* 🔴 **basename이 아니라 전체 경로**를 반환한다 — 인벤토리의 `response_path`와 같은 단위여야 집합 비교가
|
|
400
|
+
* 모호해지지 않는다(하위 디렉터리를 허용하게 되어도 동명 파일 충돌이 생기지 않는다).
|
|
401
|
+
* 🔴 **워킹 디렉터리를 읽지 않는다.** 워킹 트리만 고치고 HEAD는 손상된 경우를 잡는 것이 이 검사의 목적이다.
|
|
402
|
+
*/
|
|
403
|
+
headArchivePaths(responsesDirRel: string): string[]
|
|
404
|
+
/** 현재 `HEAD` 커밋 SHA. */
|
|
405
|
+
headCommitSha(): string
|
|
406
|
+
/**
|
|
407
|
+
* 🔴 **지정한 경로만** 커밋한다(pathspec 범위). 나머지 index는 **그대로 보존**된다.
|
|
408
|
+
*
|
|
409
|
+
* 전체 index를 커밋하거나 "staged 전체"를 leak으로 판정하면 안 된다 — design 리뷰는 **index의 설계 문서**를
|
|
410
|
+
* 대상으로 돌 수 있으므로, 설계 문서를 stage한 채 승인하는 것이 정상 경로다. 그 상태에서 index 전체를 보면
|
|
411
|
+
* 자동 내구화가 **항상 실패**하고(호출부가 삼켜 승인만 남음) `--finalize-design`도 같은 가드로 실패해
|
|
412
|
+
* 증거가 영원히 커밋되지 않는다(phase-3 리뷰 P1).
|
|
413
|
+
*/
|
|
414
|
+
commitPaths(paths: string[], message: string): void
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 내구화 결과. `already-durable`=진짜 no-op, `committed`=신규 기록, `recommitted`=부분 상태 복구. */
|
|
418
|
+
export type DurableOutcome = 'already-durable' | 'committed' | 'recommitted'
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 승인된 **design evidence를 내구화한다** — 호출자가 아는 것은 이 한 문장뿐이다(DEC-1).
|
|
422
|
+
* 매니페스트 형식·stage 목록·멱등 판정은 전부 이 안에 있고, 정상 승인 경로(`review-codex`)와
|
|
423
|
+
* 복구 경로(`req:commit --finalize-design`)가 **같은 구현**을 부른다 → 동작이 갈라질 수 없다.
|
|
424
|
+
*
|
|
425
|
+
* 🔴 **멱등은 온디스크가 아니라 `HEAD` 기준이다**(DEC-3, design r01 P1-2).
|
|
426
|
+
*
|
|
427
|
+
* | 온디스크 엔트리 | HEAD에 내구화됨 | 동작 |
|
|
428
|
+
* |---|---|---|
|
|
429
|
+
* | 없음 | — | append → stage → commit (`committed`) |
|
|
430
|
+
* | 있음 | 예 | 진짜 no-op (`already-durable`) |
|
|
431
|
+
* | 있음 | 아니오 | **append 없이** stage → commit 재시도 (`recommitted`) |
|
|
432
|
+
*
|
|
433
|
+
* 온디스크 엔트리 존재만으로 skip하면, 매니페스트 append·stage까지 되고 `git commit`만 실패한
|
|
434
|
+
* 부분 상태에서 재시도가 **영구히 skip**되어 HEAD 증거를 결코 복구하지 못한다.
|
|
435
|
+
*/
|
|
436
|
+
export function durableDesignEvidence(args: {
|
|
437
|
+
ticketId: string
|
|
438
|
+
ticketRel: string
|
|
439
|
+
evidence: ApprovalEvidence
|
|
440
|
+
validPhaseIds: string[]
|
|
441
|
+
nowIso: string
|
|
442
|
+
ports: EvidencePorts
|
|
443
|
+
}): { outcome: DurableOutcome; stagePaths: string[] } {
|
|
444
|
+
const { ticketRel, evidence: ev, ports } = args
|
|
445
|
+
if (ev.review_kind !== 'design') throw new Error(`durableDesignEvidence: review_kind != design (${String(ev.review_kind)})`)
|
|
446
|
+
const manifestRel = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/approvals.jsonl`
|
|
447
|
+
const opts = { ticketRel, validPhaseIds: args.validPhaseIds }
|
|
448
|
+
const identity = { kind: 'design' as ReviewKind, phaseId: null, responseSha256: ev.response_sha256 }
|
|
449
|
+
|
|
450
|
+
const existing = ports.readText(manifestRel) ?? ''
|
|
451
|
+
// 기존 매니페스트 단독 무결성 먼저(오염 위에 덧쓰기 금지 — fail-closed).
|
|
452
|
+
if (existing.trim()) {
|
|
453
|
+
const p = validateManifest(existing, opts)
|
|
454
|
+
if (p.length) throw new Error(`기존 approvals.jsonl 무결성 실패(fail-closed): ${p.join('; ')}`)
|
|
455
|
+
}
|
|
456
|
+
const onDiskRow = findEvidenceRow(existing, identity)
|
|
457
|
+
|
|
458
|
+
// HEAD 내구화 판정: 매니페스트 행이 커밋돼 있고, 그 행의 인벤토리 아카이브가 전부 HEAD에 있으며 sha가 일치.
|
|
459
|
+
const headRow = findEvidenceRow(ports.headText(manifestRel) ?? '', identity)
|
|
460
|
+
const headInventory = headRow?.archive_inventory ?? []
|
|
461
|
+
const headDurable =
|
|
462
|
+
headRow !== null &&
|
|
463
|
+
ports.headBlobSha256(ev.response_path) !== null &&
|
|
464
|
+
headInventory.every((i) => ports.headBlobSha256(i.response_path) === i.sha256)
|
|
465
|
+
|
|
466
|
+
if (onDiskRow && headDurable) return { outcome: 'already-durable', stagePaths: [] }
|
|
467
|
+
|
|
468
|
+
let inventory: ArchiveInventoryItem[]
|
|
469
|
+
if (onDiskRow) {
|
|
470
|
+
// 부분 상태 복구: **append하지 않는다**(중복 행 금지). 기록된 인벤토리를 그대로 stage·commit 재시도.
|
|
471
|
+
inventory = onDiskRow.archive_inventory ?? buildArchiveInventory(ports.listArchiveNames(), 'design', null, ticketRel, ports.sha256)
|
|
472
|
+
} else {
|
|
473
|
+
inventory = buildArchiveInventory(ports.listArchiveNames(), 'design', null, ticketRel, ports.sha256)
|
|
474
|
+
const entry = buildManifestEntry(ev, {
|
|
475
|
+
consumedAt: args.nowIso,
|
|
476
|
+
consumedByCommitSha: ports.headCommitSha(),
|
|
477
|
+
userCommitConfirmed: null,
|
|
478
|
+
archiveInventory: inventory,
|
|
479
|
+
})
|
|
480
|
+
const candidate = existing + serializeManifestLine(entry)
|
|
481
|
+
const problems = validateManifest(candidate, opts)
|
|
482
|
+
if (problems.length) throw new Error(`design evidence 매니페스트 검증 실패: ${problems.join('; ')}`)
|
|
483
|
+
ports.writeText(manifestRel, candidate)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const stagePaths = designEvidenceStagePaths(inventory, ev.response_path, ticketRel)
|
|
487
|
+
// 🔴 가드는 **우리가 커밋할 경로**에만 건다 — index 전체가 아니다(phase-3 리뷰 P1).
|
|
488
|
+
// index 전체를 보면 설계 문서를 stage한 정상 승인 경로에서 항상 실패한다. pathspec 범위 커밋이라
|
|
489
|
+
// 무관한 staged 변경은 애초에 이 커밋에 들어갈 수 없고, 커밋 후에도 index에 그대로 남는다.
|
|
490
|
+
const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
|
|
491
|
+
const outside = stagePaths.filter((p) => !p.replace(/\\/g, '/').startsWith(prefix))
|
|
492
|
+
if (outside.length) throw new Error(`design evidence 커밋 대상이 티켓 responses/ 밖: ${outside.join(', ')}`)
|
|
493
|
+
ports.commitPaths(stagePaths, `chore(${args.ticketId}): design-finalize — design 승인 approvals.jsonl 기록`)
|
|
494
|
+
return { outcome: onDiskRow ? 'recommitted' : 'committed', stagePaths }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ───────────────────── DONE 게이트: 커밋된 증거 검증 (REQ-2026-048 DEC-4) ──
|
|
498
|
+
|
|
499
|
+
/** 내구성 marker 필드명. `req:new`가 스캐폴드 `state.json`에 심고 그 스캐폴드가 커밋된다. */
|
|
500
|
+
export const DURABILITY_MARKER = 'evidence_durability_required'
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 신규 티켓(엄격 검증 대상)인가 — 🔴 **`HEAD`의 `state.json` blob**으로 판정한다.
|
|
504
|
+
*
|
|
505
|
+
* 워킹 `state.json`은 **커밋되지 않는 캐시**다. 거기서 marker를 읽으면 캐시 재생성·브랜치 전환으로
|
|
506
|
+
* marker가 사라진 신규 티켓이 **legacy로 오인**되어 DONE 게이트가 통째로 우회된다(design r01 P1-1).
|
|
507
|
+
*
|
|
508
|
+
* | HEAD blob | 판정 |
|
|
509
|
+
* |---|---|
|
|
510
|
+
* | 읽힘 · marker=true | **신규 → 엄격** |
|
|
511
|
+
* | 읽힘 · marker 부재/false | legacy → 기존 DONE 호환 |
|
|
512
|
+
* | 읽기 불가·파손 | 🔴 **엄격** — 완료 선언은 검증 가능한 상태에서만 한다(티켓 스캐폴드가 커밋돼 있지 않다는 뜻) |
|
|
513
|
+
*/
|
|
514
|
+
export function isDurabilityRequired(headStateText: string | null): boolean {
|
|
515
|
+
if (headStateText === null) return true // 스캐폴드가 HEAD에 없다 → 완료 선언 대상 아님(보수적 엄격)
|
|
516
|
+
try {
|
|
517
|
+
const s = JSON.parse(headStateText) as Record<string, unknown>
|
|
518
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return true
|
|
519
|
+
return s[DURABILITY_MARKER] === true
|
|
520
|
+
} catch {
|
|
521
|
+
return true // 파손 → 보수적 엄격
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* **커밋된** design 증거가 완비됐는지(순수 판정 + 포트 조회). DONE 직전 게이트가 쓴다.
|
|
527
|
+
*
|
|
528
|
+
* 🔴 온디스크가 아니라 **`HEAD` blob**만 본다. D17이 온디스크 아카이브로 통과한다는 사실이 이 갭을
|
|
529
|
+
* 조용하게 만들었다 — 여기서 다시 온디스크를 보면 같은 사각이 재발한다.
|
|
530
|
+
*/
|
|
531
|
+
export function verifyCommittedDesignEvidence(args: {
|
|
532
|
+
ticketRel: string
|
|
533
|
+
ports: Pick<EvidencePorts, 'headText' | 'headBlobSha256' | 'headArchivePaths'>
|
|
534
|
+
}): { durable: boolean; reason: string } {
|
|
535
|
+
const { ticketRel, ports } = args
|
|
536
|
+
const tRel = ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
537
|
+
const manifestRel = `${tRel}/responses/approvals.jsonl`
|
|
538
|
+
const responsesRel = `${tRel}/responses`
|
|
539
|
+
const no = (reason: string): { durable: boolean; reason: string } => ({ durable: false, reason })
|
|
540
|
+
|
|
541
|
+
// ── 1. HEAD state 해석 가능성 ──
|
|
542
|
+
// 완료 선언은 **해석 가능한 상태**에서만 한다. 파손·부재·`phases` 비배열이면 판단 근거가 없으므로 BLOCKED.
|
|
543
|
+
const headState = ports.headText(`${tRel}/state.json`)
|
|
544
|
+
if (headState === null) return no(`커밋된 ${tRel}/state.json 없음 — 티켓 스캐폴드가 HEAD에 없다`)
|
|
545
|
+
let statePhases: unknown
|
|
546
|
+
try {
|
|
547
|
+
const s = JSON.parse(headState) as Record<string, unknown>
|
|
548
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return no('커밋된 state.json이 객체가 아님')
|
|
549
|
+
statePhases = s.phases
|
|
550
|
+
} catch {
|
|
551
|
+
return no('커밋된 state.json 파싱 실패(파손)')
|
|
552
|
+
}
|
|
553
|
+
if (!Array.isArray(statePhases)) return no('커밋된 state.json의 phases가 배열이 아님 — phase 정보를 해석할 수 없다')
|
|
554
|
+
|
|
555
|
+
// ── 2. 매니페스트 전체 검증 ──
|
|
556
|
+
const manifest = ports.headText(manifestRel)
|
|
557
|
+
if (manifest === null) return no(`커밋된 ${manifestRel} 없음`)
|
|
558
|
+
/**
|
|
559
|
+
* ⚠️ `validPhaseIds`는 **매니페스트 자신의 phase 행 id**로 만든다 → phase_id **멤버십 검사만** 무효화된다.
|
|
560
|
+
*
|
|
561
|
+
* 이유: `state.json`은 설계상 스캐폴드 이후 **재커밋되지 않으므로**(evidence 커밋은 pathspec으로 `responses/`만
|
|
562
|
+
* 담는다) HEAD의 `phases`는 항상 `[]`다. 그것으로 검사하면 **정상 증거가 전부 차단**된다(실측 확인).
|
|
563
|
+
* phase 행의 phase_id 바인딩은 커밋 시점에 `evidencePreflight`가 이미 강제한다.
|
|
564
|
+
*
|
|
565
|
+
* 🔴 무효화되는 것은 **이 한 가지뿐**이다. 스키마·경로 confinement·`-approved.json` 파일명·SHA 형식·
|
|
566
|
+
* extra field·중복/주입·design 행 제약(phase_id=null·design_hash·approved_tree 금지)은 전부 그대로 강제된다.
|
|
567
|
+
*/
|
|
568
|
+
const manifestPhaseIds: string[] = []
|
|
569
|
+
for (const line of manifest.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
570
|
+
try {
|
|
571
|
+
const e = JSON.parse(line) as { kind?: unknown; phase_id?: unknown }
|
|
572
|
+
if (e && e.kind === 'phase' && typeof e.phase_id === 'string') manifestPhaseIds.push(e.phase_id)
|
|
573
|
+
} catch {
|
|
574
|
+
// malformed는 아래 validateManifest가 잡는다
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const problems = validateManifest(manifest, { ticketRel: tRel, validPhaseIds: manifestPhaseIds })
|
|
578
|
+
if (problems.length) return no(`커밋된 approvals.jsonl 무결성 실패: ${problems.join('; ')}`)
|
|
579
|
+
|
|
580
|
+
// ── 3. design 행 선택(재승인이 있으면 마지막이 유효 승인) ──
|
|
581
|
+
let row: ManifestEntry | null = null
|
|
582
|
+
for (const line of manifest.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
583
|
+
try {
|
|
584
|
+
const e = JSON.parse(line) as ManifestEntry
|
|
585
|
+
if (e && typeof e === 'object' && e.kind === 'design') row = e
|
|
586
|
+
} catch {
|
|
587
|
+
// 위에서 이미 걸러졌다
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!row) return no('커밋된 approvals.jsonl에 design 승인 행이 없음')
|
|
591
|
+
|
|
592
|
+
// ── 4. top-level SHA 대조 ── 🔴 "존재"가 아니라 "일치"를 본다.
|
|
593
|
+
const approvedHeadSha = ports.headBlobSha256(row.response_path)
|
|
594
|
+
if (approvedHeadSha === null) return no(`승인 아카이브가 HEAD에 없음: ${row.response_path}`)
|
|
595
|
+
if (approvedHeadSha !== row.response_sha256)
|
|
596
|
+
return no(`승인 아카이브 SHA 불일치(HEAD ≠ manifest): ${row.response_path}`)
|
|
597
|
+
|
|
598
|
+
// ── 5. inventory 비어있지 않음 ── 🔴 `[]`는 `every()`가 공허 참이라 과거 구현이 통과시켰다.
|
|
599
|
+
const inv = row.archive_inventory
|
|
600
|
+
if (!Array.isArray(inv)) return no('design 행에 archive_inventory 없음(구버전 형식 — 재-finalize 필요)')
|
|
601
|
+
if (inv.length === 0) return no('archive_inventory가 비어 있음 — 라운드 증거가 하나도 기록되지 않았다')
|
|
602
|
+
|
|
603
|
+
// ── 6. 승인본이 정확한 SHA로 인벤토리에 포함 ──
|
|
604
|
+
const self = inv.find((i) => i.response_path === row.response_path)
|
|
605
|
+
if (!self) return no(`archive_inventory에 승인 아카이브가 없음: ${row.response_path}`)
|
|
606
|
+
if (self.sha256 !== row.response_sha256)
|
|
607
|
+
return no(`archive_inventory의 승인 아카이브 SHA가 manifest와 불일치: ${row.response_path}`)
|
|
608
|
+
|
|
609
|
+
// ── 7. HEAD의 design 아카이브 **전체 집합**과 정확히 일치(빠짐·잉여 모두 거부) ──
|
|
610
|
+
// 🔴 부분집합만 보면 needs-fix 라운드를 빼고도 통과한다 — 완전성이 이 게이트의 목적이다.
|
|
611
|
+
const designBase = archiveBaseName('design', null)
|
|
612
|
+
const headDesign = ports
|
|
613
|
+
.headArchivePaths(responsesRel)
|
|
614
|
+
.filter((p) => new RegExp(`^${escapeRegExp(designBase)}-r\\d{2,}-(approved|needs-fix)\\.json$`).test(p.split('/').pop() ?? ''))
|
|
615
|
+
const invPaths = new Set(inv.map((i) => i.response_path))
|
|
616
|
+
const headSet = new Set(headDesign)
|
|
617
|
+
const missing = [...headSet].filter((p) => !invPaths.has(p)).sort()
|
|
618
|
+
const extra = [...invPaths].filter((p) => !headSet.has(p)).sort()
|
|
619
|
+
if (missing.length) return no(`HEAD의 design 아카이브가 archive_inventory에 빠져 있음: ${missing.join(', ')}`)
|
|
620
|
+
if (extra.length) return no(`archive_inventory에 HEAD에 없는 항목이 있음: ${extra.join(', ')}`)
|
|
621
|
+
|
|
622
|
+
// ── 8. 각 인벤토리 항목의 SHA 일치 ──
|
|
623
|
+
for (const item of inv) {
|
|
624
|
+
const actual = ports.headBlobSha256(item.response_path)
|
|
625
|
+
if (actual === null) return no(`인벤토리 아카이브가 HEAD에 없음: ${item.response_path}`)
|
|
626
|
+
if (actual !== item.sha256) return no(`인벤토리 아카이브 SHA 불일치: ${item.response_path}`)
|
|
627
|
+
}
|
|
628
|
+
return { durable: true, reason: 'design 승인 증거가 HEAD에 완비됨' }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* approvals.jsonl에 **이 evidence가** 이미 finalize된 엔트리가 있는지(순수, 멱등 finalize용).
|
|
633
|
+
* ⚠️ B3-R2: consumed_by_commit_sha만으로는 부족 — 같은 source SHA를 쓰는 design-finalize row 등에 오인될 수 있음.
|
|
634
|
+
* sourceSha **+ evidence identity(kind·phase_id·response_sha256)** 전부 일치해야 동일 엔트리로 판정.
|
|
635
|
+
*/
|
|
636
|
+
export function manifestHasConsumed(
|
|
637
|
+
content: string,
|
|
638
|
+
sourceSha: string,
|
|
639
|
+
identity: { reviewKind: ReviewKind; phaseId: string | null; responseSha256: string },
|
|
640
|
+
): boolean {
|
|
641
|
+
for (const line of content.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
642
|
+
try {
|
|
643
|
+
const e = JSON.parse(line) as { consumed_by_commit_sha?: unknown; kind?: unknown; phase_id?: unknown; response_sha256?: unknown }
|
|
644
|
+
if (
|
|
645
|
+
e &&
|
|
646
|
+
typeof e === 'object' &&
|
|
647
|
+
e.consumed_by_commit_sha === sourceSha &&
|
|
648
|
+
e.kind === identity.reviewKind &&
|
|
649
|
+
(e.phase_id ?? null) === identity.phaseId &&
|
|
650
|
+
e.response_sha256 === identity.responseSha256
|
|
651
|
+
)
|
|
652
|
+
return true
|
|
653
|
+
} catch {
|
|
654
|
+
// malformed 줄은 무시(무결성은 validateManifest 담당)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false
|
|
658
|
+
}
|