commitgate 0.4.0 → 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 +8 -0
- package/CHANGELOG.md +115 -0
- package/README.en.md +112 -17
- package/README.md +117 -17
- package/bin/commitgate.mjs +15 -6
- package/bin/dispatch.d.mts +6 -0
- package/bin/dispatch.mjs +38 -0
- package/bin/init.ts +788 -78
- package/bin/migrate.ts +244 -0
- package/bin/uninstall.ts +43 -1
- package/package.json +6 -3
- package/req.config.json.sample +2 -0
- package/scripts/req/lib/adapters.ts +56 -8
- package/scripts/req/lib/config.ts +25 -0
- 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 +33 -17
- package/scripts/req/review-codex.ts +198 -68
- package/scripts/verify-review-overrides.mjs +96 -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 +5 -1
- package/workflow/req.config.schema.json +5 -0
- package/workflow/review-persona.md +22 -1
package/bin/migrate.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* commitgate migrate — **Stage A(vendored scaffold) → Stage B(런타임 패키지)** 비파괴 전환 (REQ-2026-014 R5).
|
|
4
|
+
*
|
|
5
|
+
* 이 명령이 하는 일은 **하나뿐**이다: 대상 `package.json`의 `req:*` 중 **현재 값이 정확히 Stage A 주입값인 키만**
|
|
6
|
+
* `commitgate <verb>`로 바꾼다. 그 외에는 아무것도 하지 않는다.
|
|
7
|
+
*
|
|
8
|
+
* 비파괴 계약(PM 결정):
|
|
9
|
+
* - **아무것도 삭제하지 않는다.** `scripts/req/**`·schema·persona·`req.config.json`·진입점·`workflow/REQ-*` 증거를
|
|
10
|
+
* 자동 삭제하지 않는다. 정리는 읽기 전용 `commitgate uninstall` 계획 또는 `git revert` 안내로만.
|
|
11
|
+
* - **사용자 정의 script를 절대 덮어쓰지 않는다.** 정확한 Stage A 주입값이 아니면 보존하고 수동 조치를 안내한다.
|
|
12
|
+
* - **커밋하지 않는다.** 사용자가 검토 후 stage/commit 한다.
|
|
13
|
+
* - 기본은 **dry-run**(쓰기 0건). `--apply`에서만 쓴다.
|
|
14
|
+
*
|
|
15
|
+
* 쓰기 범위는 **`package.json` 한 파일**이다. 그래서 다중 파일 rollback 프레임워크가 필요 없다
|
|
16
|
+
* (REQ-2026-014 D11 제거와 일관 — 기존 init도 rollback이 없고 "쓰기 前 실패"가 계약이다).
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **동기(sync) 구현이어야 한다.** launcher(`bin/commitgate.mjs`)가 `mod.runCli(rest)`를 **await 없이** 호출하고
|
|
19
|
+
* 기존 7개 runCli가 전부 sync `void`다. async면 promise가 버려져 오류가 unhandledRejection이 되고 exit code가 소실된다.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **대상 root는 `--dir`(기본 cwd)로만 해소한다.** `resolveRoot`(scripts/req/lib/config.ts)는 config를 못 찾으면
|
|
22
|
+
* fallback으로 **패키지 자신의 root**를 반환한다 — package.json을 쓰는 이 명령이 그 fallback을 타면
|
|
23
|
+
* CommitGate 패키지 자신의 package.json을 재작성한다. init·uninstall과 같은 `--dir` 방식을 쓴다.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from 'node:fs'
|
|
26
|
+
import { resolve, join } from 'node:path'
|
|
27
|
+
import { pathToFileURL } from 'node:url'
|
|
28
|
+
import { stripBom } from '../scripts/req/lib/config'
|
|
29
|
+
import { REQ_SCRIPTS, STAGE_B_REQ_SCRIPTS, KIT_SOURCE_DIR_REL, assertGitWorkTree, commitgateDeclared } from './init'
|
|
30
|
+
|
|
31
|
+
export interface MigrateOptions {
|
|
32
|
+
dir: string
|
|
33
|
+
/** 기본 false = dry-run(쓰기 0건). true면 package.json 한 파일을 쓴다. */
|
|
34
|
+
apply: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `req:*` 키 하나의 전환 판정. */
|
|
38
|
+
export interface ScriptDecision {
|
|
39
|
+
key: string
|
|
40
|
+
current: string | undefined
|
|
41
|
+
/** 'convert' = 정확한 Stage A 값 → 전환 대상. 'stage-b' = 이미 전환됨. 'custom' = 사용자 값(보존). 'absent' = 키 없음. */
|
|
42
|
+
kind: 'convert' | 'stage-b' | 'custom' | 'absent'
|
|
43
|
+
next: string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MigratePlan {
|
|
47
|
+
targetRoot: string
|
|
48
|
+
decisions: ScriptDecision[]
|
|
49
|
+
/** 실제로 바꿀 키(kind==='convert'). 비어 있으면 쓸 것이 없다. */
|
|
50
|
+
converts: ScriptDecision[]
|
|
51
|
+
/** 보존하는 사용자 정의 키(kind==='custom') — 수동 조치 안내 대상. */
|
|
52
|
+
customs: ScriptDecision[]
|
|
53
|
+
/** vendored `scripts/req/**`가 남아 있는가(삭제하지 않는다 — 안내만). */
|
|
54
|
+
vendoredPresent: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `req:*` 전환 판정(순수 — REQ-2026-014 D5).
|
|
59
|
+
*
|
|
60
|
+
* **정확한 바이트 일치일 때만 전환**한다. 이 술어는 `bin/uninstall.ts:295-303`의 `cur === injected`와 같은 계약이며
|
|
61
|
+
* `REQ_SCRIPTS`가 그 SSOT다. 사용자 정의 값(`req:new = "node custom.mjs"` 등)은 **절대 덮어쓰지 않는다**.
|
|
62
|
+
*/
|
|
63
|
+
export function decideScripts(scripts: Record<string, string>): ScriptDecision[] {
|
|
64
|
+
return Object.keys(REQ_SCRIPTS).map((key) => {
|
|
65
|
+
const current = scripts[key]
|
|
66
|
+
const stageA = REQ_SCRIPTS[key]
|
|
67
|
+
const stageB = STAGE_B_REQ_SCRIPTS[key]
|
|
68
|
+
if (current === undefined) return { key, current, kind: 'absent' as const, next: undefined }
|
|
69
|
+
if (current === stageA) return { key, current, kind: 'convert' as const, next: stageB }
|
|
70
|
+
if (current === stageB) return { key, current, kind: 'stage-b' as const, next: undefined }
|
|
71
|
+
return { key, current, kind: 'custom' as const, next: undefined }
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 대상을 읽어 계획을 만든다(**쓰기 0건**). */
|
|
76
|
+
export function planMigrate(opts: MigrateOptions): MigratePlan {
|
|
77
|
+
const targetRoot = resolve(opts.dir)
|
|
78
|
+
if (!existsSync(targetRoot) || !statSync(targetRoot).isDirectory())
|
|
79
|
+
throw new Error(`대상 디렉터리가 없음: ${targetRoot}`)
|
|
80
|
+
assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
|
|
81
|
+
|
|
82
|
+
const pkgPath = join(targetRoot, 'package.json')
|
|
83
|
+
if (!existsSync(pkgPath)) throw new Error(`package.json 없음: ${targetRoot}`)
|
|
84
|
+
const pkg = parsePkg(pkgPath)
|
|
85
|
+
const scripts = pkg.scripts ?? {}
|
|
86
|
+
|
|
87
|
+
const decisions = decideScripts(scripts)
|
|
88
|
+
return {
|
|
89
|
+
targetRoot,
|
|
90
|
+
decisions,
|
|
91
|
+
converts: decisions.filter((d) => d.kind === 'convert'),
|
|
92
|
+
customs: decisions.filter((d) => d.kind === 'custom'),
|
|
93
|
+
vendoredPresent: existsSync(join(targetRoot, KIT_SOURCE_DIR_REL)),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** package.json 파싱(BOM 허용 — PowerShell `Set-Content -Encoding UTF8`). shape 검증 포함(fail-closed). */
|
|
98
|
+
function parsePkg(pkgPath: string): { scripts?: Record<string, string>; devDependencies?: Record<string, string> } {
|
|
99
|
+
let raw: unknown
|
|
100
|
+
try {
|
|
101
|
+
raw = JSON.parse(stripBom(readFileSync(pkgPath, 'utf8')))
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`package.json 파싱 실패(${pkgPath}): ${err instanceof Error ? err.message : String(err)}`)
|
|
104
|
+
}
|
|
105
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`package.json이 JSON 객체가 아님: ${pkgPath}`)
|
|
106
|
+
for (const field of ['scripts', 'devDependencies'] as const) {
|
|
107
|
+
const v = (raw as Record<string, unknown>)[field]
|
|
108
|
+
if (v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v)))
|
|
109
|
+
throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
|
|
110
|
+
}
|
|
111
|
+
return raw as { scripts?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 계획을 사람이 읽는 줄 배열로. **shell 연산자(`&&`)를 쓰지 않는다**(Windows PowerShell 5.1·cmd.exe 비호환 — DEC-011-8). */
|
|
115
|
+
export function renderPlan(plan: MigratePlan, apply: boolean): string[] {
|
|
116
|
+
const L: string[] = []
|
|
117
|
+
L.push('')
|
|
118
|
+
L.push(`[commitgate migrate] Stage A → Stage B 전환 ${apply ? '(--apply: package.json 을 씁니다)' : '계획 (dry-run — 아무것도 쓰지 않습니다)'}`)
|
|
119
|
+
L.push(` 대상: ${plan.targetRoot}`)
|
|
120
|
+
L.push('')
|
|
121
|
+
|
|
122
|
+
if (plan.converts.length > 0) {
|
|
123
|
+
L.push(` 전환 대상 — 현재 값이 정확히 Stage A 주입값인 키 ${plan.converts.length}개:`)
|
|
124
|
+
for (const d of plan.converts) L.push(` package.json#scripts.${d.key}: "${d.current}" → "${d.next}"`)
|
|
125
|
+
} else {
|
|
126
|
+
L.push(' 전환 대상 없음 — 정확한 Stage A 주입값인 req:* 키가 없습니다.')
|
|
127
|
+
}
|
|
128
|
+
L.push('')
|
|
129
|
+
|
|
130
|
+
const stageB = plan.decisions.filter((d) => d.kind === 'stage-b')
|
|
131
|
+
if (stageB.length > 0) L.push(` 이미 Stage B: ${stageB.map((d) => d.key).join(', ')}`)
|
|
132
|
+
|
|
133
|
+
if (plan.customs.length > 0) {
|
|
134
|
+
L.push('')
|
|
135
|
+
L.push(' ⚠️ 사용자 정의 값 — **덮어쓰지 않습니다**. 수동 조치가 필요합니다:')
|
|
136
|
+
for (const d of plan.customs) L.push(` package.json#scripts.${d.key} = "${d.current}"`)
|
|
137
|
+
L.push(' Stage B 런타임을 쓰려면 그 스크립트가 `commitgate <verb>` 를 호출하도록 직접 고치십시오.')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (plan.vendoredPresent) {
|
|
141
|
+
L.push('')
|
|
142
|
+
L.push(` ℹ️ ${KIT_SOURCE_DIR_REL}/ 가 남아 있습니다 — migrate 는 **아무것도 삭제하지 않습니다**(비파괴).`)
|
|
143
|
+
L.push(' Stage B 에서 실행 코드는 node_modules/commitgate 에서 돕니다. 남은 vendored 파일은 사용하지 않습니다.')
|
|
144
|
+
L.push(' 정리하려면 먼저 읽기 전용 계획을 보십시오: npx commitgate uninstall')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
L.push('')
|
|
148
|
+
if (!apply) {
|
|
149
|
+
L.push(' 적용하려면: npx commitgate migrate --apply')
|
|
150
|
+
L.push(' (--apply 는 package.json 만 씁니다. 커밋하지 않습니다 — 직접 검토 후 stage/commit 하십시오.)')
|
|
151
|
+
} else if (plan.converts.length > 0) {
|
|
152
|
+
L.push(' 다음: git diff package.json 으로 확인한 뒤 커밋하십시오.')
|
|
153
|
+
L.push(' git add -- package.json')
|
|
154
|
+
L.push(' git commit -m "chore: migrate commitgate to Stage B runtime"')
|
|
155
|
+
}
|
|
156
|
+
return L
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 실행. 기본 dry-run(쓰기 0건), `--apply`에서만 `package.json` 한 파일을 쓴다.
|
|
161
|
+
*
|
|
162
|
+
* `--apply` 전 **선행 설치 확인**: Stage B 스크립트(`commitgate <verb>`)를 심으므로 대상에 commitgate가
|
|
163
|
+
* devDependency로 있어야 한다. init의 D14와 **같은 축소 규칙** — **키 존재만** 보고 값 형태는 검증하지 않는다
|
|
164
|
+
* (`npm i -D <tgz>`는 `file:…tgz`를 쓴다).
|
|
165
|
+
*/
|
|
166
|
+
export function runMigrate(opts: MigrateOptions): MigratePlan {
|
|
167
|
+
const plan = planMigrate(opts)
|
|
168
|
+
|
|
169
|
+
if (opts.apply) {
|
|
170
|
+
const pkgPath = join(plan.targetRoot, 'package.json')
|
|
171
|
+
const pkg = parsePkg(pkgPath)
|
|
172
|
+
if (!commitgateDeclared(pkg.devDependencies ?? {}))
|
|
173
|
+
throw new Error(
|
|
174
|
+
`devDependencies.commitgate 선언이 없습니다 — Stage B 는 req:* 를 'commitgate <verb>' 로 심으므로 ` +
|
|
175
|
+
`대상에 commitgate 가 devDependency 로 있어야 합니다. 먼저 'npm install -D commitgate' 를 실행한 뒤 다시 시도하십시오.`,
|
|
176
|
+
)
|
|
177
|
+
if (plan.converts.length > 0) {
|
|
178
|
+
const scripts = pkg.scripts ?? {}
|
|
179
|
+
for (const d of plan.converts) if (d.next !== undefined) scripts[d.key] = d.next
|
|
180
|
+
pkg.scripts = scripts
|
|
181
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const line of renderPlan(plan, opts.apply)) console.log(line)
|
|
186
|
+
return plan
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** CLI 파싱(fail-closed). 기존 `bin/init.ts`·`bin/uninstall.ts` 관례를 따른다 — `--flag=value` 미지원, 미지 토큰은 throw. */
|
|
190
|
+
export function parseArgs(argv: string[]): MigrateOptions {
|
|
191
|
+
let dir = process.cwd()
|
|
192
|
+
let apply = false
|
|
193
|
+
for (let i = 0; i < argv.length; i++) {
|
|
194
|
+
const a = argv[i]
|
|
195
|
+
if (a === '--dir') {
|
|
196
|
+
const v = argv[i + 1]
|
|
197
|
+
if (v === undefined) throw new Error('--dir 값 누락')
|
|
198
|
+
dir = v
|
|
199
|
+
i++
|
|
200
|
+
} else if (a === '--apply') {
|
|
201
|
+
apply = true
|
|
202
|
+
} else if (a === '--dry-run') {
|
|
203
|
+
apply = false // 기본값이지만 명시 허용(문서화된 의도 표현)
|
|
204
|
+
} else if (a === '-h' || a === '--help') {
|
|
205
|
+
printHelp()
|
|
206
|
+
process.exit(0)
|
|
207
|
+
} else {
|
|
208
|
+
throw new Error(`알 수 없는 인자: ${a}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { dir: resolve(dir), apply }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function printHelp(): void {
|
|
215
|
+
console.log(`commitgate migrate — Stage A(vendored) → Stage B(런타임 패키지) 비파괴 전환
|
|
216
|
+
|
|
217
|
+
사용법:
|
|
218
|
+
npx commitgate migrate [--dir <대상repo>] 계획만 출력(기본 — 아무것도 쓰지 않음)
|
|
219
|
+
npx commitgate migrate --apply [--dir <대상repo>] package.json 의 req:* 전환
|
|
220
|
+
|
|
221
|
+
하는 일:
|
|
222
|
+
package.json 의 req:* 중 **현재 값이 정확히 Stage A 주입값인 키만** "commitgate <verb>" 로 바꿉니다.
|
|
223
|
+
|
|
224
|
+
하지 않는 일:
|
|
225
|
+
- 아무것도 삭제하지 않습니다(scripts/req/**·schema·persona·config·진입점·workflow 증거 전부 보존).
|
|
226
|
+
- 사용자 정의 script 를 덮어쓰지 않습니다(보존 + 수동 조치 안내).
|
|
227
|
+
- 커밋하지 않습니다.
|
|
228
|
+
|
|
229
|
+
--apply 전제:
|
|
230
|
+
대상 package.json 에 devDependencies.commitgate 선언이 있어야 합니다(먼저 'npm install -D commitgate').
|
|
231
|
+
`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function runCli(argv: string[]): void {
|
|
235
|
+
try {
|
|
236
|
+
runMigrate(parseArgs(argv))
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error(`commitgate migrate: ${err instanceof Error ? err.message : String(err)}`)
|
|
239
|
+
process.exitCode = 1
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href
|
|
244
|
+
if (isMain) runCli(process.argv.slice(2))
|
package/bin/uninstall.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
KIT_SCHEMA_RELPATHS,
|
|
30
30
|
KIT_COPY_RELPATHS,
|
|
31
31
|
KIT_AGENT_ENTRYPOINTS,
|
|
32
|
+
KIT_GITIGNORE,
|
|
32
33
|
KIT_CLAUDE_TEMPLATE_REL,
|
|
33
34
|
KIT_CLAUDE_DEST_REL,
|
|
34
35
|
KIT_AGENTS_CONTRACT_COPY_REL,
|
|
@@ -96,6 +97,13 @@ export interface UninstallFacts {
|
|
|
96
97
|
* 사용자가 직접 넣은 파일일 수 있으므로 제거 후보에 넣지 않고, 디렉터리 통삭제도 제안하지 않는다.
|
|
97
98
|
*/
|
|
98
99
|
unknownKitFiles: string[]
|
|
100
|
+
/**
|
|
101
|
+
* 대상 `package.json`의 `devDependencies.commitgate` **선언값**(없으면 null) — REQ-2026-014 R4.
|
|
102
|
+
*
|
|
103
|
+
* Stage B 런타임 제거 안내(`npm uninstall -D commitgate`)를 낼지 판정하는 데만 쓴다. **진단용 표시**이고
|
|
104
|
+
* 이 값으로 무엇을 삭제하지 않는다. 값의 형태는 검증하지 않는다(`file:…tgz`·`workspace:*` 전부 정당한 설치 형태).
|
|
105
|
+
*/
|
|
106
|
+
commitgateDevDependency: string | null
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
export type UninstallMode = 'not-installed' | 'uncommitted' | 'committed' | 'mixed'
|
|
@@ -220,6 +228,8 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
220
228
|
...KIT_AGENT_ENTRYPOINTS.map((e) => ({ destRel: e.dest, srcRel: e.src })),
|
|
221
229
|
// 마커 없는 AGENTS.md 경로에서만 설치되는 계약 템플릿 사본. 부재가 정상이므로 present=false여도 문제 없다.
|
|
222
230
|
{ destRel: KIT_AGENTS_CONTRACT_COPY_REL, srcRel: 'AGENTS.template.md' },
|
|
231
|
+
// workflow/.gitignore(REQ-2026-012): src≠dest. identical이면 제거 대상, differs면 review(사용자 편집 보존). 부재 정상.
|
|
232
|
+
{ destRel: KIT_GITIGNORE.dest, srcRel: KIT_GITIGNORE.src },
|
|
223
233
|
]
|
|
224
234
|
const tool: ToolArtifact[] = toolEntries.map(({ destRel, srcRel }) => {
|
|
225
235
|
const dest = join(targetRoot, destRel)
|
|
@@ -335,6 +345,9 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
335
345
|
const packageJsonDirty = existsSync(pkgAbs) && git.exec(['status', '--porcelain', '--', 'package.json']).length > 0
|
|
336
346
|
const installed = tool.some((t) => t.present) || ambiguous.some((a) => a.present)
|
|
337
347
|
|
|
348
|
+
// Stage B 런타임 제거 안내용(REQ-2026-014 R4) — 선언값 표시만. 값 형태는 검증하지 않는다.
|
|
349
|
+
const commitgateDevDependency = devDeps['commitgate'] ?? null
|
|
350
|
+
|
|
338
351
|
return {
|
|
339
352
|
targetRoot,
|
|
340
353
|
ticketRoot,
|
|
@@ -347,6 +360,7 @@ export function collectFacts(opts: UninstallOptions, run?: GitRunner): Uninstall
|
|
|
347
360
|
evidence,
|
|
348
361
|
info,
|
|
349
362
|
unknownKitFiles,
|
|
363
|
+
commitgateDevDependency,
|
|
350
364
|
}
|
|
351
365
|
}
|
|
352
366
|
|
|
@@ -461,7 +475,11 @@ export function renderPlan(plan: UninstallPlan): string {
|
|
|
461
475
|
L.push(...renderRevertSection(plan))
|
|
462
476
|
L.push('')
|
|
463
477
|
|
|
464
|
-
L.push('## 5.
|
|
478
|
+
L.push('## 5. 런타임 패키지 제거 (Stage B)')
|
|
479
|
+
L.push(...renderRuntimeRemovalSection(plan))
|
|
480
|
+
L.push('')
|
|
481
|
+
|
|
482
|
+
L.push('## 6. 잔여물 경고')
|
|
465
483
|
L.push(' - git은 빈 디렉터리를 추적하지 않습니다. 위 파일을 지운 뒤 `git status`가 clean이어도')
|
|
466
484
|
L.push(` 빈 디렉터리(scripts/ · ${facts.ticketRoot}/ · .claude/ · .cursor/)가 파일시스템에 남을 수 있습니다.`)
|
|
467
485
|
L.push(' - node_modules의 ajv · cross-spawn · tsx 는 다른 패키지도 쓸 수 있어 제거를 권하지 않습니다.')
|
|
@@ -471,6 +489,30 @@ export function renderPlan(plan: UninstallPlan): string {
|
|
|
471
489
|
return L.join('\n')
|
|
472
490
|
}
|
|
473
491
|
|
|
492
|
+
/**
|
|
493
|
+
* **Stage B 런타임 제거 안내**(REQ-2026-014 R4). Stage B에서 실행 코드는 이 저장소가 아니라
|
|
494
|
+
* `node_modules/commitgate`에 있으므로, 런타임 제거는 **package manager의 책임**이다.
|
|
495
|
+
*
|
|
496
|
+
* ⚠️ **문자열로 출력만 한다 — npm을 spawn하지 않는다.** 이 파일의 읽기 전용 불변식이다(`node:fs` 조회 API만 쓰고
|
|
497
|
+
* `node:child_process`를 import하지 않는다). 이 안내를 실제 실행으로 바꾸지 말 것.
|
|
498
|
+
*/
|
|
499
|
+
function renderRuntimeRemovalSection(plan: UninstallPlan): string[] {
|
|
500
|
+
const declared = plan.facts.commitgateDevDependency
|
|
501
|
+
const L: string[] = []
|
|
502
|
+
if (declared !== null) {
|
|
503
|
+
L.push(` package.json 에 devDependencies.commitgate = "${declared}" 가 선언돼 있습니다(Stage B 런타임).`)
|
|
504
|
+
L.push(' 실행 코드(scripts/req/**)는 이 저장소가 아니라 node_modules/commitgate 에 있습니다.')
|
|
505
|
+
L.push(' 런타임 제거는 package manager 가 담당합니다 — 이 명령은 실행하지 않습니다. 직접 실행하세요:')
|
|
506
|
+
L.push(' npm uninstall -D commitgate (pnpm: pnpm remove -D commitgate · yarn: yarn remove commitgate)')
|
|
507
|
+
L.push(' 그러면 devDependencies 선언 · node_modules · lockfile 항목이 함께 정리됩니다.')
|
|
508
|
+
} else {
|
|
509
|
+
L.push(' devDependencies.commitgate 선언이 없습니다 — Stage B 런타임 패키지로 설치된 상태가 아닙니다.')
|
|
510
|
+
L.push(' (Stage A vendored 설치본이거나 `npx commitgate` 일회 실행인 경우입니다 — 아래 npx 캐시 항목 참고.)')
|
|
511
|
+
}
|
|
512
|
+
L.push(' package.json 의 req:* 스크립트는 **수동 정리 후보**입니다 — 이 명령은 고치지 않습니다(2번 항목 참조).')
|
|
513
|
+
return L
|
|
514
|
+
}
|
|
515
|
+
|
|
474
516
|
function renderRevertSection(plan: UninstallPlan): string[] {
|
|
475
517
|
const L: string[] = []
|
|
476
518
|
const { facts } = plan
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commitgate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/next/review-codex/doctor/commit)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
"req:commit": "tsx scripts/req/req-commit.ts",
|
|
37
37
|
"test": "vitest run",
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"smoke": "node scripts/smoke.mjs"
|
|
39
|
+
"smoke": "node scripts/smoke.mjs",
|
|
40
|
+
"verify:overrides": "node scripts/verify-review-overrides.mjs"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
42
43
|
"scripts/req",
|
|
44
|
+
"scripts/verify-review-overrides.mjs",
|
|
43
45
|
"workflow/machine.schema.json",
|
|
44
46
|
"workflow/req.config.schema.json",
|
|
45
47
|
"workflow/review-persona.md",
|
|
@@ -48,7 +50,8 @@
|
|
|
48
50
|
"AGENTS.template.md",
|
|
49
51
|
"req.config.json.sample",
|
|
50
52
|
"README.md",
|
|
51
|
-
"README.en.md"
|
|
53
|
+
"README.en.md",
|
|
54
|
+
"CHANGELOG.md"
|
|
52
55
|
],
|
|
53
56
|
"engines": {
|
|
54
57
|
"node": ">=18.17"
|
package/req.config.json.sample
CHANGED
|
@@ -76,6 +76,10 @@ export interface ReviewRequest {
|
|
|
76
76
|
schemaPath: string
|
|
77
77
|
resumeThreadId: string | null
|
|
78
78
|
cwd: string
|
|
79
|
+
/** REQ-2026-013 P1: codex `-c model=` override. null = 생략(전역 상속). */
|
|
80
|
+
model: string | null
|
|
81
|
+
/** REQ-2026-013 P1: codex `-c model_reasoning_effort=` override. null = 생략. */
|
|
82
|
+
reasoningEffort: string | null
|
|
79
83
|
}
|
|
80
84
|
export interface ReviewResult {
|
|
81
85
|
rawStdout: string
|
|
@@ -109,18 +113,55 @@ const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
|
|
|
109
113
|
// ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId 등)의 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 — P1 수정.
|
|
110
114
|
safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
|
|
111
115
|
|
|
116
|
+
/** unknown → 평범한 객체(배열·null 제외). 스키마 경로 탐색용. */
|
|
117
|
+
function asPlainObject(v: unknown): Record<string, unknown> | null {
|
|
118
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 출력 스키마의 `findings[].severity`를 **P1만** 허용하도록 좁힌다(REQ-2026-018 D2).
|
|
123
|
+
*
|
|
124
|
+
* **왜 출력 스키마에서만 강제하는가(D1)**: 검증 SSOT(`machine.schema.json`)의 enum을 P1로 좁히면 P2/P3를 담은
|
|
125
|
+
* **기존 archive가 전부 invalid**가 된다(하위호환 파괴). 반면 목표는 "리뷰어가 P2를 findings에 **낼 수 없게**"이므로
|
|
126
|
+
* 리뷰어가 실제로 보는 출력 스키마 한 곳만 좁히면 충분하다. 그러면 비차단 지적은 `observations`로 갈 수밖에 없고,
|
|
127
|
+
* `classifyReview`의 "findings 있으면 차단"은 고칠 필요 없이 그대로 옳아진다.
|
|
128
|
+
*
|
|
129
|
+
* **왜 조용히 건너뛰지 않고 throw하는가(D3)**: 건너뛰면 P2가 다시 차단 채널로 들어오는 **정책 구멍**이 열리는데,
|
|
130
|
+
* 그 구멍은 스키마가 깨진 순간에만 열려 아무도 눈치채지 못한다. throw는 리뷰 실패 = 승인 불가 = fail-closed.
|
|
131
|
+
* `machine_schema_version`이 `1.1`로 고정된 MANAGED 파일이므로 정상 경로에서 이 throw는 발생하지 않는다.
|
|
132
|
+
*/
|
|
133
|
+
function narrowFindingsSeverityToP1(schema: Record<string, unknown>): void {
|
|
134
|
+
const properties = asPlainObject(schema.properties)
|
|
135
|
+
const findings = asPlainObject(properties?.findings)
|
|
136
|
+
const items = asPlainObject(findings?.items)
|
|
137
|
+
const itemProps = asPlainObject(items?.properties)
|
|
138
|
+
const severity = asPlainObject(itemProps?.severity)
|
|
139
|
+
if (!severity || !Array.isArray(severity.enum)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'출력 스키마 파생 실패: `properties.findings.items.properties.severity.enum` 경로가 없음 — ' +
|
|
142
|
+
'findings를 P1 전용(차단)으로 강제할 수 없어 중단합니다(REQ-2026-018 D3, fail-closed).',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
severity.enum = ['P1']
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
/**
|
|
113
|
-
* Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
149
|
+
* Codex `--output-schema`용 **strict copy** 파생(REQ-2026-005 + REQ-2026-018). 원본 스키마
|
|
150
|
+
* (`workflow/machine.schema.json`)는 **검증 SSOT로 불변**이고, codex 호출 직전에만 아래 두 가지를 적용한 copy를 파생한다.
|
|
151
|
+
* 응답/archive 검증은 계속 원본으로 한다.
|
|
152
|
+
*
|
|
153
|
+
* 1. **root `required` = `properties` 전체 키**(REQ-2026-005) — OpenAI structured-outputs strict mode는 root required가
|
|
154
|
+
* properties의 모든 키를 포함해야 한다. optional 필드(예: observations)가 있으면 400 invalid_json_schema.
|
|
155
|
+
* 중첩 객체(findings/observations items)는 이미 모든 필드 required + additionalProperties:false라 root만 확장하면 충분.
|
|
156
|
+
* 2. **`findings[].severity` = `["P1"]`**(REQ-2026-018) — 차단 채널에 P2/P3가 들어오지 못하게 구조적으로 막는다.
|
|
157
|
+
* 상세 근거는 `narrowFindingsSeverityToP1` 참조.
|
|
118
158
|
*/
|
|
119
159
|
export function deriveStrictOutputSchema(schemaText: string): string {
|
|
120
160
|
const schema = JSON.parse(schemaText) as { properties?: Record<string, unknown>; required?: string[]; [k: string]: unknown }
|
|
121
161
|
if (schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object') {
|
|
122
162
|
schema.required = Object.keys(schema.properties)
|
|
123
163
|
}
|
|
164
|
+
narrowFindingsSeverityToP1(schema)
|
|
124
165
|
return JSON.stringify(schema)
|
|
125
166
|
}
|
|
126
167
|
|
|
@@ -136,15 +177,22 @@ export function deriveStrictOutputSchema(schemaText: string): string {
|
|
|
136
177
|
*/
|
|
137
178
|
export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner): ReviewerAdapter {
|
|
138
179
|
return {
|
|
139
|
-
review({ prompt, schemaPath, resumeThreadId, cwd }) {
|
|
180
|
+
review({ prompt, schemaPath, resumeThreadId, cwd, model, reasoningEffort }) {
|
|
140
181
|
const tmpDir = mkdtempSync(join(tmpdir(), 'req-codex-'))
|
|
141
182
|
const lastPath = join(tmpDir, 'last.json')
|
|
142
183
|
// 원본(검증 SSOT)을 읽어 strict copy 파생 → temp에 기록 → --output-schema로 전달(archive 검증엔 원본 사용).
|
|
143
184
|
const outputSchemaPath = join(tmpDir, 'output-schema.json')
|
|
144
185
|
writeFileSync(outputSchemaPath, deriveStrictOutputSchema(readFileSync(schemaPath, 'utf8')), 'utf8')
|
|
186
|
+
// REQ-2026-013 P1: 리뷰 모델·추론강도 override(D2·D2-1). null이면 생략(전역 상속).
|
|
187
|
+
// 값은 TOML 문자열 리터럴(`sandbox_mode="read-only"`와 동형). 주입 안전은 스키마 제약(model=slug 패턴·effort=enum)이
|
|
188
|
+
// `"`·개행을 입력단에서 차단하므로 조립부 escaping 불필요 — 이 안전은 config.ts CONFIG_SCHEMA에 의존한다.
|
|
189
|
+
const overrideArgs: string[] = []
|
|
190
|
+
if (model) overrideArgs.push('-c', `model="${model}"`)
|
|
191
|
+
if (reasoningEffort) overrideArgs.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
|
|
192
|
+
// exec·resume 양쪽에 동일 주입(codex `-c`는 두 서브커맨드 모두 받음 — 실측).
|
|
145
193
|
const args = resumeThreadId
|
|
146
|
-
? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
|
|
147
|
-
: ['exec', '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
|
|
194
|
+
? ['exec', 'resume', resumeThreadId, '-c', 'sandbox_mode="read-only"', ...overrideArgs, '--json', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
|
|
195
|
+
: ['exec', ...overrideArgs, '--json', '--sandbox', 'read-only', '--output-schema', outputSchemaPath, '--output-last-message', lastPath, '-']
|
|
148
196
|
const rawStdout = run(args, prompt, cwd)
|
|
149
197
|
const threadId = resumeThreadId ?? parseThreadId(rawStdout)
|
|
150
198
|
const lastMessage = existsSync(lastPath) ? readFileSync(lastPath, 'utf8') : ''
|
|
@@ -21,6 +21,12 @@ 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
|
|
@@ -32,6 +38,10 @@ export interface RawConfig {
|
|
|
32
38
|
packageManager?: PackageManager
|
|
33
39
|
granularityMaxFiles?: number
|
|
34
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
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
|
|
@@ -45,6 +55,8 @@ export interface ResolvedConfig {
|
|
|
45
55
|
packageManager: PackageManager
|
|
46
56
|
granularityMaxFiles: number
|
|
47
57
|
designDocs: DesignDocs
|
|
58
|
+
reviewModel: string | null
|
|
59
|
+
reviewReasoningEffort: ReviewReasoningEffort | null
|
|
48
60
|
// 파생(절대경로)
|
|
49
61
|
workflowDirAbs: string
|
|
50
62
|
schemaPathAbs: string
|
|
@@ -87,6 +99,11 @@ export const DEFAULTS = {
|
|
|
87
99
|
packageManager: 'pnpm' as PackageManager,
|
|
88
100
|
granularityMaxFiles: 8,
|
|
89
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,
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
|
|
@@ -104,6 +121,10 @@ export const CONFIG_SCHEMA = {
|
|
|
104
121
|
branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
|
|
105
122
|
packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
|
|
106
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] },
|
|
107
128
|
designDocs: {
|
|
108
129
|
type: 'object',
|
|
109
130
|
additionalProperties: false,
|
|
@@ -184,6 +205,10 @@ export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): R
|
|
|
184
205
|
packageManager: raw.packageManager ?? DEFAULTS.packageManager,
|
|
185
206
|
granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
|
|
186
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,
|
|
187
212
|
}
|
|
188
213
|
|
|
189
214
|
// repo-내부 자원(ticketRoot·schemaPath·reviewPersonaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable).
|
|
@@ -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
|