commitgate 0.9.8 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:close — 레거시 완료 티켓 **마이그레이션 종결**(REQ-2026-053·DEC-M).
4
+ *
5
+ * close-proof/`phase_design_ref` regime **이전에** 완료·병합돼 dev-complete로 자기증명될 수 없는 durable
6
+ * 티켓을, 운영자 확인 후 `migrated-complete` close-proof 행으로 감사 가능하게 종결한다. 자격 판정은
7
+ * `lib/close-migrate`(순수)가, HEAD blob 수집·integrated 계산·durable 커밋은 이 CLI가 낸다.
8
+ *
9
+ * 🔴 **HEAD-committed 증거 + git ancestry만** 근거로 쓴다 — 워킹 state·미커밋 원장·추정 phase 목록 미사용.
10
+ * 🔴 완료성 증명 = **integrated**(티켓 매니페스트 커밋이 본선 조상). mainline ref는 **운영자 입력을 받지 않고**
11
+ * 신뢰된 ref로만 해소(origin/HEAD→origin/main→로컬 main). 미해소면 fail-closed.
12
+ * 🔴 기본 **dry-run**. `--run` 후에만 write. 새 행은 `reconstructed:true` + 비어있지 않은 evidence_basis.
13
+ * 쓰기 전 close-proof clean 가드(미커밋 close-proof를 HEAD 기반 쓰기가 덮지 않게). append-only·자연키 멱등.
14
+ *
15
+ * 사용: req:close <REQ> --migrate [--run] [--root <dir>]
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join, relative } from 'node:path'
19
+ import { pathToFileURL } from 'node:url'
20
+ import { loadConfig, packageRoot } from './lib/config'
21
+ import { createGitAdapter, safeSpawnSyncStatus, type GitAdapter } from './lib/adapters'
22
+ import { createEvidencePorts } from './lib/evidence-ports'
23
+ import {
24
+ isDurabilityRequired,
25
+ verifyCommittedEvidenceIntegrity,
26
+ validateManifest,
27
+ evidencedPhaseIdsFromManifest,
28
+ designHashFromManifest,
29
+ } from './lib/evidence'
30
+ import { parseCloseProof, appendCloseProofRow, closeProofPath } from './lib/close-proof'
31
+ import { planMigrationClose, type MigrationFacts } from './lib/close-migrate'
32
+
33
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
34
+ function git(args: string[]): string {
35
+ return gitAdapter.exec(args)
36
+ }
37
+
38
+ export interface Opts {
39
+ reqId: string | null
40
+ migrate: boolean
41
+ run: boolean
42
+ root: string | null
43
+ }
44
+
45
+ /** 인자 파싱(fail-closed): 값 누락·알 수 없는 옵션은 즉시 throw. mainline override는 **의도적으로 없다**(DEC-M3.7). */
46
+ export function parseArgs(argv: string[]): Opts {
47
+ const o: Opts = { reqId: null, migrate: false, run: false, root: null }
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i]
50
+ if (a === undefined) continue
51
+ if (a === '--') continue
52
+ else if (a === '--migrate') o.migrate = true
53
+ else if (a === '--run') o.run = true
54
+ else if (a === '--root') {
55
+ const v = argv[++i]
56
+ if (v === undefined) throw new Error('--root 값 필요')
57
+ o.root = v
58
+ } else if (a.startsWith('-')) throw new Error(`알 수 없는 옵션: ${a}`)
59
+ else o.reqId = a
60
+ }
61
+ return o
62
+ }
63
+
64
+ /**
65
+ * 🔴 mainline ref를 **신뢰된 소스로만** 해소한다(DEC-M3.7·r02 P1). 운영자 입력을 받지 않는다 —
66
+ * 임의 feature/HEAD ref로 integrated를 통과시키는 우회를 막는다. 순서:
67
+ * ① origin/HEAD(원격이 선언한 기본 브랜치) → ② origin/main·master → ③ 로컬 main·master. 없으면 null(fail-closed).
68
+ */
69
+ export function resolveMainline(gitFn: (a: string[]) => string): string | null {
70
+ try {
71
+ const sym = gitFn(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).trim()
72
+ if (sym) return sym.replace(/^refs\/remotes\//, '') // 예: refs/remotes/origin/main → origin/main
73
+ } catch {
74
+ /* origin/HEAD 없음 — 다음 후보로 */
75
+ }
76
+ for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
77
+ try {
78
+ if (gitFn(['rev-parse', '--verify', '--quiet', ref]).trim()) return ref
79
+ } catch {
80
+ /* 해당 ref 없음 */
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ /** `git merge-base --is-ancestor a b` — a가 b의 조상이면 true(exit 0), 아니면 false(exit 1), 그 외는 throw. */
87
+ export function isAncestor(root: string, a: string, b: string): boolean {
88
+ const res = safeSpawnSyncStatus('git', ['merge-base', '--is-ancestor', a, b], { cwd: root })
89
+ if (res.status === 0) return true
90
+ if (res.status === 1) return false
91
+ throw new Error(`git merge-base --is-ancestor 실패(status=${res.status ?? 'null'}): ${res.stderr.trim()}`)
92
+ }
93
+
94
+ /** HEAD state.json 본문에서 커밋된 phase 계획 id(`phases[].id`)를 뽑는다(파싱 불가·부재 → []). r02 P1 완료성 기준. */
95
+ export function committedPlannedPhaseIds(stateText: string | null): string[] {
96
+ if (!stateText) return []
97
+ let raw: unknown
98
+ try {
99
+ raw = JSON.parse(stateText)
100
+ } catch {
101
+ return []
102
+ }
103
+ const phases = (raw as { phases?: unknown }).phases
104
+ if (!Array.isArray(phases)) return []
105
+ return phases
106
+ .map((p) => (p && typeof p === 'object' ? (p as { id?: unknown }).id : undefined))
107
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
108
+ }
109
+
110
+ export function main(argv: string[] = process.argv.slice(2)): void {
111
+ const o = parseArgs(argv)
112
+ if (!o.reqId) throw new Error('REQ 필요 (예: req:close 2026-049 --migrate)')
113
+ if (!o.migrate) throw new Error('현재 --migrate 모드만 지원합니다 (예: req:close 2026-049 --migrate --run)')
114
+ const cfg = loadConfig({ root: o.root })
115
+ gitAdapter = createGitAdapter(cfg.root)
116
+ const reqId = o.reqId.startsWith('REQ-') ? o.reqId : `REQ-${o.reqId}`
117
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
118
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
119
+ const manifestRel = `${ticketRel}/responses/approvals.jsonl`
120
+
121
+ // 1. HEAD 사실 수집(read-only). intake와 같은 포트·함수.
122
+ const ports = createEvidencePorts(cfg.root, `${ticketRel}/responses`)
123
+ const stateText = ports.headText(`${ticketRel}/state.json`)
124
+ const durabilityRequired = isDurabilityRequired(stateText)
125
+ const manifestText = ports.headText(manifestRel)
126
+ const closeText = ports.headText(closeProofPath(ticketRel))
127
+ const evidencedPhaseIdsAll = manifestText ? evidencedPhaseIdsFromManifest(manifestText) : []
128
+ const manifestProblems = manifestText ? validateManifest(manifestText, { ticketRel, validPhaseIds: evidencedPhaseIdsAll }) : []
129
+ const closeParsed = closeText ? parseCloseProof(closeText) : { rows: [], problems: [] }
130
+ const committedDesignRef = manifestText ? designHashFromManifest(manifestText) : null
131
+ const evidencedPhaseIdsBound = manifestText ? evidencedPhaseIdsFromManifest(manifestText, committedDesignRef) : []
132
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
133
+
134
+ // 2. integrated(완료성 증명) — 티켓 매니페스트를 마지막으로 수정한 HEAD 커밋이 mainline의 조상인가.
135
+ const mainline = resolveMainline((a) => git(a))
136
+ if (!mainline)
137
+ throw new Error(
138
+ `${reqId}: mainline(main/origin/main)을 결정할 수 없어 integrated(완료성)를 판정할 수 없습니다 — fail-closed. ` +
139
+ `본선 브랜치가 존재하는 저장소에서 실행하세요.`,
140
+ )
141
+ let lastManifestCommit = ''
142
+ try {
143
+ lastManifestCommit = git(['rev-list', '-1', 'HEAD', '--', manifestRel]).trim()
144
+ } catch {
145
+ lastManifestCommit = ''
146
+ }
147
+ const integrated = !!lastManifestCommit && isAncestor(cfg.root, lastManifestCommit, mainline)
148
+
149
+ // 3. 순수 판정.
150
+ const facts: MigrationFacts = {
151
+ ticketId: reqId,
152
+ ticketRel,
153
+ durabilityRequired,
154
+ manifestText,
155
+ manifestProblems,
156
+ closeProblems: closeParsed.problems,
157
+ closeRows: closeParsed.rows,
158
+ evidenceIntegrityProblems: integrity.problems,
159
+ committedDesignRef,
160
+ evidencedPhaseIdsAll,
161
+ evidencedPhaseIdsBound,
162
+ committedPlannedPhaseIds: committedPlannedPhaseIds(stateText),
163
+ integrated,
164
+ nowIso: new Date().toISOString(),
165
+ evidenceBasis: [manifestRel], // 마이그레이션 근거: 티켓 매니페스트(design_hash·phase_design_ref·archive_inventory의 단일 출처).
166
+ }
167
+ const plan = planMigrationClose(facts)
168
+
169
+ if (plan.kind === 'refuse')
170
+ throw new Error(`${reqId} 마이그레이션 종결 불가: ${plan.reason}\n → ${plan.hint}`)
171
+ if (plan.kind === 'noop') {
172
+ console.log(`[req:close] ${reqId} 이미 종결(${plan.existingState}) — no-op(write 0).`)
173
+ return
174
+ }
175
+
176
+ // plan.kind === 'stamp'
177
+ console.log(`[req:close] ${reqId} 마이그레이션 종결(migrated-complete) 계획 (integrated=본선 조상, mainline=${mainline}):`)
178
+ console.log(
179
+ ` phase_inventory=[${plan.row.phase_inventory?.join(', ')}] design_ref=${(plan.row.design_ref ?? '').slice(0, 12)} ` +
180
+ `evidence_basis=[${plan.row.evidence_basis?.join(', ')}]`,
181
+ )
182
+ if (!o.run) {
183
+ console.log('[req:close] DRY-RUN — write 없음(--run 시 실행).')
184
+ return
185
+ }
186
+
187
+ // 🔴 쓰기 전 clean 가드: close-proof가 HEAD와 동일해야(미커밋 close-proof를 HEAD 기반 쓰기가 잃지 않게).
188
+ const cpRel = closeProofPath(ticketRel)
189
+ const cpDirty = git(['status', '--porcelain', '--', cpRel]).trim()
190
+ if (cpDirty)
191
+ throw new Error(
192
+ `${reqId}: close-proof(${cpRel})에 미커밋 변경이 있어 종결을 거부합니다(fail-closed) — HEAD 기반 쓰기가 미커밋 행을 덮을 수 있습니다.\n` +
193
+ ` 먼저 미커밋 close-proof 변경을 커밋/정리한 뒤 다시 실행하세요.`,
194
+ )
195
+
196
+ const abs = join(cfg.root, cpRel)
197
+ const existing = existsSync(abs) ? readFileSync(abs, 'utf8') : ''
198
+ const res = appendCloseProofRow(existing, plan.row)
199
+ if (res.outcome === 'conflict') throw new Error(`close proof 충돌(fail-closed): ${res.problems.join('; ')}`)
200
+ if (res.outcome === 'duplicate') {
201
+ console.log('[req:close] 동일 migrated-complete 행이 이미 존재 — no-op(멱등).')
202
+ return
203
+ }
204
+ mkdirSync(dirname(abs), { recursive: true })
205
+ writeFileSync(abs, res.content, 'utf8')
206
+ git(['add', '--', cpRel])
207
+ git(['commit', '-m', `chore(${reqId}): migrated-complete close proof (레거시 마이그레이션 종결·REQ-2026-053)`, '--', cpRel])
208
+ console.log(`[req:close] ✅ ${reqId} migrated-complete durable commit(reconstructed:true·evidence_basis).`)
209
+ }
210
+
211
+ /** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). */
212
+ export function runCli(argv: string[]): void {
213
+ try {
214
+ main(argv)
215
+ } catch (err) {
216
+ console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
217
+ process.exitCode = 1
218
+ }
219
+ }
220
+
221
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
222
+ if (isMain) main()