commitgate 0.1.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,173 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * req:new — AI REQ 워크플로우 1차 (단계 4A): REQ 티켓 + feat/req-* 브랜치 생성.
4
+ *
5
+ * SSOT: palm-kiosk/docs/evaluation/ai-req-workflow-design.md §9.1·§9.2·DEC-WF-020(D11).
6
+ * - state.json은 **BOM 없이** 생성(Node, review-codex의 writeState 재사용).
7
+ * - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
8
+ * - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
9
+ *
10
+ * 사용: pnpm req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."]
11
+ */
12
+ import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
13
+ import { join, relative } from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+ import { writeState, type WorkflowState } from './review-codex'
16
+ import { loadConfig, packageRoot, type DesignDocs } from './lib/config'
17
+ import { createGitAdapter, type GitAdapter } from './lib/adapters'
18
+
19
+ // 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
20
+ let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
21
+
22
+ function git(args: string[]): string {
23
+ return gitAdapter.exec(args)
24
+ }
25
+
26
+ /** slug 검증: kebab-case(a-z0-9, '-' 구분). */
27
+ export function validateSlug(slug: string): void {
28
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
29
+ throw new Error(`slug는 kebab-case(a-z0-9, '-' 구분)여야 함: "${slug}"`)
30
+ }
31
+
32
+ /** REQ id 채번(순수): 같은 연도 기존 id의 max+1, 3자리 zero-pad. */
33
+ export function nextReqId(year: number, existingIds: string[]): string {
34
+ const prefix = `REQ-${year}-`
35
+ const maxN = existingIds
36
+ .filter((id) => id.startsWith(prefix))
37
+ .map((id) => Number.parseInt(id.slice(prefix.length), 10))
38
+ .filter((n) => Number.isFinite(n))
39
+ .reduce((a, b) => Math.max(a, b), 0)
40
+ return `${prefix}${String(maxN + 1).padStart(3, '0')}`
41
+ }
42
+
43
+ export function branchName(reqId: string, slug: string, branchPrefix: string): string {
44
+ return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
45
+ }
46
+
47
+ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH'): WorkflowState {
48
+ return {
49
+ id: reqId,
50
+ branch,
51
+ phase: 'INTAKE',
52
+ risk_level: risk,
53
+ codex_thread_id: null,
54
+ review_base_sha: null,
55
+ review_diff_hash: null,
56
+ approved_diff_hash: null,
57
+ commit_allowed: false,
58
+ // DEC-WF-027 design-first / phase-gated 상태(Phase 2 도입). design 승인·phase 추적은 후속 단계에서 사용.
59
+ design_approved: false,
60
+ design_approved_hash: null,
61
+ current_phase: null,
62
+ phases: [],
63
+ // REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
64
+ approval_evidence_required: true,
65
+ }
66
+ }
67
+
68
+ function listExistingReqIds(workflowDir: string): string[] {
69
+ if (!existsSync(workflowDir)) return []
70
+ return readdirSync(workflowDir, { withFileTypes: true })
71
+ .filter((d) => d.isDirectory() && /^REQ-\d{4}-\d+$/.test(d.name))
72
+ .map((d) => d.name)
73
+ }
74
+
75
+ export interface Opts {
76
+ slug: string | null
77
+ risk: 'LOW' | 'HIGH'
78
+ title: string | null
79
+ run: boolean
80
+ root: string | null
81
+ }
82
+
83
+ /** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 수 없는 옵션은 즉시 throw(조용한 fallback 금지). */
84
+ export function parseArgs(argv: string[]): Opts {
85
+ const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null }
86
+ for (let i = 0; i < argv.length; i++) {
87
+ const a = argv[i]
88
+ if (a === undefined) continue
89
+ if (a === '--run') {
90
+ o.run = true
91
+ } else if (a === '--root') {
92
+ const v = argv[++i]
93
+ if (v === undefined) throw new Error('--root 값 필요')
94
+ o.root = v
95
+ } else if (a === '--risk') {
96
+ const v = argv[++i]
97
+ if (v !== 'LOW' && v !== 'HIGH')
98
+ throw new Error(`--risk 값은 LOW 또는 HIGH여야 함 (받음: ${v ?? '(없음)'})`)
99
+ o.risk = v
100
+ } else if (a === '--title') {
101
+ const v = argv[++i]
102
+ if (v === undefined) throw new Error('--title 값 필요')
103
+ o.title = v
104
+ } else if (a.startsWith('-')) {
105
+ throw new Error(`알 수 없는 옵션: ${a}`)
106
+ } else {
107
+ o.slug = a
108
+ }
109
+ }
110
+ return o
111
+ }
112
+
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)')
116
+ validateSlug(o.slug)
117
+
118
+ const cfg = loadConfig({ root: o.root })
119
+ gitAdapter = createGitAdapter(cfg.root) // 모든 git 호출 cwd = config.root
120
+ const dd: DesignDocs = cfg.designDocs
121
+
122
+ const year = new Date().getFullYear()
123
+ const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
124
+ const branch = branchName(reqId, o.slug, cfg.branchPrefix)
125
+ const ticketDir = join(cfg.workflowDirAbs, reqId)
126
+ const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
127
+
128
+ if (!o.run) {
129
+ console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
130
+ console.log(` REQ : ${reqId}`)
131
+ console.log(` branch : ${branch}`)
132
+ console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
133
+ console.log(` risk : ${o.risk}`)
134
+ return
135
+ }
136
+
137
+ // 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지)
138
+ const dirty = git(['-c', 'core.quotePath=false', 'status', '--porcelain'])
139
+ if (dirty) throw new Error(`워킹트리가 clean이어야 req:new --run 가능:\n${dirty}`)
140
+ const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
141
+ if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
142
+
143
+ git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
144
+ mkdirSync(ticketDir, { recursive: true })
145
+ writeState(ticketDir, buildInitialState(reqId, branch, o.risk))
146
+ writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
147
+ // DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 — 첫 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
148
+ writeFileSync(
149
+ join(ticketDir, dd.design),
150
+ `# ${reqId} 설계\n\n> 정본 결정은 SSOT(해당 DEC). 본 문서는 그 결정을 현재 코드/구조에 어떻게 반영할지 기록.\n\n## 현재 상태(변경 대상)\n\n## 핵심 설계 결정\n\n## Phase별 구현\n\n## 변경 파일\n\n## 하위호환·안전\n`,
151
+ 'utf8',
152
+ )
153
+ writeFileSync(
154
+ join(ticketDir, dd.plan),
155
+ `# ${reqId} 계획 — phase 분해 (DEC-WF-027 §9.0)\n\n설계 승인 후 phase별 진행. **각 phase 후 Codex 리뷰·승인 → 다음.**\n\n> **Granularity 정책(REQ-2026-016 Phase C)**: phase 1개는 리뷰 가능한 크기로 — 코드 변경 ${cfg.granularityMaxFiles}파일 이하 권고. 초과 시 req:doctor가 D18 WARN(분할 권고·FAIL 아님). 큰 phase는 런타임 분할(예: B→B1/B2/B3)로 검수 면적을 줄인다.\n\n## Phase 1 — (제목) (\`phase-1-...\`)\n범위:\nExit: eslint0·typecheck0 · 단위 그린 · Codex phase 리뷰 승인.\n\n## 완료\n- 게이트 해당분(unit·typecheck·lint) · 사용자 main 머지(별도 승인).\n`,
156
+ 'utf8',
157
+ )
158
+ writeFileSync(
159
+ join(ticketDir, 'codex-request.md'),
160
+ `# ${reqId} 리뷰 요청\n\n## 배경\n\n## 변경 요약\n\n## 리뷰 포인트\n`,
161
+ 'utf8',
162
+ )
163
+ git(['add', ticketRel])
164
+ git(['commit', '-m', `chore(req): ${reqId} 티켓 생성`])
165
+
166
+ console.log(`[req:new] 생성 완료: ${reqId}`)
167
+ console.log(` branch : ${branch} (체크아웃됨)`)
168
+ console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
169
+ console.log(` 다음 : 코드 변경 → git add → pnpm req:review-codex ${reqId.replace(/^REQ-/, '')} --run`)
170
+ }
171
+
172
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
173
+ if (isMain) main()