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
|
@@ -21,15 +21,27 @@ export interface DesignDocs {
|
|
|
21
21
|
plan: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* codex 리뷰어의 추론강도(REQ-2026-013 P1). 실측 확정(R15): codex의 invalid-effort 거부 메시지가
|
|
26
|
+
* `none|minimal|low|medium|high|xhigh`를 지원값으로 명시. `null`은 override 생략(전역 상속) 탈출구.
|
|
27
|
+
*/
|
|
28
|
+
export type ReviewReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
|
29
|
+
|
|
24
30
|
/** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
|
|
25
31
|
export interface RawConfig {
|
|
26
32
|
ticketRoot?: string
|
|
27
33
|
schemaPath?: string
|
|
28
34
|
handoffPath?: string | null
|
|
35
|
+
/** null = 의도적 비활성(persona 블록 생략). 미지정 = DEFAULTS(활성). */
|
|
36
|
+
reviewPersonaPath?: string | null
|
|
29
37
|
branchPrefix?: string
|
|
30
38
|
packageManager?: PackageManager
|
|
31
39
|
granularityMaxFiles?: number
|
|
32
40
|
designDocs?: Partial<DesignDocs>
|
|
41
|
+
/** codex 리뷰 모델(REQ-2026-013 P1). null = `-c model=` 생략(전역 상속). 미지정 = DEFAULTS. */
|
|
42
|
+
reviewModel?: string | null
|
|
43
|
+
/** codex 리뷰 추론강도(REQ-2026-013 P1). null = `-c model_reasoning_effort=` 생략. 미지정 = DEFAULTS. */
|
|
44
|
+
reviewReasoningEffort?: ReviewReasoningEffort | null
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
|
|
@@ -38,16 +50,33 @@ export interface ResolvedConfig {
|
|
|
38
50
|
ticketRoot: string
|
|
39
51
|
schemaPath: string
|
|
40
52
|
handoffPath: string | null
|
|
53
|
+
reviewPersonaPath: string | null
|
|
41
54
|
branchPrefix: string
|
|
42
55
|
packageManager: PackageManager
|
|
43
56
|
granularityMaxFiles: number
|
|
44
57
|
designDocs: DesignDocs
|
|
58
|
+
reviewModel: string | null
|
|
59
|
+
reviewReasoningEffort: ReviewReasoningEffort | null
|
|
45
60
|
// 파생(절대경로)
|
|
46
61
|
workflowDirAbs: string
|
|
47
62
|
schemaPathAbs: string
|
|
48
63
|
handoffPathAbs: string | null
|
|
64
|
+
reviewPersonaPathAbs: string | null
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Codex 리뷰 프롬프트에 주입되는 **리뷰어 페르소나** 문서의 repo-상대 경로(코어 기본값).
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ 이 상수는 두 축의 SSOT다(REQ-2026-010 D3-1).
|
|
71
|
+
* - **설치 축**: `bin/init.ts`의 `KIT_COPY_RELPATHS`가 이 경로를 대상 repo에 복사한다.
|
|
72
|
+
* - **설정 축**: `DEFAULTS.reviewPersonaPath`가 이 값으로 해소된다(phase-1b에서 도입).
|
|
73
|
+
*
|
|
74
|
+
* 둘이 갈라지면 신규 설치본은 프롬프트 조립 시 이 파일을 찾지 못하고 **모든 리뷰가 fail-closed로 멈춘다.**
|
|
75
|
+
* `tests/unit/init.test.ts`의 "설치 축 SSOT"가 그 드리프트를 회귀로 잡는다.
|
|
76
|
+
* `package.json`의 `files[]`는 또 **다른 축**(npm tarball 적재분)이므로 함께 갱신해야 한다.
|
|
77
|
+
*/
|
|
78
|
+
export const DEFAULT_REVIEW_PERSONA_RELPATH = 'workflow/review-persona.md'
|
|
79
|
+
|
|
51
80
|
/**
|
|
52
81
|
* 코어 기본값. `req.config.json` 부재 시 이 값으로 해소된다.
|
|
53
82
|
*
|
|
@@ -63,10 +92,18 @@ export const DEFAULTS = {
|
|
|
63
92
|
ticketRoot: 'workflow',
|
|
64
93
|
schemaPath: 'workflow/machine.schema.json',
|
|
65
94
|
handoffPath: null as string | null,
|
|
95
|
+
// ⚠️ handoffPath와 달리 코어 기본이 **활성**이다. init이 이 경로에 파일을 깔기 때문(KIT_COPY_RELPATHS).
|
|
96
|
+
// 비활성이 필요하면 config에 `null`을 명시한다. `as string | null`은 handoffPath와 같은 이유(직접 import 소비자 계약).
|
|
97
|
+
reviewPersonaPath: DEFAULT_REVIEW_PERSONA_RELPATH as string | null,
|
|
66
98
|
branchPrefix: 'feat/req-',
|
|
67
99
|
packageManager: 'pnpm' as PackageManager,
|
|
68
100
|
granularityMaxFiles: 8,
|
|
69
101
|
designDocs: { requirement: '00-requirement.md', design: '01-design.md', plan: '02-plan.md' } as DesignDocs,
|
|
102
|
+
// REQ-2026-013 P1: 리뷰어 모델·추론강도 고정. 코어 기본은 DEFAULTS 중립성의 의도적 예외(D3) —
|
|
103
|
+
// 리뷰어 모델은 게이트 무결성 핵심이라 미고정 시 전역 ultra 상속이 곧 결함. 미지원 CLI는 config override/null.
|
|
104
|
+
// `as ... | null`은 handoffPath와 같은 이유(직접 import 소비자의 `| null` 계약 보존).
|
|
105
|
+
reviewModel: 'gpt-5.6-terra' as string | null,
|
|
106
|
+
reviewReasoningEffort: 'high' as ReviewReasoningEffort | null,
|
|
70
107
|
}
|
|
71
108
|
|
|
72
109
|
const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
|
|
@@ -79,9 +116,15 @@ export const CONFIG_SCHEMA = {
|
|
|
79
116
|
ticketRoot: { type: 'string', minLength: 1 },
|
|
80
117
|
schemaPath: { type: 'string', minLength: 1 },
|
|
81
118
|
handoffPath: { type: ['string', 'null'] },
|
|
119
|
+
// null = 의도적 비활성. 문자열이면 minLength 1(빈 문자열은 "비활성"의 애매한 표현 → 거부, null을 쓰게 한다).
|
|
120
|
+
reviewPersonaPath: { type: ['string', 'null'], minLength: 1 },
|
|
82
121
|
branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
|
|
83
122
|
packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
|
|
84
123
|
granularityMaxFiles: { type: 'integer', minimum: 1 },
|
|
124
|
+
// REQ-2026-013 P1. null=override 생략(전역 상속). model은 slug 패턴(따옴표·개행 거부 → TOML `model="…"` 주입 안전; null은 pattern에 vacuously 통과).
|
|
125
|
+
reviewModel: { type: ['string', 'null'], pattern: BASENAME_RE },
|
|
126
|
+
// effort는 실측 확정 enum(R15) + null. null을 enum에 포함해야 `{effort:null}`이 통과(JSON Schema enum은 타입 무관 전체 적용).
|
|
127
|
+
reviewReasoningEffort: { type: ['string', 'null'], enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', null] },
|
|
85
128
|
designDocs: {
|
|
86
129
|
type: 'object',
|
|
87
130
|
additionalProperties: false,
|
|
@@ -156,17 +199,29 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
|
|
|
156
199
|
ticketRoot: raw.ticketRoot ?? DEFAULTS.ticketRoot,
|
|
157
200
|
schemaPath: raw.schemaPath ?? DEFAULTS.schemaPath,
|
|
158
201
|
handoffPath: raw.handoffPath !== undefined ? raw.handoffPath : DEFAULTS.handoffPath, // null = 명시적 비활성
|
|
202
|
+
reviewPersonaPath:
|
|
203
|
+
raw.reviewPersonaPath !== undefined ? raw.reviewPersonaPath : DEFAULTS.reviewPersonaPath, // null = 명시적 비활성
|
|
159
204
|
branchPrefix: raw.branchPrefix ?? DEFAULTS.branchPrefix,
|
|
160
205
|
packageManager: raw.packageManager ?? DEFAULTS.packageManager,
|
|
161
206
|
granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
|
|
162
207
|
designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
|
|
208
|
+
// REQ-2026-013 P1: nullable — 명시적 null 보존을 위해 `!== undefined`(`??` 금지: null이 기본값으로 복귀해 탈출구가 깨짐).
|
|
209
|
+
reviewModel: raw.reviewModel !== undefined ? raw.reviewModel : DEFAULTS.reviewModel,
|
|
210
|
+
reviewReasoningEffort:
|
|
211
|
+
raw.reviewReasoningEffort !== undefined ? raw.reviewReasoningEffort : DEFAULTS.reviewReasoningEffort,
|
|
163
212
|
}
|
|
164
213
|
|
|
165
|
-
// repo-내부 자원(ticketRoot·schemaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
|
|
214
|
+
// repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
|
|
215
|
+
// handoffPath만 면제 — 형제 repo의 SSOT 문서를 읽는 **외부 참조**이기 때문.
|
|
216
|
+
// reviewPersonaPath는 패키지가 배포하고 init이 repo 안에 까는 자원이라 schemaPath와 같은 축이다(REQ-2026-010 D2).
|
|
166
217
|
assertRelative(merged.ticketRoot, 'ticketRoot')
|
|
167
218
|
assertRelative(merged.schemaPath, 'schemaPath')
|
|
168
219
|
assertUnderRoot(rootAbs, merged.ticketRoot, 'ticketRoot')
|
|
169
220
|
assertUnderRoot(rootAbs, merged.schemaPath, 'schemaPath')
|
|
221
|
+
if (merged.reviewPersonaPath !== null) {
|
|
222
|
+
assertRelative(merged.reviewPersonaPath, 'reviewPersonaPath')
|
|
223
|
+
assertUnderRoot(rootAbs, merged.reviewPersonaPath, 'reviewPersonaPath')
|
|
224
|
+
}
|
|
170
225
|
|
|
171
226
|
return {
|
|
172
227
|
root: rootAbs,
|
|
@@ -174,6 +229,7 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
|
|
|
174
229
|
workflowDirAbs: resolve(rootAbs, merged.ticketRoot),
|
|
175
230
|
schemaPathAbs: resolve(rootAbs, merged.schemaPath),
|
|
176
231
|
handoffPathAbs: merged.handoffPath ? resolve(rootAbs, merged.handoffPath) : null,
|
|
232
|
+
reviewPersonaPathAbs: merged.reviewPersonaPath ? resolve(rootAbs, merged.reviewPersonaPath) : null,
|
|
177
233
|
}
|
|
178
234
|
}
|
|
179
235
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git status --porcelain=v1 -z` 파서 (REQ-2026-012 phase-1a · 설계 D11·D11-1).
|
|
3
|
+
*
|
|
4
|
+
* 목적: porcelain 출력을 해석하는 **단일 지점**. 이전에는 세 곳이 각자 파싱했고 둘은 경로를 망가뜨렸다
|
|
5
|
+
* - `bin/init.ts`의 `parsePorcelainLine`(rename에서 dest만) — 삭제 예정(phase-1b)
|
|
6
|
+
* - `req-doctor.ts`의 `statusPaths`(인용 해제 없음 + 역슬래시를 `/`로 치환)
|
|
7
|
+
* - `review-codex.ts`의 `findUnstagedOrUntracked` 인라인 파싱(동일 결함)
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **왜 `-z`인가.** 기본 porcelain은 `"`·`\`·제어문자·(quotePath=true면)비-ASCII가 든 경로를 **C-인용**한다.
|
|
10
|
+
* `core.quotePath=false`는 비-ASCII 인용만 끄므로 나머지는 여전히 인용된다. 인용된 경로를 디코드하면
|
|
11
|
+
* "원문 보존"과 "디코드된 경로 반환"을 동시에 만족할 수 없고(설계 D11), 경로에 ` -> `가 들어가면
|
|
12
|
+
* rename delimiter 분할도 깨진다. `-z`는 **인용을 아예 하지 않으므로** 두 문제가 함께 사라진다.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **rename/copy 필드 순서가 `->` 형식과 반대다.**
|
|
15
|
+
* `--porcelain` : `R old -> new`
|
|
16
|
+
* `--porcelain -z` : `R new\0old\0` ← NEW가 먼저
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **`R`/`C`는 index(X)와 worktree(Y) 양쪽에 온다**(설계 D11-1, `git-status(1)`의 `[ D] R`·`[ D] C`).
|
|
19
|
+
* 실측: `mv a c && git add -N c` → ` R c.txt\0a.txt\0` (X=' ', Y='R').
|
|
20
|
+
* X만 검사하면 OLD 경로가 **독립 레코드로 새어 나가고** `origPath`가 소실된다. 그러면
|
|
21
|
+
* `findUnstagedOrUntracked`/D13이 rename의 src·dest를 둘 다 검사해 막던
|
|
22
|
+
* "비허용 경로 → 허용 경로 rename으로 `responses/` 주입·코드 삭제 우회"(A2-P2-1)가 뚫린다. 보안 회귀다.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **경로를 정규화하지 않는다.** git은 언제나 `/`를 구분자로 내므로 역슬래시는 **파일명의 일부**다.
|
|
25
|
+
* 옛 코드의 `.replace(/\\/g, '/')`는 `a\b.txt`를 `a/b.txt`로 뭉갰다 — 그 버그를 여기서 고친다.
|
|
26
|
+
*
|
|
27
|
+
* fail-closed: 형식이 어긋나거나 rename 레코드의 `origPath`가 없으면 throw한다. `undefined`를 흘리지 않는다.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** porcelain v1 레코드 하나. `-z`이므로 `path`는 인용되지 않은 원문이다. */
|
|
31
|
+
export interface StatusEntry {
|
|
32
|
+
/** X — index(스테이지) 상태 문자. 변경 없음은 `' '`. */
|
|
33
|
+
index: string
|
|
34
|
+
/** Y — worktree 상태 문자. 변경 없음은 `' '`. untracked는 X=Y=`'?'`. */
|
|
35
|
+
worktree: string
|
|
36
|
+
/** `-z`가 먼저 내는 경로. rename/copy면 **목적지(NEW)**, 그 외는 유일한 경로. */
|
|
37
|
+
path: string
|
|
38
|
+
/** rename/copy일 때만 존재하는 **원본(OLD)** 경로. */
|
|
39
|
+
origPath?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `R`(rename)·`C`(copy)는 index·worktree 어느 열에 와도 추가 경로 필드를 소비한다(설계 D11-1). */
|
|
43
|
+
export function isRenameOrCopy(index: string, worktree: string): boolean {
|
|
44
|
+
return index === 'R' || index === 'C' || worktree === 'R' || worktree === 'C'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `git status --porcelain=v1 -z --untracked-files=all`의 **원문**을 엔트리 배열로 분해한다.
|
|
49
|
+
*
|
|
50
|
+
* 레코드 형식은 `XY<space><path>`이고 NUL로 구분된다. rename/copy 레코드는 뒤이어 OLD 경로 필드를 하나 더 갖는다.
|
|
51
|
+
* 후행 NUL이 만드는 마지막 빈 필드만 버린다 — 그 외 위치의 빈 필드는 형식 오류다(fail-closed).
|
|
52
|
+
*/
|
|
53
|
+
export function parseStatusZ(raw: string): StatusEntry[] {
|
|
54
|
+
const fields = raw.split('\0')
|
|
55
|
+
// `-z` 출력은 언제나 NUL로 끝나므로 마지막 원소는 빈 문자열이다. 클린 트리(`''`)도 `['']`가 된다.
|
|
56
|
+
if (fields.length > 0 && fields[fields.length - 1] === '') fields.pop()
|
|
57
|
+
|
|
58
|
+
const out: StatusEntry[] = []
|
|
59
|
+
for (let i = 0; i < fields.length; i++) {
|
|
60
|
+
const rec = fields[i]
|
|
61
|
+
if (rec === undefined) throw new Error('porcelain -z: 레코드 인덱스 이탈(내부 오류)')
|
|
62
|
+
// `XY<space>` + 최소 한 글자 경로.
|
|
63
|
+
if (rec.length < 4 || rec[2] !== ' ')
|
|
64
|
+
throw new Error(`porcelain -z: 레코드 형식 오류(XY<space><path> 아님): ${JSON.stringify(rec)}`)
|
|
65
|
+
|
|
66
|
+
const index = rec[0] as string
|
|
67
|
+
const worktree = rec[1] as string
|
|
68
|
+
const path = rec.slice(3) // 정규화하지 않는다 — 역슬래시는 파일명의 일부다.
|
|
69
|
+
|
|
70
|
+
if (isRenameOrCopy(index, worktree)) {
|
|
71
|
+
const origPath = fields[++i]
|
|
72
|
+
if (origPath === undefined || origPath === '')
|
|
73
|
+
throw new Error(`porcelain -z: rename/copy 레코드에 원본 경로가 없다(truncated): ${JSON.stringify(rec)}`)
|
|
74
|
+
out.push({ index, worktree, path, origPath })
|
|
75
|
+
} else {
|
|
76
|
+
out.push({ index, worktree, path })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 이 엔트리가 건드리는 **모든** 경로. rename/copy는 `[OLD, NEW]` 순서다.
|
|
84
|
+
*
|
|
85
|
+
* 순서는 옛 `statusPaths`(`[body.slice(0,arrow), body.slice(arrow+4)]` = `[old, new]`)와 같다 —
|
|
86
|
+
* 호출부의 `flatMap(statusPaths)` 시맨틱을 보존한다.
|
|
87
|
+
*/
|
|
88
|
+
export function entryPaths(e: StatusEntry): string[] {
|
|
89
|
+
return e.origPath === undefined ? [e.path] : [e.origPath, e.path]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** untracked(`??`) 판정. `-z`에서도 X=Y=`'?'`다. */
|
|
93
|
+
export function isUntracked(e: StatusEntry): boolean {
|
|
94
|
+
return e.index === '?' && e.worktree === '?'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 사람이 읽는 한 줄. 에러 메시지·진단 출력용(`->` 형식으로 되돌려 익숙한 모양을 유지). */
|
|
98
|
+
export function formatStatusEntry(e: StatusEntry): string {
|
|
99
|
+
const code = `${e.index}${e.worktree}`
|
|
100
|
+
return e.origPath === undefined ? `${code} ${e.path}` : `${code} ${e.origPath} -> ${e.path}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 모든 호출부가 공유하는 정본 인자. 다른 형태를 쓰면 파싱 경계가 깨진다(설계 D10). */
|
|
104
|
+
export const STATUS_Z_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all'] as const
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scratch(도구 산출물) 정의의 **단일 지점** (REQ-2026-012 phase-1b · 설계 D7·D8).
|
|
3
|
+
*
|
|
4
|
+
* 이전엔 세 곳(`req-next.ts`·`req-doctor.ts`·`review-codex.ts`)이 `codex-response.json`·
|
|
5
|
+
* `.review-preview.txt`·`state.json` 세 경로를 각자 리터럴로 적었다. DRY가 아니라 **정확성** 문제였다 —
|
|
6
|
+
* 한 곳이 바뀌면 clean-tree 판정이 갈라진다.
|
|
7
|
+
*
|
|
8
|
+
* 두 종류의 scratch가 있고 **범위가 다르다**:
|
|
9
|
+
* - review/doctor용: `reviewScratchPaths` — 현재 티켓의 정확한 3경로(`state.json` 포함).
|
|
10
|
+
* - `req:new`용: `isToolOutputScratch` — 티켓 생성 **전**이라 현재 티켓이 없다. 그래서 **어느 티켓의**
|
|
11
|
+
* untracked 도구 산출물이든 허용하되, `state.json`·`responses/**`는 **제외**한다(설계 D8: 그것을
|
|
12
|
+
* 허용하면 증거 변조 구멍이 된다). 즉 `req:new`의 예외는 나머지 셋의 **진부분집합**이다.
|
|
13
|
+
*
|
|
14
|
+
* 아카이브 파일명 판정(`isArchiveFileName`)도 여기로 모은다 — `isAllowedResponsesScratch`가 그것을 쓰고,
|
|
15
|
+
* 이 파일을 leaf(포르셀린만 의존)로 두면 `review-codex`↔`scratch` 순환 import가 생기지 않는다.
|
|
16
|
+
*/
|
|
17
|
+
import type { StatusEntry } from './porcelain'
|
|
18
|
+
import { isUntracked } from './porcelain'
|
|
19
|
+
|
|
20
|
+
/** 티켓 디렉터리 안의 순수 untracked 도구 산출물. 커밋된 적이 없고 승인 증거가 아니다. */
|
|
21
|
+
export const TOOL_OUTPUT_BASENAMES = ['codex-response.json', '.review-preview.txt'] as const
|
|
22
|
+
|
|
23
|
+
/** 경로 정규화: 역슬래시→슬래시(호출부가 넘기는 repo-상대는 이미 `/`지만 방어), 후행 슬래시 제거. */
|
|
24
|
+
function normDir(dirRel: string): string {
|
|
25
|
+
return dirRel.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* review/doctor의 clean-tree 검사가 허용하는 **현재 티켓** 3경로(repo-상대).
|
|
30
|
+
* 세 호출부가 리터럴로 만들던 `[codex-response.json, .review-preview.txt, state.json]`을 대체한다.
|
|
31
|
+
*/
|
|
32
|
+
export function reviewScratchPaths(ticketDirRel: string): string[] {
|
|
33
|
+
const dir = normDir(ticketDirRel)
|
|
34
|
+
return [`${dir}/${TOOL_OUTPUT_BASENAMES[0]}`, `${dir}/${TOOL_OUTPUT_BASENAMES[1]}`, `${dir}/state.json`]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `REQ-<4자리>-<숫자>` 디렉터리명인가(문자열 분해 — 정규식 보간 금지, 설계 D7). */
|
|
38
|
+
function isTicketDirName(seg: string): boolean {
|
|
39
|
+
if (!seg.startsWith('REQ-')) return false
|
|
40
|
+
const rest = seg.slice(4) // `2026-001`
|
|
41
|
+
const dash = rest.indexOf('-')
|
|
42
|
+
if (dash < 0) return false
|
|
43
|
+
const year = rest.slice(0, dash)
|
|
44
|
+
const num = rest.slice(dash + 1)
|
|
45
|
+
if (year.length !== 4 || !isAllDigits(year)) return false
|
|
46
|
+
return num.length > 0 && isAllDigits(num)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAllDigits(s: string): boolean {
|
|
50
|
+
for (let i = 0; i < s.length; i++) {
|
|
51
|
+
const c = s.charCodeAt(i)
|
|
52
|
+
if (c < 48 || c > 57) return false
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `req:new`의 좁은 예외(설계 D7). **다음을 모두** 만족하는 엔트리만 무시한다:
|
|
59
|
+
* - untracked(X=Y=`?`). ` M`·`M `·`R ` 등 tracked·staged·rename은 무시하지 않는다.
|
|
60
|
+
* - `path`가 `<ticketRoot>/REQ-<4자리>-<숫자>/<basename>`이고 `<basename>` ∈ `TOOL_OUTPUT_BASENAMES`.
|
|
61
|
+
*
|
|
62
|
+
* `state.json`·`responses/**`는 basename 목록에 없으므로 자동으로 제외된다(설계 D8).
|
|
63
|
+
* 이 술어는 승인을 **부여하지 않는다**(설계 D9) — 위조 파일을 통과시켜도 `req:new`는
|
|
64
|
+
* `commit_allowed:false`인 새 state만 쓴다.
|
|
65
|
+
*/
|
|
66
|
+
export function isToolOutputScratch(entry: StatusEntry, ticketRoot: string): boolean {
|
|
67
|
+
if (!isUntracked(entry)) return false
|
|
68
|
+
const root = normDir(ticketRoot)
|
|
69
|
+
// ticketRoot='.' 또는 canonical repo-root('')도 유효하다. 이때 Git 경로에는 './' 접두사가 없다.
|
|
70
|
+
const prefix = root === '' || root === '.' ? '' : `${root}/`
|
|
71
|
+
if (!entry.path.startsWith(prefix)) return false
|
|
72
|
+
const rest = entry.path.slice(prefix.length) // `REQ-2026-001/codex-response.json`
|
|
73
|
+
const slash = rest.indexOf('/')
|
|
74
|
+
if (slash < 0) return false
|
|
75
|
+
const ticketSeg = rest.slice(0, slash)
|
|
76
|
+
const basename = rest.slice(slash + 1)
|
|
77
|
+
if (basename.includes('/')) return false // 티켓 직계만
|
|
78
|
+
if (!(TOOL_OUTPUT_BASENAMES as readonly string[]).includes(basename)) return false
|
|
79
|
+
return isTicketDirName(ticketSeg)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────── 승인 증거 아카이브 (REQ-016 A1, review-codex에서 이동) ──
|
|
83
|
+
|
|
84
|
+
/** 아카이브 파일명 패턴: `<base>-rNN-(approved|needs-fix).json`(NN≥2자리). approvals.jsonl 등은 불일치. */
|
|
85
|
+
const ARCHIVE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]*-r\d{2,}-(approved|needs-fix)\.json$/
|
|
86
|
+
export function isArchiveFileName(name: string): boolean {
|
|
87
|
+
return ARCHIVE_NAME_RE.test(name)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 현재 티켓 `responses/` 하위의 **untracked 승인 아카이브 하나**만 스크래치로 허용(REQ-016 A1·D-016-4).
|
|
92
|
+
* `approvals.jsonl`·tracked 수정/삭제/리네임·타 티켓·collapsed dir은 전부 위반(커밋된 증거 변조·주입 차단).
|
|
93
|
+
*
|
|
94
|
+
* StatusEntry 기반(설계 D11). untracked만 허용하므로 rename의 origPath는 볼 필요가 없다.
|
|
95
|
+
* ⚠️ `entry.path`를 정규화하지 않는다 — `-z`가 준 원문이다. 역슬래시는 파일명의 일부다(옛 코드의 버그를 안 물려받는다).
|
|
96
|
+
*/
|
|
97
|
+
export function isAllowedResponsesScratch(entry: StatusEntry, ticketRel: string): boolean {
|
|
98
|
+
if (!isUntracked(entry)) return false // X=Y=`?`
|
|
99
|
+
const prefix = `${normDir(ticketRel)}/responses/`
|
|
100
|
+
if (!entry.path.startsWith(prefix)) return false
|
|
101
|
+
const name = entry.path.slice(prefix.length)
|
|
102
|
+
if (name.includes('/')) return false // 직계만
|
|
103
|
+
return isArchiveFileName(name)
|
|
104
|
+
}
|
|
@@ -449,7 +449,10 @@ export function parseArgs(argv: string[]): CommitArgs {
|
|
|
449
449
|
for (let i = 0; i < argv.length; i++) {
|
|
450
450
|
const a = argv[i]
|
|
451
451
|
if (a === undefined) continue
|
|
452
|
-
|
|
452
|
+
// bare `--`는 POSIX end-of-options 마커(DEC-011-3). ⚠️ 이후 인자도 계속 옵션으로 파싱해야 한다 —
|
|
453
|
+
// 전부 위치인자로 삼키면 `req:commit <id> -- --run`이 조용히 dry-run으로 끝난다(가장 나쁜 실패).
|
|
454
|
+
if (a === '--') continue
|
|
455
|
+
else if (a === '--ticket') ticket = argv[++i] ?? null
|
|
453
456
|
else if (a === '--run') run = true
|
|
454
457
|
else if (a === '--message' || a === '-m') message = argv[++i] ?? null
|
|
455
458
|
else if (a === '--message-file') {
|
|
@@ -642,8 +645,8 @@ function designFinalize(args: {
|
|
|
642
645
|
console.log('[req:commit] ✅ design-finalize 완료 — approvals.jsonl 기록')
|
|
643
646
|
}
|
|
644
647
|
|
|
645
|
-
function main(): void {
|
|
646
|
-
const opts = parseArgs(
|
|
648
|
+
export function main(argv: string[] = process.argv.slice(2)): void {
|
|
649
|
+
const opts = parseArgs(argv)
|
|
647
650
|
const cfg = loadConfig({ root: opts.root })
|
|
648
651
|
gitRoot = cfg.root // runDoctor(pnpm/npm) cwd
|
|
649
652
|
pkgManager = cfg.packageManager
|
|
@@ -788,5 +791,15 @@ function main(): void {
|
|
|
788
791
|
console.log(`[req:commit] ✅ 완료 — source=${sourceSha.slice(0, 8)} · evidence-finalize · commit_allowed 소비됨`)
|
|
789
792
|
}
|
|
790
793
|
|
|
794
|
+
/** bin dispatch 진입점(친절한 1줄 오류 + exit 1 경계). 직접 `tsx` 실행은 아래 `if (isMain) main()`이 그대로 담당(하위호환). */
|
|
795
|
+
export function runCli(argv: string[]): void {
|
|
796
|
+
try {
|
|
797
|
+
main(argv)
|
|
798
|
+
} catch (err) {
|
|
799
|
+
console.error(`commitgate: ${err instanceof Error ? err.message : String(err)}`)
|
|
800
|
+
process.exitCode = 1
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
791
804
|
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
792
805
|
if (isMain) main()
|