commitgate 0.9.8 → 0.9.10

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/bin/uninstall.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  KIT_CLAUDE_DEST_REL,
36
36
  KIT_AGENTS_CONTRACT_COPY_REL,
37
37
  REQ_SCRIPTS,
38
+ STAGE_B_REQ_SCRIPTS,
38
39
  REQ_DEV_DEPS,
39
40
  assertGitWorkTree,
40
41
  } from './init'
@@ -121,6 +122,11 @@ export interface UninstallPlan {
121
122
  /** 티켓이 실제로 쌓인 증거 디렉터리 — 삭제 금지. */
122
123
  protect: EvidenceDir[]
123
124
  scaffoldCommits: ScaffoldCommit[]
125
+ /**
126
+ * 도입 커밋 revert가 `workflow/.gitignore`를 함께 되돌려 **보존 대상 티켓의 scratch가 드러나는가**
127
+ * (REQ-2026-058 F-6). git 조회가 필요하므로 순수 `buildPlan`은 항상 `false`로 두고 `enrichCommits`가 채운다.
128
+ */
129
+ revertDropsWorkflowGitignore: boolean
124
130
  }
125
131
 
126
132
  const TICKET_DIR_RE = /^REQ-\d{4}-\d+$/
@@ -304,13 +310,18 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
304
310
  const pkg = existsSync(pkgAbs) ? readJsonObject(pkgAbs) : null
305
311
  const scripts = stringMap(pkg, 'scripts')
306
312
  const devDeps = stringMap(pkg, 'devDependencies')
307
- for (const [k, injected] of Object.entries(REQ_SCRIPTS)) {
313
+ // 🔴 DEC-D3: req:* script를 **Stage-A 서명(REQ_SCRIPTS) ∪ Stage-B 값(STAGE_B_REQ_SCRIPTS)** 양쪽 기준으로
314
+ // 분류한다. 현재 값이 둘 중 하나와 일치하면 CommitGate 주입값(제거 대상 표시), 아니면 사용자 정의(보존).
315
+ // Stage-B 표면(dispatch 파생)이라 reconstruct 등 신규 verb도 포함된다.
316
+ const reqKeys = [...new Set([...Object.keys(REQ_SCRIPTS), ...Object.keys(STAGE_B_REQ_SCRIPTS)])].sort()
317
+ for (const k of reqKeys) {
308
318
  const cur = scripts[k]
309
319
  if (cur === undefined) continue
320
+ const isInjected = cur === REQ_SCRIPTS[k] || cur === STAGE_B_REQ_SCRIPTS[k]
310
321
  ambiguous.push({
311
322
  path: `package.json#scripts.${k}`,
312
323
  present: true,
313
- note: cur === injected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
324
+ note: isInjected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
314
325
  })
315
326
  }
316
327
  for (const [k, injected] of Object.entries(REQ_DEV_DEPS)) {
@@ -401,6 +412,7 @@ export function buildPlan(facts: UninstallFacts): UninstallPlan {
401
412
  keep: facts.ambiguous.filter((a) => a.present),
402
413
  protect: facts.evidence.filter((e) => e.ticketCount > 0),
403
414
  scaffoldCommits,
415
+ revertDropsWorkflowGitignore: false, // git 조회는 enrichCommits가 한다(순수 유지)
404
416
  }
405
417
  }
406
418
 
@@ -416,7 +428,10 @@ function enrichCommits(plan: UninstallPlan, git: GitAdapter): UninstallPlan {
416
428
  const found = anchor ? introducingCommit(git, anchor) : null
417
429
  return { sha: c.sha, subject: found?.subject ?? '' }
418
430
  })
419
- return { ...plan, scaffoldCommits }
431
+ // F-6: 보존할 증거가 있을 때만 판정한다 — 증거가 없으면 드러날 scratch도 없어 경고가 소음이다.
432
+ const revertDropsWorkflowGitignore =
433
+ plan.protect.length > 0 && revertDropsWorkflowGitignoreCheck(scaffoldCommits, git)
434
+ return { ...plan, scaffoldCommits, revertDropsWorkflowGitignore }
420
435
  }
421
436
 
422
437
  // ────────────────────────────────────────────────────── 출력 (순수) ──
