commitgate 0.2.1 → 0.3.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,551 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * commitgate uninstall — **읽기 전용 removal planner** (REQ-2026-007).
4
+ *
5
+ * 이 모듈은 아무것도 지우지 않는다. repo를 읽어 제거 계획과 "사용자가 직접 검토 후 실행할 명령"만 출력한다.
6
+ *
7
+ * 왜 실제 삭제를 하지 않는가:
8
+ * `runInit`은 무엇을 새로 만들었고(copied) 무엇이 이미 있었는지(skipped/merged)를 계산만 하고 **디스크에 원장을 남기지 않는다**.
9
+ * 따라서 uninstall 시점에 `AGENTS.md`(부재 시에만 생성)·`req.config.json`(누락 키만 병합)·`package.json`(부재 키만 주입)이
10
+ * CommitGate 소유인지 사용자 소유인지 구분할 수 없다. 원장 없는 blind delete는 사용자 데이터를 파괴한다.
11
+ * CommitGate는 git hook·git config를 건드리지 않는 **순수 in-tree 스캐폴더**이므로 되돌리기의 정본은 git이다.
12
+ *
13
+ * 읽기 전용 계약(테스트로 고정 — tests/unit/uninstall.test.ts):
14
+ * - `node:fs`에서 조회 API만 가져온다(파일을 만들거나 고치거나 지우는 API를 import하지 않는다).
15
+ * - git은 read-only 서브커맨드 allowlist(`rev-parse`·`status`·`ls-files`·`log`)만 호출한다.
16
+ * - 해시는 `node:crypto`로 계산한다(`git hash-object`는 objects/에 쓸 수 있어 쓰지 않는다).
17
+ * - npm을 spawn하지 않는다. 캐시 정리 명령은 문자열로 출력만 한다.
18
+ * - 삭제 플래그(`--run`/`--force`)를 제공하지 않는다 — 이 부재가 계약이다.
19
+ */
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
22
+ import { resolve, join, relative } from 'node:path'
23
+ import { pathToFileURL } from 'node:url'
24
+ import { loadConfig, stripBom, DEFAULTS } from '../scripts/req/lib/config'
25
+ import { createGitAdapter, type GitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
26
+ import {
27
+ PACKAGE_ROOT,
28
+ KIT_SOURCE_DIR_REL,
29
+ KIT_SCHEMA_RELPATHS,
30
+ REQ_SCRIPTS,
31
+ REQ_DEV_DEPS,
32
+ assertGitWorkTree,
33
+ } from './init'
34
+
35
+ export interface UninstallOptions {
36
+ dir: string
37
+ }
38
+
39
+ /** CommitGate가 복사한 파일(kit 소스 + init이 실제로 복사한 스키마). 바이트 비교로 무결성만 표기. */
40
+ export interface ToolArtifact {
41
+ path: string // repo-상대
42
+ present: boolean
43
+ /** 현재 실행 중인 패키지의 원본과 바이트 동일? `differs`는 "편집됐거나 **다른 버전**이 설치함"일 뿐 사용자 소유 단정이 아니다. */
44
+ match: 'identical' | 'differs' | 'absent'
45
+ tracked: boolean
46
+ /**
47
+ * 이 경로를 추가(A)한 **가장 최근** 커밋 sha. **현재 tracked인 파일에 한해서만** 채운다(phase R2 P2).
48
+ * 과거에 추가됐다가 삭제된 경로는 `git log --diff-filter=A`에 낡은 add 커밋이 남아 있어,
49
+ * 새로 설치한 untracked 파일을 "커밋됨"으로 오판하고 엉뚱한 커밋의 revert를 권하게 된다.
50
+ */
51
+ introducedBy: string | null
52
+ }
53
+
54
+ /** origin 판별 불가 → **항상 자동 제거 대상에서 제외**. `path`는 파일 또는 `package.json#scripts.req:new` 형태. */
55
+ export interface AmbiguousArtifact {
56
+ path: string
57
+ present: boolean
58
+ note: string
59
+ }
60
+
61
+ /** 설정된 ticketRoot — REQ 티켓 state.json·approvals.jsonl 등 감사 증거. */
62
+ export interface EvidenceDir {
63
+ path: string
64
+ ticketCount: number
65
+ }
66
+
67
+ export interface ScaffoldCommit {
68
+ sha: string
69
+ subject: string
70
+ }
71
+
72
+ export interface UninstallFacts {
73
+ targetRoot: string
74
+ /** `loadConfig` 해소값. 증거 보호 축(설치 경로 축은 KIT_SCHEMA_RELPATHS). */
75
+ ticketRoot: string
76
+ schemaPath: string
77
+ /** config를 읽지 못해 DEFAULTS로 강등했으면 사유. planner는 쓰기가 없으므로 강등이 안전하다. */
78
+ configError: string | null
79
+ installed: boolean
80
+ packageJsonDirty: boolean
81
+ tool: ToolArtifact[]
82
+ ambiguous: AmbiguousArtifact[]
83
+ evidence: EvidenceDir[]
84
+ info: string[]
85
+ /**
86
+ * kit 디렉터리(`scripts/req/`) 안에 있지만 이 패키지가 설치한 파일이 **아닌** 것들(phase R1 P1).
87
+ * 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
88
+ */
89
+ unknownKitFiles: string[]
90
+ }
91
+
92
+ export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
93
+
94
+ export interface UninstallPlan {
95
+ facts: UninstallFacts
96
+ mode: UninstallMode
97
+ /** 패키지 원본과 바이트 동일 → 사용자가 지워도 잃을 것이 없는 파일. */
98
+ removable: ToolArtifact[]
99
+ /** 원본과 다름 → 사용자 검토 후 판단. */
100
+ review: ToolArtifact[]
101
+ /** ambiguous 중 실제로 존재하는 것 — 자동 제거 금지 목록. */
102
+ keep: AmbiguousArtifact[]
103
+ /** 티켓이 실제로 쌓인 증거 디렉터리 — 삭제 금지. */
104
+ protect: EvidenceDir[]
105
+ scaffoldCommits: ScaffoldCommit[]
106
+ }
107
+
108
+ const TICKET_DIR_RE = /^REQ-\d{4}-\d+$/
109
+
110
+ // ─────────────────────────────────────────────────────── 읽기 헬퍼 ──
111
+
112
+ function sha256(abs: string): string {
113
+ return createHash('sha256').update(readFileSync(abs)).digest('hex')
114
+ }
115
+
116
+ /** dir 하위 모든 파일의 절대경로(재귀, 조회만). */
117
+ function walkFiles(dir: string): string[] {
118
+ const out: string[] = []
119
+ for (const entry of readdirSync(dir)) {
120
+ const abs = join(dir, entry)
121
+ if (statSync(abs).isDirectory()) out.push(...walkFiles(abs))
122
+ else out.push(abs)
123
+ }
124
+ return out
125
+ }
126
+
127
+ const toRel = (abs: string): string => relative(PACKAGE_ROOT, abs).replace(/\\/g, '/')
128
+
129
+ /** git이 추적 중인가(인덱스에 있는가). */
130
+ function isTracked(git: GitAdapter, rel: string): boolean {
131
+ return git.exec(['ls-files', '--', rel]).length > 0
132
+ }
133
+
134
+ /** 이 경로를 처음 추가한 커밋(sha, subject). HEAD 이력에 없으면 null. */
135
+ function introducingCommit(git: GitAdapter, rel: string): ScaffoldCommit | null {
136
+ const out = git.exec(['log', '--diff-filter=A', '-n', '1', '--format=%H%x09%s', '--', rel])
137
+ if (!out) return null
138
+ const tab = out.indexOf('\t')
139
+ if (tab < 0) return null
140
+ return { sha: out.slice(0, tab), subject: out.slice(tab + 1) }
141
+ }
142
+
143
+ /** JSON 객체 파싱(실패/비-객체면 null — planner는 fail-closed 하지 않고 "확인 불가"로 표기). */
144
+ function readJsonObject(abs: string): Record<string, unknown> | null {
145
+ try {
146
+ const v: unknown = JSON.parse(stripBom(readFileSync(abs, 'utf8')))
147
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
148
+ } catch {
149
+ return null
150
+ }
151
+ }
152
+
153
+ function stringMap(obj: Record<string, unknown> | null, key: string): Record<string, string> {
154
+ const v = obj?.[key]
155
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, string>
156
+ return {}
157
+ }
158
+
159
+ // ──────────────────────────────────────────────── 사실 수집 (IO=읽기) ──
160
+
161
+ /**
162
+ * 대상 repo를 읽어 `UninstallFacts`를 만든다. **읽기만 한다.**
163
+ * `run` 주입 시 모든 git 호출이 그 runner를 통과한다(테스트가 서브커맨드를 감시).
164
+ */
165
+ export function collectFacts(opts: UninstallOptions, run?: GitRunner): UninstallFacts {
166
+ const targetRoot = resolve(opts.dir)
167
+ if (!existsSync(targetRoot) || !statSync(targetRoot).isDirectory())
168
+ throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
169
+ assertGitWorkTree(targetRoot, run)
170
+ const git = createGitAdapter(targetRoot, run)
171
+
172
+ // 증거 보호 축: config에서 해소. 읽지 못하면 DEFAULTS로 강등하되 그 사실을 알린다
173
+ // (깨진 config 때문에 제거 안내를 못 받는 건 부당하다 — planner는 쓰기가 없어 강등이 안전).
174
+ let ticketRoot = DEFAULTS.ticketRoot
175
+ let schemaPath = DEFAULTS.schemaPath
176
+ let configError: string | null = null
177
+ try {
178
+ const cfg = loadConfig({ root: targetRoot })
179
+ ticketRoot = cfg.ticketRoot
180
+ schemaPath = cfg.schemaPath
181
+ } catch (e) {
182
+ configError = (e as Error).message
183
+ }
184
+
185
+ const info: string[] = []
186
+ if (configError)
187
+ info.push(`req.config.json을 읽을 수 없어 기본값(DEFAULTS)으로 강등했습니다 — ${configError}`)
188
+ // 설치 경로 축(KIT_SCHEMA_RELPATHS)과 설정 경로 축(schemaPath)은 갈라질 수 있다.
189
+ if (!(KIT_SCHEMA_RELPATHS as readonly string[]).includes(schemaPath))
190
+ info.push(
191
+ `schemaPath=${schemaPath} — 런타임이 읽는 경로이지만 init이 복사한 파일이 아닙니다(제거 후보 아님).`,
192
+ )
193
+
194
+ // ── tool: kit 소스 + init이 **실제로 복사한** 스키마(항상 리터럴 workflow/ — ticketRoot 무관)
195
+ const kitSourceRels = walkFiles(join(PACKAGE_ROOT, KIT_SOURCE_DIR_REL)).map(toRel)
196
+ const toolRels = [...kitSourceRels, ...KIT_SCHEMA_RELPATHS]
197
+ const tool: ToolArtifact[] = toolRels.map((rel) => {
198
+ const dest = join(targetRoot, rel)
199
+ const src = join(PACKAGE_ROOT, rel)
200
+ if (!existsSync(dest))
201
+ return { path: rel, present: false, match: 'absent', tracked: false, introducedBy: null }
202
+ const match: ToolArtifact['match'] =
203
+ existsSync(src) && sha256(dest) === sha256(src) ? 'identical' : 'differs'
204
+ const tracked = isTracked(git, rel)
205
+ return {
206
+ path: rel,
207
+ present: true,
208
+ match,
209
+ tracked,
210
+ // untracked면 이력의 add 커밋은 "지금 이 설치본"의 도입 커밋이 아니다 → 조회조차 하지 않는다(phase R2 P2).
211
+ introducedBy: tracked ? (introducingCommit(git, rel)?.sha ?? null) : null,
212
+ }
213
+ })
214
+
215
+ // ── ambiguous: origin 판별 불가 → 자동 제거 대상에서 항상 제외
216
+ const ambiguous: AmbiguousArtifact[] = []
217
+
218
+ const agentsAbs = join(targetRoot, 'AGENTS.md')
219
+ if (existsSync(agentsAbs)) {
220
+ const tpl = join(PACKAGE_ROOT, 'AGENTS.template.md')
221
+ const same = existsSync(tpl) && sha256(agentsAbs) === sha256(tpl)
222
+ ambiguous.push({
223
+ path: 'AGENTS.md',
224
+ present: true,
225
+ note: same
226
+ ? 'init 템플릿과 동일 — 그래도 자동 제거 대상이 아닙니다(Codex 계약 파일)'
227
+ : '템플릿과 다름 — 사용자/팀이 작성했거나 편집했습니다',
228
+ })
229
+ }
230
+
231
+ const cfgAbs = join(targetRoot, 'req.config.json')
232
+ if (existsSync(cfgAbs)) {
233
+ const parsed = readJsonObject(cfgAbs)
234
+ let note: string
235
+ if (!parsed) note = '파싱 불가 — 내용을 직접 확인하세요'
236
+ else {
237
+ const keys = Object.keys(parsed).sort().join(',')
238
+ note =
239
+ keys === 'handoffPath,packageManager' && parsed.handoffPath === null
240
+ ? 'init 시드와 동일 — 그래도 자동 제거 대상이 아닙니다'
241
+ : '사용자 값 포함(init은 누락 키만 병합합니다) — 값을 보존하세요'
242
+ }
243
+ ambiguous.push({ path: 'req.config.json', present: true, note })
244
+ }
245
+
246
+ const pkgAbs = join(targetRoot, 'package.json')
247
+ const pkg = existsSync(pkgAbs) ? readJsonObject(pkgAbs) : null
248
+ const scripts = stringMap(pkg, 'scripts')
249
+ const devDeps = stringMap(pkg, 'devDependencies')
250
+ for (const [k, injected] of Object.entries(REQ_SCRIPTS)) {
251
+ const cur = scripts[k]
252
+ if (cur === undefined) continue
253
+ ambiguous.push({
254
+ path: `package.json#scripts.${k}`,
255
+ present: true,
256
+ note: cur === injected ? 'init 주입값과 동일' : `사용자 값(init 주입값과 다름): ${cur}`,
257
+ })
258
+ }
259
+ for (const [k, injected] of Object.entries(REQ_DEV_DEPS)) {
260
+ const cur = devDeps[k]
261
+ if (cur === undefined) continue
262
+ ambiguous.push({
263
+ path: `package.json#devDependencies.${k}`,
264
+ present: true,
265
+ note:
266
+ cur === injected
267
+ ? 'init 주입값과 동일 — 다른 곳에서도 쓰일 수 있습니다'
268
+ : `사용자 값(init 주입값과 다름): ${cur}`,
269
+ })
270
+ }
271
+
272
+ // ── evidence: 설정된 ticketRoot 하위 REQ-* (하드코딩 workflow/ 아님)
273
+ const evidence: EvidenceDir[] = []
274
+ const ticketRootAbs = join(targetRoot, ticketRoot)
275
+ if (existsSync(ticketRootAbs) && statSync(ticketRootAbs).isDirectory()) {
276
+ const ticketCount = readdirSync(ticketRootAbs, { withFileTypes: true }).filter(
277
+ (d) => d.isDirectory() && TICKET_DIR_RE.test(d.name),
278
+ ).length
279
+ evidence.push({ path: ticketRoot, ticketCount })
280
+ }
281
+
282
+ // ── kit 디렉터리 안의 미분류 파일(phase R1 P1): 사용자가 넣었을 수 있으므로 제거 후보에서 제외하고 통삭제도 금지.
283
+ const kitDirAbs = join(targetRoot, KIT_SOURCE_DIR_REL)
284
+ const knownKit = new Set(kitSourceRels)
285
+ const unknownKitFiles =
286
+ existsSync(kitDirAbs) && statSync(kitDirAbs).isDirectory()
287
+ ? walkFiles(kitDirAbs)
288
+ .map((abs) => relative(targetRoot, abs).replace(/\\/g, '/'))
289
+ .filter((rel) => !knownKit.has(rel))
290
+ .sort()
291
+ : []
292
+
293
+ const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
294
+ const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
295
+
296
+ return {
297
+ targetRoot,
298
+ ticketRoot,
299
+ schemaPath,
300
+ configError,
301
+ installed,
302
+ packageJsonDirty,
303
+ tool,
304
+ ambiguous,
305
+ evidence,
306
+ info,
307
+ unknownKitFiles,
308
+ }
309
+ }
310
+
311
+ // ────────────────────────────────────────────────────── 계획 (순수) ──
312
+
313
+ /** facts → plan. **순수 함수**(IO 없음). */
314
+ export function buildPlan(facts: UninstallFacts): UninstallPlan {
315
+ const present = facts.tool.filter((t) => t.present)
316
+ // 도입 커밋은 **현재 tracked인 파일**에서만 인정한다(phase R2 P2 — 낡은 add 커밋으로 인한 오판 차단).
317
+ const introduced = present.filter((t) => t.tracked && t.introducedBy !== null)
318
+
319
+ let mode: UninstallMode
320
+ if (!facts.installed) mode = 'not-installed'
321
+ else if (introduced.length === 0) mode = 'uncommitted'
322
+ else if (introduced.length === present.length) mode = 'committed'
323
+ else mode = 'mixed'
324
+
325
+ // 도입 커밋 후보(중복 제거, 첫 등장 순).
326
+ const seen = new Set<string>()
327
+ const scaffoldCommits: ScaffoldCommit[] = []
328
+ for (const t of introduced) {
329
+ const sha = t.introducedBy as string
330
+ if (seen.has(sha)) continue
331
+ seen.add(sha)
332
+ scaffoldCommits.push({ sha, subject: '' })
333
+ }
334
+
335
+ return {
336
+ facts,
337
+ mode,
338
+ removable: present.filter((t) => t.match === 'identical'),
339
+ review: present.filter((t) => t.match === 'differs'),
340
+ keep: facts.ambiguous.filter((a) => a.present),
341
+ protect: facts.evidence.filter((e) => e.ticketCount > 0),
342
+ scaffoldCommits,
343
+ }
344
+ }
345
+
346
+ /** subject를 붙인 scaffoldCommits(IO=git log). buildPlan은 순수하게 유지하고 여기서만 보강. */
347
+ function enrichCommits(plan: UninstallPlan, git: GitAdapter): UninstallPlan {
348
+ if (plan.scaffoldCommits.length === 0) return plan
349
+ const anchorByS = new Map<string, string>()
350
+ for (const t of plan.facts.tool) {
351
+ if (t.introducedBy && !anchorByS.has(t.introducedBy)) anchorByS.set(t.introducedBy, t.path)
352
+ }
353
+ const scaffoldCommits = plan.scaffoldCommits.map((c) => {
354
+ const anchor = anchorByS.get(c.sha)
355
+ const found = anchor ? introducingCommit(git, anchor) : null
356
+ return { sha: c.sha, subject: found?.subject ?? '' }
357
+ })
358
+ return { ...plan, scaffoldCommits }
359
+ }
360
+
361
+ // ────────────────────────────────────────────────────── 출력 (순수) ──
362
+
363
+ const MODE_LABEL: Record<UninstallMode, string> = {
364
+ 'not-installed': '설치 흔적 없음',
365
+ uncommitted: '설치됨 — 아직 커밋되지 않음',
366
+ committed: '설치됨 — 커밋됨',
367
+ mixed: '설치됨 — 일부만 커밋됨',
368
+ }
369
+
370
+ /** plan → 사람이 읽는 계획 텍스트. **순수 함수**. 여기서 출력하는 명령은 전부 "사용자가 직접 실행할 것"이다. */
371
+ export function renderPlan(plan: UninstallPlan): string {
372
+ const { facts } = plan
373
+ const L: string[] = []
374
+
375
+ L.push('[commitgate uninstall] 읽기 전용 제거 계획 — 이 명령은 어떤 파일도 지우지 않습니다.')
376
+ L.push(` 대상 : ${facts.targetRoot}`)
377
+ L.push(` 상태 : ${MODE_LABEL[plan.mode]}`)
378
+ L.push(` ticketRoot : ${facts.ticketRoot}${facts.configError ? ' (기본값 강등)' : ''}`)
379
+ for (const i of facts.info) L.push(` 참고 : ${i}`)
380
+ L.push('')
381
+
382
+ if (plan.mode === 'not-installed') {
383
+ L.push('이 repo에서 CommitGate 설치 흔적을 찾지 못했습니다. 되돌릴 것이 없습니다.')
384
+ L.push('')
385
+ L.push(renderNpxSection())
386
+ return L.join('\n')
387
+ }
388
+
389
+ L.push('## 1. CommitGate 소유 파일')
390
+ if (plan.removable.length) {
391
+ L.push(' 패키지 원본과 바이트 동일 — 지워도 잃을 내용이 없습니다:')
392
+ for (const t of plan.removable) L.push(` - ${t.path}${t.tracked ? ' (tracked)' : ' (untracked)'}`)
393
+ }
394
+ if (plan.review.length) {
395
+ L.push(' 원본과 다름 — 편집됐거나 다른 버전이 설치했습니다. 지우기 전에 직접 확인하세요:')
396
+ for (const t of plan.review) L.push(` ~ ${t.path}${t.tracked ? ' (tracked)' : ' (untracked)'}`)
397
+ }
398
+ if (!plan.removable.length && !plan.review.length) L.push(' (없음)')
399
+ if (facts.unknownKitFiles.length) {
400
+ L.push(` ${KIT_SOURCE_DIR_REL}/ 안에 CommitGate가 설치하지 않은 파일이 있습니다 — 건드리지 마세요:`)
401
+ for (const f of facts.unknownKitFiles) L.push(` ? ${f}`)
402
+ }
403
+ L.push('')
404
+
405
+ L.push('## 2. 자동 제거 대상이 아님 — 직접 판단하세요')
406
+ L.push(' init은 무엇을 새로 만들었는지 디스크에 기록하지 않습니다. 따라서 아래 항목이')
407
+ L.push(' CommitGate 소유인지 원래 있던 것인지 이 도구는 알 수 없습니다.')
408
+ if (plan.keep.length) for (const a of plan.keep) L.push(` - ${a.path} — ${a.note}`)
409
+ else L.push(' (없음)')
410
+ L.push('')
411
+
412
+ L.push('## 3. 감사 증거 — 삭제하지 마세요')
413
+ if (plan.protect.length)
414
+ for (const e of plan.protect) L.push(` - ${e.path}/ (REQ 티켓 ${e.ticketCount}개 · state.json · approvals.jsonl)`)
415
+ else L.push(` ${facts.ticketRoot}/ 에 REQ 티켓이 아직 없습니다. 티켓이 생기면 이 디렉터리는 감사 증거가 됩니다.`)
416
+ L.push('')
417
+
418
+ L.push('## 4. 되돌리는 방법 (아래 명령은 직접 실행하세요)')
419
+ L.push(...renderRevertSection(plan))
420
+ L.push('')
421
+
422
+ L.push('## 5. 잔여물 경고')
423
+ L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
424
+ L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/)가 파일시스템에 남을 수 있습니다.`)
425
+ L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
426
+ L.push('')
427
+
428
+ L.push(renderNpxSection())
429
+ return L.join('\n')
430
+ }
431
+
432
+ function renderRevertSection(plan: UninstallPlan): string[] {
433
+ const L: string[] = []
434
+ const { facts } = plan
435
+
436
+ if (plan.mode === 'committed' || plan.mode === 'mixed') {
437
+ if (plan.scaffoldCommits.length === 1) {
438
+ const c = plan.scaffoldCommits[0] as ScaffoldCommit
439
+ L.push(' 스캐폴딩 도입 커밋:')
440
+ L.push(` ${c.sha} ${c.subject}`)
441
+ L.push(' 이 커밋이 스캐폴딩만 담고 있다면:')
442
+ L.push(` git revert ${c.sha}`)
443
+ L.push(' 다른 변경이 섞여 있으면 revert가 무관한 작업까지 되돌립니다 — 먼저 `git show`로 확인하세요.')
444
+ } else {
445
+ L.push(' ⚠️ 스캐폴딩 도입 커밋이 여러 개로 흩어져 있어 단일 revert로 되돌릴 수 없습니다:')
446
+ for (const c of plan.scaffoldCommits) L.push(` ${c.sha} ${c.subject}`)
447
+ L.push(' 각 커밋의 내용을 확인한 뒤(`git show <sha>`) 되돌릴 범위를 직접 정하세요.')
448
+ }
449
+ if (plan.mode === 'mixed') L.push(' 일부 파일은 아직 커밋되지 않았습니다 — 아래 미커밋 절차도 함께 보세요.')
450
+ }
451
+
452
+ if (plan.mode === 'uncommitted' || plan.mode === 'mixed') {
453
+ L.push(' 1) 무엇이 바뀌었는지 확인:')
454
+ L.push(' git status --porcelain -uall')
455
+ L.push(' git diff -- package.json')
456
+ if (facts.packageJsonDirty) {
457
+ L.push(' 2) package.json 되돌리기:')
458
+ L.push(' git checkout HEAD -- package.json')
459
+ L.push(' ⚠️ 이 명령은 package.json의 다른 미커밋 편집도 함께 버립니다. 먼저 위 diff를 확인하세요.')
460
+ L.push(' ⚠️ `HEAD`를 빼면 인덱스에서 복원되어, `git add` 이후에는 주입된 req:* 스크립트가 그대로 남습니다.')
461
+ }
462
+ if (plan.removable.length) {
463
+ // ⚠️ 디렉터리 통삭제(`rm -rf <kit dir>`)는 제안하지 않는다(phase R1 P1): 그 디렉터리에 사용자가 넣은
464
+ // 미분류 파일이 있으면 함께 지워진다. **분류된 파일만 파일 단위로** 나열한다.
465
+ L.push(' 3) 원본과 동일한 파일만 삭제(직접 실행):')
466
+ for (const t of plan.removable) L.push(` rm -f ${t.path}`)
467
+ }
468
+ if (plan.review.length) L.push(' 4) 위 "원본과 다름" 파일은 내용을 확인한 뒤 직접 결정하세요.')
469
+ }
470
+ return L
471
+ }
472
+
473
+ function renderNpxSection(): string {
474
+ return [
475
+ '## npx 캐시 (repo 스캐폴딩과 무관 — 별도 정리)',
476
+ ' `npx commitgate`는 전역 설치가 아닙니다. 패키지는 npm 캐시의 `_npx/<hash>/`에만 들어갑니다.',
477
+ ' 확인 : npm ls -g commitgate (비어 있으면 전역 설치 아님)',
478
+ ' 전역이었다면 : npm uninstall -g commitgate',
479
+ ' 캐시에 남은 npx 패키지 정리:',
480
+ ' Windows (PowerShell) : Remove-Item -Recurse -Force "$(npm config get cache)\\_npx"',
481
+ ' macOS / Linux : rm -rf "$(npm config get cache)/_npx"',
482
+ ' ⚠️ `npm cache clean --force`는 `_cacache`만 비우고 `_npx`는 지우지 않습니다 — CommitGate 제거 명령이 아닙니다.',
483
+ ].join('\n')
484
+ }
485
+
486
+ // ─────────────────────────────────────────────────────────── 파사드 ──
487
+
488
+ /** 사실 수집 → 계획. 읽기 전용. `run` 주입 시 모든 git 호출이 그 runner를 통과. */
489
+ export function planUninstall(opts: UninstallOptions, run?: GitRunner): UninstallPlan {
490
+ const facts = collectFacts(opts, run)
491
+ const git = createGitAdapter(resolve(opts.dir), run)
492
+ return enrichCommits(buildPlan(facts), git)
493
+ }
494
+
495
+ /** 계획을 stdout에 출력하고 텍스트를 반환. */
496
+ export function runUninstall(opts: UninstallOptions, run?: GitRunner): string {
497
+ const text = renderPlan(planUninstall(opts, run))
498
+ console.log(text)
499
+ return text
500
+ }
501
+
502
+ export function parseArgs(argv: string[]): UninstallOptions {
503
+ let dir = process.cwd()
504
+ for (let i = 0; i < argv.length; i++) {
505
+ const a = argv[i]
506
+ if (a === '--dir') {
507
+ const v = argv[i + 1]
508
+ if (v === undefined) throw new Error('--dir 값 누락')
509
+ dir = v
510
+ i++
511
+ } else if (a === '-h' || a === '--help') {
512
+ printHelp()
513
+ process.exit(0)
514
+ } else {
515
+ throw new Error(`알 수 없는 인자: ${a}`)
516
+ }
517
+ }
518
+ return { dir: resolve(dir) }
519
+ }
520
+
521
+ function printHelp(): void {
522
+ console.log(`commitgate uninstall — 제거 계획 출력(읽기 전용)
523
+
524
+ 사용법:
525
+ npx commitgate uninstall [--dir <대상repo>]
526
+
527
+ 이 명령은 **아무것도 지우지 않습니다.** repo를 읽어 무엇이 설치됐는지 분류하고,
528
+ 사용자가 직접 검토 후 실행할 git/삭제 명령을 출력합니다.
529
+
530
+ 옵션:
531
+ --dir <path> 대상 repo 루트(기본: 현재 디렉터리)
532
+ -h, --help 도움말
533
+
534
+ 왜 자동 삭제가 없나:
535
+ init은 "무엇을 새로 만들었는지"를 디스크에 기록하지 않습니다. 그래서 AGENTS.md·req.config.json·
536
+ package.json의 값이 CommitGate 소유인지 사용자 소유인지 구분할 수 없고, blind 삭제는 데이터를 파괴합니다.
537
+ 이 kit은 git repo에 파일만 추가하므로 되돌리기의 정본은 git입니다.`)
538
+ }
539
+
540
+ /** CLI 경계: 예상된 실패(throw)를 친절한 한 줄 + exit 1로 변환(스택트레이스 노출 방지). init.ts runCli와 동일 정책. */
541
+ export function runCli(argv: string[]): void {
542
+ try {
543
+ runUninstall(parseArgs(argv))
544
+ } catch (err) {
545
+ console.error(`commitgate uninstall: ${err instanceof Error ? err.message : String(err)}`)
546
+ process.exitCode = 1
547
+ }
548
+ }
549
+
550
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
551
+ if (isMain) runCli(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitgate",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  * 본 모듈은 req 스크립트에 의존하지 않는 leaf(순환 의존 금지) — parseThreadId도 여기로 이동(codex CLI 전용 로직).
10
10
  */
11
11
  import { execFileSync } from 'node:child_process'
12
- import { existsSync, readFileSync, mkdtempSync } from 'node:fs'
12
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { tmpdir } from 'node:os'
15
15
  import spawn from 'cross-spawn'
@@ -54,15 +54,19 @@ export interface GitAdapter {
54
54
  }
55
55
 
56
56
  /** GitAdapter default 구현의 내부 실행자(주입 가능 — 테스트). encoding:'utf8'이라 string 반환. */
57
- export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8' }) => string
57
+ export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8'; maxBuffer: number }) => string
58
58
  const defaultGitRunner: GitRunner = (file, args, opts) => execFileSync(file, args, opts)
59
59
 
60
+ /** git stdout 상한 — codex 경로(safeSpawnSync)와 동일 64 MiB. 큰 staged diff/status에서 Node 기본 1 MiB의 ENOBUFS throw 방지. */
61
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024
62
+
60
63
  /**
61
- * default GitAdapter — `execFileSync('git', args, {cwd: root, encoding:'utf8'})` 후 **trailing 공백만 제거**.
64
+ * default GitAdapter — `execFileSync('git', args, {cwd: root, encoding:'utf8', maxBuffer})` 후 **trailing 공백만 제거**.
62
65
  * ⚠️ `.trim()`이 아니라 `.replace(/\s+$/, '')` — `git status --porcelain`의 **선행 공백(XY 코드)**을 보존(behavior-preserving).
66
+ * maxBuffer=64MiB: `git diff --cached`/`git show :<path>`가 1 MiB 기본 상한을 넘겨 codex 호출 전에 하드 실패하는 것 방지.
63
67
  */
64
68
  export function createGitAdapter(root: string, run: GitRunner = defaultGitRunner): GitAdapter {
65
- return { exec: (args) => run('git', args, { cwd: root, encoding: 'utf8' }).replace(/\s+$/, '') }
69
+ return { exec: (args) => run('git', args, { cwd: root, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }).replace(/\s+$/, '') }
66
70
  }
67
71
 
68
72
  // ─────────────────────────────────────────────── Reviewer (codex) ──
@@ -105,19 +109,42 @@ const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
105
109
  // ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId 등)의 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 — P1 수정.
106
110
  safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
107
111
 
112
+ /**
113
+ * Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005). OpenAI structured-outputs strict mode는
114
+ * root `required`가 `properties`의 **모든 키**를 포함해야 한다 — optional 필드(예: observations)가 있으면 400 invalid_json_schema.
115
+ * 원본 스키마(`workflow/machine.schema.json`)는 **검증 SSOT로 불변**(observations optional → 기존 archive 하위호환)이고,
116
+ * codex 호출 직전에만 root.required를 `properties` 전체로 확장한 copy를 파생한다. 응답/archive 검증은 계속 원본으로 한다.
117
+ * 중첩 객체(findings/observations items)는 이미 모든 필드 required + additionalProperties:false라 root만 확장하면 충분.
118
+ */
119
+ export function deriveStrictOutputSchema(schemaText: string): string {
120
+ const schema = JSON.parse(schemaText) as { properties?: Record<string, unknown>; required?: string[]; [k: string]: unknown }
121
+ if (schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object') {
122
+ schema.required = Object.keys(schema.properties)
123
+ }
124
+ return JSON.stringify(schema)
125
+ }
126
+
108
127
  /**
109
128
  * default ReviewerAdapter(codex CLI). exec(신규)/resume(thread_id) 분기 + `--output-last-message`(임시파일) 캡처 + thread 파싱.
110
- * - resume은 `--sandbox` 미수용(0차 실측) → 생략(정책 상속). exec은 `--sandbox read-only`.
129
+ * - **read-only 리뷰어 강제( 라운드, REQ-2026-006/R9)**: exec은 `--sandbox read-only`. resume은 `-s/--sandbox` 플래그를 **거부**하므로
130
+ * (`error: unexpected argument '--sandbox'` — spike 확인) `-c sandbox_mode="read-only"` **config override**로 read-only를 강제한다.
131
+ * 행동 검증: resume(무 override)은 실제 write 성공(sandbox drop=갭 실재), resume(override)은 write가 `Access is denied`로 차단(enforced).
132
+ * `-c`가 향후 CLI에서 거부되면 resume 자체가 실패=fail-closed(리뷰 미승인).
133
+ * - `--output-schema`에는 원본이 아니라 **strict 파생 copy**를 넘긴다(codex strict mode 400 방지, REQ-2026-005). 원본은 검증 SSOT.
111
134
  * - threadId: resume이면 resumeThreadId, exec이면 parseThreadId(stdout)(없으면 null → 호출처가 fail-closed).
112
135
  * - codex 미설치/실패 → runner가 throw(그대로 전파, fail-closed).
113
136
  */
114
137
  export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner): ReviewerAdapter {
115
138
  return {
116
139
  review({ prompt, schemaPath, resumeThreadId, cwd }) {
117
- const lastPath = join(mkdtempSync(join(tmpdir(), 'req-codex-')), 'last.json')
140
+ const tmpDir = mkdtempSync(join(tmpdir(), 'req-codex-'))
141
+ const lastPath = join(tmpDir, 'last.json')
142
+ // 원본(검증 SSOT)을 읽어 strict copy 파생 → temp에 기록 → --output-schema로 전달(archive 검증엔 원본 사용).
143
+ const outputSchemaPath = join(tmpDir, 'output-schema.json')
144
+ writeFileSync(outputSchemaPath, deriveStrictOutputSchema(readFileSync(schemaPath, 'utf8')), 'utf8')
118
145
  const args = resumeThreadId
119
- ? ['exec', 'resume', resumeThreadId, '--json', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
120
- : ['exec', '--json', '--sandbox', 'read-only', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
146
+ ? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
147
+ : ['exec', '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
121
148
  const rawStdout = run(args, prompt, cwd)
122
149
  const threadId = resumeThreadId ?? parseThreadId(rawStdout)
123
150
  const lastMessage = existsSync(lastPath) ? readFileSync(lastPath, 'utf8') : ''