commitgate 0.3.1 → 0.7.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 +17 -0
- package/CHANGELOG.md +115 -0
- package/README.en.md +194 -49
- package/README.md +201 -51
- package/bin/commitgate.mjs +15 -6
- package/bin/dispatch.d.mts +6 -0
- package/bin/dispatch.mjs +38 -0
- package/bin/init.ts +916 -57
- package/bin/migrate.ts +244 -0
- package/bin/uninstall.ts +97 -13
- package/package.json +10 -4
- package/req.config.json.sample +16 -13
- package/scripts/req/lib/adapters.ts +56 -8
- package/scripts/req/lib/config.ts +57 -1
- package/scripts/req/lib/porcelain.ts +104 -0
- package/scripts/req/lib/scratch.ts +104 -0
- package/scripts/req/req-commit.ts +16 -3
- package/scripts/req/req-doctor.ts +135 -31
- package/scripts/req/req-new.ts +60 -10
- package/scripts/req/req-next.ts +654 -0
- package/scripts/req/review-codex.ts +349 -73
- package/scripts/verify-review-overrides.mjs +96 -0
- package/templates/CLAUDE.template.md +16 -0
- package/templates/claude-command.md +39 -0
- package/templates/claude-skill.md +62 -0
- package/templates/cursor-rule.mdc +58 -0
- package/templates/workflow.gitignore +8 -0
- package/workflow/machine.schema.json +5 -1
- package/workflow/req.config.schema.json +6 -0
- package/workflow/review-persona.md +66 -0
|
@@ -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,16 @@
|
|
|
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 "..."] (저장소 패키지매니저의 실행 형식으로)
|
|
11
11
|
*/
|
|
12
12
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
|
13
13
|
import { join, relative } from 'node:path'
|
|
14
14
|
import { pathToFileURL } from 'node:url'
|
|
15
15
|
import { writeState, type WorkflowState } from './review-codex'
|
|
16
|
-
import { loadConfig, packageRoot, type DesignDocs } from './lib/config'
|
|
16
|
+
import { loadConfig, packageRoot, buildScriptInvocation, type DesignDocs, type PackageManager } from './lib/config'
|
|
17
17
|
import { createGitAdapter, type GitAdapter } from './lib/adapters'
|
|
18
|
+
import { parseStatusZ, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
|
|
19
|
+
import { isToolOutputScratch } from './lib/scratch'
|
|
18
20
|
|
|
19
21
|
// 모든 git 호출은 GitAdapter 경유(D-017-3). main()이 loadConfig 후 config.root로 재생성(기본 = packageRoot — 현재 동작 보존).
|
|
20
22
|
let gitAdapter: GitAdapter = createGitAdapter(packageRoot())
|
|
@@ -23,6 +25,17 @@ function git(args: string[]): string {
|
|
|
23
25
|
return gitAdapter.exec(args)
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `req:new` 전용 clean-tree 위반 목록(REQ-2026-012 D7·D8).
|
|
30
|
+
*
|
|
31
|
+
* 허용하는 것은 기존 티켓 직계의 **untracked 도구 산출물** 두 종류뿐이다. `state.json`, `responses/**`,
|
|
32
|
+
* staged-only 변경, rename/copy 등 그 밖의 모든 상태는 그대로 위반이다. review용
|
|
33
|
+
* `findUnstagedOrUntracked`는 staged-only(`M `)를 통과시키므로 여기서 재사용하지 않는다.
|
|
34
|
+
*/
|
|
35
|
+
export function findReqNewDirtyEntries(rawStatusZ: string, ticketRoot: string): StatusEntry[] {
|
|
36
|
+
return parseStatusZ(rawStatusZ).filter((entry) => !isToolOutputScratch(entry, ticketRoot))
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/** slug 검증: kebab-case(a-z0-9, '-' 구분). */
|
|
27
40
|
export function validateSlug(slug: string): void {
|
|
28
41
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
|
|
@@ -44,6 +57,18 @@ export function branchName(reqId: string, slug: string, branchPrefix: string): s
|
|
|
44
57
|
return `${branchPrefix}${reqId.replace(/^REQ-/, '')}-${slug}`
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `--run` 성공 후 다음 단계 안내(DEC-011-1). **config 로드 이후**이므로 pm별 실행 형식으로 파생한다.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ `DEFAULTS.packageManager`(= `'pnpm'`)를 폴백으로 쓰지 마라. `bin/init.ts`의 감지 폴백은 `'npm'`이라
|
|
64
|
+
* 두 값이 갈라져 있고, 그걸 문구에 끌어오면 npm 프로젝트가 pnpm 명령을 안내받는다 — 이 REQ가 고치는 결함이다.
|
|
65
|
+
* config가 없는 지점(헤더 주석·early throw)은 pm-중립 bare 표기를 쓴다.
|
|
66
|
+
*/
|
|
67
|
+
export function nextStepHint(pm: PackageManager, reqId: string): string {
|
|
68
|
+
const id = reqId.replace(/^REQ-/, '')
|
|
69
|
+
return `코드 변경 → git add → ${buildScriptInvocation(pm, 'req:review-codex', [id, '--run']).join(' ')}`
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
export function buildInitialState(reqId: string, branch: string, risk: 'LOW' | 'HIGH'): WorkflowState {
|
|
48
73
|
return {
|
|
49
74
|
id: reqId,
|
|
@@ -86,7 +111,10 @@ export function parseArgs(argv: string[]): Opts {
|
|
|
86
111
|
for (let i = 0; i < argv.length; i++) {
|
|
87
112
|
const a = argv[i]
|
|
88
113
|
if (a === undefined) continue
|
|
89
|
-
|
|
114
|
+
// bare `--`는 POSIX end-of-options 마커(DEC-011-3). pnpm/yarn은 이를 스크립트에 그대로 넘긴다.
|
|
115
|
+
if (a === '--') {
|
|
116
|
+
continue
|
|
117
|
+
} else if (a === '--run') {
|
|
90
118
|
o.run = true
|
|
91
119
|
} else if (a === '--root') {
|
|
92
120
|
const v = argv[++i]
|
|
@@ -110,9 +138,10 @@ export function parseArgs(argv: string[]): Opts {
|
|
|
110
138
|
return o
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
function main(): void {
|
|
114
|
-
const o = parseArgs(
|
|
115
|
-
|
|
141
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
142
|
+
const o = parseArgs(argv)
|
|
143
|
+
// ⚠️ loadConfig 이전이라 pm을 모른다 → pm-중립 bare 표기(DEFAULTS 폴백 금지, DEC-011-1).
|
|
144
|
+
if (!o.slug) throw new Error('slug 필요 (예: req:new camera-hardfail --run)')
|
|
116
145
|
validateSlug(o.slug)
|
|
117
146
|
|
|
118
147
|
const cfg = loadConfig({ root: o.root })
|
|
@@ -123,6 +152,8 @@ function main(): void {
|
|
|
123
152
|
const reqId = nextReqId(year, listExistingReqIds(cfg.workflowDirAbs))
|
|
124
153
|
const branch = branchName(reqId, o.slug, cfg.branchPrefix)
|
|
125
154
|
const ticketDir = join(cfg.workflowDirAbs, reqId)
|
|
155
|
+
// config 문자열이 `.`·`workflow/.`처럼 같은 위치를 다르게 표현해도 Git이 내는 canonical repo-relative 경로와 맞춘다.
|
|
156
|
+
const ticketRootRel = relative(cfg.root, cfg.workflowDirAbs).replace(/\\/g, '/')
|
|
126
157
|
const ticketRel = relative(cfg.root, ticketDir).replace(/\\/g, '/')
|
|
127
158
|
|
|
128
159
|
if (!o.run) {
|
|
@@ -134,9 +165,18 @@ function main(): void {
|
|
|
134
165
|
return
|
|
135
166
|
}
|
|
136
167
|
|
|
137
|
-
// 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지)
|
|
138
|
-
|
|
139
|
-
|
|
168
|
+
// 클린 트리 요구(새 브랜치 깨끗이 시작 — 의도 변경 섞임 방지).
|
|
169
|
+
// 단, gitignore 규칙이 없는 레거시 설치본도 기존 티켓의 순수 untracked 도구 산출물만 좁게 허용한다(D6·D7).
|
|
170
|
+
const dirtyEntries = findReqNewDirtyEntries(git([...STATUS_Z_ARGS]), ticketRootRel)
|
|
171
|
+
if (dirtyEntries.length > 0) {
|
|
172
|
+
const dirty = dirtyEntries.map(formatStatusEntry).join('\n')
|
|
173
|
+
throw new Error(
|
|
174
|
+
`워킹트리가 clean이어야 req:new --run 가능:\n${dirty}\n` +
|
|
175
|
+
`힌트: 방금 \`npx commitgate\`를 실행했다면 **설치분만** 먼저 커밋하십시오(\`git add -A\` 금지 —\n` +
|
|
176
|
+
` 무관한 변경·.env가 함께 커밋되고, 이어지는 req:review-codex가 그것을 외부로 전송합니다).\n` +
|
|
177
|
+
` 설치 출력의 "다음:" 안내가 stage할 정확한 경로 목록을 알려 줍니다.`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
140
180
|
const cur = git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
141
181
|
if (cur !== 'main') console.warn(`⚠️ 현재 브랜치가 main이 아님(${cur}) — REQ는 main에서 시작 권장(DEC-WF-020)`)
|
|
142
182
|
|
|
@@ -166,7 +206,17 @@ function main(): void {
|
|
|
166
206
|
console.log(`[req:new] 생성 완료: ${reqId}`)
|
|
167
207
|
console.log(` branch : ${branch} (체크아웃됨)`)
|
|
168
208
|
console.log(` ticket : ${ticketRel}/ (스캐폴드 커밋)`)
|
|
169
|
-
console.log(` 다음 :
|
|
209
|
+
console.log(` 다음 : ${nextStepHint(cfg.packageManager, reqId)}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
|
|
213
|
+
export function runCli(argv: string[]): void {
|
|
214
|
+
try {
|
|
215
|
+
main(argv)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
218
|
+
process.exitCode = 1
|
|
219
|
+
}
|
|
170
220
|
}
|
|
171
221
|
|
|
172
222
|
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|