@@ -442,6 +457,15 @@ export function renderPlan(plan: UninstallPlan): string {
442
457
 
443
458
  if (plan.mode === 'not-installed') {
444
459
  L.push('이 repo에서 CommitGate 설치 흔적을 찾지 못했습니다. 되돌릴 것이 없습니다.')
460
+ // 🔴 REQ-2026-058 F-8: "되돌릴 것이 없다"와 "감사 증거가 남아 있다"는 **동시에 참일 수 있다**.
461
+ // 제거를 마친 뒤의 정상적인 모습이 정확히 이것이다 — 티켓 디렉터리는 보존하도록 안내했기 때문이다.
462
+ // 이 절을 건너뛰면 사용자는 남은 증거의 존재를 모른 채 "아무것도 없다"고 읽는다.
463
+ if (plan.protect.length) {
464
+ L.push('')
465
+ L.push('## 남아 있는 감사 증거 — 삭제하지 마세요')
466
+ for (const e of plan.protect) L.push(` - ${e.path}/ (REQ 티켓 ${e.ticketCount}개 · state.json · approvals.jsonl)`)
467
+ L.push(' 설치는 제거됐지만 이 기록은 남습니다. 이력을 보존할 것인지 함께 정리할 것인지는 직접 판단하세요.')
468
+ }
445
469
  L.push('')
446
470
  L.push(renderNpxSection())
447
471
  return L.join('\n')
@@ -477,7 +501,7 @@ export function renderPlan(plan: UninstallPlan): string {
477
501
  L.push('')
478
502
 
479
503
  L.push('## 4. 되돌리는 방법 (아래 명령은 직접 실행하세요)')
480
- L.push(...renderRevertSection(plan))
504
+ L.push(...renderRevertSection(plan, plan.revertDropsWorkflowGitignore))
481
505
  L.push('')
482
506
 
483
507
  L.push('## 5. 런타임 패키지 제거 (Stage B)')
@@ -485,8 +509,13 @@ export function renderPlan(plan: UninstallPlan): string {
485
509
  L.push('')
486
510
 
487
511
  L.push('## 6. 잔여물 경고')
512
+ // 🔴 REQ-2026-058 F-7: `scripts/`는 **Stage A(vendored) 설치본에만** 존재한다. Stage B init은 실행 코드를
513
+ // 복사하지 않으므로 그 디렉터리가 애초에 생기지 않는다 — 나열하면 없는 잔여물을 찾게 만든다.
514
+ // `unknownKitFiles`/tool 분류에 kit 디렉터리 흔적이 있을 때만 언급한다.
515
+ const hasVendoredDir = facts.tool.some((t) => t.present && t.path.startsWith(`${KIT_SOURCE_DIR_REL}/`)) || facts.unknownKitFiles.length > 0
516
+ const emptyDirs = [...(hasVendoredDir ? [`${KIT_SOURCE_DIR_REL}/`] : []), `${facts.ticketRoot}/`, '.claude/', '.cursor/']
488
517
  L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
489
- L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
518
+ L.push(` 빈 디렉터리(${emptyDirs.join(' · ')})가 파일시스템에 남을 수 있습니다.`)
490
519
  L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
491
520
  L.push('')
492
521
 
@@ -518,7 +547,37 @@ function renderRuntimeRemovalSection(plan: UninstallPlan): string[] {
518
547
  return L
519
548
  }
520
549
 
521
- function renderRevertSection(plan: UninstallPlan): string[] {
550
+ /**
551
+ * revert가 `workflow/.gitignore`를 함께 되돌려 **기존 티켓 scratch가 드러나는** 파급을 경고할 것인가
552
+ * (REQ-2026-058 F-6).
553
+ *
554
+ * 두 조건이 모두 참일 때만 낸다:
555
+ * 1. 그 도입 커밋이 실제로 `KIT_GITIGNORE.dest`를 담고 있다(git이 오라클 — 추측하지 않는다).
556
+ * 2. 보존해야 할 티켓 증거가 있다(`plan.protect`). 증거가 없으면 드러날 scratch도 없어 경고가 소음이다.
557
+ *
558
+ * 이 경고가 없으면 계획이 스스로 모순된다 — §3은 "증거 디렉터리를 보존하라"고 하면서 §4는 그 디렉터리의
559
+ * 무시 규칙을 지우는 명령을 권하게 된다(Nuxt 소비자 감사에서 scratch 5건 노출로 실측).
560
+ */
561
+ function revertDropsWorkflowGitignoreCheck(commits: readonly ScaffoldCommit[], git: GitAdapter): boolean {
562
+ for (const c of commits) {
563
+ let names: string
564
+ try {
565
+ names = git.exec(['show', '--pretty=format:', '--name-only', c.sha])
566
+ } catch {
567
+ continue // 조회 실패는 경고 근거로 삼지 않는다(읽기 전용 planner — 추측 금지)
568
+ }
569
+ if (
570
+ names
571
+ .split('\n')
572
+ .map((l) => l.trim())
573
+ .includes(KIT_GITIGNORE.dest)
574
+ )
575
+ return true
576
+ }
577
+ return false
578
+ }
579
+
580
+ function renderRevertSection(plan: UninstallPlan, gitignoreWarning: boolean): string[] {
522
581
  const L: string[] = []
523
582
  const { facts } = plan
524
583
 
@@ -535,6 +594,13 @@ function renderRevertSection(plan: UninstallPlan): string[] {
535
594
  for (const c of plan.scaffoldCommits) L.push(` ${c.sha} ${c.subject}`)
536
595
  L.push(' 각 커밋의 내용을 확인한 뒤(`git show <sha>`) 되돌릴 범위를 직접 정하세요.')
537
596
  }
597
+ if (gitignoreWarning) {
598
+ L.push(` ⚠️ 이 커밋에는 ${KIT_GITIGNORE.dest} 가 들어 있습니다 — revert하면 그 파일도 함께 사라집니다.`)
599
+ L.push(' 그러면 위 3번의 티켓 안 scratch(codex-response.json · .review-preview.txt · .review-calls.jsonl)가')
600
+ L.push(' 무시되지 않아 `git status`에 드러납니다. 둘 중 하나를 고르십시오:')
601
+ L.push(` - 증거를 계속 쓸 것이면: revert 뒤 ${KIT_GITIGNORE.dest} 를 복원하거나 그 규칙을 루트 .gitignore로 옮기십시오.`)
602
+ L.push(' - 티켓 증거까지 정리할 것이면: 그 scratch 파일들도 함께 지우십시오(그때는 노출이 문제가 아닙니다).')
603
+ }
538
604
  if (plan.mode === 'mixed') L.push(' 일부 파일은 아직 커밋되지 않았습니다 — 아래 미커밋 절차도 함께 보세요.')
539
605
  }
540
606
 
@@ -565,6 +631,9 @@ function renderNpxSection(): string {
565
631
  ' `npx commitgate`는 전역 설치가 아닙니다. 패키지는 npm 캐시의 `_npx/<hash>/`에만 들어갑니다.',
566
632
  ' 확인 : npm ls -g commitgate (비어 있으면 전역 설치 아님)',
567
633
  ' 전역이었다면 : npm uninstall -g commitgate',
634
+ // 🔴 REQ-2026-058 F-9: 이 명령의 범위를 밝힌다 — CommitGate만 지우는 것이 **아니다**.
635
+ ' ⚠️ 아래 명령은 CommitGate 것만이 아니라 이 사용자가 npx 로 실행한 모든 패키지 캐시를 삭제합니다.',
636
+ ' CommitGate만 정리하려면 `_npx/<hash>/` 중 해당 항목만 골라 지우십시오(선택적 · 필수 아님).',
568
637
  ' 캐시에 남은 npx 패키지 정리:',
569
638
  ' Windows (PowerShell) : Remove-Item -Recurse -Force "$(npm config get cache)\\_npx"',
570
639
  ' macOS / Linux : rm -rf "$(npm config get cache)/_npx"',
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "commitgate",
3
- "version": "0.9.8",
4
- "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "sol5288",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/sol5288/commitgate.git"
11
- },
12
- "homepage": "https://github.com/sol5288/commitgate#readme",
13
- "bugs": {
14
- "url": "https://github.com/sol5288/commitgate/issues"
15
- },
16
- "keywords": [
17
- "ai",
18
- "code-review",
19
- "workflow",
20
- "codex",
21
- "claude",
22
- "git",
23
- "commit-gate",
24
- "fail-closed",
25
- "review-gate",
26
- "cli"
27
- ],
28
- "bin": {
29
- "commitgate": "bin/commitgate.mjs"
30
- },
31
- "scripts": {
32
- "req:new": "tsx scripts/req/req-new.ts",
33
- "req:review-codex": "tsx scripts/req/review-codex.ts",
34
- "req:doctor": "tsx scripts/req/req-doctor.ts",
35
- "req:next": "tsx scripts/req/req-next.ts",
36
- "req:commit": "tsx scripts/req/req-commit.ts",
37
- "test": "vitest run",
38
- "typecheck": "tsc --noEmit",
39
- "smoke": "node scripts/smoke.mjs",
40
- "verify:overrides": "node scripts/verify-review-overrides.mjs",
41
- "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
- },
43
- "files": [
44
- "scripts/req",
45
- "scripts/verify-review-overrides.mjs",
46
- "workflow/machine.schema.json",
47
- "workflow/req.config.schema.json",
48
- "workflow/review-persona.md",
49
- "bin",
50
- "templates",
51
- "skills",
52
- "AGENTS.template.md",
53
- "req.config.json.sample",
54
- "README.md",
55
- "README.en.md",
56
- "CHANGELOG.md"
57
- ],
58
- "engines": {
59
- "node": ">=18.17"
60
- },
61
- "dependencies": {
62
- "ajv": "^8.20.0",
63
- "cross-spawn": "^7.0.6",
64
- "semver": "^7.6.3",
65
- "tsx": "^4.19.1"
66
- },
67
- "devDependencies": {
68
- "@types/cross-spawn": "^6.0.6",
69
- "@types/node": "^22.7.4",
70
- "@types/semver": "^7.5.8",
71
- "remark-cli": "^12.0.1",
72
- "remark-validate-links": "^13.1.0",
73
- "typescript": "^5.6.2",
74
- "vitest": "^2.1.2"
75
- }
76
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.9.10",
4
+ "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "sol5288",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sol5288/commitgate.git"
11
+ },
12
+ "homepage": "https://github.com/sol5288/commitgate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sol5288/commitgate/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "code-review",
19
+ "workflow",
20
+ "codex",
21
+ "claude",
22
+ "git",
23
+ "commit-gate",
24
+ "fail-closed",
25
+ "review-gate",
26
+ "cli"
27
+ ],
28
+ "bin": {
29
+ "commitgate": "bin/commitgate.mjs"
30
+ },
31
+ "scripts": {
32
+ "req:new": "tsx scripts/req/req-new.ts",
33
+ "req:review-codex": "tsx scripts/req/review-codex.ts",
34
+ "req:doctor": "tsx scripts/req/req-doctor.ts",
35
+ "req:next": "tsx scripts/req/req-next.ts",
36
+ "req:commit": "tsx scripts/req/req-commit.ts",
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "verify:overrides": "node scripts/verify-review-overrides.mjs",
41
+ "docs:lint": "remark docs README.md README.en.md --quiet --frail"
42
+ },
43
+ "files": [
44
+ "scripts/req",
45
+ "scripts/verify-review-overrides.mjs",
46
+ "workflow/machine.schema.json",
47
+ "workflow/req.config.schema.json",
48
+ "workflow/review-persona.md",
49
+ "bin",
50
+ "templates",
51
+ "skills",
52
+ "AGENTS.template.md",
53
+ "req.config.json.sample",
54
+ "README.md",
55
+ "README.en.md",
56
+ "CHANGELOG.md"
57
+ ],
58
+ "engines": {
59
+ "node": ">=18.17"
60
+ },
61
+ "dependencies": {
62
+ "ajv": "^8.20.0",
63
+ "cross-spawn": "^7.0.6",
64
+ "semver": "^7.6.3",
65
+ "tsx": "^4.19.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/cross-spawn": "^6.0.6",
69
+ "@types/node": "^22.7.4",
70
+ "@types/semver": "^7.5.8",
71
+ "remark-cli": "^12.0.1",
72
+ "remark-validate-links": "^13.1.0",
73
+ "typescript": "^5.6.2",
74
+ "vitest": "^2.1.2"
75
+ }
76
+ }
@@ -46,6 +46,38 @@ export function safeSpawnSync(file: string, args: readonly string[], opts: SafeS
46
46
  return res.stdout ? res.stdout.toString('utf8') : ''
47
47
  }
48
48
 
49
+ /**
50
+ * `safeSpawnSync`의 **exit code 보존** 변형 (REQ-2026-050 D5).
51
+ *
52
+ * `safeSpawnSync`는 non-zero를 전부 실패로 보고 throw한다. 그러나 **non-zero가 정상 신호**인 명령이 있다 —
53
+ * `git diff --no-index`는 두 파일 내용이 다르면 exit **1**을 낸다(정상 결과이지 오류가 아니다).
54
+ * 그런 명령에 `safeSpawnSync`를 쓰면 정상 결과를 오류로 오판한다.
55
+ *
56
+ * 🔴 **새 spawn 경로를 만드는 것이 아니다.** shell 없는 `cross-spawn` 단일 경로(주입 차단 경계)를 그대로
57
+ * 재사용하고, 달라지는 것은 **exit code 해석을 호출자에게 넘긴다**는 것뿐이다. 어떤 code가 정상인지는
58
+ * 명령마다 다르므로 여기서 정책을 갖지 않는다.
59
+ *
60
+ * spawn 자체의 실패(`res.error` — 예: git 부재 ENOENT)는 여기서도 throw한다. 그건 code 해석의 문제가 아니다.
61
+ */
62
+ export function safeSpawnSyncStatus(
63
+ file: string,
64
+ args: readonly string[],
65
+ opts: SafeSpawnOptions = {},
66
+ ): { status: number | null; stdout: string; stderr: string } {
67
+ const res = spawn.sync(file, args as string[], {
68
+ cwd: opts.cwd,
69
+ input: opts.input,
70
+ stdio: opts.stdio ?? 'pipe',
71
+ maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
72
+ })
73
+ if (res.error) throw res.error
74
+ return {
75
+ status: res.status,
76
+ stdout: res.stdout ? res.stdout.toString('utf8') : '',
77
+ stderr: res.stderr ? res.stderr.toString('utf8') : '',
78
+ }
79
+ }
80
+
49
81
  // ──────────────────────────────────────────────────────────── Git ──
50
82
 
51
83
  /** git 호출 경계(D-017-3). exec(args) = trim된 stdout, 실패 시 throw(fail-closed). */
@@ -57,6 +89,20 @@ export interface GitAdapter {
57
89
  export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8'; maxBuffer: number }) => string
58
90
  const defaultGitRunner: GitRunner = (file, args, opts) => execFileSync(file, args, opts)
59
91
 
92
+ /**
93
+ * **부재가 정상인 조회** 전용 runner — git의 stderr를 버린다 (REQ-2026-058 F-5).
94
+ *
95
+ * `git show HEAD:<path>` 류는 "아직 커밋되지 않음"이 **정상 상태**이고 호출부가 `catch → null`로 처리한다.
96
+ * 그런데 `execFileSync`는 기본적으로 자식 stderr를 부모로 흘리므로, 정상 경로에서
97
+ * `fatal: path '…' does not exist in 'HEAD'`가 사용자 화면에 뜬다 — 실패로 오해된다(Nuxt 소비자 감사 실측).
98
+ *
99
+ * 🔴 **전역으로 쓰지 말 것.** 진짜 오류(권한·손상·잠금)의 진단까지 사라진다. `bin/init.ts`의
100
+ * `assertGitWorkTree`가 probe 전용 quiet runner를 쓰는 것과 같은 좁은 용도다.
101
+ * ⚠️ 판정은 바뀌지 않는다 — 실패는 여전히 throw이고 호출부의 `catch`가 부재로 해석한다.
102
+ */
103
+ export const quietGitRunner: GitRunner = (file, args, opts) =>
104
+ execFileSync(file, args, { ...opts, stdio: ['ignore', 'pipe', 'ignore'] })
105
+
60
106
  /** git stdout 상한 — codex 경로(safeSpawnSync)와 동일 64 MiB. 큰 staged diff/status에서 Node 기본 1 MiB의 ENOBUFS throw 방지. */
61
107
  const GIT_MAX_BUFFER = 64 * 1024 * 1024
62
108
 
@@ -106,12 +152,48 @@ export function parseThreadId(jsonl: string): string | null {
106
152
  return null
107
153
  }
108
154
 
109
- /** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw. */
155
+ /**
156
+ * 리뷰 호출 실패의 **타입된 분류**(REQ-2026-054·DEC-C1). 예산 환불·lifecycle 판정이 메시지 문자열 sniffing이
157
+ * 아니라 이 타입으로 결정된다.
158
+ * - `pre-dispatch`: reviewer subprocess가 **기동조차 못 함**(spawn 실패·ENOENT). 청구 불가 → 환불 대상.
159
+ * - `dispatched`: subprocess는 떴으나 사용 가능한 결과 없음(non-zero exit·thread_id 없음). 청구 가능성 → 차감.
160
+ */
161
+ export class ReviewCallError extends Error {
162
+ readonly dispatchPhase: 'pre-dispatch' | 'dispatched'
163
+ constructor(dispatchPhase: 'pre-dispatch' | 'dispatched', message: string) {
164
+ super(message)
165
+ this.name = 'ReviewCallError'
166
+ this.dispatchPhase = dispatchPhase
167
+ }
168
+ }
169
+
170
+ /** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw(`ReviewCallError`로 dispatch 단계 분류). */
110
171
  export type CodexRunner = (args: string[], input: string, cwd: string) => string
111
- const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
112
- // shell 없이 안전 실행(safeSpawnSync/cross-spawn). 프롬프트는 stdin(input)으로 전달.
113
- // ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId ) 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 P1 수정.
114
- safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
172
+
173
+ /** status 보존 spawn 함수 타입(주입 seam — `safeSpawnSyncStatus` 서명). */
174
+ export type StatusSpawn = (file: string, args: readonly string[], opts?: SafeSpawnOptions) => { status: number | null; stdout: string; stderr: string }
175
+
176
+ /**
177
+ * codex 러너 팩토리(REQ-2026-054·DEC-C1). spawn을 주입해 분류 로직을 테스트 가능하게 한다.
178
+ * - spawn 자체 실패(`res.error` throw — ENOENT 등): subprocess 미기동 = `pre-dispatch`(청구 불가·환불 대상).
179
+ * - `res.status !== 0`(subprocess는 떴고 non-zero — usage limit 등 모델 부분 실행 가능): `dispatched`(차감).
180
+ */
181
+ export function makeCodexRunner(spawn: StatusSpawn): CodexRunner {
182
+ return (args, input, cwd) => {
183
+ // shell 없이 안전 실행(cross-spawn). 프롬프트는 stdin(input). 과거 `shell:true`의 명령 주입/공백 경로 결함 방지.
184
+ let res: { status: number | null; stdout: string; stderr: string }
185
+ try {
186
+ res = spawn('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
187
+ } catch (err) {
188
+ throw new ReviewCallError('pre-dispatch', `codex 실행(spawn) 실패 — subprocess 미기동: ${err instanceof Error ? err.message : String(err)}`)
189
+ }
190
+ if (res.status !== 0)
191
+ throw new ReviewCallError('dispatched', `codex 종료 코드 ${res.status ?? 'null'}: ${res.stderr.trim()}`.trim())
192
+ return res.stdout
193
+ }
194
+ }
195
+
196
+ const defaultCodexRunner: CodexRunner = makeCodexRunner(safeSpawnSyncStatus)
115
197
 
116
198
  /** unknown → 평범한 객체(배열·null 제외). 스키마 경로 탐색용. */
117
199
  function asPlainObject(v: unknown): Record<string, unknown> | null {
@@ -202,13 +284,37 @@ export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner
202
284
  }
203
285
 
204
286
  /** 테스트 전용 ReviewerAdapter — canned 응답 반환 + 받은 요청 기록(live codex 없이 review-codex 플로 검증, 수용기준 #4). */
205
- export function createFakeReviewerAdapter(result: ReviewResult): ReviewerAdapter & { requests: ReviewRequest[] } {
287
+ export function createFakeReviewerAdapter(
288
+ result: ReviewResult,
289
+ opts: {
290
+ /**
291
+ * REQ-2026-052: `true`(기본)면 응답의 `review_base_sha`를 **프롬프트의 `REVIEW_BASE_SHA`로 덮어쓴다**.
292
+ * pre-call 원장 커밋이 HEAD를 옮겨 실제 approval base가 테스트 setup 시점의 head와 달라지므로, 고정
293
+ * canned 응답의 base가 stale해진다. 프롬프트 base를 echo하면 near-e2e가 그 커밋을 신경 쓰지 않아도 된다.
294
+ * base 불일치(invalid)를 **의도적으로** 테스트하려면 `echoPromptBase:false`로 끈다.
295
+ */
296
+ echoPromptBase?: boolean
297
+ } = {},
298
+ ): ReviewerAdapter & { requests: ReviewRequest[] } {
206
299
  const requests: ReviewRequest[] = []
300
+ const echo = opts.echoPromptBase !== false
207
301
  return {
208
302
  requests,
209
303
  review(req: ReviewRequest): ReviewResult {
210
304
  requests.push(req)
211
- return result
305
+ if (!echo) return result
306
+ // 프롬프트에서 `REVIEW_BASE_SHA: <sha>` 추출.
307
+ const m = /^REVIEW_BASE_SHA:\s*([0-9a-f]{7,64})\s*$/m.exec(req.prompt)
308
+ if (!m) return result
309
+ let parsed: unknown
310
+ try {
311
+ parsed = JSON.parse(result.lastMessage)
312
+ } catch {
313
+ return result // JSON이 아니면 손대지 않는다.
314
+ }
315
+ if (!parsed || typeof parsed !== 'object' || !('review_base_sha' in parsed)) return result
316
+ const patched = { ...(parsed as Record<string, unknown>), review_base_sha: m[1] }
317
+ return { ...result, lastMessage: JSON.stringify(patched) }
212
318
  },
213
319
  }
214
320
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `req:close --migrate`의 **순수 판정기** (REQ-2026-053·DEC-M3/M7).
3
+ *
4
+ * close-proof/`phase_design_ref` regime **이전에** 완료·병합돼 dev-complete로 자기증명될 수 없는 레거시
5
+ * durable 티켓을, HEAD-committed 증거만으로 마이그레이션 종결 자격을 판정한다. IO는 호출부(`req-close.ts`)가 낸다 —
6
+ * 이 모듈은 fs·git을 모른다(`lib/close-proof`·`lib/reconstruct`와 같은 태도).
7
+ *
8
+ * 🔴 자격은 좁다: **손상 아님 + durable + 커밋된 design 승인 + phase 증거 ≥1 + 정상 dev-complete 불가 +
9
+ * integrated(본선 병합)**. 하나라도 어긋나면 fail-closed 거부. 이미 종결이면 거부가 아니라 성공 no-op(멱등).
10
+ *
11
+ * 🔴 **`migrated-complete` close 이벤트·기본 상태·파서·`deriveBaseState` 확장(dev-complete 아래·needs-recovery
12
+ * 위 비차단)은 phase-1(커밋 `3ed1b95` `close-proof.ts`)에 이미 landed.** 이 모듈(phase-2)은 그 이벤트를
13
+ * **발행할지 판정**만 한다. 종결→intake pass 전 파이프라인은 `req-close.test.ts` ⑮가 실 git으로 실증한다.
14
+ */
15
+ import type { CloseProofRow, CloseProofEvent } from './close-proof'
16
+
17
+ /** 판정 입력 — 전부 HEAD-committed 사실 + integrated(git ancestry, 호출부가 계산). */
18
+ export interface MigrationFacts {
19
+ ticketId: string
20
+ ticketRel: string
21
+ /** HEAD scaffold marker(`isDurabilityRequired`). false면 legacy. */
22
+ durabilityRequired: boolean
23
+ /** HEAD approvals.jsonl 본문(없으면 null). */
24
+ manifestText: string | null
25
+ /** `validateManifest` 결과(빈 배열=정상). 비어있지 않으면 corrupt 거부. */
26
+ manifestProblems: readonly string[]
27
+ /** HEAD ticket-close.jsonl 파싱 problems. 비어있지 않으면 corrupt 거부. */
28
+ closeProblems: readonly string[]
29
+ /** HEAD ticket-close.jsonl 파싱 행(이미 종결 여부 판정). */
30
+ closeRows: readonly CloseProofRow[]
31
+ /** committed 증거(design+phase) 무결성 problems(DEC-B6·B7). 비어있지 않으면 corrupt 거부. */
32
+ evidenceIntegrityProblems: readonly string[]
33
+ /** 커밋된 design 승인 참조(design_hash). 없으면 null. */
34
+ committedDesignRef: string | null
35
+ /** 매니페스트의 **모든** phase-evidenced id(결속 무관). 완료 inventory 원천. */
36
+ evidencedPhaseIdsAll: readonly string[]
37
+ /** **현재 design_ref에 결속된** phase id(정상 dev-complete 가능성 판정). */
38
+ evidencedPhaseIdsBound: readonly string[]
39
+ /**
40
+ * 🔴 티켓의 **커밋된 phase 계획**(HEAD state.json `phases[].id`). r02 P1 대응 — integrated(마지막 매니페스트
41
+ * 커밋이 본선 조상)만으로는 "앞 phase만 병합되고 뒷 phase가 진행 중/중단"인 부분 완료를 못 거른다.
42
+ * 커밋된 계획이 있으면 그 **모든** phase가 증거로 있어야 완료로 본다. 계획이 비었으면(레거시 스캐폴드
43
+ * state.phases=[]) 이 검사는 vacuous — dev-completable(결속) 검사와 integrated가 남은 방어다.
44
+ */
45
+ committedPlannedPhaseIds: readonly string[]
46
+ /** 티켓 증거가 본선(mainline)의 조상인가(완료성 증명 — DEC-M3.7). 호출부가 git ancestry로 계산. */
47
+ integrated: boolean
48
+ /** 발행 시각(ISO, 호출부가 실시계로 넣음). */
49
+ nowIso: string
50
+ /** 발행 행의 evidence_basis(마이그레이션 근거 아티팩트 경로 — 비어있으면 안 됨). */
51
+ evidenceBasis: readonly string[]
52
+ }
53
+
54
+ export type MigrationPlan =
55
+ | { kind: 'stamp'; row: CloseProofRow }
56
+ /** 이미 terminal close(dev-complete/series-terminal/migrated-complete) — 성공 no-op(DEC-M7). */
57
+ | { kind: 'noop'; existingState: CloseProofEvent }
58
+ /** 자격 미달 — fail-closed 거부(비-스탬프). */
59
+ | { kind: 'refuse'; reason: string; hint: string }
60
+
61
+ function refuse(reason: string, hint: string): MigrationPlan {
62
+ return { kind: 'refuse', reason, hint }
63
+ }
64
+
65
+ /**
66
+ * 마이그레이션 종결 계획(순수·DEC-M3/M7). 판정 순서가 계약이다:
67
+ * corrupt 가드 → durability → **이미 종결이면 no-op** → design 승인 → phase 증거 → 정상 dev-complete 불가 →
68
+ * integrated. 마지막에만 stamp.
69
+ */
70
+ export function planMigrationClose(f: MigrationFacts): MigrationPlan {
71
+ // 🔴 corrupt 가드 — pass 조건이 읽는 아티팩트가 손상됐으면 완료를 스탬프하지 않는다(fail-closed).
72
+ if (f.manifestText !== null && f.manifestProblems.length)
73
+ return refuse(`HEAD approvals.jsonl 손상: ${f.manifestProblems.slice(0, 3).join('; ')}`, '손상 증거에는 완료를 스탬프하지 않습니다 — 먼저 정정/복구')
74
+ if (f.closeProblems.length)
75
+ return refuse(`HEAD ticket-close.jsonl 손상: ${f.closeProblems.slice(0, 3).join('; ')}`, '손상 close-proof 정리 후 재시도')
76
+ if (f.evidenceIntegrityProblems.length)
77
+ return refuse(`committed 증거(design·phase archive) 손상/부재: ${f.evidenceIntegrityProblems.slice(0, 3).join('; ')}`, 'req:reconstruct 등으로 복구 후 재시도')
78
+
79
+ // durability marker 없음 = legacy → intake가 애초에 차단하지 않으므로 종결 불필요.
80
+ if (!f.durabilityRequired)
81
+ return refuse('legacy 티켓(durability marker 없음) — intake가 차단하지 않아 종결이 불필요', '조치 불필요')
82
+
83
+ // 🔴 DEC-M7: 이미 terminal close면 거부가 아니라 성공 no-op(재실행 멱등). 다른 검사보다 앞에 둬 재실행이
84
+ // 깨끗한 no-op이 되게 한다(기존 행의 at을 보존).
85
+ const terminal = f.closeRows.find(
86
+ (r) => r.event === 'series-terminal' || r.event === 'dev-complete' || r.event === 'migrated-complete',
87
+ )
88
+ if (terminal) return { kind: 'noop', existingState: terminal.event }
89
+
90
+ // 커밋된 design 승인 — 무엇에 대한 완료인지 불명이면 거부.
91
+ if (f.committedDesignRef === null)
92
+ return refuse('커밋된 design 승인(design_hash)이 없다 — 무엇에 대한 완료인지 불명', 'design 승인 증거 없이 마이그레이션 불가')
93
+
94
+ // phase 증거 ≥1 — 실제 phase를 거친 티켓만.
95
+ const inventory = [...new Set(f.evidencedPhaseIdsAll)].sort()
96
+ if (inventory.length === 0)
97
+ return refuse('커밋된 phase 증거가 없다 — 실제 phase를 거친 티켓만 마이그레이션', 'phase 리뷰·커밋을 먼저 완료')
98
+
99
+ // 🔴 r02 P1: **부분 완료(진행 중/중단) 배제** — 커밋된 phase 계획(state.phases)이 있으면 그 모든 phase가
100
+ // 증거로 있어야 한다. integrated는 "마지막 매니페스트 커밋이 본선 조상"만 봐서, 앞 phase만 병합되고 뒤
101
+ // phase가 아직 증거를 안 낸 티켓을 통과시킨다 — 이 committed 계획 검사가 그 틈을 닫는다.
102
+ const evidencedSet = new Set(inventory)
103
+ const missingPlanned = f.committedPlannedPhaseIds.filter((id) => !evidencedSet.has(id))
104
+ if (missingPlanned.length)
105
+ return refuse(
106
+ `커밋된 phase 계획 중 증거 없는 phase: ${missingPlanned.join(', ')} — 부분 완료(진행 중/중단) 티켓은 마이그레이션 불가`,
107
+ '모든 계획 phase를 완료·커밋(정상 dev-complete)하거나 종결한 뒤 재시도',
108
+ )
109
+
110
+ // 🔴 정상 dev-complete가 가능하면 거부 — 마이그레이션으로 강한 경로를 우회하지 않는다(DEC-M3.6).
111
+ const bound = new Set(f.evidencedPhaseIdsBound)
112
+ if (inventory.every((id) => bound.has(id)))
113
+ return refuse('phase 증거가 현재 design_ref에 전부 결속됨 — 정상 dev-complete 가능', '`req:commit --finalize --run`(정상 완료 경로)을 사용')
114
+
115
+ // 🔴 완료성 증명 = integrated(DEC-M3.7·P1-1) — 본선 미병합이면 진행 중일 수 있어 거부.
116
+ if (!f.integrated)
117
+ return refuse('티켓 작업이 본선(mainline)에 병합되지 않음 — 미완료/진행 중 가능성', '완료·병합 후 재시도(마이그레이션은 병합된 완료 티켓만)')
118
+
119
+ // 방어: evidence_basis 비어있으면 스키마 위반(reconstructed:true는 근거 필수). 호출부가 항상 채운다.
120
+ if (f.evidenceBasis.length === 0)
121
+ return refuse('evidence_basis가 비어 있음(내부 오류 — 마이그레이션 근거 경로 미제공)', '버그 신고')
122
+
123
+ const row: CloseProofRow = {
124
+ ticket_id: f.ticketId,
125
+ event: 'migrated-complete',
126
+ series_id: null,
127
+ resolution: null,
128
+ phase_inventory: inventory,
129
+ design_ref: f.committedDesignRef,
130
+ at: f.nowIso,
131
+ reconstructed: true,
132
+ evidence_basis: [...f.evidenceBasis],
133
+ }
134
+ return { kind: 'stamp', row }
135
+ }