commitgate 0.4.0 → 0.8.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.
@@ -21,6 +21,18 @@ export interface DesignDocs {
21
21
  plan: string
22
22
  }
23
23
 
24
+ /**
25
+ * codex 리뷰어의 추론강도(REQ-2026-013 P1). 실측 확정(R15): codex의 invalid-effort 거부 메시지가
26
+ * `none|minimal|low|medium|high|xhigh`를 지원값으로 명시. `null`은 override 생략(전역 상속) 탈출구.
27
+ */
28
+ export type ReviewReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
29
+
30
+ /** review 예산(REQ-2026-028 A-2a). config↔review-codex 순환 방지를 위해 여기(config)에 정의. */
31
+ export interface ReviewBudget {
32
+ autoBudget: number
33
+ hardCap: number
34
+ }
35
+
24
36
  /** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
25
37
  export interface RawConfig {
26
38
  ticketRoot?: string
@@ -32,6 +44,12 @@ export interface RawConfig {
32
44
  packageManager?: PackageManager
33
45
  granularityMaxFiles?: number
34
46
  designDocs?: Partial<DesignDocs>
47
+ /** codex 리뷰 모델(REQ-2026-013 P1). null = `-c model=` 생략(전역 상속). 미지정 = DEFAULTS. */
48
+ reviewModel?: string | null
49
+ /** codex 리뷰 추론강도(REQ-2026-013 P1). null = `-c model_reasoning_effort=` 생략. 미지정 = DEFAULTS. */
50
+ reviewReasoningEffort?: ReviewReasoningEffort | null
51
+ /** REQ-2026-028 A-2a: review 예산. 미지정 = DEFAULTS(5/8). hardCap≤8·autoBudget≤hardCap은 loadConfig 검증. */
52
+ reviewBudget?: ReviewBudget
35
53
  }
36
54
 
