commitgate 0.4.0 → 0.7.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,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
+ }
@@ -449,7 +449,10 @@ export function parseArgs(argv: string[]): CommitArgs {
449
449
  for (let i = 0; i < argv.length; i++) {
450
450
  const a = argv[i]
451
451
  if (a === undefined) continue
452
- if (a === '--ticket') ticket = argv[++i] ?? null
452
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
453
+ // 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
454
+ if (a === '--') continue
455
+ else if (a === '--ticket') ticket = argv[++i] ?? null
453
456
  else if (a === '--run') run = true
454
457
  else if (a === '--message' || a === '-m') message = argv[++i] ?? null
455
458
  else if (a === '--message-file') {
@@ -642,8 +645,8 @@ function designFinalize(args: {
642
645
  console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
643
646
  }
644
647
 
645
- function main(): void {
646
- const opts = parseArgs(process.argv.slice(2))
648
+ export function main(argv: string[] = process.argv.slice(2)): void {
649
+ const opts = parseArgs(argv)
647
650
  const cfg = loadConfig({ root: opts.root })
648
651
  gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
649
652
  pkgManager = cfg.packageManager
@@ -788,5 +791,15 @@ function main(): void {
788
791
  console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
789
792
  }
790
793
 
794
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
795
+ export function runCli(argv: string[]): void {
796
+ try {
797
+ main(argv)
798
+ } catch (err) {
799
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
800
+ process.exitCode = 1
801
+ }
802
+ }
803
+
791
804
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
792
805
  if (isMain) main()
@@ -5,12 +5,14 @@
5
5
  * 1차 최소셋(registry 비의존): D2·D3·D5·D6·D9·D10·D11 + D13(design 선행·freshness)·D15(NEEDS_FIX actionable). (D1/D7/D7b·D4a 등 registry/merge 의존은 2차)
6
6
  * FAIL 1건 이상 → exit 1, 자동 보정 금지(P9). review-codex 헬퍼 재사용.
7
7
  *
8
- * 사용: pnpm req:doctor <REQ-id> | pnpm req:doctor --ticket <dir>
8
+ * 사용: req:doctor <REQ-id> | req:doctor --ticket <dir> (저장소 패키지매니저의 실행 형식으로)
9
9
  */
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { resolve, join, relative } from 'node:path'
12
12
  import { pathToFileURL } from 'node:url'
13
13
  import { createHash } from 'node:crypto'
14
+ import { parseStatusZ, entryPaths, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
15
+ import { isArchiveFileName, isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
14
16
  import {
15
17
  loadState,
16
18
  validateVerdict,
@@ -18,13 +20,11 @@ import {
18
20
  findUnstagedOrUntracked,
19
21
  captureDesignBinding,
20
22
  designDocPaths,
21
- isArchiveFileName,
22
- isAllowedResponsesScratch,
23
23
  type WorkflowState,
24
24
  type Verdict,
25
25
  type ApprovalEvidence,
26
26
  } from './review-codex'
27
- import { loadConfig, packageRoot, type ResolvedConfig } from './lib/config'
27
+ import { loadConfig, packageRoot, stripBom, type ResolvedConfig } from './lib/config'
28
28
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
29
29
 
30
30
  // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — config 부재 시 현재 동작 보존).
@@ -52,7 +52,7 @@ export interface DoctorInputs {
52
52
  // D18 granularity 임계(config, advisory). 미지정 시 GRANULARITY_MAX_FILES(현재 동작). main이 cfg.granularityMaxFiles 주입.
53
53
  granularityMaxFiles?: number
54
54
  stagedTree: string
55
- statusLines: string[]
55
+ statusEntries: StatusEntry[]
56
56
  scratch: string[]
57
57
  responseVerdict: Verdict | null
58
58
  responseStructureOk: boolean
@@ -72,6 +72,15 @@ export interface DoctorInputs {
72
72
  // B3: finalize(복구) 모드. D9 비교 대상을 staged tree → pending_evidence_for.source_commit_sha의 source 커밋 tree로 교체(우회 아님).
73
73
  finalize?: boolean
74
74
  finalizeSourceTree?: string | null // git rev-parse <pending.source_commit_sha>^{tree}
75
+ /**
76
+ * D19(REQ-2026-014): 대상 `package.json`의 `scripts` 맵. main()이 읽어 채운다(runChecks는 순수).
77
+ * - `undefined` = main()이 조회하지 않음(legacy/2-arg 호출) → OK '점검 불요'
78
+ * - `null` = package.json 없음/파손 → OK '점검 불요'(읽기 전용 advisory라 FAIL 아님)
79
+ * - object = 파싱된 scripts 맵
80
+ * ⚠️ **optional이어야 한다** — required면 `tests/unit/req-doctor.test.ts`의 `const base: DoctorInputs = {…}`
81
+ * 리터럴이 즉시 tsc 오류가 난다(기존 optional 필드 관례와 동일).
82
+ */
83
+ reqScripts?: Record<string, string> | null
75
84
  }
76
85
 
77
86
  /**
@@ -114,6 +123,48 @@ export function phaseGranularityWarnings(codeFiles: string[], maxFiles: number):
114
123
  return []
115
124
  }
116
125
 
126
+ /** 설치 모드(REQ-2026-014 D19 진단). `req:*` 스크립트 **값의 형태**로만 판정한다. */
127
+ export type InstallMode = 'stage-a' | 'stage-b' | 'mixed' | 'none' | 'custom'
128
+
129
+ /**
130
+ * 진단 대상 `req:*` 키.
131
+ *
132
+ * 설치 축의 SSOT는 `bin/init.ts`의 `REQ_SCRIPTS`지만 여기서 import하지 않는다(아래 `classifyInstallMode` 주석 — 레이어 역전).
133
+ * 키가 늘면 이 목록도 늘려야 한다. 드리프트가 나도 이 검사는 **advisory(WARN 상한)** 라 게이트를 깨지 않는다.
134
+ */
135
+ const REQ_SCRIPT_KEYS = ['req:new', 'req:next', 'req:review-codex', 'req:doctor', 'req:commit'] as const
136
+
137
+ /** Stage A 형태: `tsx scripts/req/<file>.ts` (과거 vendored scaffold가 주입하던 모양). */
138
+ const STAGE_A_SCRIPT_RE = /^tsx\s+scripts\/req\/[A-Za-z0-9._-]+\.ts$/
139
+ /** Stage B 형태: `commitgate <verb>` (설치된 패키지 bin dispatch). */
140
+ const STAGE_B_SCRIPT_RE = /^commitgate\s+req:[A-Za-z0-9-]+$/
141
+
142
+ /**
143
+ * 설치 모드 진단(REQ-2026-014 D19 — doctor D19, 순수).
144
+ *
145
+ * **`package.json`의 `req:*` 값 형태만** 본다. manifest·lockfile·node_modules·버전에 의존하지 않는다.
146
+ *
147
+ * ⚠️ **`bin/init.ts`를 import하지 않는다**(레이어 역전 방지). init.ts는 cross-spawn·semver·git spawn을 끌고 오는
148
+ * ~1250줄 설치 CLI이고, 매 커밋 게이트로 도는 이 스크립트가 그것을 로드해선 안 된다. 그래서 바이트 일치(`REQ_SCRIPTS`)가
149
+ * 아니라 **shape**로 판정한다 — 요구(R7)도 "script 형태를 기준으로"다.
150
+ *
151
+ * ⚠️ **migrate와의 비대칭은 의도적이다**: `bin/migrate.ts`의 전환은 **쓰기**라 `REQ_SCRIPTS` 바이트 정확 일치를
152
+ * 요구한다(사용자 값을 덮지 않기 위해). 이 진단은 **읽기 전용 advisory**라 shape로 충분하다. 강도를 바꿔야 하는 쪽은 migrate다.
153
+ *
154
+ * @param scripts `package.json`의 `scripts` 맵. `undefined`/`null`이면 판정 불가 → 호출부가 '점검 불요'.
155
+ */
156
+ export function classifyInstallMode(scripts: Record<string, string>): InstallMode {
157
+ const values = REQ_SCRIPT_KEYS.map((k) => scripts[k]).filter((v): v is string => typeof v === 'string')
158
+ if (values.length === 0) return 'none'
159
+ const a = values.filter((v) => STAGE_A_SCRIPT_RE.test(v)).length
160
+ const b = values.filter((v) => STAGE_B_SCRIPT_RE.test(v)).length
161
+ if (a > 0 && b > 0) return 'mixed'
162
+ if (a > 0 && a === values.length) return 'stage-a'
163
+ if (b > 0 && b === values.length) return 'stage-b'
164
+ // Stage A/B 형태가 하나도 없거나(전부 사용자 값), 일부만 kit 형태이고 나머지는 사용자 값.
165
+ return 'custom'
166
+ }
167
+
117
168
  /** 승인 증거 아카이브 파일의 온디스크 검사(main이 읽어 채움 — runChecks는 순수). */
118
169
  export interface ArchiveCheck {
119
170
  exists: boolean
@@ -189,18 +240,8 @@ function evidenceProblems(
189
240
  return r
190
241
  }
191
242
 
192
- /**
193
- * porcelain v1 라인의 **모든** 변경 경로 추출. 비-ASCII는 core.quotePath=false 전제.
194
- * rename/copy(`R`/`C`, ` -> `)는 [원본, 목적지] **둘 다** 반환 — D13이 양쪽 모두 검사하여
195
- * 비허용 경로→허용 경로 rename으로 코드 삭제를 우회하는 것을 차단(Codex P2). 그 외는 단일 경로.
196
- */
197
- export function statusPaths(line: string): string[] {
198
- if (line.length < 4) return []
199
- const body = line.slice(3).replace(/\\/g, '/')
200
- const arrow = body.indexOf(' -> ')
201
- if (arrow >= 0) return [body.slice(0, arrow), body.slice(arrow + 4)]
202
- return [body]
203
- }
243
+ // statusPaths는 lib/porcelain의 entryPaths로 대체(REQ-2026-012). `-z`가 rename의 src·dest를
244
+ // 필드로 확실히 주므로 ` -> ` 분할과 인용 해제가 필요 없다.
204
245
 
205
246
  /** 순수: 입력으로부터 1차 최소셋 점검 결과 산출(부수효과 없음 — 테스트 용이). */
206
247
  export function runChecks(inp: DoctorInputs): Check[] {
@@ -269,8 +310,9 @@ export function runChecks(inp: DoctorInputs): Check[] {
269
310
  }
270
311
 
271
312
  // D10: unstaged/untracked(비-스크래치) — review용 클린. A2: ticketRel 전달 시 responses/ untracked 아카이브만 스크래치 허용(tracked 변조·approvals.jsonl·타 티켓 flag).
272
- const dirty = findUnstagedOrUntracked(inp.statusLines, inp.scratch, inp.ticketRel)
273
- if (dirty.length) c.push({ id: 'D10', level: 'FAIL', msg: `unstaged/untracked 존재:\n ${dirty.join('\n ')}` })
313
+ const dirty = findUnstagedOrUntracked(inp.statusEntries, inp.scratch, inp.ticketRel)
314
+ if (dirty.length)
315
+ c.push({ id: 'D10', level: 'FAIL', msg: `unstaged/untracked 존재:\n ${dirty.map(formatStatusEntry).join('\n ')}` })
274
316
  else c.push({ id: 'D10', level: 'OK', msg: '워킹트리 클린(staged + 스크래치)' })
275
317
 
276
318
  // D11: phase≠DONE인데 main 또는 비-<branchPrefix>* 브랜치(DEC-WF-020). branchPrefix=config(기본 feat/req-).
@@ -293,10 +335,10 @@ export function runChecks(inp: DoctorInputs): Check[] {
293
335
  // A2(A2-R2-P2-1): 허용된 untracked 응답 아카이브(D10 scratch)는 D13 코드변경 분류에서도 제외 — D10/D13 scratch 정책 일치.
294
336
  // (tracked evidence 변조·approvals.jsonl·타 티켓·collapsed dir은 isAllowedResponsesScratch=false라 제외되지 않음 → D10/D13 모두 FAIL 유지.)
295
337
  const responsesScratch = inp.ticketRel
296
- ? inp.statusLines.filter((l) => isAllowedResponsesScratch(l, inp.ticketRel as string)).flatMap(statusPaths)
338
+ ? inp.statusEntries.filter((e) => isAllowedResponsesScratch(e, inp.ticketRel as string)).flatMap(entryPaths)
297
339
  : []
298
340
  const allowD13 = new Set([...inp.ticketDocs, ...inp.scratch, ...responsesScratch].map((p) => p.replace(/\\/g, '/')))
299
- const codeChanges = [...new Set(inp.statusLines.flatMap(statusPaths).filter((p) => p !== '' && !allowD13.has(p)))]
341
+ const codeChanges = [...new Set(inp.statusEntries.flatMap(entryPaths).filter((p) => p !== '' && !allowD13.has(p)))]
300
342
  if (!validDesign && codeChanges.length)
301
343
  c.push({
302
344
  id: 'D13',
@@ -361,9 +403,37 @@ export function runChecks(inp: DoctorInputs): Check[] {
361
403
  }
362
404
  } else c.push({ id: 'D17', level: 'OK', msg: 'design_approved=false(점검 불요)' })
363
405
 
406
+ // D19(REQ-2026-014): 설치 모드 진단 — `req:*` 값의 **형태**만 본다(manifest·lockfile·node_modules 미사용).
407
+ //
408
+ // 🔴 **level 상한은 WARN — 절대 FAIL이 아니다.** CommitGate 자신의 package.json이 Stage A 형태이고(개발 repo가
409
+ // 자기 스크립트를 직접 실행하므로 정상), `req:commit`이 이 doctor를 exit≠0에 throw하는 하드 게이트로 spawn한다.
410
+ // FAIL이면 **이 저장소 자신의 커밋과 정당한 Stage A 소비자 전원의 커밋이 영구 차단**된다.
411
+ // Stage A는 결함이 아니라 지원되는 설치 형태다 → mixed만 WARN한다.
412
+ if (inp.reqScripts === undefined || inp.reqScripts === null) {
413
+ c.push({ id: 'D19', level: 'OK', msg: 'package.json scripts 미조회/없음(점검 불요)' })
414
+ } else {
415
+ const mode = classifyInstallMode(inp.reqScripts)
416
+ if (mode === 'mixed')
417
+ c.push({
418
+ id: 'D19',
419
+ level: 'WARN',
420
+ msg: 'req:* 스크립트에 Stage A(tsx scripts/req/*.ts)와 Stage B(commitgate <verb>) 형태가 섞여 있습니다 — `commitgate migrate` 로 전환하세요(형태 기준 진단)',
421
+ })
422
+ else c.push({ id: 'D19', level: 'OK', msg: `설치 모드: ${INSTALL_MODE_LABEL[mode]}(req:* 스크립트 형태 기준)` })
423
+ }
424
+
364
425
  return c
365
426
  }
366
427
 
428
+ /** D19 메시지용 라벨. */
429
+ const INSTALL_MODE_LABEL: Record<InstallMode, string> = {
430
+ 'stage-a': 'Stage A(vendored — scripts/req/** 를 직접 실행)',
431
+ 'stage-b': 'Stage B(런타임 패키지 — commitgate <verb> dispatch)',
432
+ mixed: 'mixed',
433
+ none: 'req:* 스크립트 없음',
434
+ custom: '사용자 정의 req:* 값(kit 형태 아님)',
435
+ }
436
+
367
437
  // ──────────────────────────────────────────────────────────────── CLI ──
368
438
 
369
439
  export interface DoctorArgs {
@@ -382,7 +452,9 @@ export function parseArgs(argv: string[]): DoctorArgs {
382
452
  for (let i = 0; i < argv.length; i++) {
383
453
  const a = argv[i]
384
454
  if (a === undefined) continue
385
- if (a === '--ticket') ticket = argv[++i] ?? null
455
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
456
+ if (a === '--') continue
457
+ else if (a === '--ticket') ticket = argv[++i] ?? null
386
458
  else if (a === '--finalize') finalize = true // B3: D9를 finalize(source tree) 모드로
387
459
  else if (a === '--root') {
388
460
  const v = argv[++i]
@@ -394,6 +466,31 @@ export function parseArgs(argv: string[]): DoctorArgs {
394
466
  return { ticket, reqId, finalize, root }
395
467
  }
396
468
 
469
+ /**
470
+ * D19(REQ-2026-014): 대상 `package.json`의 `scripts` 맵을 읽는다. 없거나 파손이면 `null`(→ D19는 '점검 불요' OK).
471
+ *
472
+ * **읽기 전용 advisory이므로 throw하지 않는다** — package.json이 깨졌다는 사실은 다른 게이트(init·migrate)가
473
+ * fail-closed로 알린다. 여기서 throw하면 무관한 이유로 `req:commit`의 doctor 게이트가 죽는다.
474
+ *
475
+ * `stripBom` 필수: PowerShell `Set-Content -Encoding UTF8`이 만든 BOM'd package.json은 이 플랫폼에서 실제로
476
+ * 발생하는 실패다. 없으면 정상 파일을 '파손'으로 오분류한다.
477
+ */
478
+ function readReqScripts(root: string): Record<string, string> | null {
479
+ const p = join(root, 'package.json')
480
+ if (!existsSync(p)) return null
481
+ try {
482
+ const raw: unknown = JSON.parse(stripBom(readFileSync(p, 'utf8')))
483
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
484
+ const s = (raw as { scripts?: unknown }).scripts
485
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return null
486
+ const out: Record<string, string> = {}
487
+ for (const [k, v] of Object.entries(s as Record<string, unknown>)) if (typeof v === 'string') out[k] = v
488
+ return out
489
+ } catch {
490
+ return null
491
+ }
492
+ }
493
+
397
494
  function resolveTicketDir(opts: DoctorArgs, cfg: ResolvedConfig): string {
398
495
  if (opts.ticket) return resolve(opts.ticket)
399
496
  if (opts.reqId) return join(cfg.workflowDirAbs, `REQ-${opts.reqId.replace(/^REQ-/, '')}`)
@@ -410,8 +507,8 @@ function branchExistsLocal(branch: string): boolean {
410
507
  }
411
508
  }
412
509
 
413
- function main(): void {
414
- const opts = parseArgs(process.argv.slice(2))
510
+ export function main(argv: string[] = process.argv.slice(2)): void {
511
+ const opts = parseArgs(argv)
415
512
  const cfg = loadConfig({ root: opts.root })
416
513
  gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
417
514
  const ticketDir = resolveTicketDir(opts, cfg)
@@ -490,13 +587,9 @@ function main(): void {
490
587
  branchPrefix: cfg.branchPrefix,
491
588
  granularityMaxFiles: cfg.granularityMaxFiles,
492
589
  stagedTree: git(['write-tree']),
493
- // --untracked-files=all: untracked 디렉터리 collapse(`?? responses/`) 방지 — responses/ 아카이브를 개별 파일로 판단(A2-P2 후속).
494
- statusLines: git(['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean),
495
- scratch: [
496
- repoRel(join(ticketDir, 'codex-response.json')),
497
- repoRel(join(ticketDir, '.review-preview.txt')),
498
- repoRel(join(ticketDir, 'state.json')), // 도구가 쓰는 메타데이터(4C e2e: review-codex 후 unstaged) — D10 제외
499
- ],
590
+ // `-z`: 경로 인용 없음(설계 D11) → core.quotePath 불필요. --untracked-files=all: `?? responses/` collapse 방지.
591
+ statusEntries: parseStatusZ(git([...STATUS_Z_ARGS])),
592
+ scratch: reviewScratchPaths(ticketRel),
500
593
  responseVerdict,
501
594
  responseStructureOk,
502
595
  designApproved: state.design_approved === true,
@@ -512,6 +605,7 @@ function main(): void {
512
605
  liveResponseSha256,
513
606
  finalize,
514
607
  finalizeSourceTree,
608
+ reqScripts: readReqScripts(cfg.root),
515
609
  }
516
610
 
517
611
  const checks = runChecks(inp)
@@ -521,5 +615,15 @@ function main(): void {
521
615
  if (fails.length) process.exit(1)
522
616
  }
523
617
 
618
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
619
+ export function runCli(argv: string[]): void {
620
+ try {
621
+ main(argv)
622
+ } catch (err) {
623
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
624
+ process.exitCode = 1
625
+ }
626
+ }
627
+
524
628
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
525
629
  if (isMain) main()
@@ -7,14 +7,16 @@
7
7
  * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
8
  * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
9
9
  *
10
- * 사용: pnpm req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."]
10
+ * 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] (저장소 패키지매니저의 실행 형식으로)
11
11
  */
12
12
  import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
13
13
  import { join, relative } from 'node:path'
14
14
  import { pathToFileURL } from 'node:url'
15
15
  import { writeState, type WorkflowState } from './review-codex'
16
- import { loadConfig, packageRoot, type DesignDocs } from './lib/config'
16
+ import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
17
17
  import { createGitAdapter, type GitAdapter } from './lib/adapters'
18
+ import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
19
+ import { isToolOutputScratch } from './lib/scratch'
18
20
 
19
21
  // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
20
22
  let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
@@ -23,6 +25,17 @@ function git(args: string[]): string {
23
25
  return gitAdapter.exec(args)
24
26
  }
25
27
 
28
+ /**
29
+ * `req:new` 전용 clean-tree 위반 목록(REQ-2026-012 D7·D8).
30
+ *
31
+ * 허용하는 것은 기존 티켓 직계의 **untracked 도구 산출물** 두 종류뿐이다. `state.json`, `responses/**`,
32
+ * staged-only 변경, rename/copy 등 그 밖의 모든 상태는 그대로 위반이다. review용
33
+ * `findUnstagedOrUntracked`는 staged-only(`M `)를 통과시키므로 여기서 재사용하지 않는다.
34
+ */
35
+ export function findReqNewDirtyEntries(rawStatusZ: string, ticketRoot: string): StatusEntry[] {
36
+ return parseStatusZ(rawStatusZ).filter((entry) => !isToolOutputScratch(entry, ticketRoot))
37
+ }
38
+
26
39
  /** slug 검증: kebab-case(a-z0-9, '-' 구분). */
27
40
  export function validateSlug(slug: string): void {
28
41
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
@@ -44,6 +57,18 @@ export function branchName(reqId: string, slug: string, branchPrefix: string): s
44
57
  return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
45
58
  }
46
59
 
60
+ /**
61
+ * `--run` 성공 후 다음 단계 안내(DEC-011-1). **config 로드 이후**이므로 pm별 실행 형식으로 파생한다.
62
+ *
63
+ * ⚠️ `DEFAULTS.packageManager`(= `'pnpm'`)를 폴백으로 쓰지 마라. `bin/init.ts`의 감지 폴백은 `'npm'`이라
64
+ * 두 값이 갈라져 있고, 그걸 문구에 끌어오면 npm 프로젝트가 pnpm 명령을 안내받는다 — 이 REQ가 고치는 결함이다.
65
+ * config가 없는 지점(헤더 주석·early throw)은 pm-중립 bare 표기를 쓴다.
66
+ */
67
+ export function nextStepHint(pm: PackageManager, reqId: string): string {
68
+ const id = reqId.replace(/^REQ-/, '')
69
+ return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
70
+ }
71
+
47
72
  export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH'): WorkflowState {
48
73
  return {
49
74
  id: reqId,
@@ -86,7 +111,10 @@ export function parseArgs(argv: string[]): Opts {
86
111
  for (let i = 0; i < argv.length; i++) {
87
112
  const a = argv[i]
88
113
  if (a === undefined) continue
89
- if (a === '--run') {
114
+ // bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
115
+ if (a === '--') {
116
+ continue
117
+ } else if (a === '--run') {
90
118
  o.run = true
91
119
  } else if (a === '--root') {
92
120
  const v = argv[++i]
@@ -110,9 +138,10 @@ export function parseArgs(argv: string[]): Opts {
110
138
  return o
111
139
  }
112
140
 
113
- function main(): void {
114
- const o = parseArgs(process.argv.slice(2))
115
- if (!o.slug) throw new Error('slug 필요 (예: pnpm req:new camera-hardfail --run)')
141
+ export function main(argv: string[] = process.argv.slice(2)): void {
142
+ const o = parseArgs(argv)
143
+ // ⚠️ loadConfig 이전이라 pm을 모른다 → pm-중립 bare 표기(DEFAULTS 폴백 금지, DEC-011-1).
144
+ if (!o.slug) throw new Error('slug 필요 (예: req:new camera-hardfail --run)')
116
145
  validateSlug(o.slug)
117
146
 
118
147
  const cfg = loadConfig({ root: o.root })
@@ -123,6 +152,8 @@ function main(): void {
123
152
  const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
124
153
  const branch = branchName(reqId, o.slug, cfg.branchPrefix)
125
154
  const ticketDir = join(cfg.workflowDirAbs, reqId)
155
+ // config 문자열이 `.`·`workflow/.`처럼 같은 위치를 다르게 표현해도 Git이 내는 canonical repo-relative 경로와 맞춘다.
156
+ const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
126
157
  const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
127
158
 
128
159
  if (!o.run) {
@@ -134,9 +165,18 @@ function main(): void {
134
165
  return
135
166
  }
136
167
 
137
- // 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지)
138
- const dirty = git(['-c', 'core.quotePath=false', 'status', '--porcelain'])
139
- if (dirty) throw new Error(`워킹트리가 clean이어야 req:new --run 가능:\n${dirty}`)
168
+ // 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
169
+ // 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
170
+ const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
171
+ if (dirtyEntries.length > 0) {
172
+ const dirty = dirtyEntries.map(formatStatusEntry).join('\n')
173
+ throw new Error(
174
+ `워킹트리가 clean이어야 req:new --run 가능:\n${dirty}\n` +
175
+ `힌트: 방금 \`npx commitgate\`를 실행했다면 **설치분만** 먼저 커밋하십시오(\`git add -A\` 금지 —\n` +
176
+ ` 무관한 변경·.env가 함께 커밋되고, 이어지는 req:review-codex가 그것을 외부로 전송합니다).\n` +
177
+ ` 설치 출력의 "다음:" 안내가 stage할 정확한 경로 목록을 알려 줍니다.`,
178
+ )
179
+ }
140
180
  const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
141
181
  if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
142
182
 
@@ -166,7 +206,17 @@ function main(): void {
166
206
  console.log(`[req:new] 생성 완료: ${reqId}`)
167
207
  console.log(` branch : ${branch} (체크아웃됨)`)
168
208
  console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
169
- console.log(` 다음 : 코드 변경 → git add → pnpm req:review-codex ${reqId.replace(/^REQ-/, '')} --run`)
209
+ console.log(` 다음 : ${nextStepHint(cfg.packageManager, reqId)}`)
210
+ }
211
+
212
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
213
+ export function runCli(argv: string[]): void {
214
+ try {
215
+ main(argv)
216
+ } catch (err) {
217
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
218
+ process.exitCode = 1
219
+ }
170
220
  }
171
221
 
172
222
  const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href