commitgate 0.4.0 → 0.8.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.
- package/AGENTS.template.md +8 -0
- package/CHANGELOG.md +154 -0
- package/README.en.md +187 -20
- package/README.md +187 -20
- package/bin/commitgate.mjs +15 -6
- package/bin/dispatch.d.mts +6 -0
- package/bin/dispatch.mjs +38 -0
- package/bin/init.ts +965 -84
- package/bin/migrate.ts +244 -0
- package/bin/uninstall.ts +48 -1
- package/package.json +73 -69
- package/req.config.json.sample +3 -0
- package/scripts/req/lib/adapters.ts +56 -8
- package/scripts/req/lib/config.ts +55 -0
- package/scripts/req/lib/porcelain.ts +104 -0
- package/scripts/req/lib/scratch.ts +104 -0
- package/scripts/req/req-commit.ts +21 -7
- package/scripts/req/req-doctor.ts +135 -31
- package/scripts/req/req-new.ts +94 -15
- package/scripts/req/req-next.ts +94 -17
- package/scripts/req/review-codex.ts +851 -94
- package/scripts/verify-review-overrides.mjs +96 -0
- package/skills/ATTRIBUTION.md +85 -0
- package/skills/commitgate-diagnosing-bugs/SKILL.md +149 -0
- package/skills/commitgate-discovery/SKILL.md +93 -0
- package/skills/commitgate-research/SKILL.md +85 -0
- package/skills/commitgate-tdd/SKILL.md +113 -0
- package/templates/CLAUDE.template.md +2 -1
- package/templates/claude-command.md +5 -2
- package/templates/claude-skill.md +4 -1
- package/templates/cursor-rule.mdc +5 -2
- package/templates/workflow.gitignore +8 -0
- package/workflow/machine.schema.json +10 -1
- package/workflow/req.config.schema.json +90 -10
- package/workflow/review-persona.md +56 -1
|
@@ -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
|
-
* 사용:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
273
|
-
if (dirty.length)
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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:
|
|
494
|
-
|
|
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()
|
package/scripts/req/req-new.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
* - 기본 dry-run(계획 출력), `--run` 시 실제 브랜치 생성·티켓 파일·스캐폴드 커밋.
|
|
8
8
|
* - REQ id 채번은 registry 미사용(1차) — workflow/REQ-* 디렉터리 스캔으로 max+1.
|
|
9
9
|
*
|
|
10
|
-
* 사용:
|
|
10
|
+
* 사용: req:new <slug> [--run] [--risk LOW|HIGH] [--title "..."] [--successor-of <REQ-id>]
|
|
11
|
+
* --successor-of: 대체 REQ. 부모에 replace 종결(human-resolution) 기록이 있어야 하며, 없으면 fail-closed
|
|
12
|
+
* (티켓 미생성). lineage는 부모 state에서 읽는다(REQ-2026-029 A-2b).
|
|
11
13
|
*/
|
|
12
14
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
|
13
15
|
import { join, relative } from 'node:path'
|
|
14
16
|
import { pathToFileURL } from 'node:url'
|
|
15
|
-
import { writeState, type WorkflowState } from './review-codex'
|
|
16
|
-
import { loadConfig, packageRoot, type DesignDocs } from './lib/config'
|
|
17
|
+
import { writeState, loadState, resolveSuccessorLineage, type WorkflowState, type SuccessorOf } from './review-codex'
|
|
18
|
+
import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
|
|
17
19
|
import { createGitAdapter, type GitAdapter } from './lib/adapters'
|
|
20
|
+
import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
|
|
21
|
+
import { isToolOutputScratch } from './lib/scratch'
|
|
18
22
|
|
|
19
23
|
// 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
|
|
20
24
|
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
@@ -23,6 +27,17 @@ function git(args: string[]): string {
|
|
|
23
27
|
return gitAdapter.exec(args)
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* `req:new` 전용 clean-tree 위반 목록(REQ-2026-012 D7·D8).
|
|
32
|
+
*
|
|
33
|
+
* 허용하는 것은 기존 티켓 직계의 **untracked 도구 산출물** 두 종류뿐이다. `state.json`, `responses/**`,
|
|
34
|
+
* staged-only 변경, rename/copy 등 그 밖의 모든 상태는 그대로 위반이다. review용
|
|
35
|
+
* `findUnstagedOrUntracked`는 staged-only(`M `)를 통과시키므로 여기서 재사용하지 않는다.
|
|
36
|
+
*/
|
|
37
|
+
export function findReqNewDirtyEntries(rawStatusZ: string, ticketRoot: string): StatusEntry[] {
|
|
38
|
+
return parseStatusZ(rawStatusZ).filter((entry) => !isToolOutputScratch(entry, ticketRoot))
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
/** slug 검증: kebab-case(a-z0-9, '-' 구분). */
|
|
27
42
|
export function validateSlug(slug: string): void {
|
|
28
43
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
|
|
@@ -44,7 +59,19 @@ export function branchName(reqId: string, slug: string, branchPrefix: string): s
|
|
|
44
59
|
return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
|
|
62
|
+
/**
|
|
63
|
+
* `--run` 성공 후 다음 단계 안내(DEC-011-1). **config 로드 이후**이므로 pm별 실행 형식으로 파생한다.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ `DEFAULTS.packageManager`(= `'pnpm'`)를 폴백으로 쓰지 마라. `bin/init.ts`의 감지 폴백은 `'npm'`이라
|
|
66
|
+
* 두 값이 갈라져 있고, 그걸 문구에 끌어오면 npm 프로젝트가 pnpm 명령을 안내받는다 — 이 REQ가 고치는 결함이다.
|
|
67
|
+
* config가 없는 지점(헤더 주석·early throw)은 pm-중립 bare 표기를 쓴다.
|
|
68
|
+
*/
|
|
69
|
+
export function nextStepHint(pm: PackageManager, reqId: string): string {
|
|
70
|
+
const id = reqId.replace(/^REQ-/, '')
|
|
71
|
+
return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH', successorOf?: SuccessorOf): WorkflowState {
|
|
48
75
|
return {
|
|
49
76
|
id: reqId,
|
|
50
77
|
branch,
|
|
@@ -62,7 +89,12 @@ export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | '
|
|
|
62
89
|
phases: [],
|
|
63
90
|
// REQ-016 A1(D-016-6): grandfathering 트리거 — 신규 REQ는 승인 증거를 강제(FAIL), legacy(필드 부재)는 WARN.
|
|
64
91
|
approval_evidence_required: true,
|
|
65
|
-
|
|
92
|
+
// REQ-2026-027 D1: review series 모델 버전. 첫 리뷰 전에도 존재해 "새 ticket(레코드 없음)"과
|
|
93
|
+
// "legacy(필드 부재)"를 구분한다. 필드 부재 = legacy → 새 재리뷰 시 AWAIT_HUMAN/throw(자동 초기화 금지).
|
|
94
|
+
review_series_model_version: 1,
|
|
95
|
+
// REQ-2026-029 D3: 대체 REQ면 부모 lineage(--successor-of). 빈 review_series로 새 예산 — 부모 이력만 보존.
|
|
96
|
+
...(successorOf ? { successor_of: successorOf } : {}),
|
|
97
|
+
} as WorkflowState
|
|
66
98
|
}
|
|
67
99
|
|
|
68
100
|
function listExistingReqIds(workflowDir: string): string[] {
|
|
@@ -78,20 +110,28 @@ export interface Opts {
|
|
|
78
110
|
title: string | null
|
|
79
111
|
run: boolean
|
|
80
112
|
root: string | null
|
|
113
|
+
successorOf: string | null // REQ-2026-029 D3: 부모 REQ id(대체 REQ lineage)
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/** 인자 파싱(fail-closed): 잘못된 --risk·값 누락·알 수 없는 옵션은 즉시 throw(조용한 fallback 금지). */
|
|
84
117
|
export function parseArgs(argv: string[]): Opts {
|
|
85
|
-
const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null }
|
|
118
|
+
const o: Opts = { slug: null, risk: 'LOW', title: null, run: false, root: null, successorOf: null }
|
|
86
119
|
for (let i = 0; i < argv.length; i++) {
|
|
87
120
|
const a = argv[i]
|
|
88
121
|
if (a === undefined) continue
|
|
89
|
-
|
|
122
|
+
// bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
|
|
123
|
+
if (a === '--') {
|
|
124
|
+
continue
|
|
125
|
+
} else if (a === '--run') {
|
|
90
126
|
o.run = true
|
|
91
127
|
} else if (a === '--root') {
|
|
92
128
|
const v = argv[++i]
|
|
93
129
|
if (v === undefined) throw new Error('--root 값 필요')
|
|
94
130
|
o.root = v
|
|
131
|
+
} else if (a === '--successor-of') {
|
|
132
|
+
const v = argv[++i]
|
|
133
|
+
if (v === undefined) throw new Error('--successor-of 값 필요(부모 REQ id)')
|
|
134
|
+
o.successorOf = v
|
|
95
135
|
} else if (a === '--risk') {
|
|
96
136
|
const v = argv[++i]
|
|
97
137
|
if (v !== 'LOW' && v !== 'HIGH')
|
|
@@ -110,9 +150,10 @@ export function parseArgs(argv: string[]): Opts {
|
|
|
110
150
|
return o
|
|
111
151
|
}
|
|
112
152
|
|
|
113
|
-
function main(): void {
|
|
114
|
-
const o = parseArgs(
|
|
115
|
-
|
|
153
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
154
|
+
const o = parseArgs(argv)
|
|
155
|
+
// ⚠️ loadConfig 이전이라 pm을 모른다 → pm-중립 bare 표기(DEFAULTS 폴백 금지, DEC-011-1).
|
|
156
|
+
if (!o.slug) throw new Error('slug 필요 (예: req:new camera-hardfail --run)')
|
|
116
157
|
validateSlug(o.slug)
|
|
117
158
|
|
|
118
159
|
const cfg = loadConfig({ root: o.root })
|
|
@@ -123,26 +164,54 @@ function main(): void {
|
|
|
123
164
|
const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
|
|
124
165
|
const branch = branchName(reqId, o.slug, cfg.branchPrefix)
|
|
125
166
|
const ticketDir = join(cfg.workflowDirAbs, reqId)
|
|
167
|
+
// config 문자열이 `.`·`workflow/.`처럼 같은 위치를 다르게 표현해도 Git이 내는 canonical repo-relative 경로와 맞춘다.
|
|
168
|
+
const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
|
|
126
169
|
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
127
170
|
|
|
171
|
+
// REQ-2026-029 D3: --successor-of lineage 해소. **branch 생성·mkdir 前**에 검증(design-r01 observation) —
|
|
172
|
+
// 부모 없음·replace 기록 없음·형식 위반이면 여기서 throw해 티켓이 생성되지 않는다(R6 fail-closed).
|
|
173
|
+
let successorOf: SuccessorOf | undefined
|
|
174
|
+
if (o.successorOf !== null) {
|
|
175
|
+
const parentId = o.successorOf.startsWith('REQ-') ? o.successorOf : `REQ-${o.successorOf}`
|
|
176
|
+
const parentDir = join(cfg.workflowDirAbs, parentId)
|
|
177
|
+
let parentState: WorkflowState
|
|
178
|
+
try {
|
|
179
|
+
parentState = loadState(parentDir)
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`--successor-of ${parentId}: 부모 티켓 state를 읽을 수 없다(${parentDir})`)
|
|
182
|
+
}
|
|
183
|
+
// recorded_at은 자식 생성 시각(부모 값 아님). dry-run에선 검증만 하고 값은 버린다.
|
|
184
|
+
successorOf = resolveSuccessorLineage(parentState, parentId, new Date().toISOString())
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
if (!o.run) {
|
|
129
188
|
console.log('[req:new] DRY-RUN (--run 시 실제 생성)')
|
|
130
189
|
console.log(` REQ : ${reqId}`)
|
|
131
190
|
console.log(` branch : ${branch}`)
|
|
132
191
|
console.log(` ticket : ${ticketRel}/ (state.json·${dd.requirement}·${dd.design}·${dd.plan}·codex-request.md)`)
|
|
133
192
|
console.log(` risk : ${o.risk}`)
|
|
193
|
+
if (successorOf) console.log(` successor_of : ${successorOf.req_id} (부모 ${successorOf.parent_attempts_total}회 · replace 승인 확인)`)
|
|
134
194
|
return
|
|
135
195
|
}
|
|
136
196
|
|
|
137
|
-
// 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지)
|
|
138
|
-
|
|
139
|
-
|
|
197
|
+
// 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
|
|
198
|
+
// 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
|
|
199
|
+
const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
|
|
200
|
+
if (dirtyEntries.length > 0) {
|
|
201
|
+
const dirty = dirtyEntries.map(formatStatusEntry).join('\n')
|
|
202
|
+
throw new Error(
|
|
203
|
+
`워킹트리가 clean이어야 req:new --run 가능:\n${dirty}\n` +
|
|
204
|
+
`힌트: 방금 \`npx commitgate\`를 실행했다면 **설치분만** 먼저 커밋하십시오(\`git add -A\` 금지 —\n` +
|
|
205
|
+
` 무관한 변경·.env가 함께 커밋되고, 이어지는 req:review-codex가 그것을 외부로 전송합니다).\n` +
|
|
206
|
+
` 설치 출력의 "다음:" 안내가 stage할 정확한 경로 목록을 알려 줍니다.`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
140
209
|
const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
141
210
|
if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
|
|
142
211
|
|
|
143
212
|
git(['checkout', '-b', branch]) // D11/DEC-WF-020: feat/req-* 생성·체크아웃
|
|
144
213
|
mkdirSync(ticketDir, { recursive: true })
|
|
145
|
-
writeState(ticketDir, buildInitialState(reqId, branch, o.risk))
|
|
214
|
+
writeState(ticketDir, buildInitialState(reqId, branch, o.risk, successorOf))
|
|
146
215
|
writeFileSync(join(ticketDir, dd.requirement), `# ${reqId} 요구사항\n\n${o.title ?? '(요구사항 작성)'}\n`, 'utf8')
|
|
147
216
|
// DEC-WF-027 design-first: design·plan 스캐폴드도 함께 생성 — 첫 --kind design 리뷰가 문서 누락으로 fail-closed 되지 않게.
|
|
148
217
|
writeFileSync(
|
|
@@ -166,7 +235,17 @@ function main(): void {
|
|
|
166
235
|
console.log(`[req:new] 생성 완료: ${reqId}`)
|
|
167
236
|
console.log(` branch : ${branch} (체크아웃됨)`)
|
|
168
237
|
console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
|
|
169
|
-
console.log(` 다음 :
|
|
238
|
+
console.log(` 다음 : ${nextStepHint(cfg.packageManager, reqId)}`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
|
|
242
|
+
export function runCli(argv: string[]): void {
|
|
243
|
+
try {
|
|
244
|
+
main(argv)
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
247
|
+
process.exitCode = 1
|
|
248
|
+
}
|
|
170
249
|
}
|
|
171
250
|
|
|
172
251
|
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|