37
55
  /** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
@@ -45,6 +63,9 @@ export interface ResolvedConfig {
45
63
  packageManager: PackageManager
46
64
  granularityMaxFiles: number
47
65
  designDocs: DesignDocs
66
+ reviewModel: string | null
67
+ reviewReasoningEffort: ReviewReasoningEffort | null
68
+ reviewBudget: ReviewBudget
48
69
  // 파생(절대경로)
49
70
  workflowDirAbs: string
50
71
  schemaPathAbs: string
@@ -87,6 +108,13 @@ export const DEFAULTS = {
87
108
  packageManager: 'pnpm' as PackageManager,
88
109
  granularityMaxFiles: 8,
89
110
  designDocs: { requirement: '00-requirement.md', design: '01-design.md', plan: '02-plan.md' } as DesignDocs,
111
+ // REQ-2026-013 P1: 리뷰어 모델·추론강도 고정. 코어 기본은 DEFAULTS 중립성의 의도적 예외(D3) —
112
+ // 리뷰어 모델은 게이트 무결성 핵심이라 미고정 시 전역 ultra 상속이 곧 결함. 미지원 CLI는 config override/null.
113
+ // `as ... | null`은 handoffPath와 같은 이유(직접 import 소비자의 `| null` 계약 보존).
114
+ reviewModel: 'gpt-5.6-terra' as string | null,
115
+ reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
116
+ // REQ-2026-028 A-2a: review 예산. autoBudget=자동 허용 회차, hardCap=절대 상한(9번째 차단 → 8).
117
+ reviewBudget: { autoBudget: 5, hardCap: 8 } as ReviewBudget,
90
118
  }
91
119
 
92
120
  const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
@@ -104,6 +132,20 @@ export const CONFIG_SCHEMA = {
104
132
  branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
105
133
  packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
106
134
  granularityMaxFiles: { type: 'integer', minimum: 1 },
135
+ // REQ-2026-013 P1. null=override 생략(전역 상속). model은 slug 패턴(따옴표·개행 거부 → TOML `model="…"` 주입 안전; null은 pattern에 vacuously 통과).
136
+ reviewModel: { type: ['string', 'null'], pattern: BASENAME_RE },
137
+ // effort는 실측 확정 enum(R15) + null. null을 enum에 포함해야 `{effort:null}`이 통과(JSON Schema enum은 타입 무관 전체 적용).
138
+ reviewReasoningEffort: { type: ['string', 'null'], enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', null] },
139
+ // REQ-2026-028 A-2a: 예산. 스키마는 타입·상한(hardCap≤8·최소 1)까지. 교차검증(autoBudget≤hardCap)은 loadConfig.
140
+ reviewBudget: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ required: ['autoBudget', 'hardCap'],
144
+ properties: {
145
+ autoBudget: { type: 'integer', minimum: 1 },
146
+ hardCap: { type: 'integer', minimum: 1, maximum: 8 },
147
+ },
148
+ },
107
149
  designDocs: {
108
150
  type: 'object',
109
151
  additionalProperties: false,
@@ -184,8 +226,21 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
184
226
  packageManager: raw.packageManager ?? DEFAULTS.packageManager,
185
227
  granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
186
228
  designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
229
+ // REQ-2026-013 P1: nullable — 명시적 null 보존을 위해 `!== undefined`(`??` 금지: null이 기본값으로 복귀해 탈출구가 깨짐).
230
+ reviewModel: raw.reviewModel !== undefined ? raw.reviewModel : DEFAULTS.reviewModel,
231
+ reviewReasoningEffort:
232
+ raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
233
+ reviewBudget: raw.reviewBudget ?? DEFAULTS.reviewBudget,
187
234
  }
188
235
 
236
+ // REQ-2026-028 R7: 교차검증(스키마가 표현 못 함). AJV가 이미 hardCap∈[1,8]·autoBudget≥1을 잡았고,
237
+ // 여기서 autoBudget ≤ hardCap을 강제(fail-closed). R4("9번째는 어떤 경로로도 차단")는 설정을 넘는 코드
238
+ // 상수 경계다 — hardCap>8은 스키마가 거부하므로 config 한 줄로 뚫을 수 없다.
239
+ if (merged.reviewBudget.autoBudget > merged.reviewBudget.hardCap)
240
+ throw new Error(
241
+ `req.config: reviewBudget.autoBudget(${merged.reviewBudget.autoBudget}) > hardCap(${merged.reviewBudget.hardCap}) — autoBudget는 hardCap 이하여야 한다`,
242
+ )
243
+
189
244
  // repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
190
245
  // handoffPath만 면제 — 형제 repo의 SSOT 문서를 읽는 **외부 참조**이기 때문.
191
246
  // reviewPersonaPath는 패키지가 배포하고 init이 repo 안에 까는 자원이라 schemaPath와 같은 축이다(REQ-2026-010 D2).
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `git status --porcelain=v1 -z` 파서 (REQ-2026-012 phase-1a · 설계 D11·D11-1).
3
+ *
4
+ * 목적: porcelain 출력을 해석하는 **단일 지점**. 이전에는 세 곳이 각자 파싱했고 둘은 경로를 망가뜨렸다
5
+ * - `bin/init.ts`의 `parsePorcelainLine`(rename에서 dest만) — 삭제 예정(phase-1b)
6
+ * - `req-doctor.ts`의 `statusPaths`(인용 해제 없음 + 역슬래시를 `/`로 치환)
7
+ * - `review-codex.ts`의 `findUnstagedOrUntracked` 인라인 파싱(동일 결함)
8
+ *
9
+ * ⚠️ **왜 `-z`인가.** 기본 porcelain은 `"`·`\`·제어문자·(quotePath=true면)비-ASCII가 든 경로를 **C-인용**한다.
10
+ * `core.quotePath=false`는 비-ASCII 인용만 끄므로 나머지는 여전히 인용된다. 인용된 경로를 디코드하면
11
+ * "원문 보존"과 "디코드된 경로 반환"을 동시에 만족할 수 없고(설계 D11), 경로에 ` -> `가 들어가면
12
+ * rename delimiter 분할도 깨진다. `-z`는 **인용을 아예 하지 않으므로** 두 문제가 함께 사라진다.
13
+ *
14
+ * ⚠️ **rename/copy 필드 순서가 `->` 형식과 반대다.**
15
+ * `--porcelain` : `R old -> new`
16
+ * `--porcelain -z` : `R new\0old\0` ← NEW가 먼저
17
+ *
18
+ * ⚠️ **`R`/`C`는 index(X)와 worktree(Y) 양쪽에 온다**(설계 D11-1, `git-status(1)`의 `[ D] R`·`[ D] C`).
19
+ * 실측: `mv a c && git add -N c` → ` R c.txt\0a.txt\0` (X=' ', Y='R').
20
+ * X만 검사하면 OLD 경로가 **독립 레코드로 새어 나가고** `origPath`가 소실된다. 그러면
21
+ * `findUnstagedOrUntracked`/D13이 rename의 src·dest를 둘 다 검사해 막던
22
+ * "비허용 경로 → 허용 경로 rename으로 `responses/` 주입·코드 삭제 우회"(A2-P2-1)가 뚫린다. 보안 회귀다.
23
+ *
24
+ * ⚠️ **경로를 정규화하지 않는다.** git은 언제나 `/`를 구분자로 내므로 역슬래시는 **파일명의 일부**다.
25
+ * 옛 코드의 `.replace(/\\/g, '/')`는 `a\b.txt`를 `a/b.txt`로 뭉갰다 — 그 버그를 여기서 고친다.
26
+ *
27
+ * fail-closed: 형식이 어긋나거나 rename 레코드의 `origPath`가 없으면 throw한다. `undefined`를 흘리지 않는다.
28
+ */
29
+
30
+ /** porcelain v1 레코드 하나. `-z`이므로 `path`는 인용되지 않은 원문이다. */
31
+ export interface StatusEntry {
32
+ /** X — index(스테이지) 상태 문자. 변경 없음은 `' '`. */
33
+ index: string
34
+ /** Y — worktree 상태 문자. 변경 없음은 `' '`. untracked는 X=Y=`'?'`. */
35
+ worktree: string
36
+ /** `-z`가 먼저 내는 경로. rename/copy면 **목적지(NEW)**, 그 외는 유일한 경로. */
37
+ path: string
38
+ /** rename/copy일 때만 존재하는 **원본(OLD)** 경로. */
39
+ origPath?: string
40
+ }
41
+
42
+ /** `R`(rename)·`C`(copy)는 index·worktree 어느 열에 와도 추가 경로 필드를 소비한다(설계 D11-1). */
43
+ export function isRenameOrCopy(index: string, worktree: string): boolean {
44
+ return index === 'R' || index === 'C' || worktree === 'R' || worktree === 'C'
45
+ }
46
+
47
+ /**
48
+ * `git status --porcelain=v1 -z --untracked-files=all`의 **원문**을 엔트리 배열로 분해한다.
49
+ *
50
+ * 레코드 형식은 `XY<space><path>`이고 NUL로 구분된다. rename/copy 레코드는 뒤이어 OLD 경로 필드를 하나 더 갖는다.
51
+ * 후행 NUL이 만드는 마지막 빈 필드만 버린다 — 그 외 위치의 빈 필드는 형식 오류다(fail-closed).
52
+ */
53
+ export function parseStatusZ(raw: string): StatusEntry[] {
54
+ const fields = raw.split('\0')
55
+ // `-z` 출력은 언제나 NUL로 끝나므로 마지막 원소는 빈 문자열이다. 클린 트리(`''`)도 `['']`가 된다.
56
+ if (fields.length > 0 && fields[fields.length - 1] === '') fields.pop()
57
+
58
+ const out: StatusEntry[] = []
59
+ for (let i = 0; i < fields.length; i++) {
60
+ const rec = fields[i]
61
+ if (rec === undefined) throw new Error('porcelain -z: 레코드 인덱스 이탈(내부 오류)')
62
+ // `XY<space>` + 최소 한 글자 경로.
63
+ if (rec.length < 4 || rec[2] !== ' ')
64
+ throw new Error(`porcelain -z: 레코드 형식 오류(XY<space><path> 아님): ${JSON.stringify(rec)}`)
65
+
66
+ const index = rec[0] as string
67
+ const worktree = rec[1] as string
68
+ const path = rec.slice(3) // 정규화하지 않는다 — 역슬래시는 파일명의 일부다.
69
+
70
+ if (isRenameOrCopy(index, worktree)) {
71
+ const origPath = fields[++i]
72
+ if (origPath === undefined || origPath === '')
73
+ throw new Error(`porcelain -z: rename/copy 레코드에 원본 경로가 없다(truncated): ${JSON.stringify(rec)}`)
74
+ out.push({ index, worktree, path, origPath })
75
+ } else {
76
+ out.push({ index, worktree, path })
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * 이 엔트리가 건드리는 **모든** 경로. rename/copy는 `[OLD, NEW]` 순서다.
84
+ *
85
+ * 순서는 옛 `statusPaths`(`[body.slice(0,arrow), body.slice(arrow+4)]` = `[old, new]`)와 같다 —
86
+ * 호출부의 `flatMap(statusPaths)` 시맨틱을 보존한다.
87
+ */
88
+ export function entryPaths(e: StatusEntry): string[] {
89
+ return e.origPath === undefined ? [e.path] : [e.origPath, e.path]
90
+ }
91
+
92
+ /** untracked(`??`) 판정. `-z`에서도 X=Y=`'?'`다. */
93
+ export function isUntracked(e: StatusEntry): boolean {
94
+ return e.index === '?' && e.worktree === '?'
95
+ }
96
+
97
+ /** 사람이 읽는 한 줄. 에러 메시지·진단 출력용(`->` 형식으로 되돌려 익숙한 모양을 유지). */
98
+ export function formatStatusEntry(e: StatusEntry): string {
99
+ const code = `${e.index}${e.worktree}`
100
+ return e.origPath === undefined ? `${code} ${e.path}` : `${code} ${e.origPath} -> ${e.path}`
101
+ }
102
+
103
+ /** 모든 호출부가 공유하는 정본 인자. 다른 형태를 쓰면 파싱 경계가 깨진다(설계 D10). */
104
+ export const STATUS_Z_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all'] as const
@@ -0,0 +1,104 @@
1
+ /**
2
+ * scratch(도구 산출물) 정의의 **단일 지점** (REQ-2026-012 phase-1b · 설계 D7·D8).
3
+ *
4
+ * 이전엔 세 곳(`req-next.ts`·`req-doctor.ts`·`review-codex.ts`)이 `codex-response.json`·
5
+ * `.review-preview.txt`·`state.json` 세 경로를 각자 리터럴로 적었다. DRY가 아니라 **정확성** 문제였다 —
6
+ * 한 곳이 바뀌면 clean-tree 판정이 갈라진다.
7
+ *
8
+ * 두 종류의 scratch가 있고 **범위가 다르다**:
9
+ * - review/doctor용: `reviewScratchPaths` — 현재 티켓의 정확한 3경로(`state.json` 포함).
10
+ * - `req:new`용: `isToolOutputScratch` — 티켓 생성 **전**이라 현재 티켓이 없다. 그래서 **어느 티켓의**
11
+ * untracked 도구 산출물이든 허용하되, `state.json`·`responses/**`는 **제외**한다(설계 D8: 그것을
12
+ * 허용하면 증거 변조 구멍이 된다). 즉 `req:new`의 예외는 나머지 셋의 **진부분집합**이다.
13
+ *
14
+ * 아카이브 파일명 판정(`isArchiveFileName`)도 여기로 모은다 — `isAllowedResponsesScratch`가 그것을 쓰고,
15
+ * 이 파일을 leaf(포르셀린만 의존)로 두면 `review-codex`↔`scratch` 순환 import가 생기지 않는다.
16
+ */
17
+ import type { StatusEntry } from './porcelain'
18
+ import { isUntracked } from './porcelain'
19
+
20
+ /** 티켓 디렉터리 안의 순수 untracked 도구 산출물. 커밋된 적이 없고 승인 증거가 아니다. */
21
+ export const TOOL_OUTPUT_BASENAMES = ['codex-response.json', '.review-preview.txt'] as const
22
+
23
+ /** 경로 정규화: 역슬래시→슬래시(호출부가 넘기는 repo-상대는 이미 `/`지만 방어), 후행 슬래시 제거. */
24
+ function normDir(dirRel: string): string {
25
+ return dirRel.replace(/\\/g, '/').replace(/\/+$/, '')
26
+ }
27
+
28
+ /**
29
+ * review/doctor의 clean-tree 검사가 허용하는 **현재 티켓** 3경로(repo-상대).
30
+ * 세 호출부가 리터럴로 만들던 `[codex-response.json, .review-preview.txt, state.json]`을 대체한다.
31
+ */
32
+ export function reviewScratchPaths(ticketDirRel: string): string[] {
33
+ const dir = normDir(ticketDirRel)
34
+ return [`${dir}/${TOOL_OUTPUT_BASENAMES[0]}`, `${dir}/${TOOL_OUTPUT_BASENAMES[1]}`, `${dir}/state.json`]
35
+ }
36
+
37
+ /** `REQ-<4자리>-<숫자>` 디렉터리명인가(문자열 분해 — 정규식 보간 금지, 설계 D7). */
38
+ function isTicketDirName(seg: string): boolean {
39
+ if (!seg.startsWith('REQ-')) return false
40
+ const rest = seg.slice(4) // `2026-001`
41
+ const dash = rest.indexOf('-')
42
+ if (dash < 0) return false
43
+ const year = rest.slice(0, dash)
44
+ const num = rest.slice(dash + 1)
45
+ if (year.length !== 4 || !isAllDigits(year)) return false
46
+ return num.length > 0 && isAllDigits(num)
47
+ }
48
+
49
+ function isAllDigits(s: string): boolean {
50
+ for (let i = 0; i < s.length; i++) {
51
+ const c = s.charCodeAt(i)
52
+ if (c < 48 || c > 57) return false
53
+ }
54
+ return true
55
+ }
56
+
57
+ /**
58
+ * `req:new`의 좁은 예외(설계 D7). **다음을 모두** 만족하는 엔트리만 무시한다:
59
+ * - untracked(X=Y=`?`). ` M`·`M `·`R ` 등 tracked·staged·rename은 무시하지 않는다.
60
+ * - `path`가 `<ticketRoot>/REQ-<4자리>-<숫자>/<basename>`이고 `<basename>` ∈ `TOOL_OUTPUT_BASENAMES`.
61
+ *
62
+ * `state.json`·`responses/**`는 basename 목록에 없으므로 자동으로 제외된다(설계 D8).
63
+ * 이 술어는 승인을 **부여하지 않는다**(설계 D9) — 위조 파일을 통과시켜도 `req:new`는
64
+ * `commit_allowed:false`인 새 state만 쓴다.
65
+ */
66
+ export function isToolOutputScratch(entry: StatusEntry, ticketRoot: string): boolean {
67
+ if (!isUntracked(entry)) return false
68
+ const root = normDir(ticketRoot)
69
+ // ticketRoot='.' 또는 canonical repo-root('')도 유효하다. 이때 Git 경로에는 './' 접두사가 없다.
70
+ const prefix = root === '' || root === '.' ? '' : `${root}/`
71
+ if (!entry.path.startsWith(prefix)) return false
72
+ const rest = entry.path.slice(prefix.length) // `REQ-2026-001/codex-response.json`
73
+ const slash = rest.indexOf('/')
74
+ if (slash < 0) return false
75
+ const ticketSeg = rest.slice(0, slash)
76
+ const basename = rest.slice(slash + 1)
77
+ if (basename.includes('/')) return false // 티켓 직계만
78
+ if (!(TOOL_OUTPUT_BASENAMES as readonly string[]).includes(basename)) return false
79
+ return isTicketDirName(ticketSeg)
80
+ }
81
+
82
+ // ─────────────────────────────────── 승인 증거 아카이브 (REQ-016 A1, review-codex에서 이동) ──
83
+
84
+ /** 아카이브 파일명 패턴: `<base>-rNN-(approved|needs-fix).json`(NN≥2자리). approvals.jsonl 등은 불일치. */
85
+ const ARCHIVE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]*-r\d{2,}-(approved|needs-fix)\.json$/
86
+ export function isArchiveFileName(name: string): boolean {
87
+ return ARCHIVE_NAME_RE.test(name)
88
+ }
89
+
90
+ /**
91
+ * 현재 티켓 `responses/` 하위의 **untracked 승인 아카이브 하나**만 스크래치로 허용(REQ-016 A1·D-016-4).
92
+ * `approvals.jsonl`·tracked 수정/삭제/리네임·타 티켓·collapsed dir은 전부 위반(커밋된 증거 변조·주입 차단).
93
+ *
94
+ * StatusEntry 기반(설계 D11). untracked만 허용하므로 rename의 origPath는 볼 필요가 없다.
95
+ * ⚠️ `entry.path`를 정규화하지 않는다 — `-z`가 준 원문이다. 역슬래시는 파일명의 일부다(옛 코드의 버그를 안 물려받는다).
96
+ */
97
+ export function isAllowedResponsesScratch(entry: StatusEntry, ticketRel: string): boolean {
98
+ if (!isUntracked(entry)) return false // X=Y=`?`
99
+ const prefix = `${normDir(ticketRel)}/responses/`
100
+ if (!entry.path.startsWith(prefix)) return false
101
+ const name = entry.path.slice(prefix.length)
102
+ if (name.includes('/')) return false // 직계만
103
+ return isArchiveFileName(name)
104
+ }
@@ -20,6 +20,7 @@ import {
20
20
  readPhases,
21
21
  archiveBaseName,
22
22
  isArchiveFileName,
23
+ isValidIsoInstant,
23
24
  type ApprovalEvidence,
24
25
  type ReviewKind,
25
26
  type WorkflowState,
@@ -34,7 +35,7 @@ let pkgManager: PackageManager = DEFAULTS.packageManager
34
35
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
35
36
  const SHA256_RE = /^[0-9a-f]{64}$/i
36
37
  const GIT_OID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i // git OID: 40(SHA-1) 또는 64(SHA-256)
37
- const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/ // new Date().toISOString() 형식
38
+ // ISO 검증은 review-codex의 isValidIsoInstant(형식+달력 유효성) 재사용 달력 불가능 값 거부(REQ-2026-030).
38
39
  // evidencePreflight 구조 사전검증용 placeholder(실제 sourceSha/consumedAt는 source 커밋 후 채움). valid OID/ISO 형식.
39
40
  const PREFLIGHT_PLACEHOLDER_OID = '0'.repeat(40)
40
41
  const PREFLIGHT_PLACEHOLDER_ISO = '2000-01-01T00:00:00.000Z'
@@ -163,8 +164,8 @@ export function validateManifest(
163
164
  if (typeof e.response_sha256 !== 'string' || !SHA256_RE.test(e.response_sha256)) problems.push(`line ${ln}: response_sha256 형식 오류(64hex)`)
164
165
  if (typeof e.review_base_sha !== 'string' || !GIT_OID_RE.test(e.review_base_sha)) problems.push(`line ${ln}: review_base_sha 비-OID`)
165
166
  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`)
166
- if (typeof e.approved_at !== 'string' || !ISO_RE.test(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
167
- if (typeof e.consumed_at !== 'string' || !ISO_RE.test(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
167
+ if (!isValidIsoInstant(e.approved_at)) problems.push(`line ${ln}: approved_at 비-ISO`)
168
+ if (!isValidIsoInstant(e.consumed_at)) problems.push(`line ${ln}: consumed_at 비-ISO`)
168
169
  // kind별 strict 바인딩(반대 kind 필드 금지).
169
170
  if (kind === 'phase') {
170
171
  if (typeof e.phase_id !== 'string' || !e.phase_id || !opts.validPhaseIds.includes(e.phase_id))
@@ -228,7 +229,7 @@ export function userConfirmProblem(ucc: unknown): string | null {
228
229
  const c = ucc as { confirmed?: unknown; method?: unknown; confirmed_at?: unknown }
229
230
  if (c.confirmed !== true) return 'confirmed=true 아님'
230
231
  if (typeof c.method !== 'string' || !c.method.trim()) return 'method(공백 아닌 문자열) 필요'
231
- if (typeof c.confirmed_at !== 'string' || !ISO_RE.test(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
+ if (!isValidIsoInstant(c.confirmed_at)) return 'confirmed_at(ISO) 필요'
232
233
  return null
233
234
  }
234
235
 
@@ -449,7 +450,10 @@ export function parseArgs(argv: string[]): CommitArgs {
449
450
  for (let i = 0; i < argv.length; i++) {
450
451
  const a = argv[i]
451
452
  if (a === undefined) continue
452
- if (a === '--ticket') ticket = argv[++i] ?? null
453
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
454
+ // 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
455
+ if (a === '--') continue
456
+ else if (a === '--ticket') ticket = argv[++i] ?? null
453
457
  else if (a === '--run') run = true
454
458
  else if (a === '--message' || a === '-m') message = argv[++i] ?? null
455
459
  else if (a === '--message-file') {
@@ -642,8 +646,8 @@ function designFinalize(args: {
642
646
  console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
643
647
  }
644
648
 
645
- function main(): void {
646
- const opts = parseArgs(process.argv.slice(2))
649
+ export function main(argv: string[] = process.argv.slice(2)): void {
650
+ const opts = parseArgs(argv)
647
651
  const cfg = loadConfig({ root: opts.root })
648
652
  gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
649
653
  pkgManager = cfg.packageManager
@@ -788,5 +792,15 @@ function main(): void {
788
792
  console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
789
793
  }
790
794
 
795
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
796
+ export function runCli(argv: string[]): void {
797
+ try {
798
+ main(argv)
799
+ } catch (err) {
800
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
801
+ process.exitCode = 1
802
+ }
803
+ }
804
+
791
805
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
792
806
  if (isMain) main()