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,197 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:review-exception — needs-exception 구간의 사람 예외를 **검증·원자 기록**한다(REQ-2026-055·DEC-RE).
4
+ *
5
+ * 지금까지 `state.json`의 `review_exception_confirmed`를 손으로 편집하던 것을 대체한다: 현재 예산이 실제
6
+ * needs-exception인지·어느 series·회차인지 **소비 게이트와 같은 함수**로 계산하고, 구조화 rationale을 durable하게
7
+ * 남긴 **뒤에만** 소비 가능한 state를 기록한다(부분 실패 시 rationale 없는 예외 방지).
8
+ *
9
+ * 🔴 durable 먼저(review-exceptions.jsonl 커밋) → state.json 마지막. 🔴 confirmed_at 실시계(날조 금지).
10
+ * 🔴 소비 로직(consumeReviewException)·예산 게이트 무변경 — 이 명령은 **기록만** 한다.
11
+ *
12
+ * 사용: req:review-exception <REQ> --kind design|phase [--phase <id>] --method "<승인문장>" --rationale-file <path> [--run]
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join, relative, resolve } from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+ import { loadConfig, packageRoot, type ReviewBudget } from './lib/config'
18
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
19
+ import {
20
+ loadState,
21
+ writeState,
22
+ openSeriesRecord,
23
+ openSeriesAttempts,
24
+ checkReviewBudget,
25
+ isSeriesKeyTerminal,
26
+ type WorkflowState,
27
+ type ReviewKind,
28
+ type ReviewExceptionConfirmed,
29
+ } from './review-codex'
30
+ import {
31
+ exceptionsPath,
32
+ appendExceptionGrant,
33
+ findExistingGrant,
34
+ parseRationale,
35
+ type ExceptionGrantRow,
36
+ } from './lib/review-exception'
37
+
38
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
39
+ function git(args: string[]): string {
40
+ return gitAdapter.exec(args)
41
+ }
42
+
43
+ export interface Opts {
44
+ reqId: string | null
45
+ kind: ReviewKind | null
46
+ phase: string | null
47
+ method: string | null
48
+ rationaleFile: string | null
49
+ run: boolean
50
+ root: string | null
51
+ }
52
+
53
+ /** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. */
54
+ export function parseArgs(argv: string[]): Opts {
55
+ const o: Opts = { reqId: null, kind: null, phase: null, method: null, rationaleFile: null, run: false, root: null }
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]
58
+ if (a === undefined) continue
59
+ if (a === '--') continue
60
+ else if (a === '--run') o.run = true
61
+ else if (a === '--kind') {
62
+ const v = argv[++i]
63
+ if (v !== 'design' && v !== 'phase') throw new Error(`--kind 값은 design 또는 phase여야 함 (받음: ${v ?? '(없음)'})`)
64
+ o.kind = v
65
+ } else if (a === '--phase') {
66
+ const v = argv[++i]
67
+ if (v === undefined) throw new Error('--phase 값 필요')
68
+ o.phase = v
69
+ } else if (a === '--method') {
70
+ const v = argv[++i]
71
+ if (v === undefined) throw new Error('--method 값 필요')
72
+ o.method = v
73
+ } else if (a === '--rationale-file') {
74
+ const v = argv[++i]
75
+ if (v === undefined) throw new Error('--rationale-file 값 필요')
76
+ o.rationaleFile = v
77
+ } else if (a === '--root') {
78
+ const v = argv[++i]
79
+ if (v === undefined) throw new Error('--root 값 필요')
80
+ o.root = v
81
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
82
+ else o.reqId = a
83
+ }
84
+ return o
85
+ }
86
+
87
+ export type ReviewExceptionPlan =
88
+ | { ok: true; seriesId: string; forAttempt: number }
89
+ | { ok: false; reason: string; hint: string }
90
+
91
+ /**
92
+ * 예외 부여 자격 판정(순수·DEC-RE1). 🔴 회차를 **소비 게이트와 같은 함수**(openSeriesAttempts·checkReviewBudget)로
93
+ * 계산 → consumeReviewException의 for_attempt 검증과 정확히 일치(REQ-2026-054 유효 회차 기준).
94
+ */
95
+ export function planReviewException(state: WorkflowState, kind: ReviewKind, phaseId: string | null, budget: ReviewBudget): ReviewExceptionPlan {
96
+ if (isSeriesKeyTerminal(state, kind, phaseId))
97
+ return { ok: false, reason: '이 series는 human-resolution으로 종결됨', hint: '예산 예외가 아니라 대체 REQ가 필요(req:new --successor-of)' }
98
+ const open = openSeriesRecord(state, kind, phaseId)
99
+ if (!open) return { ok: false, reason: '열린 series가 없음 — 예외 걸 대상이 없다', hint: '먼저 req:review-codex로 리뷰를 시작하세요' }
100
+ const openAttempts = openSeriesAttempts(state, kind, phaseId)
101
+ const decision = checkReviewBudget(openAttempts, budget)
102
+ if (decision.kind === 'allow')
103
+ return { ok: false, reason: `아직 예외 불요(유효 회차 ${openAttempts} < autoBudget ${budget.autoBudget})`, hint: '그냥 req:review-codex로 리뷰하세요' }
104
+ if (decision.kind === 'hard-blocked')
105
+ return { ok: false, reason: `예산 소진 — 예외로도 불가(hardCap ${budget.hardCap})`, hint: '종료하거나 정합한 대체 REQ를 작성하세요' }
106
+ return { ok: true, seriesId: open.series_id, forAttempt: decision.attempt }
107
+ }
108
+
109
+ export function main(argv: string[] = process.argv.slice(2)): void {
110
+ const o = parseArgs(argv)
111
+ if (!o.reqId) throw new Error('REQ 필요 (예: req:review-exception 2026-001 --kind design --method "…" --rationale-file r.md)')
112
+ if (!o.kind) throw new Error('--kind design|phase 필요')
113
+ if (o.kind === 'phase' && !o.phase) throw new Error('--kind phase는 --phase <id> 필요')
114
+ if (!o.method || o.method.trim() === '') throw new Error('--method "<승인문장>" 필요')
115
+ if (!o.rationaleFile) throw new Error('--rationale-file <path> 필요')
116
+
117
+ const cfg = loadConfig({ root: o.root })
118
+ gitAdapter = createGitAdapter(cfg.root)
119
+ const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
120
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
121
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
122
+ const state = loadState(ticketDir)
123
+ const phaseId = o.kind === 'phase' ? o.phase : null
124
+
125
+ // 1. 자격 판정 + rationale 검증(순수 — write 前 모든 거부 조건).
126
+ const plan = planReviewException(state, o.kind, phaseId, cfg.reviewBudget)
127
+ const rp = parseRationale(readFileSync(resolve(o.rationaleFile), 'utf8'))
128
+ if (!plan.ok) throw new Error(`${reqId} 예외 부여 불가: ${plan.reason}\n → ${plan.hint}`)
129
+ if (!rp.ok) throw new Error(`rationale 검증 실패(--rationale-file):\n - ${rp.problems.join('\n - ')}`)
130
+
131
+ console.log(`[req:review-exception] ${reqId} 예외 부여 계획: series=${plan.seriesId} for_attempt=${plan.forAttempt} (kind=${o.kind}${phaseId ? ` phase=${phaseId}` : ''})`)
132
+ if (!o.run) {
133
+ console.log('[req:review-exception] DRY-RUN — write 없음(--run 시 실행). rationale 4섹션 검증 OK.')
134
+ return
135
+ }
136
+
137
+ // 2. 🔴 clean 가드 — state 변경 前(DEC-RE4·r01 P1). 미커밋 review-exceptions를 HEAD 기반이 덮지 않게.
138
+ const exRel = exceptionsPath(ticketRel)
139
+ const exDirty = git(['status', '--porcelain', '--', exRel]).trim()
140
+ if (exDirty)
141
+ throw new Error(
142
+ `${reqId}: ${exRel}에 미커밋 변경이 있어 예외 기록을 거부합니다(fail-closed) — HEAD 기반 append가 미커밋 행을 덮을 수 있습니다.\n` +
143
+ ` 먼저 미커밋 변경을 커밋/정리한 뒤 다시 실행하세요.`,
144
+ )
145
+
146
+ // 3. 🔴 durable rationale 먼저. 기존 행이 있으면 confirmed_at 재사용(재실행 복구·conflict 아님).
147
+ const abs = join(cfg.root, exRel)
148
+ const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
149
+ const naturalKey = { ticket_id: reqId, series_id: plan.seriesId, for_attempt: plan.forAttempt }
150
+ const prior = findExistingGrant(existing, naturalKey)
151
+ const confirmedAt = prior ? prior.confirmed_at : new Date().toISOString()
152
+ const row: ExceptionGrantRow = {
153
+ ticket_id: reqId,
154
+ review_kind: o.kind,
155
+ phase_id: phaseId,
156
+ series_id: plan.seriesId,
157
+ for_attempt: plan.forAttempt,
158
+ method: o.method,
159
+ confirmed_at: confirmedAt,
160
+ rationale: rp.rationale,
161
+ reconstructed: false,
162
+ }
163
+ const res = appendExceptionGrant(existing, row)
164
+ if (res.outcome === 'conflict') throw new Error(`${reqId}: 예외 원장 충돌(fail-closed): ${res.problems.join('; ')}`)
165
+ if (res.outcome === 'appended') {
166
+ mkdirSync(dirname(abs), { recursive: true })
167
+ writeFileSync(abs, res.content, 'utf8')
168
+ git(['add', '--', exRel])
169
+ git(['commit', '-m', `chore(${reqId}): review exception grant ${plan.seriesId} #${plan.forAttempt}`, '--', exRel])
170
+ }
171
+ // res.outcome === 'duplicate' → durable 이미 존재(멱등·커밋 없음).
172
+
173
+ // 4. durable 확정 후에만 소비 가능한 state 기록(scratch). confirmed_at은 durable 행과 동일 값.
174
+ const exception: ReviewExceptionConfirmed = {
175
+ confirmed: true,
176
+ method: o.method,
177
+ confirmed_at: confirmedAt,
178
+ for_series_id: plan.seriesId,
179
+ for_attempt: plan.forAttempt,
180
+ note: rp.rationale.prev_findings.split('\n')[0]?.slice(0, 200) ?? '',
181
+ }
182
+ writeState(ticketDir, { ...state, review_exception_confirmed: exception } as WorkflowState)
183
+ console.log(`[req:review-exception] ✅ ${reqId} 예외 기록 완료 — rationale durable(${res.outcome === 'appended' ? '신규 커밋' : '기존 재사용'}) + review_exception_confirmed 기록. 이제 req:review-codex를 실행하세요.`)
184
+ }
185
+
186
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
187
+ export function runCli(argv: string[]): void {
188
+ try {
189
+ main(argv)
190
+ } catch (err) {
191
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
192
+ process.exitCode = 1
193
+ }
194
+ }
195
+
196
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
197
+ if (isMain) main()