commitgate 0.1.0

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,526 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:doctor — AI REQ 워크플로우 1차 (단계 4B): 일관성 점검(fail-closed).
4
+ *
5
+ * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §8.3.
6
+ * 1차 최소셋(registry 비의존): D2·D3·D5·D6·D9·D10·D11 + D13(design 선행·freshness)·D15(NEEDS_FIX actionable). (D1/D7/D7b·D4a 등 registry/merge 의존은 2차)
7
+ * FAIL 1건 이상 → exit 1, 자동 보정 금지(P9). review-codex 헬퍼 재사용.
8
+ *
9
+ * 사용: pnpm req:doctor <REQ-id> | pnpm req:doctor --ticket <dir>
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { resolve, join, relative } from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { createHash } from 'node:crypto'
15
+ import {
16
+ loadState,
17
+ validateVerdict,
18
+ validateResponseStructure,
19
+ findUnstagedOrUntracked,
20
+ captureDesignBinding,
21
+ designDocPaths,
22
+ isArchiveFileName,
23
+ isAllowedResponsesScratch,
24
+ type WorkflowState,
25
+ type Verdict,
26
+ type ApprovalEvidence,
27
+ } from './review-codex'
28
+ import { loadConfig, packageRoot, type ResolvedConfig } from './lib/config'
29
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
30
+
31
+ // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
32
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
33
+
34
+ function git(args: string[]): string {
35
+ return gitAdapter.exec(args)
36
+ }
37
+
38
+ export type Level = 'OK' | 'WARN' | 'FAIL'
39
+ export interface Check {
40
+ id: string
41
+ level: Level
42
+ msg: string
43
+ }
44
+
45
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
46
+
47
+ export interface DoctorInputs {
48
+ state: WorkflowState
49
+ currentBranch: string
50
+ branchExists: boolean
51
+ // D11 feature-branch 게이트의 prefix(config). main이 cfg.branchPrefix 주입. 빈 prefix는 config 스키마가 금지(D11 무력화 방지).
52
+ branchPrefix: string
53
+ // D18 granularity 임계(config, advisory). 미지정 시 GRANULARITY_MAX_FILES(현재 동작). main이 cfg.granularityMaxFiles 주입.
54
+ granularityMaxFiles?: number
55
+ stagedTree: string
56
+ statusLines: string[]
57
+ scratch: string[]
58
+ responseVerdict: Verdict | null
59
+ responseStructureOk: boolean
60
+ // D13(design 선행 + freshness): 유효 승인 = designApproved && designApprovedHash === currentDesignHash.
61
+ designApproved: boolean
62
+ designApprovedHash: string | null
63
+ currentDesignHash: string | null // 현재 00/01/02 index 재계산 해시(미추적 등 계산 불가 시 null → 승인 무효)
64
+ ticketDocs: string[] // 현재 티켓 docs(00/01/02/codex-request)의 exact repo-rel 경로 — D13 코드/문서 분류용
65
+ // A2(D-016-5/6): 승인 증거 아카이브 정본 검증(D16 phase·D17 design)용 입력. main()이 채움(미지정 시 legacy/2-arg 동작).
66
+ ticketRel?: string // responses/ 스크래치 매처(D10)용
67
+ approvalEvidenceRequired?: boolean // state.approval_evidence_required(신규 REQ면 FAIL, legacy면 WARN)
68
+ approvalEvidence?: ApprovalEvidence | null // state.approval_evidence(phase)
69
+ designApprovalEvidence?: ApprovalEvidence | null // state.design_approval_evidence(design)
70
+ approvalArchive?: ArchiveCheck | null // approvalEvidence.response_path 온디스크 검사
71
+ designArchive?: ArchiveCheck | null // designApprovalEvidence.response_path 온디스크 검사
72
+ liveResponseSha256?: string | null // 현재 codex-response.json 바이트 sha — D16(phase) live↔evidence 일치(D-016-5). design 미사용.
73
+ // B3: finalize(복구) 모드. D9 비교 대상을 staged tree → pending_evidence_for.source_commit_sha의 source 커밋 tree로 교체(우회 아님).
74
+ finalize?: boolean
75
+ finalizeSourceTree?: string | null // git rev-parse <pending.source_commit_sha>^{tree}
76
+ }
77
+
78
+ /**
79
+ * D9 검사(순수, 정상/finalize 공용). commit_allowed=true일 때 tree == approved_diff_hash.
80
+ * - 정상(finalize=false): staged tree와 비교.
81
+ * - finalize=true(B3 복구): **pending_evidence_for.source_commit_sha의 source 커밋 tree**와 비교 — source 재커밋 없이 evidence/consume만 복구.
82
+ * ⚠️ B3-P1: HEAD 커밋 tree가 아니라 **source 커밋 tree**(HEAD는 evidence 커밋일 수 있음). 우회가 아님 — 비교 대상만 교체, fail-closed(source tree 없거나 불일치 시 FAIL).
83
+ */
84
+ export function finalizeD9Check(opts: {
85
+ commitAllowed: boolean
86
+ finalize: boolean
87
+ approvedDiffHash: string | null
88
+ stagedTree: string | null
89
+ finalizeSourceTree: string | null
90
+ }): { ok: boolean; msg: string } {
91
+ if (!opts.commitAllowed) return { ok: true, msg: 'commit_allowed=false(점검 불요)' }
92
+ if (!opts.approvedDiffHash) return { ok: false, msg: 'commit_allowed=true인데 approved_diff_hash 없음' }
93
+ if (opts.finalize) {
94
+ const ok = opts.finalizeSourceTree !== null && opts.finalizeSourceTree === opts.approvedDiffHash
95
+ return ok
96
+ ? { ok: true, msg: 'finalize: source 커밋 tree == approved(복구 유효)' }
97
+ : { ok: false, msg: `finalize: source 커밋 tree(${String(opts.finalizeSourceTree)}) != approved(${opts.approvedDiffHash}) — pending 마커 없음/불일치(정상 req:commit 사용)` }
98
+ }
99
+ const ok = opts.stagedTree === opts.approvedDiffHash
100
+ return ok
101
+ ? { ok: true, msg: 'staged tree == approved' }
102
+ : { ok: false, msg: `staged tree(${String(opts.stagedTree)}) != approved(${opts.approvedDiffHash}) — stale 승인` }
103
+ }
104
+
105
+ /** Phase C granularity 정책: phase당 코드 변경 권고 상한(초과 시 D18 WARN — 분할 권고). FAIL 아님(advisory). */
106
+ export const GRANULARITY_MAX_FILES = 8
107
+
108
+ /**
109
+ * Phase 분할 권고(순수, advisory). phase 1개의 코드 변경 파일 수가 maxFiles 초과면 WARN 메시지(빈 배열=권고 없음).
110
+ * ⚠️ 절대 FAIL 아님 — 리뷰 면적을 줄이도록 **다음 phase부터** 분할을 유도하는 조언일 뿐(이미 큰 phase를 막지 않음).
111
+ */
112
+ export function phaseGranularityWarnings(codeFiles: string[], maxFiles: number): string[] {
113
+ if (codeFiles.length > maxFiles)
114
+ return [`phase 코드 변경 ${codeFiles.length}파일 > 권고 ${maxFiles} — 리뷰 면적 큼, 다음부터 phase 분할 권고(granularity 정책)`]
115
+ return []
116
+ }
117
+
118
+ /** 승인 증거 아카이브 파일의 온디스크 검사(main이 읽어 채움 — runChecks는 순수). */
119
+ export interface ArchiveCheck {
120
+ exists: boolean
121
+ sha256: string | null
122
+ verdict: Verdict | null
123
+ structureOk: boolean
124
+ }
125
+
126
+ /**
127
+ * evidence `response_path`가 **현재 티켓 `responses/` 직계 아카이브**인지(D-016 confinement).
128
+ * 절대경로·`..`·다른 티켓·중첩경로·`approvals.jsonl` 등 비아카이브는 거부. ticketRel 미지정 시 false(fail-closed).
129
+ */
130
+ export function isConfinedArchivePath(p: string, ticketRel: string | undefined): boolean {
131
+ if (!ticketRel || typeof p !== 'string' || !p) return false
132
+ const norm = p.replace(/\\/g, '/')
133
+ if (norm.includes('..') || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm)) return false
134
+ const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
135
+ if (!norm.startsWith(prefix)) return false
136
+ const name = norm.slice(prefix.length)
137
+ if (!name || name.includes('/')) return false
138
+ return isArchiveFileName(name)
139
+ }
140
+
141
+ /**
142
+ * 승인 증거(evidence)와 그 아카이브 파일의 정합 문제 목록(순수, A2/D-016-5).
143
+ * evidence 누락 → 단일 문제. 그 외: 경로 confinement·아카이브 존재·SHA·구조(AJV)·validateVerdict·review_kind·승인 status·바인딩 정합.
144
+ * base-sha 검증은 **evidence 자신의 review_base_sha 기준**(design 승인 base는 고정 — 이후 phase의 state.review_base_sha 변동과 무관, 오탐 방지).
145
+ */
146
+ function evidenceProblems(
147
+ ev: ApprovalEvidence | null | undefined,
148
+ archive: ArchiveCheck | null | undefined,
149
+ kind: 'phase' | 'design',
150
+ s: WorkflowState,
151
+ ticketRel: string | undefined,
152
+ liveResponseSha256?: string | null,
153
+ ): string[] {
154
+ const r: string[] = []
155
+ if (!ev) return [`${kind} 승인 증거(evidence) 누락`]
156
+ if (!isConfinedArchivePath(ev.response_path, ticketRel))
157
+ r.push(`response_path가 현재 티켓 responses/ 직계 아카이브가 아님: ${ev.response_path}`)
158
+ if (!archive || !archive.exists) {
159
+ r.push(`아카이브 파일 없음: ${ev.response_path}`)
160
+ return r
161
+ }
162
+ if (archive.sha256 !== ev.response_sha256) r.push(`아카이브 SHA 불일치(기대 ${ev.response_sha256})`)
163
+ if (!archive.structureOk) r.push('아카이브 구조(AJV) 비적합')
164
+ const v = archive.verdict
165
+ if (!v) r.push('아카이브 verdict 파싱 불가')
166
+ else {
167
+ // base-sha 교차검증은 아래 evidence vs 아카이브 비교로 한다(state.review_base_sha 기준 금지 — design 오탐).
168
+ const dom = validateVerdict(v)
169
+ if (!dom.ok) r.push(...dom.errors)
170
+ if (v.review_kind !== kind) r.push(`아카이브 review_kind 불일치(기대 ${kind}, 실제 ${String(v.review_kind)})`)
171
+ if (v.commit_approved !== 'yes') r.push(`아카이브 commit_approved≠yes(${String(v.commit_approved)})`)
172
+ if (v.status !== 'STEP_COMPLETE' && v.status !== 'COMPLETE') r.push(`아카이브 status 비승인(${String(v.status)})`)
173
+ if (ev.review_base_sha !== v.review_base_sha) r.push('evidence review_base_sha != 아카이브 review_base_sha')
174
+ }
175
+ if (ev.review_kind !== kind) r.push(`evidence review_kind 불일치(${String(ev.review_kind)})`)
176
+ if (kind === 'phase') {
177
+ const approvedTree = typeof s.approved_diff_hash === 'string' ? s.approved_diff_hash : null
178
+ if (!approvedTree || ev.approved_tree !== approvedTree) r.push('evidence approved_tree != state.approved_diff_hash')
179
+ // phase evidence만 현재 state.review_base_sha와 일치 요구(design 고정 base는 비교 안 함).
180
+ if (typeof s.review_base_sha === 'string' && ev.review_base_sha !== s.review_base_sha)
181
+ r.push('evidence review_base_sha != state.review_base_sha')
182
+ // D-016-5(A2-R3): live codex-response.json(있으면)은 pinned evidence와 동일 SHA여야 함 — D6가 phase 게이팅에 live 응답을 쓰므로.
183
+ // (design은 미적용 — live 파일은 단일 캐시라 이후 phase 리뷰가 덮으면 phase 응답이 됨, D17은 archive SHA로만 검증.)
184
+ if (typeof liveResponseSha256 === 'string' && liveResponseSha256 !== ev.response_sha256)
185
+ r.push('live codex-response.json SHA != evidence response_sha256 (손편집 의심)')
186
+ } else {
187
+ const dh = typeof s.design_approved_hash === 'string' ? s.design_approved_hash : null
188
+ if (!dh || ev.design_hash !== dh) r.push('evidence design_hash != state.design_approved_hash')
189
+ }
190
+ return r
191
+ }
192
+
193
+ /**
194
+ * porcelain v1 라인의 **모든** 변경 경로 추출. 비-ASCII는 core.quotePath=false 전제.
195
+ * rename/copy(`R`/`C`, ` -> `)는 [원본, 목적지] **둘 다** 반환 — D13이 양쪽 모두 검사하여
196
+ * 비허용 경로→허용 경로 rename으로 코드 삭제를 우회하는 것을 차단(Codex P2). 그 외는 단일 경로.
197
+ */
198
+ export function statusPaths(line: string): string[] {
199
+ if (line.length < 4) return []
200
+ const body = line.slice(3).replace(/\\/g, '/')
201
+ const arrow = body.indexOf(' -> ')
202
+ if (arrow >= 0) return [body.slice(0, arrow), body.slice(arrow + 4)]
203
+ return [body]
204
+ }
205
+
206
+ /** 순수: 입력으로부터 1차 최소셋 점검 결과 산출(부수효과 없음 — 테스트 용이). */
207
+ export function runChecks(inp: DoctorInputs): Check[] {
208
+ const c: Check[] = []
209
+ const s = inp.state
210
+ const branch = typeof s.branch === 'string' ? s.branch : ''
211
+ const phase = String(s.phase)
212
+ const commitAllowed = s.commit_allowed === true
213
+
214
+ // D2: state.branch == 현재 브랜치
215
+ if (branch && inp.currentBranch !== branch)
216
+ c.push({ id: 'D2', level: 'FAIL', msg: `state.branch(${branch}) != current(${inp.currentBranch})` })
217
+ else c.push({ id: 'D2', level: 'OK', msg: 'branch 일치' })
218
+
219
+ // D3: state.branch 로컬 존재
220
+ if (branch && !inp.branchExists) c.push({ id: 'D3', level: 'FAIL', msg: `state.branch 로컬에 없음: ${branch}` })
221
+ else c.push({ id: 'D3', level: 'OK', msg: 'branch 존재' })
222
+
223
+ // D5: codex_thread_id 형식(설정 시 UUID)
224
+ const tid = s.codex_thread_id
225
+ if (typeof tid === 'string' && tid.length > 0 && !UUID_RE.test(tid))
226
+ c.push({ id: 'D5', level: 'FAIL', msg: `codex_thread_id 형식 오류: ${tid}` })
227
+ else c.push({ id: 'D5', level: 'OK', msg: 'thread_id 형식 OK(또는 미설정)' })
228
+
229
+ // D6: commit_allowed=true → 온디스크 응답 재파싱·재검증 + **실제 승인 여부**·state 바인딩 정합(§9.6, DEC-WF-025).
230
+ // 저장 플래그(commit_allowed)를 믿지 않고, 응답이 정말로 승인(commit_approved=yes·승인 status)했는지 + 바인딩 필드가 정합한지 재확인.
231
+ if (commitAllowed) {
232
+ const reasons: string[] = []
233
+ const v = inp.responseVerdict
234
+ if (!v) {
235
+ reasons.push('codex-response.json 없음/파손')
236
+ } else {
237
+ if (!inp.responseStructureOk) reasons.push('구조(AJV) 비적합')
238
+ const dom = validateVerdict(v, {
239
+ reviewBaseSha: typeof s.review_base_sha === 'string' ? s.review_base_sha : undefined,
240
+ })
241
+ if (!dom.ok) reasons.push(...dom.errors)
242
+ if (v.commit_approved !== 'yes') reasons.push(`응답 commit_approved≠yes(${String(v.commit_approved)})`)
243
+ if (v.status !== 'STEP_COMPLETE' && v.status !== 'COMPLETE')
244
+ reasons.push(`응답 status 비승인(${String(v.status)})`)
245
+ }
246
+ const baseSha = typeof s.review_base_sha === 'string' ? s.review_base_sha : ''
247
+ const reviewTree = typeof s.review_diff_hash === 'string' ? s.review_diff_hash : ''
248
+ const approvedTree = typeof s.approved_diff_hash === 'string' ? s.approved_diff_hash : ''
249
+ if (!baseSha) reasons.push('state.review_base_sha 없음')
250
+ if (!reviewTree) reasons.push('state.review_diff_hash 없음')
251
+ if (!approvedTree) reasons.push('state.approved_diff_hash 없음')
252
+ else if (reviewTree && approvedTree !== reviewTree) reasons.push('approved_diff_hash != review_diff_hash')
253
+
254
+ if (reasons.length)
255
+ c.push({ id: 'D6', level: 'FAIL', msg: `commit_allowed=true 재검증 실패: ${reasons.join('; ')}` })
256
+ else c.push({ id: 'D6', level: 'OK', msg: '재검증 OK(승인 verdict + 바인딩 정합)' })
257
+ } else c.push({ id: 'D6', level: 'OK', msg: 'commit_allowed=false(점검 불요)' })
258
+
259
+ // D9: commit_allowed=true → tree == approved_diff_hash(§8.4). 정상=staged tree, **finalize(B3)=현재 HEAD 커밋 tree**.
260
+ // finalize는 우회가 아니라 비교 **대상만** 교체(여전히 fail-closed) — source 재커밋 없이 evidence/consume만 복구.
261
+ {
262
+ const d9 = finalizeD9Check({
263
+ commitAllowed,
264
+ finalize: inp.finalize === true,
265
+ approvedDiffHash: typeof s.approved_diff_hash === 'string' ? s.approved_diff_hash : null,
266
+ stagedTree: inp.stagedTree,
267
+ finalizeSourceTree: inp.finalizeSourceTree ?? null,
268
+ })
269
+ c.push({ id: 'D9', level: d9.ok ? 'OK' : 'FAIL', msg: d9.msg })
270
+ }
271
+
272
+ // D10: unstaged/untracked(비-스크래치) — review용 클린. A2: ticketRel 전달 시 responses/ untracked 아카이브만 스크래치 허용(tracked 변조·approvals.jsonl·타 티켓 flag).
273
+ const dirty = findUnstagedOrUntracked(inp.statusLines, inp.scratch, inp.ticketRel)
274
+ if (dirty.length) c.push({ id: 'D10', level: 'FAIL', msg: `unstaged/untracked 존재:\n ${dirty.join('\n ')}` })
275
+ else c.push({ id: 'D10', level: 'OK', msg: '워킹트리 클린(staged + 스크래치)' })
276
+
277
+ // D11: phase≠DONE인데 main 또는 비-<branchPrefix>* 브랜치(DEC-WF-020). branchPrefix=config(기본 feat/req-).
278
+ if (phase !== 'DONE' && (inp.currentBranch === 'main' || !branch.startsWith(inp.branchPrefix)))
279
+ c.push({
280
+ id: 'D11',
281
+ level: 'FAIL',
282
+ msg: `REQ 작업이 자기 feature 브랜치 밖(current=${inp.currentBranch}, state.branch=${branch || '(없음)'})`,
283
+ })
284
+ else c.push({ id: 'D11', level: 'OK', msg: 'feature 브랜치 OK' })
285
+
286
+ // D13: design 선행 게이트(DEC-WF-027). 유효 design 승인(freshness 포함) 없으면 비-티켓 코드 변경 금지.
287
+ // 유효 승인 = design_approved=true AND design_approved_hash === 현재 00/01/02 index 재계산 해시(불일치=승인 후 설계 변경→무효).
288
+ // 코드 변경 = statusLines(staged/unstaged/untracked)의 경로 중 **티켓 docs/scratch 외**(exact 매칭 — 다른 REQ·.bak·src 모두 코드).
289
+ const validDesign =
290
+ inp.designApproved === true &&
291
+ typeof inp.designApprovedHash === 'string' &&
292
+ inp.designApprovedHash.length > 0 &&
293
+ inp.designApprovedHash === inp.currentDesignHash
294
+ // A2(A2-R2-P2-1): 허용된 untracked 응답 아카이브(D10 scratch)는 D13 코드변경 분류에서도 제외 — D10/D13 scratch 정책 일치.
295
+ // (tracked evidence 변조·approvals.jsonl·타 티켓·collapsed dir은 isAllowedResponsesScratch=false라 제외되지 않음 → D10/D13 모두 FAIL 유지.)
296
+ const responsesScratch = inp.ticketRel
297
+ ? inp.statusLines.filter((l) => isAllowedResponsesScratch(l, inp.ticketRel as string)).flatMap(statusPaths)
298
+ : []
299
+ const allowD13 = new Set([...inp.ticketDocs, ...inp.scratch, ...responsesScratch].map((p) => p.replace(/\\/g, '/')))
300
+ const codeChanges = [...new Set(inp.statusLines.flatMap(statusPaths).filter((p) => p !== '' && !allowD13.has(p)))]
301
+ if (!validDesign && codeChanges.length)
302
+ c.push({
303
+ id: 'D13',
304
+ level: 'FAIL',
305
+ msg: `유효 design 승인 없이 비-티켓 코드 변경 존재(설계 선행 위반): ${codeChanges.join(', ')}`,
306
+ })
307
+ else
308
+ c.push({
309
+ id: 'D13',
310
+ level: 'OK',
311
+ msg: validDesign ? 'design 승인 유효(freshness OK) — 코드 변경 허용' : '비-티켓 코드 변경 없음',
312
+ })
313
+
314
+ // D18(Phase C, granularity 정책): phase 코드 변경 파일 수가 임계 초과면 분할 권고. **advisory WARN — 절대 FAIL 아님**.
315
+ // 임계 = config(cfg.granularityMaxFiles) 주입, 미지정 시 GRANULARITY_MAX_FILES(현재 동작).
316
+ {
317
+ const maxFiles = inp.granularityMaxFiles ?? GRANULARITY_MAX_FILES
318
+ const adv = phaseGranularityWarnings(codeChanges, maxFiles)
319
+ if (adv.length) c.push({ id: 'D18', level: 'WARN', msg: adv.join(' / ') })
320
+ else c.push({ id: 'D18', level: 'OK', msg: `granularity OK(코드 변경 ${codeChanges.length}파일 ≤ ${maxFiles})` })
321
+ }
322
+
323
+ // D15: 온디스크 응답이 NEEDS_FIX면 findings·next_action이 actionable해야 함(스키마/validateVerdict와 중복이라도 명시 점검).
324
+ // typeof 가드: 파손된 next_action(비-문자열)이 .trim()에서 throw하지 않게(fail-closed).
325
+ const rv = inp.responseVerdict
326
+ if (rv && rv.status === 'NEEDS_FIX') {
327
+ const findingsOk = Array.isArray(rv.findings) && rv.findings.length > 0
328
+ const nextOk = typeof rv.next_action === 'string' && rv.next_action.trim().length > 0
329
+ if (!findingsOk || !nextOk)
330
+ c.push({
331
+ id: 'D15',
332
+ level: 'FAIL',
333
+ msg: `NEEDS_FIX 응답인데 actionable 아님(findings ${findingsOk ? 'OK' : '없음'}, next_action ${nextOk ? 'OK' : '공백'})`,
334
+ })
335
+ else c.push({ id: 'D15', level: 'OK', msg: 'NEEDS_FIX 응답 actionable(findings + next_action)' })
336
+ } else c.push({ id: 'D15', level: 'OK', msg: 'NEEDS_FIX 응답 아님(점검 불요)' })
337
+
338
+ // D16(A2/D-016-5): phase 승인 증거 아카이브 정본 검증. commit_allowed=true일 때만.
339
+ // 신규 REQ(approval_evidence_required)면 누락/불일치 FAIL, legacy면 (증거 없음=OK / 증거 있는데 불일치=WARN). 기존 D6/D9 대체 아님(추가 게이트).
340
+ if (commitAllowed) {
341
+ const required = inp.approvalEvidenceRequired === true
342
+ if (!required && !inp.approvalEvidence) {
343
+ c.push({ id: 'D16', level: 'OK', msg: 'legacy(증거 미요구) — 점검 불요' })
344
+ } else {
345
+ const problems = evidenceProblems(inp.approvalEvidence, inp.approvalArchive, 'phase', s, inp.ticketRel, inp.liveResponseSha256)
346
+ if (problems.length === 0) c.push({ id: 'D16', level: 'OK', msg: 'phase 승인 증거 아카이브 정합' })
347
+ else if (required) c.push({ id: 'D16', level: 'FAIL', msg: `phase 승인 증거 검증 실패: ${problems.join('; ')}` })
348
+ else c.push({ id: 'D16', level: 'WARN', msg: `phase 승인 증거 미정합(legacy): ${problems.join('; ')}` })
349
+ }
350
+ } else c.push({ id: 'D16', level: 'OK', msg: 'commit_allowed=false(점검 불요)' })
351
+
352
+ // D17(A2/D-016-5·6): design 승인 증거 아카이브 정본 검증. design_approved=true일 때만(D13 freshness와 별개의 증거 게이트).
353
+ if (inp.designApproved === true) {
354
+ const required = inp.approvalEvidenceRequired === true
355
+ if (!required && !inp.designApprovalEvidence) {
356
+ c.push({ id: 'D17', level: 'OK', msg: 'legacy(증거 미요구) — 점검 불요' })
357
+ } else {
358
+ const problems = evidenceProblems(inp.designApprovalEvidence, inp.designArchive, 'design', s, inp.ticketRel)
359
+ if (problems.length === 0) c.push({ id: 'D17', level: 'OK', msg: 'design 승인 증거 아카이브 정합' })
360
+ else if (required) c.push({ id: 'D17', level: 'FAIL', msg: `design 승인 증거 검증 실패: ${problems.join('; ')}` })
361
+ else c.push({ id: 'D17', level: 'WARN', msg: `design 승인 증거 미정합(legacy): ${problems.join('; ')}` })
362
+ }
363
+ } else c.push({ id: 'D17', level: 'OK', msg: 'design_approved=false(점검 불요)' })
364
+
365
+ return c
366
+ }
367
+
368
+ // ──────────────────────────────────────────────────────────────── CLI ──
369
+
370
+ export interface DoctorArgs {
371
+ ticket: string | null
372
+ reqId: string | null
373
+ finalize: boolean
374
+ root: string | null
375
+ }
376
+
377
+ /** CLI 파싱(fail-closed). `--ticket`·`--finalize`(B3)·`--root <dir>`(config 탐색 루트). 알 수 없는 옵션·`--root` 값 누락은 throw. */
378
+ export function parseArgs(argv: string[]): DoctorArgs {
379
+ let ticket: string | null = null
380
+ let reqId: string | null = null
381
+ let finalize = false
382
+ let root: string | null = null
383
+ for (let i = 0; i < argv.length; i++) {
384
+ const a = argv[i]
385
+ if (a === undefined) continue
386
+ if (a === '--ticket') ticket = argv[++i] ?? null
387
+ else if (a === '--finalize') finalize = true // B3: D9를 finalize(source tree) 모드로
388
+ else if (a === '--root') {
389
+ const v = argv[++i]
390
+ if (v === undefined) throw new Error('--root 값 필요')
391
+ root = v
392
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
393
+ else reqId = a
394
+ }
395
+ return { ticket, reqId, finalize, root }
396
+ }
397
+
398
+ function resolveTicketDir(opts: DoctorArgs, cfg: ResolvedConfig): string {
399
+ if (opts.ticket) return resolve(opts.ticket)
400
+ if (opts.reqId) return join(cfg.workflowDirAbs, `REQ-${opts.reqId.replace(/^REQ-/, '')}`)
401
+ throw new Error('REQ id 또는 --ticket <dir> 필요')
402
+ }
403
+
404
+ function branchExistsLocal(branch: string): boolean {
405
+ if (!branch) return false
406
+ try {
407
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
408
+ return true
409
+ } catch {
410
+ return false
411
+ }
412
+ }
413
+
414
+ function main(): void {
415
+ const opts = parseArgs(process.argv.slice(2))
416
+ const cfg = loadConfig({ root: opts.root })
417
+ gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
418
+ const ticketDir = resolveTicketDir(opts, cfg)
419
+ const finalize = opts.finalize // B3: D9를 source tree 모드로
420
+ const state = loadState(ticketDir)
421
+
422
+ const respPath = join(ticketDir, 'codex-response.json')
423
+ let responseVerdict: Verdict | null = null
424
+ let responseStructureOk = false
425
+ let liveResponseSha256: string | null = null
426
+ if (existsSync(respPath)) {
427
+ const bytes = readFileSync(respPath)
428
+ liveResponseSha256 = createHash('sha256').update(bytes).digest('hex') // D16 live↔evidence SHA(D-016-5)
429
+ try {
430
+ responseVerdict = JSON.parse(bytes.toString('utf8')) as Verdict
431
+ responseStructureOk = validateResponseStructure(responseVerdict, cfg.schemaPathAbs).ok
432
+ } catch {
433
+ responseVerdict = null
434
+ }
435
+ }
436
+
437
+ const repoRel = (abs: string) => relative(cfg.root, abs).replace(/\\/g, '/')
438
+ const ticketRel = repoRel(ticketDir)
439
+
440
+ // D13 freshness: 현재 설계문서 index 해시 재계산. 문서 미추적 등으로 계산 불가면 null(→ 유효 승인 불가, fail-closed).
441
+ let currentDesignHash: string | null = null
442
+ try {
443
+ currentDesignHash = captureDesignBinding(ticketRel, git, cfg.designDocs).designHash
444
+ } catch {
445
+ currentDesignHash = null
446
+ }
447
+
448
+ // A2: 승인 증거 아카이브 온디스크 검사(D16/D17). evidence.response_path 파일을 읽어 sha/verdict/구조를 채움.
449
+ const readArchive = (ev: ApprovalEvidence | null): ArchiveCheck | null => {
450
+ if (!ev || typeof ev.response_path !== 'string' || !ev.response_path) return null
451
+ // confinement: 현재 티켓 responses/ 직계 아카이브만 읽음(범위 밖 경로는 미존재 처리 → evidenceProblems가 FAIL).
452
+ if (!isConfinedArchivePath(ev.response_path, ticketRel)) return { exists: false, sha256: null, verdict: null, structureOk: false }
453
+ const abs = resolve(cfg.root, ev.response_path)
454
+ if (!existsSync(abs)) return { exists: false, sha256: null, verdict: null, structureOk: false }
455
+ try {
456
+ const bytes = readFileSync(abs)
457
+ let v: Verdict | null = null
458
+ let sOk = false
459
+ try {
460
+ v = JSON.parse(bytes.toString('utf8')) as Verdict
461
+ sOk = validateResponseStructure(v, cfg.schemaPathAbs).ok
462
+ } catch {
463
+ v = null
464
+ }
465
+ return { exists: true, sha256: createHash('sha256').update(bytes).digest('hex'), verdict: v, structureOk: sOk }
466
+ } catch {
467
+ return { exists: false, sha256: null, verdict: null, structureOk: false }
468
+ }
469
+ }
470
+ const approvalEvidence = (state.approval_evidence as ApprovalEvidence | undefined) ?? null
471
+ const designApprovalEvidence = (state.design_approval_evidence as ApprovalEvidence | undefined) ?? null
472
+
473
+ // B3 finalize: pending_evidence_for.source_commit_sha의 source 커밋 tree(없거나 계산 불가 → null → D9 FAIL).
474
+ let finalizeSourceTree: string | null = null
475
+ if (finalize) {
476
+ const pending = state.pending_evidence_for as { source_commit_sha?: unknown } | undefined
477
+ const sha = pending && typeof pending.source_commit_sha === 'string' && pending.source_commit_sha ? pending.source_commit_sha : null
478
+ if (sha) {
479
+ try {
480
+ finalizeSourceTree = git(['rev-parse', `${sha}^{tree}`])
481
+ } catch {
482
+ finalizeSourceTree = null
483
+ }
484
+ }
485
+ }
486
+
487
+ const inp: DoctorInputs = {
488
+ state,
489
+ currentBranch: git(['rev-parse', '--abbrev-ref', 'HEAD']),
490
+ branchExists: branchExistsLocal(typeof state.branch === 'string' ? state.branch : ''),
491
+ branchPrefix: cfg.branchPrefix,
492
+ granularityMaxFiles: cfg.granularityMaxFiles,
493
+ stagedTree: git(['write-tree']),
494
+ // --untracked-files=all: untracked 디렉터리 collapse(`?? responses/`) 방지 — responses/ 아카이브를 개별 파일로 판단(A2-P2 후속).
495
+ statusLines: git(['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean),
496
+ scratch: [
497
+ repoRel(join(ticketDir, 'codex-response.json')),
498
+ repoRel(join(ticketDir, '.review-preview.txt')),
499
+ repoRel(join(ticketDir, 'state.json')), // 도구가 쓰는 메타데이터(4C e2e: review-codex 후 unstaged) — D10 제외
500
+ ],
501
+ responseVerdict,
502
+ responseStructureOk,
503
+ designApproved: state.design_approved === true,
504
+ designApprovedHash: typeof state.design_approved_hash === 'string' ? state.design_approved_hash : null,
505
+ currentDesignHash,
506
+ ticketDocs: [...designDocPaths(ticketRel, cfg.designDocs), `${ticketRel}/codex-request.md`],
507
+ ticketRel,
508
+ approvalEvidenceRequired: state.approval_evidence_required === true,
509
+ approvalEvidence,
510
+ designApprovalEvidence,
511
+ approvalArchive: readArchive(approvalEvidence),
512
+ designArchive: readArchive(designApprovalEvidence),
513
+ liveResponseSha256,
514
+ finalize,
515
+ finalizeSourceTree,
516
+ }
517
+
518
+ const checks = runChecks(inp)
519
+ for (const c of checks) console.log(`[req:doctor] ${c.level} ${c.id}: ${c.msg}`)
520
+ const fails = checks.filter((c) => c.level === 'FAIL')
521
+ console.log(`[req:doctor] ${fails.length ? `FAIL ${fails.length}건` : 'PASS'} (REQ=${state.id})`)
522
+ if (fails.length) process.exit(1)
523
+ }
524
+
525
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
526
+ if (isMain) main()