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
package/bin/init.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
/**
|
|
3
|
-
* commitgate init — AI REQ workflow(커밋 게이트) kit을 대상 git repo에 설치(Stage
|
|
3
|
+
* commitgate init — AI REQ workflow(커밋 게이트) kit을 대상 git repo에 설치(**Stage B: 런타임 패키지 모델**, REQ-2026-014).
|
|
4
|
+
*
|
|
5
|
+
* Stage B의 핵심: **실행 코드와 런타임 의존성은 대상에 복사·주입하지 않는다.** 그것들은 설치된 패키지
|
|
6
|
+
* (`node_modules/commitgate`)에만 있고, 대상에는 `req:* = commitgate <verb>` 스크립트와 **거버넌스·감사 데이터**만 남는다.
|
|
7
|
+
* (Stage A = 과거의 vendored 스캐폴딩 모델. `REQ_SCRIPTS`가 그 서명 기록이고 `commitgate migrate`가 전환을 담당한다.)
|
|
4
8
|
*
|
|
5
9
|
* 동작(멱등·비파괴):
|
|
6
10
|
* 1. 대상 repo 감사(git repo·package.json 필수 → 없으면 fail-closed throw)
|
|
7
|
-
* 2.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* 2. **Stage B 전제(순서가 계약)**: `detectStageA`(D19 — Stage A 설치본이면 migrate로 보냄) →
|
|
12
|
+
* `commitgateDeclared`(D14 — `devDependencies.commitgate` 키 없으면 선행 설치 안내). 둘 다 preflight = 무쓰기 실패.
|
|
13
|
+
* 3. `KIT_COPY_RELPATHS`(스키마 2종 + review-persona.md) 복사(기존 파일은 --force 없으면 스킵).
|
|
14
|
+
* **`scripts/req/**` 는 복사하지 않는다**(R3 — 패키지에서 실행).
|
|
15
|
+
* 4. `req.config.json` 시드(부재 시): 감지한 packageManager + handoffPath:null(프로젝트별 값은 코어 DEFAULTS가 아니라 config에서 흡수)
|
|
16
|
+
* 5. 대상 `package.json`에 `req:* = commitgate <verb>` 주입(기존 키 미덮어씀). **devDeps는 주입하지 않는다**(R3).
|
|
17
|
+
* 6. `AGENTS.md` 부재 시 템플릿 생성(있으면 스킵 — Codex 계약 보존)
|
|
18
|
+
* 7. 에이전트 진입점(.claude/skills·.claude/commands·.cursor/rules) 복사 + `CLAUDE.md` 부재 시 생성 (--no-agent-entrypoints로 생략)
|
|
12
19
|
*
|
|
13
|
-
* 코어 승인 바인딩·staged tree 검증은 건드리지
|
|
20
|
+
* 코어 승인 바인딩·staged tree 검증은 건드리지 않는다. 프로젝트 차이는 req.config.json에서만 흡수.
|
|
14
21
|
*/
|
|
15
22
|
import {
|
|
16
23
|
existsSync,
|
|
@@ -19,14 +26,17 @@ import {
|
|
|
19
26
|
mkdirSync,
|
|
20
27
|
readdirSync,
|
|
21
28
|
statSync,
|
|
29
|
+
lstatSync,
|
|
22
30
|
copyFileSync,
|
|
23
31
|
realpathSync,
|
|
24
32
|
} from 'node:fs'
|
|
25
33
|
import { execFileSync } from 'node:child_process'
|
|
26
|
-
import {
|
|
34
|
+
import { createHash } from 'node:crypto'
|
|
35
|
+
import { resolve, join, dirname, relative, isAbsolute } from 'node:path'
|
|
27
36
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
28
37
|
import { loadConfig, stripBom, DEFAULT_REVIEW_PERSONA_RELPATH, type PackageManager } from '../scripts/req/lib/config'
|
|
29
38
|
import { createGitAdapter, type GitRunner } from '../scripts/req/lib/adapters'
|
|
39
|
+
import { parseStatusZ, entryPaths, STATUS_Z_ARGS, type StatusEntry } from '../scripts/req/lib/porcelain'
|
|
30
40
|
import * as semver from 'semver'
|
|
31
41
|
|
|
32
42
|
/** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
|
|
@@ -73,6 +83,35 @@ export const KIT_AGENT_ENTRYPOINTS = [
|
|
|
73
83
|
export const KIT_CLAUDE_TEMPLATE_REL = 'templates/CLAUDE.template.md'
|
|
74
84
|
export const KIT_CLAUDE_DEST_REL = 'CLAUDE.md'
|
|
75
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `workflow/.gitignore` — 티켓 내부 scratch(codex-response.json 등)를 무시하는 **중첩 .gitignore** (REQ-2026-012).
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ 세 가지가 이 파일을 특별하게 만든다:
|
|
90
|
+
* 1. **`src ≠ dest`**(`KIT_AGENT_ENTRYPOINTS`처럼). npm이 tarball에서 `.gitignore` 이름을 제외하므로
|
|
91
|
+
* 패키지엔 `templates/workflow.gitignore`(비-점 이름)로 두고 `workflow/.gitignore`로 복사한다(설계 D5·D11).
|
|
92
|
+
* 2. `.gitignore`는 git 관례상 **사용자 소유**다 → `AGENTS.md`/`CLAUDE.md`와 동일: **부재 시에만** 생성,
|
|
93
|
+
* `--force`로도 덮지 않는다(설계 D12). `add()`(=`KIT_COPY_RELPATHS`)를 타지 않는다 — 거긴 `--force`가 덮는다.
|
|
94
|
+
* 3. **`--no-agent-entrypoints`와 무관**(설계 D13). 그 옵션은 `.claude/`·`.cursor/`·`CLAUDE.md`만 생략한다.
|
|
95
|
+
*/
|
|
96
|
+
export const KIT_GITIGNORE = { src: 'templates/workflow.gitignore', dest: 'workflow/.gitignore' } as const
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* companion skills (REQ-2026-020). Builder용 방법론 instruction asset — 실행 코드가 아니다.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ `CONTRACT_POINTER_RELPATHS`에 **섞지 않는다**(설계 D6). 그 상수는 "계약 포인터"라는 의미를 갖고,
|
|
102
|
+
* companion skills는 **없어도 핵심 워크플로가 동일하게 동작**한다(R10). 의미가 다르므로 별도 목록이다.
|
|
103
|
+
* gitignore WARN/`--strict` 동작은 같은 축에서 받되, 기존 계약 포인터 경고를 약화시키지 않는다.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ `add()`를 타지 않는다(설계 D3). `add()`의 skip은 `existsSync && !force`라 **`--force`가 사용자 수정을 덮는다**.
|
|
106
|
+
* 스킬은 사용자가 고치라고 만든 자산이므로 `workflow/.gitignore`(D12)와 같은 seed-once 축으로 간다.
|
|
107
|
+
*/
|
|
108
|
+
export const KIT_COMPANION_SKILLS = [
|
|
109
|
+
{ src: 'skills/commitgate-discovery/SKILL.md', dest: '.claude/skills/commitgate-discovery/SKILL.md' },
|
|
110
|
+
{ src: 'skills/commitgate-tdd/SKILL.md', dest: '.claude/skills/commitgate-tdd/SKILL.md' },
|
|
111
|
+
{ src: 'skills/commitgate-diagnosing-bugs/SKILL.md', dest: '.claude/skills/commitgate-diagnosing-bugs/SKILL.md' },
|
|
112
|
+
{ src: 'skills/commitgate-research/SKILL.md', dest: '.claude/skills/commitgate-research/SKILL.md' },
|
|
113
|
+
] as const
|
|
114
|
+
|
|
76
115
|
/** `AGENTS.md`가 CommitGate 계약인지 판별하는 마커. 진입점 포인터들이 이 마커로 SSOT를 확인한다. */
|
|
77
116
|
export const AGENTS_CONTRACT_MARKER = '<!-- commitgate:contract -->'
|
|
78
117
|
|
|
@@ -88,7 +127,47 @@ export const AGENTS_CONTRACT_MARKER = '<!-- commitgate:contract -->'
|
|
|
88
127
|
*/
|
|
89
128
|
export const KIT_AGENTS_CONTRACT_COPY_REL = 'AGENTS.commitgate.md'
|
|
90
129
|
|
|
91
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* pm별 lockfile 이름. `detectPackageManager`(아래)와 **같은 축**이다.
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ lockfile을 stage 목록에서 빠뜨리면 설치분을 커밋한 뒤에도 `M pnpm-lock.yaml`이 남아
|
|
134
|
+
* `req:new --run`이 clean-tree 게이트에서 죽는다(REQ-2026-011 design R3 P2).
|
|
135
|
+
*
|
|
136
|
+
* ⚠️ Stage B(REQ-2026-014)에서 근거가 바뀌었다: init은 더 이상 devDeps를 주입하지 않는다. 대신 **선행 `npm i -D commitgate`**
|
|
137
|
+
* 가 `package.json`+lockfile을 이미 바꿔 놓는다(D14가 그 선언을 요구한다). 즉 lockfile은 여전히 설치 커밋에 담겨야 한다 —
|
|
138
|
+
* 갱신 주체가 "init 뒤의 install"에서 "init 앞의 install"로 옮겨졌을 뿐이다.
|
|
139
|
+
*/
|
|
140
|
+
export const LOCKFILE: Record<PackageManager, string> = {
|
|
141
|
+
npm: 'package-lock.json',
|
|
142
|
+
pnpm: 'pnpm-lock.yaml',
|
|
143
|
+
yarn: 'yarn.lock',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* **계약 포인터** — 이것들이 git에 추적되지 않으면 설치 목적(팀·CI가 계약을 로드)이 조용히 무너진다.
|
|
148
|
+
* 그래서 gitignore에 걸리면 WARN하고 `--strict`에서 중단한다.
|
|
149
|
+
*
|
|
150
|
+
* lockfile 같은 나머지 산출물은 무시되더라도 정당한 repo 정책일 수 있으므로 경고하지 않고
|
|
151
|
+
* stage 목록에서 조용히 뺀다(REQ-2026-011 DEC-011-10).
|
|
152
|
+
*/
|
|
153
|
+
export const CONTRACT_POINTER_RELPATHS: readonly string[] = [
|
|
154
|
+
...KIT_AGENT_ENTRYPOINTS.map((e) => e.dest),
|
|
155
|
+
'AGENTS.md',
|
|
156
|
+
KIT_CLAUDE_DEST_REL,
|
|
157
|
+
KIT_AGENTS_CONTRACT_COPY_REL,
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* **Stage A**(vendored scaffold)가 주입하던 req:* 스크립트 값 — 이제 **주입하지 않는다**(REQ-2026-014 R3).
|
|
162
|
+
*
|
|
163
|
+
* 이 상수는 **Stage A 서명 SSOT**로 남는다. 세 소비자가 정확한 바이트 일치를 판정한다:
|
|
164
|
+
* - `detectStageA`(아래 D19) — plain init이 Stage A 프로젝트를 조용히 혼합 설치로 만들지 않게 막는다.
|
|
165
|
+
* - `bin/uninstall.ts`(`REQ_SCRIPTS` 순회, `cur === injected`) — 기존 Stage A 설치본 분류.
|
|
166
|
+
* - `bin/migrate.ts`(Phase 3) — **정확히 이 값일 때만** `commitgate <verb>`로 전환(사용자 정의 값 미덮어씀).
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ 값을 바꾸면 기존 Stage A 설치본을 더 이상 인식하지 못한다. 이것은 "우리가 무엇을 주입하는가"가 아니라
|
|
169
|
+
* **"과거에 무엇을 주입했는가"** 의 기록이다.
|
|
170
|
+
*/
|
|
92
171
|
export const REQ_SCRIPTS: Record<string, string> = {
|
|
93
172
|
'req:new': 'tsx scripts/req/req-new.ts',
|
|
94
173
|
'req:review-codex': 'tsx scripts/req/review-codex.ts',
|
|
@@ -97,6 +176,50 @@ export const REQ_SCRIPTS: Record<string, string> = {
|
|
|
97
176
|
'req:commit': 'tsx scripts/req/req-commit.ts',
|
|
98
177
|
}
|
|
99
178
|
|
|
179
|
+
/**
|
|
180
|
+
* **Stage B**가 주입하는 req:* 스크립트 값 — 로컬 패키지 bin을 dispatch한다(REQ-2026-014 R1/R2).
|
|
181
|
+
* 키 집합은 `REQ_SCRIPTS`에서 파생해 SSOT를 하나로 유지한다(값만 다르고 키는 같다).
|
|
182
|
+
*
|
|
183
|
+
* `npm run req:new -- <args>` → `commitgate req:new <args>` → `node_modules/.bin/commitgate`
|
|
184
|
+
* → `bin/commitgate.mjs`가 verb를 `scripts/req/req-new.ts`(**패키지 안**)로 dispatch.
|
|
185
|
+
*/
|
|
186
|
+
export const STAGE_B_REQ_SCRIPTS: Record<string, string> = Object.fromEntries(
|
|
187
|
+
Object.keys(REQ_SCRIPTS).map((k) => [k, `commitgate ${k}`]),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* **Stage A 서명 감지(D19 — REQ-2026-014 R7)**. 감지된 근거를 반환(없으면 `null`).
|
|
192
|
+
*
|
|
193
|
+
* 왜 필요한가: 스크립트 주입은 `if (!(k in scripts))`라 **기존 값을 덮지 않는다**. 따라서 Stage A 프로젝트에
|
|
194
|
+
* plain init을 돌리면 `req:*`가 vendored `tsx scripts/req/*.ts`인 채로 남아 **런타임은 계속 vendored인데
|
|
195
|
+
* 사용자는 Stage B라 믿는 조용한 혼합 설치**가 된다. 그래서 fail-closed로 막고 `migrate`로 보낸다.
|
|
196
|
+
*
|
|
197
|
+
* 🔴 **호출 순서가 계약이다: 이 검사는 `commitgateDeclared`(D14)보다 반드시 먼저 돌아야 한다.**
|
|
198
|
+
* Stage A 설치본에는 `devDependencies.commitgate`가 **없다** — Stage A는 `npx commitgate`로 설치되고
|
|
199
|
+
* `REQ_DEV_DEPS`는 `ajv`·`cross-spawn`·`tsx`만 주입하지 `commitgate` 자신을 넣지 않는다. 순서를 뒤집으면
|
|
200
|
+
* Stage A 사용자는 **항상 D14에서 먼저 죽어** "npm install -D commitgate"라는 엉뚱한 안내를 받고
|
|
201
|
+
* `commitgate migrate` 안내에 **영원히 도달하지 못한다**(design r20 P1). 회귀 테스트가 이 순서를 고정한다.
|
|
202
|
+
*/
|
|
203
|
+
export function detectStageA(targetRoot: string, scripts: Record<string, string>): string | null {
|
|
204
|
+
for (const [k, injected] of Object.entries(REQ_SCRIPTS)) if (scripts[k] === injected) return `package.json#scripts.${k}`
|
|
205
|
+
if (existsSync(join(targetRoot, KIT_SOURCE_DIR_REL))) return `${KIT_SOURCE_DIR_REL}/`
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* **선행 설치 확인(D14, 축소 — REQ-2026-014 R6)**. `devDependencies.commitgate` **키 존재만** 본다.
|
|
211
|
+
*
|
|
212
|
+
* 🔴 **값의 형태를 검증하지 않는다.** `npm install -D <packed tarball>`은 `"commitgate": "file:../x.tgz"`를 쓴다 —
|
|
213
|
+
* semver range가 아니다. `link:`·`workspace:`·git URL도 정당한 설치 형태다. 값을 range로 검증하면
|
|
214
|
+
* packed-tarball smoke가 **스스로 실패**한다.
|
|
215
|
+
*
|
|
216
|
+
* 범위 밖(REQ-2026-014 §4 비목표): `node_modules/commitgate` 존재 확인·실행 패키지 realpath 동일성·
|
|
217
|
+
* lockfile 해결 버전 대조. 설치 **완료** 보장은 package manager의 책임이고, 여기 계약은 "설치 의도가 선언됐는가"다.
|
|
218
|
+
*/
|
|
219
|
+
export function commitgateDeclared(devDeps: Record<string, string>): boolean {
|
|
220
|
+
return Object.prototype.hasOwnProperty.call(devDeps, 'commitgate')
|
|
221
|
+
}
|
|
222
|
+
|
|
100
223
|
/** cross-spawn 주입 spec(= 보안 하한 SSOT). 진단(#1)과 주입이 이 값을 공유. */
|
|
101
224
|
const CROSS_SPAWN_SPEC = '^7.0.6'
|
|
102
225
|
|
|
@@ -116,10 +239,37 @@ export interface InitOptions {
|
|
|
116
239
|
noAgentEntrypoints?: boolean
|
|
117
240
|
}
|
|
118
241
|
|
|
242
|
+
/**
|
|
243
|
+
* 설치 **전** 워킹트리 상태 3분류(DEC-011-11). 쓰기 전에 찍어야 CommitGate 산출물과 섞이지 않는다.
|
|
244
|
+
*
|
|
245
|
+
* - `staged`: 인덱스 ≠ HEAD. `git commit`은 인덱스 **전체**를 담으므로, 안내가 `git add`를 아무리
|
|
246
|
+
* 명시해도 이 변경들이 설치 커밋에 함께 들어간다.
|
|
247
|
+
* - `overlapping`: 설치 산출물과 겹치는 **tracked·unstaged** 변경. init이 같은 파일을 수정하므로
|
|
248
|
+
* 사용자 변경과 설치 변경을 사후 분리할 수 없다(예: 이미 고쳐 둔 `package.json`).
|
|
249
|
+
* - `unrelated`: 나머지 dirty. 인덱스에 없으므로 설치 커밋에 섞이지 않는다 — 커밋 뒤 `git stash -u`로 치우면 된다.
|
|
250
|
+
*
|
|
251
|
+
* untracked 산출물(`?? package.json` 등)은 어디에도 담지 않는다. 파일 전체가 신규라 분리할 것이 없다.
|
|
252
|
+
*/
|
|
253
|
+
export interface PreexistingDirty {
|
|
254
|
+
staged: string[]
|
|
255
|
+
overlapping: string[]
|
|
256
|
+
unrelated: string[]
|
|
257
|
+
}
|
|
258
|
+
|
|
119
259
|
export interface InitResult {
|
|
120
260
|
targetRoot: string
|
|
121
261
|
copied: string[] // repo-상대 경로(신규 복사)
|
|
122
262
|
skipped: string[] // repo-상대 경로(이미 존재 → 미덮어씀)
|
|
263
|
+
/** init이 만들거나 수정하는 repo-상대 경로 전수. ignore 검사와 설치 후 stage 목록이 **공유**하는 SSOT. */
|
|
264
|
+
artifacts: string[]
|
|
265
|
+
/** `artifacts` 중 gitignore에 걸리고 **untracked**인 것(= `git add`가 fatal인 것). */
|
|
266
|
+
gitIgnoredArtifacts: string[]
|
|
267
|
+
/** devDeps를 주입해 `<pm> install`이 갱신할 lockfile 경로. 주입이 없으면 null. */
|
|
268
|
+
lockfileRel: string | null
|
|
269
|
+
/** `node_modules`가 ignore도 track도 되지 않아 `<pm> install` 후 워킹트리를 dirty하게 만드는가. */
|
|
270
|
+
nodeModulesWillDirty: boolean
|
|
271
|
+
/** 설치 **전** 워킹트리 상태(쓰기 전 스냅샷). */
|
|
272
|
+
preexistingDirty: PreexistingDirty
|
|
123
273
|
configAction: 'created' | 'merged' | 'unchanged' // req.config.json: 신규 생성 / 누락키 병합 / 변경 없음
|
|
124
274
|
configKeysAdded: string[] // 병합 시 추가된 키(handoffPath·packageManager)
|
|
125
275
|
packageJsonAdded: string[] // 추가된 script/devDep 키
|
|
@@ -131,6 +281,11 @@ export interface InitResult {
|
|
|
131
281
|
agentsMarkerMissing: boolean // 기존 AGENTS.md에 commitgate 계약 마커가 없어 경고했는가
|
|
132
282
|
agentsContractCopyCreated: boolean // 마커 부재 시 AGENTS.commitgate.md(계약 템플릿 사본)를 놓았는가
|
|
133
283
|
agentEntrypointsSkipped: boolean // --no-agent-entrypoints
|
|
284
|
+
workflowGitignoreCreated: boolean // workflow/.gitignore를 새로 만들었는가(있으면 보존, --force로도 미덮어씀)
|
|
285
|
+
/** 기존 workflow/.gitignore가 kit과 달라 보존했는가(사용자 정책 파일). stash 안내에서 제외 대상(phase-2 리뷰 P4). */
|
|
286
|
+
workflowGitignoreUserDiffers: boolean
|
|
287
|
+
/** workflow/.gitignore가 ignored∧untracked라 설치 커밋에 못 담기고 fresh clone에 scratch 정책이 없다(phase-2 리뷰 P2). */
|
|
288
|
+
workflowGitignorePolicyAtRisk: boolean
|
|
134
289
|
}
|
|
135
290
|
|
|
136
291
|
/**
|
|
@@ -163,6 +318,272 @@ export function assertGitWorkTree(targetRoot: string, run?: GitRunner): void {
|
|
|
163
318
|
throw new Error(`대상이 git repo 최상위가 아님: ${targetRoot} (top-level=${topLevel}) — repo 루트에서 실행.`)
|
|
164
319
|
}
|
|
165
320
|
|
|
321
|
+
/**
|
|
322
|
+
* `git status --porcelain` 한 줄을 `{index, worktree, path}`로 분해. rename(`R old -> new`)은 새 경로를 쓴다.
|
|
323
|
+
* 파싱 불가면 null(무시) — 진단 목적이라 fail-closed로 만들 이유가 없다.
|
|
324
|
+
*/
|
|
325
|
+
// unquoteGitPath·parsePorcelainLine은 삭제(REQ-2026-012). `-z`는 인용을 하지 않으므로 되돌릴 게 없다 —
|
|
326
|
+
// 파싱은 lib/porcelain의 parseStatusZ 하나로 통일한다.
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 설치 전 워킹트리를 3분류(DEC-011-11). **순수 함수** — status 엔트리와 산출물 목록만 받는다.
|
|
330
|
+
* 입력은 `parseStatusZ`(= `git status --porcelain=v1 -z --untracked-files=all`)의 산출.
|
|
331
|
+
*/
|
|
332
|
+
export function classifyPreexistingDirty(entries: readonly StatusEntry[], artifacts: readonly string[]): PreexistingDirty {
|
|
333
|
+
const artifactSet = new Set(artifacts)
|
|
334
|
+
const out: PreexistingDirty = { staged: [], overlapping: [], unrelated: [] }
|
|
335
|
+
for (const e of entries) {
|
|
336
|
+
// 인덱스에 올라간 변경(`?`는 untracked 표식이라 staged가 아니다) → 커밋이 삼킨다.
|
|
337
|
+
if (e.index !== ' ' && e.index !== '?') {
|
|
338
|
+
out.staged.push(e.path)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (artifactSet.has(e.path)) {
|
|
342
|
+
// tracked + unstaged 수정만 문제다. untracked 산출물은 파일 전체가 신규라 분리할 것이 없다.
|
|
343
|
+
if (e.index === ' ' && e.worktree !== ' ' && e.worktree !== '?') out.overlapping.push(e.path)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
out.unrelated.push(e.path)
|
|
347
|
+
}
|
|
348
|
+
return out
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* `paths` 중 **gitignore에 걸리고 untracked인** 것(= `git add <path>`가 fatal인 것)만 반환한다.
|
|
353
|
+
*
|
|
354
|
+
* ⚠️ `git check-ignore`만으로 판정하면 안 된다(DEC-011-10). 그 명령은 **인덱스를 보지 않으므로**,
|
|
355
|
+
* ignore 규칙에 걸리지만 이미 tracked인 파일(강제 add된 lockfile 등)까지 "무시됨"으로 보고한다.
|
|
356
|
+
* 그런 파일은 `git add`가 정상 동작하므로 제외 대상이 아니다.
|
|
357
|
+
*
|
|
358
|
+
* exit 코드: `0`=무시됨, `1`=무시 안 됨, `128`=오류. **128은 "무시 안 됨"으로 취급**한다 —
|
|
359
|
+
* git 버전차·비정상 상태 때문에 설치를 막는 오탐을 만들지 않는다.
|
|
360
|
+
* 파일이 없어도 규칙 매칭이므로 **쓰기 전에** 판정할 수 있다(preflight 배치의 전제).
|
|
361
|
+
*/
|
|
362
|
+
function gitIsIgnored(targetRoot: string, p: string): boolean {
|
|
363
|
+
try {
|
|
364
|
+
execFileSync('git', ['check-ignore', '-q', '--', p], { cwd: targetRoot, stdio: 'ignore' })
|
|
365
|
+
return true
|
|
366
|
+
} catch {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function gitIsTracked(targetRoot: string, p: string): boolean {
|
|
372
|
+
try {
|
|
373
|
+
const out = execFileSync('git', ['ls-files', '--', p], {
|
|
374
|
+
cwd: targetRoot,
|
|
375
|
+
encoding: 'utf8',
|
|
376
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
377
|
+
})
|
|
378
|
+
return out.trim().length > 0
|
|
379
|
+
} catch {
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function findIgnoredArtifacts(targetRoot: string, paths: readonly string[]): string[] {
|
|
385
|
+
return paths.filter((p) => gitIsIgnored(targetRoot, p) && !gitIsTracked(targetRoot, p))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `destRel`의 **모든 상위 컴포넌트**를 `lstat`으로 검사해 symlink·비-디렉터리를 거부한다(phase-2 리뷰 P1).
|
|
390
|
+
*
|
|
391
|
+
* ⚠️ `realpath`는 링크를 **따라가므로** 저장소 **내부**를 가리키는 symlink를 통과시키고(git이 그 아래를
|
|
392
|
+
* 정상 stage하지 못한다), dangling symlink는 ENOENT라 "부재"로 오인한다. 그래서 컴포넌트별 `lstat`으로:
|
|
393
|
+
* - 실제 부재(ENOENT) → 그 하위는 targetRoot 안에 새로 생성됨(안전) → 통과.
|
|
394
|
+
* - symlink/junction → 목적지(내부·외부·dangling) **무관하게 거부**. `copyFileSync`가 밖에 쓰거나 git이 못 읽는다.
|
|
395
|
+
* - 디렉터리 아님(파일·특수) → 하위를 만들 수 없다 → 거부.
|
|
396
|
+
* - 그 밖의 오류(EACCES 등) → fail-closed throw.
|
|
397
|
+
* `workflow/.gitignore`로 preflight에서 돌리면 같은 `workflow/`를 쓰는 스키마 복사도 함께 보호된다.
|
|
398
|
+
*/
|
|
399
|
+
function assertConfinedDest(targetRoot: string, destRel: string): void {
|
|
400
|
+
const segs = destRel.split('/')
|
|
401
|
+
let cur = targetRoot
|
|
402
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
403
|
+
// 마지막(파일명) 제외한 상위 컴포넌트만
|
|
404
|
+
cur = join(cur, segs[i] as string)
|
|
405
|
+
let st: ReturnType<typeof lstatSync>
|
|
406
|
+
try {
|
|
407
|
+
st = lstatSync(cur)
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return // 실제 부재 → 하위를 targetRoot 안에 새로 만든다(안전)
|
|
410
|
+
throw new Error(`${destRel} 상위 경로 확인 실패(${(e as Error).message}) — fail-closed.`)
|
|
411
|
+
}
|
|
412
|
+
if (st.isSymbolicLink())
|
|
413
|
+
throw new Error(`${destRel} 의 상위 '${segs[i]}' 가 symlink/junction 입니다 — confinement 위반(밖에 쓰거나 git이 못 읽음).`)
|
|
414
|
+
if (!st.isDirectory()) throw new Error(`${destRel} 의 상위 '${segs[i]}' 가 디렉터리가 아닙니다 — confinement 위반.`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 쓰기 dest의 **confinement + leaf 상태를 한 번에** 판정한다 (REQ-2026-024 D1·D2).
|
|
420
|
+
*
|
|
421
|
+
* @returns 일반 파일이면 그 `Stats`, **실제 부재(ENOENT)면 `null`**. 그 밖은 전부 throw(fail-closed).
|
|
422
|
+
*
|
|
423
|
+
* 🔴 **`existsSync`를 부재 판정에 쓰면 안 된다.** 실측(REQ-2026-024 §2)으로 확인된 4가지 탈출:
|
|
424
|
+
* - **A. dangling leaf**: `existsSync`가 **false** → "부재" 오판 → 쓰기가 링크를 따라 대상 **밖에 생성**.
|
|
425
|
+
* - **B. ancestor dir symlink**: `existsSync`·`statSync`가 링크를 **따라가** 통과 → `mkdirSync`+쓰기가 밖에.
|
|
426
|
+
* - **C. live leaf + `--force`**: skip 조건이 `existsSync && !force`라 force면 `copyFileSync`가 **외부를 덮어씀**.
|
|
427
|
+
* - **D. live leaf + `writeFileSync`**: 기존 파일로 읽고 병합 → 쓰기가 링크 따라 **외부를 수정**.
|
|
428
|
+
* A만 막으면 C·D가 남는다 — 그쪽은 생성이 아니라 **기존 외부 파일 파괴**라 더 무겁다.
|
|
429
|
+
*
|
|
430
|
+
* 🔴 **반환값이 곧 부재 판정이다**(D2). 호출부가 `existsSync`를 쓸 이유를 없앤다 —
|
|
431
|
+
* 검사를 빼먹으려면 판정도 포기해야 하므로 **드리프트가 구조적으로 불가능**하다.
|
|
432
|
+
* "쓰기 전에 검사를 호출한다"는 규율은 이미 실패했다: `workflow/.gitignore`·companion에는 붙고
|
|
433
|
+
* 나머지 7종에는 안 붙은 것이 이번 결함이다.
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ 루트 직속 dest(`AGENTS.md`·`package.json` 등)에서 `assertConfinedDest`는 **무동작**이다
|
|
436
|
+
* (`segs.length - 1 === 0`). 정상이다 — 그 상위는 `targetRoot` 자신이고 `runInit`이 이미 검사한다.
|
|
437
|
+
* 그 경우 leaf `lstat`이 방어를 맡는다.
|
|
438
|
+
*
|
|
439
|
+
* ⚠️ **TOCTOU는 막지 않는다.** 이 판정과 실제 쓰기 사이에 경로가 바뀌는 경쟁은 남는다
|
|
440
|
+
* (Node에 `O_NOFOLLOW` 원자 API가 없다). **협조적 사용자의 우발적 symlink**를 막는 것이다.
|
|
441
|
+
*/
|
|
442
|
+
function statWritableDest(targetRoot: string, destRel: string): ReturnType<typeof lstatSync> | null {
|
|
443
|
+
assertConfinedDest(targetRoot, destRel) // 상위 컴포넌트 전부 lstat (leaf는 아래에서)
|
|
444
|
+
const abs = join(targetRoot, destRel)
|
|
445
|
+
let st: ReturnType<typeof lstatSync>
|
|
446
|
+
try {
|
|
447
|
+
st = lstatSync(abs)
|
|
448
|
+
} catch (e) {
|
|
449
|
+
// ⚠️ **ENOENT만** 부재로 인정한다 — EACCES·ELOOP를 부재로 삼키면 apply에서 늦게 실패해 부분 설치가 된다(롤백 0줄).
|
|
450
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
451
|
+
throw new Error(`${destRel} 상태 확인 실패(${(e as Error).message}) — fail-closed.`)
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
if (!st.isFile())
|
|
455
|
+
throw new Error(`${destRel} 가 일반 파일이 아닙니다(symlink·디렉터리·특수파일) — 그 경로를 옮기고 재시도하십시오.`)
|
|
456
|
+
return st
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** companion skills 계획 결과(순수 판정 — 쓰기 없음). */
|
|
460
|
+
export interface CompanionSkillsPlan {
|
|
461
|
+
/** 부재(lstat ENOENT)라 새로 만들 것. */
|
|
462
|
+
create: { srcAbs: string; destRel: string }[]
|
|
463
|
+
/** 이미 있고 **바이트 동일** = 직전 실행이 깐 것 → 설치 커밋 stage 목록에 편입. */
|
|
464
|
+
ownedSkips: string[]
|
|
465
|
+
/** 이미 있고 다름 = 사용자가 고친 것 → 보존(`--force`로도 안 덮음). */
|
|
466
|
+
userDiffers: string[]
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* companion skills seed-once 판정 + confinement preflight (설계 D3·D4).
|
|
471
|
+
* **순수 판정이라 아무것도 쓰지 않는다** — `--dry-run`도 이 검사를 그대로 받는다(쓰기 0건, 그러나 symlink면 실패).
|
|
472
|
+
*
|
|
473
|
+
* `workflow/.gitignore`(D12)와 같은 축이고, 다른 점은 **dest가 4개**라는 것뿐이다:
|
|
474
|
+
*
|
|
475
|
+
* 🔴 **각 최종 `SKILL.md` dest를 개별로 넘긴다.** skills 루트(`.claude/skills`)만 넘기면
|
|
476
|
+
* `assertConfinedDest`의 `i < segs.length - 1` 루프가 **마지막 컴포넌트를 검사하지 않아**
|
|
477
|
+
* `commitgate-<name>`이 외부 symlink여도 통과하고, `copyFileSync`가 대상 **밖에** 쓴다.
|
|
478
|
+
*
|
|
479
|
+
* confinement + leaf 판정은 `statWritableDest`가 한다 — 이 함수의 본문이 그 헬퍼의 출처다
|
|
480
|
+
* (REQ-2026-024 D1: 같은 규칙이 두 벌 있었고, 나머지 7종에 안 붙은 것이 결함이었다).
|
|
481
|
+
*/
|
|
482
|
+
export function planCompanionSkills(targetRoot: string): CompanionSkillsPlan {
|
|
483
|
+
const create: { srcAbs: string; destRel: string }[] = []
|
|
484
|
+
const ownedSkips: string[] = []
|
|
485
|
+
const userDiffers: string[] = []
|
|
486
|
+
for (const { src, dest } of KIT_COMPANION_SKILLS) {
|
|
487
|
+
// 상위 전부(.claude → .claude/skills → .claude/skills/commitgate-<name>) + leaf를 lstat으로 검사.
|
|
488
|
+
const st = statWritableDest(targetRoot, dest)
|
|
489
|
+
const destAbs = join(targetRoot, dest)
|
|
490
|
+
const srcAbs = join(PACKAGE_ROOT, src)
|
|
491
|
+
if (st === null) create.push({ srcAbs, destRel: dest })
|
|
492
|
+
else if (sha256File(destAbs) === sha256File(srcAbs)) ownedSkips.push(dest)
|
|
493
|
+
else userDiffers.push(dest) // 사용자가 고친 것 — `--force`도 보지 않는다(D3).
|
|
494
|
+
}
|
|
495
|
+
return { create, ownedSkips, userDiffers }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `<pm> install`이 만드는 `node_modules/`가 **워킹트리를 dirty하게 만드는가**.
|
|
500
|
+
* ignore되지도 tracked되지도 않으면 `?? node_modules/`로 나타나 `req:new --run`의 clean-tree 게이트를 막는다.
|
|
501
|
+
*
|
|
502
|
+
* README가 지시하는 `git init && npm init -y`에는 `.gitignore`가 없다 — 그 경로는 **100% 재현된다.**
|
|
503
|
+
* 안내가 이 사실을 짚어 주지 않으면 사용자는 설치분을 다 커밋하고도 첫 명령에서 막힌다(실측).
|
|
504
|
+
*/
|
|
505
|
+
/**
|
|
506
|
+
* `node_modules/`를 무시하는 규칙이 **저장소에 커밋되는 `.gitignore`**에서 왔는가.
|
|
507
|
+
*
|
|
508
|
+
* ⚠️ `git check-ignore`는 `.git/info/exclude`와 전역 `core.excludesFile`도 인정한다. 그 둘은
|
|
509
|
+
* **clone에 따라오지 않는다.** 설치한 사람의 로컬 설정 때문에 `nodeModulesWillDirty=false`가 되면,
|
|
510
|
+
* 팀원의 fresh clone에서 `<pm> install` 후 `?? node_modules/`가 나타나 `req:new --run`이 막힌다.
|
|
511
|
+
* 설치 결과는 **저장소에 이식 가능**해야 한다(phase-6 리뷰 R4).
|
|
512
|
+
*
|
|
513
|
+
* `check-ignore -v` 출력은 `<source>:<line>:<pattern>\t<pathname>`이다. source가 repo 내부의
|
|
514
|
+
* `.gitignore`(상대경로)일 때만 인정한다 — `.git/info/exclude`는 basename이 다르고, 전역 파일은 절대경로다.
|
|
515
|
+
*
|
|
516
|
+
* ⚠️ 조회 경로에 후행 슬래시가 필요하다. 가장 흔한 패턴 `node_modules/`는 **디렉터리 전용**이라
|
|
517
|
+
* `node_modules`(슬래시 없음)로는 매칭되지 않는다 — 경로가 없으면 git이 디렉터리인지 알 수 없기 때문.
|
|
518
|
+
*/
|
|
519
|
+
function nodeModulesIgnoredByRepoGitignore(targetRoot: string): boolean {
|
|
520
|
+
try {
|
|
521
|
+
const out = execFileSync('git', ['check-ignore', '-v', '--', 'node_modules/'], {
|
|
522
|
+
cwd: targetRoot,
|
|
523
|
+
encoding: 'utf8',
|
|
524
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
525
|
+
})
|
|
526
|
+
const source = (out.split('\t')[0] ?? '').split(':')[0]?.replace(/\\/g, '/') ?? ''
|
|
527
|
+
if (source === '' || isAbsolute(source) || source.startsWith('.git/')) return false
|
|
528
|
+
if (source !== '.gitignore' && !source.endsWith('/.gitignore')) return false
|
|
529
|
+
// ⚠️ 파일이 있는 것만으론 부족하다 — **tracked**여야 clone에 따라온다.
|
|
530
|
+
// `.gitignore`가 아직 커밋되지 않았거나(혹은 `.git/info/exclude`로 자신이 숨겨져 있어도)
|
|
531
|
+
// 그 규칙은 팀원의 fresh clone에 없다(phase-6 리뷰 R6).
|
|
532
|
+
return gitIsTracked(targetRoot, source)
|
|
533
|
+
} catch {
|
|
534
|
+
return false // exit 1 = 무시 안 됨, 128 = 오류(오탐 방지 위해 "무시 안 됨"으로)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function nodeModulesWillDirty(targetRoot: string): boolean {
|
|
539
|
+
return !nodeModulesIgnoredByRepoGitignore(targetRoot) && !gitIsTracked(targetRoot, 'node_modules/')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* `.gitignore`가 설치 커밋에 합류해야 하는가 (phase-6 리뷰 R1·R2).
|
|
544
|
+
*
|
|
545
|
+
* 두 경우다:
|
|
546
|
+
* 1. `node_modules`가 아직 무시되지 않는다 → 사용자가 규칙을 추가해야 하고, 그 수정은 설치 커밋에 담긴다.
|
|
547
|
+
* 2. **`.gitignore`가 dirty하다** → `node_modules`가 이미 무시되더라도 그 규칙이 **커밋되지 않은**
|
|
548
|
+
* `.gitignore`에서 왔을 수 있다. 안내가 그 파일을 `git stash push -u`로 치우는 순간 규칙이 사라져
|
|
549
|
+
* `?? node_modules/`가 되살아나고 clean-tree 게이트가 다시 깨진다.
|
|
550
|
+
*
|
|
551
|
+
* 산출물로 편입하면 나머지는 기존 3분류가 처리한다 — untracked면 무해(그대로 stage), tracked·unstaged면
|
|
552
|
+
* overlapping(안내를 내지 않음). dirty `.gitignore`에 무관한 수정만 있는 경우도 보수적으로 막히지만,
|
|
553
|
+
* `package.json`이 dirty할 때와 같은 정책이라 일관된다: **잘못된 안내보다 안내 없음이 낫다.**
|
|
554
|
+
*/
|
|
555
|
+
function gitignoreJoinsInstall(nodeModulesDirty: boolean, entries: readonly StatusEntry[]): boolean {
|
|
556
|
+
if (nodeModulesDirty) return true
|
|
557
|
+
// rename의 src·dest 어느 쪽이 `.gitignore`여도 그 파일은 dirty다(entryPaths로 둘 다 본다).
|
|
558
|
+
return entries.some((e) => entryPaths(e).includes('.gitignore'))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function sha256File(abs: string): string {
|
|
562
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** kit `workflow/.gitignore` 템플릿의 규칙 라인(주석·빈 줄 제외). differs WARN에서 사용자가 병합할 실제 규칙을 보여 준다. */
|
|
566
|
+
function kitGitignoreRules(): string[] {
|
|
567
|
+
return readFileSync(join(PACKAGE_ROOT, KIT_GITIGNORE.src), 'utf8')
|
|
568
|
+
.split('\n')
|
|
569
|
+
.map((l) => l.trim())
|
|
570
|
+
.filter((l) => l !== '' && !l.startsWith('#'))
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** 설치 전 워킹트리 상태(쓰기 전). git 실패 시 빈 목록 — 진단이지 게이트가 아니다. */
|
|
574
|
+
function gitStatusEntries(targetRoot: string): StatusEntry[] {
|
|
575
|
+
try {
|
|
576
|
+
const raw = execFileSync('git', [...STATUS_Z_ARGS], {
|
|
577
|
+
cwd: targetRoot,
|
|
578
|
+
encoding: 'utf8',
|
|
579
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
580
|
+
})
|
|
581
|
+
return parseStatusZ(raw)
|
|
582
|
+
} catch {
|
|
583
|
+
return []
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
166
587
|
/** lockfile로 대상 패키지매니저 감지(없으면 npm — 가장 보편적 기본). */
|
|
167
588
|
export function detectPackageManager(root: string): PackageManager {
|
|
168
589
|
if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
@@ -193,36 +614,170 @@ function walkFiles(dir: string): string[] {
|
|
|
193
614
|
}
|
|
194
615
|
|
|
195
616
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
617
|
+
* init이 만들거나 수정할 것의 **쓰기 전 계획**(REQ-2026-011 DEC-011-9).
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ 왜 `InitResult`가 아니라 별도 계획인가: `InitResult.copied`는 Apply 단계에서 채워진다.
|
|
620
|
+
* preflight의 gitignore 검사가 그것을 볼 수 없고, 검사를 복사 뒤로 옮기면 `--strict`가
|
|
621
|
+
* `scripts/req/**`·`req.config.json`·`package.json`을 이미 쓴 뒤에 throw하게 되어
|
|
622
|
+
* "파일을 하나도 쓰지 않고 throw" 계약이 깨진다(design 리뷰 R4).
|
|
623
|
+
*
|
|
624
|
+
* preflight(ignore 검사)·apply(복사)·설치 후 안내(stage 목록) **셋이 같은 계획을 읽는다.**
|
|
625
|
+
* 목록을 따로 관리하면 어긋난다 — R2에서 `AGENTS.commitgate.md`가, R3에서 lockfile이 빠졌다.
|
|
198
626
|
*/
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
627
|
+
export interface InstallPlan {
|
|
628
|
+
copies: { srcAbs: string; destRel: string }[]
|
|
629
|
+
skips: string[]
|
|
630
|
+
/**
|
|
631
|
+
* `skips` 중 **패키지 원본과 바이트가 같은** 것 = CommitGate가 소유한다고 확인된 파일
|
|
632
|
+
* (커밋 전에 init을 두 번 돌렸을 때 생긴다). 안내의 stage 목록에는 이것만 포함한다.
|
|
633
|
+
*
|
|
634
|
+
* ⚠️ `skips` 전체를 산출물로 넣으면, init이 **보존하려던 사용자 파일**(예: 원래 있던
|
|
635
|
+
* `.cursor/rules/commitgate.mdc`)을 설치 커밋에 담게 되어 `git add -A` 금지의 목적을 우회한다
|
|
636
|
+
* (phase-6 리뷰 R3). 소유권 판정은 `bin/uninstall.ts`의 sha256 비교와 같은 축이다.
|
|
637
|
+
*/
|
|
638
|
+
ownedSkips: string[]
|
|
639
|
+
configRel: string | null // req.config.json — 생성·병합 시
|
|
640
|
+
packageJsonRel: string | null // package.json — 주입 시
|
|
641
|
+
lockfileRel: string | null // LOCKFILE[pm] — 주입 시(install이 갱신)
|
|
642
|
+
agentsRel: string | null // AGENTS.md — 부재였을 때
|
|
643
|
+
claudeMdRel: string | null // CLAUDE.md — 부재였을 때
|
|
644
|
+
contractCopyRel: string | null // AGENTS.commitgate.md — 기존 AGENTS.md에 마커 없을 때
|
|
645
|
+
/**
|
|
646
|
+
* `.gitignore` — init이 쓰지는 않지만, `node_modules`가 무시되지 않아 **사용자가 고치도록 안내하고
|
|
647
|
+
* 설치 커밋에 함께 담을** 때만 산출물이 된다.
|
|
648
|
+
*
|
|
649
|
+
* ⚠️ 산출물에 넣어야 `classifyPreexistingDirty`가 이 파일을 본다. 그러지 않으면 tracked `.gitignore`에
|
|
650
|
+
* 이미 있던 unstaged 수정이 안내의 `git add`에 딸려 들어가 설치 커밋을 오염시킨다 — `git add -A`를
|
|
651
|
+
* 금지한 이유(DEC-011-7)를 정확히 우회하게 된다(phase-6 리뷰 R1).
|
|
652
|
+
*/
|
|
653
|
+
gitignoreRel: string | null
|
|
654
|
+
/** `workflow/.gitignore` — 부재라 새로 생성할 때(설계 D4). AGENTS.md 모델: 생성 시에만 산출물. */
|
|
655
|
+
workflowGitignoreRel: string | null
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** `planInstall`이 preflight에서 이미 계산해 둔 사실들(중복 계산 방지). */
|
|
659
|
+
export interface PlanFacts {
|
|
660
|
+
configWillWrite: boolean
|
|
661
|
+
packageJsonWillWrite: boolean
|
|
662
|
+
agentsWillCreate: boolean
|
|
663
|
+
claudeMdWillCreate: boolean
|
|
664
|
+
contractCopyWillCreate: boolean
|
|
665
|
+
agentEntrypointsSkipped: boolean
|
|
666
|
+
/** `node_modules`가 워킹트리를 dirty하게 만들어 (루트) `.gitignore` 수정을 안내해야 하는가. */
|
|
667
|
+
gitignoreWillJoin: boolean
|
|
668
|
+
/** `workflow/.gitignore`(kit 파일)가 부재라 새로 생성하는가. `--no-agent-entrypoints`와 무관(D13). */
|
|
669
|
+
workflowGitignoreWillCreate: boolean
|
|
670
|
+
/** 기존 `workflow/.gitignore`가 템플릿과 바이트 동일(= 직전 실행이 깐 것). ownedSkips에 직접 편입(D4 축). */
|
|
671
|
+
workflowGitignoreOwnedSkip: boolean
|
|
672
|
+
/**
|
|
673
|
+
* companion skills 계획(REQ-2026-020). `add()`를 타지 않으므로(D3 seed-once) 여기로 받아
|
|
674
|
+
* copies/ownedSkips에 **직접** 편입한다 — 그러지 않으면 stageList에서 빠져 unrelated로 오분류된다.
|
|
675
|
+
*/
|
|
676
|
+
companionSkills: CompanionSkillsPlan
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 계획이 만들거나 수정할 repo-상대 경로 전수. ignore 검사와 stage 목록이 공유한다.
|
|
681
|
+
*
|
|
682
|
+
* `skips`(이미 존재해 덮어쓰지 않는 kit 파일)도 포함한다. init은 **멱등**하므로 커밋 전에 두 번
|
|
683
|
+
* 실행될 수 있는데, 그때 skip된 kit 파일이 산출물에서 빠지면 `unrelated`로 분류되어 안내가
|
|
684
|
+
* "stash 하십시오"라고 말한다 — 방금 깐 kit을 치우라는 뜻이 된다. 안내도 멱등해야 한다.
|
|
685
|
+
*/
|
|
686
|
+
export function planArtifactPaths(plan: InstallPlan): string[] {
|
|
687
|
+
const extras = [
|
|
688
|
+
plan.configRel,
|
|
689
|
+
plan.packageJsonRel,
|
|
690
|
+
plan.lockfileRel,
|
|
691
|
+
plan.agentsRel,
|
|
692
|
+
plan.claudeMdRel,
|
|
693
|
+
plan.contractCopyRel,
|
|
694
|
+
plan.gitignoreRel,
|
|
695
|
+
plan.workflowGitignoreRel,
|
|
696
|
+
]
|
|
697
|
+
return [...plan.copies.map((c) => c.destRel), ...plan.ownedSkips, ...extras.filter((p): p is string => p !== null)]
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 복사 계획 수립(IO는 `existsSync`/`readdirSync`만 — 쓰기 없음). 기존 파일은 `force` 없으면 스킵.
|
|
702
|
+
*
|
|
703
|
+
* `scripts/req/**`와 `KIT_COPY_RELPATHS`는 **패키지-상대 = 대상-상대**(리터럴 `workflow/`).
|
|
704
|
+
* 진입점은 `src !== dest`라 명시적 매핑으로 다룬다.
|
|
705
|
+
*/
|
|
706
|
+
export function planInstall(targetRoot: string, force: boolean, pm: PackageManager, facts: PlanFacts): InstallPlan {
|
|
707
|
+
const copies: { srcAbs: string; destRel: string }[] = []
|
|
708
|
+
const skips: string[] = []
|
|
709
|
+
const ownedSkips: string[] = []
|
|
710
|
+
const sha = (p: string): string | null => {
|
|
711
|
+
try {
|
|
712
|
+
return createHash('sha256').update(readFileSync(p)).digest('hex')
|
|
713
|
+
} catch {
|
|
714
|
+
return null
|
|
213
715
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
716
|
+
}
|
|
717
|
+
const add = (srcAbs: string, destRel: string): void => {
|
|
718
|
+
const destAbs = join(targetRoot, destRel)
|
|
719
|
+
// 🔴 confinement + leaf 판정(REQ-2026-024 D2). **반환값이 곧 부재 판정이다** — `existsSync`를 쓰면
|
|
720
|
+
// dangling leaf(A)를 부재로 오판하고, ancestor dir symlink(B)를 통과시키며, `--force`가 링크를 따라
|
|
721
|
+
// 대상 **밖** 사용자 파일을 덮어쓴다(C, 실측 E8). 검사와 판정이 같은 호출이라 빼먹을 수 없다.
|
|
722
|
+
const st = statWritableDest(targetRoot, destRel)
|
|
723
|
+
if (st !== null && !force) {
|
|
724
|
+
skips.push(destRel)
|
|
725
|
+
// 바이트가 같으면 CommitGate 소유(직전 실행이 깐 것). 다르면 사용자 파일 — 설치 커밋에 담지 않는다.
|
|
726
|
+
const a = sha(destAbs)
|
|
727
|
+
if (a !== null && a === sha(srcAbs)) ownedSkips.push(destRel)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
copies.push({ srcAbs, destRel })
|
|
731
|
+
}
|
|
732
|
+
// ⚠️ Stage B(REQ-2026-014 R3): `scripts/req/**` 를 대상에 **복사하지 않는다**. 실행 코드는 패키지
|
|
733
|
+
// (`node_modules/commitgate/scripts/req/**`)에만 있고, `req:* = commitgate <verb>` 가 그리로 dispatch한다.
|
|
734
|
+
// `KIT_SOURCE_DIR_REL` 상수는 그대로 남는다 — `detectStageA`(D19)와 `bin/uninstall.ts`(기존 Stage A 설치본 분류)가 쓴다.
|
|
735
|
+
// ⚠️ `package.json` files[]의 `scripts/req` 항목은 **유지해야 한다** — 패키지 자신의 bin이 그리로 dispatch한다.
|
|
736
|
+
// 복사 축(여기)과 tarball 축(files[])은 서로 다른 축이다.
|
|
737
|
+
for (const rel of KIT_COPY_RELPATHS) add(join(PACKAGE_ROOT, rel), rel)
|
|
738
|
+
if (!facts.agentEntrypointsSkipped)
|
|
739
|
+
for (const { src, dest } of KIT_AGENT_ENTRYPOINTS) add(join(PACKAGE_ROOT, src), dest)
|
|
740
|
+
// workflow/.gitignore는 add()를 타지 않는다(D12: --force가 사용자 파일을 덮으면 안 됨). 바이트 동일 재실행분은
|
|
741
|
+
// 여기서 ownedSkips에 **직접** 편입 — 그러지 않으면 stageList에서 빠져 unrelated로 오분류된다(phase-2 리뷰 R1).
|
|
742
|
+
if (facts.workflowGitignoreOwnedSkip) ownedSkips.push(KIT_GITIGNORE.dest)
|
|
743
|
+
// companion skills도 add()를 타지 않는다(D3). 생성분은 copies로, 바이트 동일분은 ownedSkips로 직접 편입 —
|
|
744
|
+
// 그래야 artifacts·stageList·ignore 검사가 이들을 함께 본다. userDiffers는 사용자 파일이라 설치 커밋에 담지 않는다.
|
|
745
|
+
for (const c of facts.companionSkills.create) copies.push(c)
|
|
746
|
+
for (const d of facts.companionSkills.ownedSkips) ownedSkips.push(d)
|
|
747
|
+
for (const d of facts.companionSkills.userDiffers) skips.push(d)
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
copies,
|
|
751
|
+
skips,
|
|
752
|
+
ownedSkips,
|
|
753
|
+
configRel: facts.configWillWrite ? 'req.config.json' : null,
|
|
754
|
+
packageJsonRel: facts.packageJsonWillWrite ? 'package.json' : null,
|
|
755
|
+
// devDeps를 주입했으면 `<pm> install`이 lockfile을 갱신한다. 안내가 이것을 stage해야 clean-tree가 성립한다.
|
|
756
|
+
lockfileRel: facts.packageJsonWillWrite ? LOCKFILE[pm] : null,
|
|
757
|
+
agentsRel: facts.agentsWillCreate ? 'AGENTS.md' : null,
|
|
758
|
+
claudeMdRel: facts.claudeMdWillCreate ? KIT_CLAUDE_DEST_REL : null,
|
|
759
|
+
contractCopyRel: facts.contractCopyWillCreate ? KIT_AGENTS_CONTRACT_COPY_REL : null,
|
|
760
|
+
gitignoreRel: facts.gitignoreWillJoin ? '.gitignore' : null,
|
|
761
|
+
// D13: --no-agent-entrypoints와 무관하게 산출물에 편입 → stageList가 설치 커밋에 담아 팀·CI에 전파.
|
|
762
|
+
workflowGitignoreRel: facts.workflowGitignoreWillCreate ? KIT_GITIGNORE.dest : null,
|
|
218
763
|
}
|
|
219
764
|
}
|
|
220
765
|
|
|
221
766
|
/**
|
|
222
|
-
* 진입점 dest
|
|
767
|
+
* 진입점 dest의 **ENOTDIR 조기 진단**(D8) — 더 나은 에러 메시지가 목적이다.
|
|
223
768
|
*
|
|
224
769
|
* `mkdirSync(recursive)`는 경로 중간 컴포넌트가 **파일**이면 ENOTDIR로 죽는다. apply 단계에서 그러면
|
|
225
|
-
* 앞의 파일들은 이미 복사된 뒤라 **부분 설치**가 된다. 쓰기 전에 걸러서 preflight→apply 계약을
|
|
770
|
+
* 앞의 파일들은 이미 복사된 뒤라 **부분 설치**가 된다. 쓰기 전에 걸러서 preflight→apply 계약을 지키고,
|
|
771
|
+
* `--no-agent-entrypoints`라는 출구를 안내한다.
|
|
772
|
+
*
|
|
773
|
+
* 🔴 **이 함수는 confinement 방어가 아니다**(REQ-2026-024 D4). `existsSync`·`statSync`는 **링크를 따라간다** —
|
|
774
|
+
* 상위가 외부를 가리키는 symlink여도 `isDirectory()`가 true라 그대로 통과한다(실측: v0.7.0에서 `.cursor`
|
|
775
|
+
* junction이 여기를 통과해 대상 밖에 파일을 만들었다).
|
|
776
|
+
* **방어는 `statWritableDest`가 한다** — 이 함수 뒤의 `add()`·개별 dest 판정이 그것을 호출한다.
|
|
777
|
+
* 여기를 lstat 기반으로 바꾸지 **않는다**: 같은 규칙이 두 벌이 되고, 그 이중화가 이번 결함의 발생 방식이다.
|
|
778
|
+
*
|
|
779
|
+
* ⚠️ 순서 의존: symlink dest에 대해 이 함수가 먼저 **다른 메시지**로 throw할 수 있다(예: 상위가 파일 symlink면
|
|
780
|
+
* "디렉터리가 아니라 파일입니다"). 보안상 무해하다 — 둘 다 쓰기 전 throw이고 메시지도 틀리지 않았다.
|
|
226
781
|
*/
|
|
227
782
|
function assertEntrypointPathsUsable(targetRoot: string): void {
|
|
228
783
|
const dests = [...KIT_AGENT_ENTRYPOINTS.map((e) => e.dest), KIT_CLAUDE_DEST_REL, KIT_AGENTS_CONTRACT_COPY_REL]
|
|
@@ -244,28 +799,46 @@ function assertEntrypointPathsUsable(targetRoot: string): void {
|
|
|
244
799
|
}
|
|
245
800
|
|
|
246
801
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
802
|
+
* 계획대로 복사(중첩 디렉터리 생성). 호출부가 `--dry-run`이면 이 함수를 아예 부르지 않는다.
|
|
803
|
+
*
|
|
804
|
+
* 🔴 **쓰기 전 전량 검증**(REQ-2026-024 D3). `plan.copies`는 `add()`만 채우지 않는다 —
|
|
805
|
+
* `planCompanionSkills`의 결과가 **직접 편입**된다(REQ-2026-020 D3). 즉 `add()`의 preflight를
|
|
806
|
+
* **우회하는 경로가 이미 존재한다.** 여기서 불변식을 강제해 미래의 우회도 잡는다.
|
|
807
|
+
*
|
|
808
|
+
* 🔴 **두 루프여야 한다.** 검사·쓰기를 한 루프에 섞으면 중간 throw 시 앞의 파일이 이미 복사돼
|
|
809
|
+
* **부분 설치**가 된다(롤백 0줄) — preflight→apply 계약 위반이다.
|
|
810
|
+
*
|
|
811
|
+
* ⚠️ 이 검사는 **불변식 강제**지 주 방어선이 아니다. 주 방어선은 preflight(`add()`·`planCompanionSkills`)다.
|
|
812
|
+
* preflight가 완전하면 여기는 **절대 발화하지 않는다** — 그래서 `runInit` 경유로는 검증할 수 없고
|
|
813
|
+
* (preflight가 먼저 터진다) 이 함수를 **직접 호출**하는 테스트만이 공허하지 않다. export 이유가 그것이다.
|
|
814
|
+
* TOCTOU도 막지 못한다 — 검사와 `copyFileSync` 사이에도 창이 있다.
|
|
249
815
|
*/
|
|
250
|
-
function
|
|
251
|
-
targetRoot
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
skipped: string[],
|
|
255
|
-
): void {
|
|
256
|
-
for (const { src, dest } of KIT_AGENT_ENTRYPOINTS) {
|
|
257
|
-
const destAbs = join(targetRoot, dest)
|
|
258
|
-
if (existsSync(destAbs) && !opts.force) {
|
|
259
|
-
skipped.push(dest)
|
|
260
|
-
continue
|
|
261
|
-
}
|
|
262
|
-
copied.push(dest)
|
|
263
|
-
if (opts.dryRun) continue
|
|
816
|
+
export function applyCopies(targetRoot: string, plan: InstallPlan): void {
|
|
817
|
+
for (const { destRel } of plan.copies) statWritableDest(targetRoot, destRel)
|
|
818
|
+
for (const { srcAbs, destRel } of plan.copies) {
|
|
819
|
+
const destAbs = join(targetRoot, destRel)
|
|
264
820
|
mkdirSync(dirname(destAbs), { recursive: true })
|
|
265
|
-
copyFileSync(
|
|
821
|
+
copyFileSync(srcAbs, destAbs)
|
|
266
822
|
}
|
|
267
823
|
}
|
|
268
824
|
|
|
825
|
+
/**
|
|
826
|
+
* gitignore된 계약 포인터에 대한 경고 문구. **동작하는 패턴을 제시**해야 한다.
|
|
827
|
+
*
|
|
828
|
+
* git 공식 문서(`gitignore(5)`): *"It is not possible to re-include a file if a parent directory of
|
|
829
|
+
* that file is excluded."* 그래서 `.claude` + `!.claude/skills/**`는 **동작하지 않는다.**
|
|
830
|
+
* 이 함정을 알려 주지 않으면 사용자는 고쳤다고 믿으면서 여전히 추적되지 않는다.
|
|
831
|
+
*/
|
|
832
|
+
function ignoredPointerMessage(ignored: readonly string[]): string {
|
|
833
|
+
return (
|
|
834
|
+
`다음 계약 포인터가 .gitignore로 무시됩니다 — 팀·CI의 fresh clone에 공유되지 않습니다:\n` +
|
|
835
|
+
ignored.map((p) => ` ${p}`).join('\n') +
|
|
836
|
+
`\n git은 부모 디렉터리가 제외되면 하위 부정 패턴을 무시합니다(gitignore(5)).\n` +
|
|
837
|
+
` \`.claude\` 대신 아래처럼 바꾸면 설정 파일은 계속 무시하면서 진입점만 추적할 수 있습니다:\n\n` +
|
|
838
|
+
` .claude/*\n !.claude/skills/\n !.claude/skills/**\n !.claude/commands/\n !.claude/commands/**`
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
269
842
|
/** JSON 파일을 객체로 파싱(fail-closed). 파싱 실패·비-객체(배열/원시)면 throw. */
|
|
270
843
|
function parseJsonObject(path: string, label: string): Record<string, unknown> {
|
|
271
844
|
let parsed: unknown
|
|
@@ -378,7 +951,10 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
378
951
|
assertGitWorkTree(targetRoot) // 실제 git probe(fake .git 마커 거부)
|
|
379
952
|
|
|
380
953
|
const pkgPath = join(targetRoot, 'package.json')
|
|
381
|
-
|
|
954
|
+
// 🔴 confinement를 **읽기보다 앞**에 둔다(REQ-2026-024 D5). symlink면 `writeFileSync`가 링크를 따라
|
|
955
|
+
// 대상 **밖** package.json을 수정한다(실측 E6). 검사를 읽기 뒤에 두면 외부 파일을 읽고 나서 막는 셈이다.
|
|
956
|
+
// 부재(null)는 기존 메시지 그대로 — 동작 변경은 symlink/특수파일 케이스에 한정된다.
|
|
957
|
+
if (statWritableDest(targetRoot, 'package.json') === null)
|
|
382
958
|
throw new Error(`package.json 없음: ${targetRoot} — 'npm init' 등으로 먼저 생성(req:* 스크립트 주입 대상).`)
|
|
383
959
|
const pkg = parseJsonObject(pkgPath, 'package.json') as {
|
|
384
960
|
scripts?: Record<string, string>
|
|
@@ -392,7 +968,26 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
392
968
|
throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
|
|
393
969
|
}
|
|
394
970
|
|
|
971
|
+
// ══ Stage B 전제(REQ-2026-014). 순서가 계약이다: D19(Stage A 서명) → D14(선행 설치) ══
|
|
972
|
+
// 뒤집으면 Stage A 사용자가 D14에서 먼저 죽어 migrate 안내에 도달하지 못한다 — `detectStageA` 주석 참조(design r20 P1).
|
|
973
|
+
// 둘 다 preflight라 throw 시 어떤 파일도 쓰이지 않는다.
|
|
974
|
+
const stageASignature = detectStageA(targetRoot, pkg.scripts ?? {})
|
|
975
|
+
if (stageASignature !== null)
|
|
976
|
+
throw new Error(
|
|
977
|
+
`이미 Stage A(vendored) 설치본입니다 — 감지: ${stageASignature}. ` +
|
|
978
|
+
`plain init은 기존 req:* 를 덮지 않아 vendored 런타임이 계속 실행되는 혼합 설치가 됩니다. ` +
|
|
979
|
+
`'commitgate migrate' 로 전환하세요(기본 dry-run — 아무것도 삭제하지 않습니다).`,
|
|
980
|
+
)
|
|
981
|
+
if (!commitgateDeclared(pkg.devDependencies ?? {}))
|
|
982
|
+
throw new Error(
|
|
983
|
+
`devDependencies.commitgate 선언이 없습니다 — Stage B는 req:* 를 'commitgate <verb>' 로 심으므로 ` +
|
|
984
|
+
`대상에 commitgate가 devDependency로 있어야 합니다. 먼저 'npm install -D commitgate' 를 실행한 뒤 'commitgate init' 하세요.`,
|
|
985
|
+
)
|
|
986
|
+
|
|
395
987
|
// cross-spawn 보안 하한 진단(#1): 기존 cross-spawn이 하한 미만이면 WARN(기본)/throw(--strict). preflight라 strict throw 시 부분 설치 없음.
|
|
988
|
+
// ⚠️ Stage B는 대상의 cross-spawn을 **실행하지 않는다**(safeSpawnSync는 패키지 자신의 dependencies.cross-spawn에서 돈다).
|
|
989
|
+
// 대상에 cross-spawn이 없으면 자동 무동작(existingCrossSpawnSpec→null)이라 신규 Stage B 설치엔 영향이 없다.
|
|
990
|
+
// Stage B에서의 의미 재검토는 REQ-2026-014 backlog(이번 범위에서 동작 불변).
|
|
396
991
|
let crossSpawnFloorWarned = false
|
|
397
992
|
const floorCheck = crossSpawnBelowFloor(targetRoot, pkg as Record<string, unknown>)
|
|
398
993
|
if (floorCheck?.below) {
|
|
@@ -404,7 +999,9 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
404
999
|
}
|
|
405
1000
|
|
|
406
1001
|
const cfgPath = join(targetRoot, 'req.config.json')
|
|
407
|
-
|
|
1002
|
+
// 🔴 confinement를 **읽기보다 앞**에(D5). dangling이면 `writeFileSync`가 대상 밖에 **생성**하고(E4),
|
|
1003
|
+
// live symlink면 외부 파일을 읽어 병합한 뒤 링크를 따라 **수정**한다(E5). 아래 loadConfig도 이 파일을 읽는다.
|
|
1004
|
+
const existingCfg = statWritableDest(targetRoot, 'req.config.json') !== null ? parseJsonObject(cfgPath, 'req.config.json') : null
|
|
408
1005
|
// 기존 config는 워크플로 CONFIG_SCHEMA(additionalProperties·enum·type) + 경로 confinement까지 preflight 검증(phase R2 P2).
|
|
409
1006
|
// kit의 loadConfig를 재사용 — schema-invalid(unknown key·bad enum·escaping ticketRoot 등)면 복사 전 throw(첫 req:* 지연 실패 방지).
|
|
410
1007
|
// 병합은 유효 키만 추가(handoffPath:null·packageManager)라 "기존 유효 ⇒ 병합 유효".
|
|
@@ -439,22 +1036,23 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
439
1036
|
// package.json 패치 계획(쓰기 없음, 기존 키 미덮어씀)
|
|
440
1037
|
const packageJsonAdded: string[] = []
|
|
441
1038
|
const scripts = pkg.scripts ?? {}
|
|
442
|
-
|
|
443
|
-
|
|
1039
|
+
// Stage B: `commitgate <verb>` 를 주입한다(R1/R2). `if (!(k in scripts))` — **기존 키는 절대 덮지 않는다**.
|
|
1040
|
+
// 이 미덮어씀 규칙이 곧 "사용자 정의 req:* 보존"이며, Stage A 시절부터의 **기존 동작**이다(회귀 테스트로 고정).
|
|
1041
|
+
for (const [k, v] of Object.entries(STAGE_B_REQ_SCRIPTS)) {
|
|
444
1042
|
if (!(k in scripts)) {
|
|
445
1043
|
scripts[k] = v
|
|
446
1044
|
packageJsonAdded.push(`scripts.${k}`)
|
|
447
1045
|
}
|
|
448
1046
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
}
|
|
1047
|
+
// ⚠️ Stage B(R3): devDeps(`tsx`·`ajv`·`cross-spawn`)를 **주입하지 않는다**. 이들은 `commitgate` 패키지의
|
|
1048
|
+
// runtime `dependencies`라 `npm i -D commitgate` 시 전이 설치된다. 대상 package.json의 devDependencies는
|
|
1049
|
+
// **건드리지 않는다**(사용자 소유 — `devDependencies.commitgate`도 사용자가 `npm i -D`로 넣은 것이다).
|
|
1050
|
+
// `REQ_DEV_DEPS` 상수는 남는다 — `bin/uninstall.ts`가 기존 Stage A 설치본의 devDeps를 분류하는 데 쓴다.
|
|
455
1051
|
|
|
456
1052
|
const agentsPath = join(targetRoot, 'AGENTS.md')
|
|
457
|
-
|
|
1053
|
+
// 🔴 반환값이 곧 부재 판정이다(D2). `!existsSync`면 dangling을 부재로 오판해 `copyFileSync`가
|
|
1054
|
+
// 링크를 따라 대상 밖에 AGENTS.md를 만든다(실측 E1). 아래 마커 읽기(readFileSync)도 이 검사 뒤에 온다.
|
|
1055
|
+
const agentsCreated = statWritableDest(targetRoot, 'AGENTS.md') === null
|
|
458
1056
|
|
|
459
1057
|
const agentEntrypointsSkipped = opts.noAgentEntrypoints === true
|
|
460
1058
|
if (!agentEntrypointsSkipped) assertEntrypointPathsUsable(targetRoot)
|
|
@@ -465,34 +1063,174 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
465
1063
|
!agentEntrypointsSkipped && !agentsCreated && !readFileSync(agentsPath, 'utf8').includes(AGENTS_CONTRACT_MARKER)
|
|
466
1064
|
|
|
467
1065
|
const claudeMdPath = join(targetRoot, KIT_CLAUDE_DEST_REL)
|
|
468
|
-
|
|
1066
|
+
// `--no-agent-entrypoints`면 이 dest를 쓰지 않으므로 검사도 돌리지 않는다(D5/D7 의미 유지 — 단락 평가).
|
|
1067
|
+
const claudeMdCreated = !agentEntrypointsSkipped && statWritableDest(targetRoot, KIT_CLAUDE_DEST_REL) === null
|
|
469
1068
|
|
|
470
1069
|
// 마커가 없으면 포인터가 참조할 계약 템플릿을 **대상 repo에** 놓는다 — 그러지 않으면 복구 지시가 막다른 길이다.
|
|
471
1070
|
const contractCopyPath = join(targetRoot, KIT_AGENTS_CONTRACT_COPY_REL)
|
|
472
|
-
|
|
1071
|
+
// `agentsMarkerMissing`가 거짓이면 이 dest를 쓰지 않으므로 검사도 돌리지 않는다(단락 평가).
|
|
1072
|
+
// ⚠️ `|| opts.force`가 남아 있다 — force면 **존재해도 덮어쓴다**. 그래서 symlink 거부가 특히 중요하다:
|
|
1073
|
+
// 검사가 없으면 force가 링크를 따라 대상 밖 파일을 덮어쓴다(모드 C, `add()`의 E8과 같은 축).
|
|
1074
|
+
const agentsContractCopyCreated =
|
|
1075
|
+
agentsMarkerMissing && (statWritableDest(targetRoot, KIT_AGENTS_CONTRACT_COPY_REL) === null || opts.force)
|
|
1076
|
+
|
|
1077
|
+
// workflow/.gitignore(kit 파일, REQ-2026-012). AGENTS.md 정책: **부재 시에만** 생성, --force로도 안 덮음(D12).
|
|
1078
|
+
// --no-agent-entrypoints와 무관(D13).
|
|
1079
|
+
const workflowGitignorePath = join(targetRoot, KIT_GITIGNORE.dest)
|
|
1080
|
+
const workflowGitignoreSrcAbs = join(PACKAGE_ROOT, KIT_GITIGNORE.src)
|
|
1081
|
+
// confinement + leaf: workflow/(또는 그 상위)가 symlink면 copyFileSync가 밖에 쓰거나 git이 경로를 stage하지 못하고,
|
|
1082
|
+
// leaf가 symlink면 링크 대상을 따라 쓰며 git도 링크된 .gitignore를 규칙으로 안 읽는다. 디렉터리·특수파일도 복사 불가.
|
|
1083
|
+
// preflight라 스키마 복사(같은 workflow/ 경유)보다 먼저 돌아 함께 보호된다.
|
|
1084
|
+
const wgLstat = statWritableDest(targetRoot, KIT_GITIGNORE.dest)
|
|
1085
|
+
const workflowGitignoreExists = wgLstat !== null // isFile 보장
|
|
1086
|
+
const workflowGitignoreCreated = !workflowGitignoreExists
|
|
1087
|
+
// 존재 & 바이트 동일 = 직전 실행이 깐 것(소유). ⚠️ add()를 타지 않으므로(D12) ownedSkips를 **직접** 채운다 —
|
|
1088
|
+
// 아니면 커밋 전 재실행 시 stageList에서 빠져 unrelated로 오분류된다(설계 D4 축, phase-2 리뷰 R1).
|
|
1089
|
+
const workflowGitignoreOwnedSkip = workflowGitignoreExists && sha256File(workflowGitignorePath) === sha256File(workflowGitignoreSrcAbs)
|
|
1090
|
+
// 존재하지만 다름 = 사용자 파일(보존). **효과를 판정하지 않고 보수적으로 WARN**한다(phase-2 리뷰 P2·P3):
|
|
1091
|
+
// check-ignore 효과 판정은 로컬 전용 소스(.git/info/exclude·전역)를 이식 가능으로 오인하고,
|
|
1092
|
+
// 단일 샘플은 scoped negation(`!/REQ-2026-...`)을 놓친다. 그래서 "다르면 무조건 안내".
|
|
1093
|
+
const workflowGitignoreUserDiffers = workflowGitignoreExists && !workflowGitignoreOwnedSkip
|
|
1094
|
+
// 정책 파일이 fresh clone·CI에 전달되지 못하는가(설치 커밋에 못 담김) — ignored∧untracked면 `git add`가 fatal이다.
|
|
1095
|
+
// 소유(생성/ownedSkip)든 사용자 파일(differs)이든, scratch 정책이 팀에 없으면 안전한 설치 안내를 낼 수 없다(phase-2 리뷰 P2).
|
|
1096
|
+
const workflowGitignorePolicyAtRisk =
|
|
1097
|
+
(workflowGitignoreCreated || workflowGitignoreOwnedSkip || workflowGitignoreUserDiffers) &&
|
|
1098
|
+
gitIsIgnored(targetRoot, KIT_GITIGNORE.dest) &&
|
|
1099
|
+
!gitIsTracked(targetRoot, KIT_GITIGNORE.dest)
|
|
1100
|
+
|
|
1101
|
+
// companion skills(REQ-2026-020 D3·D4). **preflight**라 `--dry-run`도 이 검사를 받는다 —
|
|
1102
|
+
// dry-run이 조용히 통과하면 실설치 직전에야 터진다. 판정은 순수(쓰기 0건)다.
|
|
1103
|
+
// `.claude/` 계층이므로 `--no-agent-entrypoints`면 통째로 건너뛴다(D5/D7 의미 유지).
|
|
1104
|
+
const companionSkills: CompanionSkillsPlan = agentEntrypointsSkipped
|
|
1105
|
+
? { create: [], ownedSkips: [], userDiffers: [] }
|
|
1106
|
+
: planCompanionSkills(targetRoot)
|
|
1107
|
+
|
|
1108
|
+
// 설치 **전** 워킹트리(쓰기 전 스냅샷). `.gitignore`의 dirty 여부 판정에도 쓰이므로 계획보다 먼저 찍는다.
|
|
1109
|
+
const porcelain = gitStatusEntries(targetRoot)
|
|
1110
|
+
const nmWillDirty = nodeModulesWillDirty(targetRoot)
|
|
1111
|
+
|
|
1112
|
+
// 산출물 계획을 **쓰기 전에** 확정한다(DEC-011-9). ignore 검사·복사·설치 후 안내가 이 하나를 공유한다.
|
|
1113
|
+
const plan = planInstall(targetRoot, opts.force, packageManager, {
|
|
1114
|
+
configWillWrite: configToWrite !== null,
|
|
1115
|
+
packageJsonWillWrite: packageJsonAdded.length > 0,
|
|
1116
|
+
agentsWillCreate: agentsCreated,
|
|
1117
|
+
claudeMdWillCreate: claudeMdCreated,
|
|
1118
|
+
contractCopyWillCreate: agentsContractCopyCreated,
|
|
1119
|
+
agentEntrypointsSkipped,
|
|
1120
|
+
gitignoreWillJoin: gitignoreJoinsInstall(nmWillDirty, porcelain),
|
|
1121
|
+
workflowGitignoreWillCreate: workflowGitignoreCreated,
|
|
1122
|
+
workflowGitignoreOwnedSkip,
|
|
1123
|
+
companionSkills,
|
|
1124
|
+
})
|
|
1125
|
+
const artifacts = planArtifactPaths(plan)
|
|
1126
|
+
|
|
1127
|
+
// gitignore 판정(D5). 계약 포인터가 무시되면 설치 목적(팀·CI 공유)이 조용히 무너진다 → WARN/strict throw.
|
|
1128
|
+
// 그 밖의 산출물(lockfile 등)은 무시돼도 정당한 정책일 수 있으므로 stage 목록에서만 조용히 뺀다.
|
|
1129
|
+
const gitIgnoredArtifacts = findIgnoredArtifacts(targetRoot, artifacts)
|
|
1130
|
+
const ignoredPointers = gitIgnoredArtifacts.filter((p) => CONTRACT_POINTER_RELPATHS.includes(p))
|
|
1131
|
+
|
|
1132
|
+
// 설치 **전** 워킹트리 3분류(DEC-011-11). 쓰기 뒤에 찍으면 CommitGate 산출물과 섞여 구분할 수 없다.
|
|
1133
|
+
const preexistingDirty = classifyPreexistingDirty(porcelain, artifacts)
|
|
1134
|
+
|
|
1135
|
+
if (ignoredPointers.length > 0) {
|
|
1136
|
+
const msg = ignoredPointerMessage(ignoredPointers)
|
|
1137
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1138
|
+
console.warn(`⚠️ ${msg}\n (설치는 계속 — 강제 중단하려면 --strict)`)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// `.gitignore`를 설치 커밋에 담아야 하는데 그 파일 **자신이** 무시된다(로컬 exclude·전역 ignore).
|
|
1142
|
+
// `git add`가 fatal이고, 규칙이 커밋되지 않아 팀원의 fresh clone에서 `?? node_modules/`가 되살아난다.
|
|
1143
|
+
// 안전한 안내를 만들 수 없으므로 알리고(기본) 중단한다(--strict) — phase-6 리뷰 R6.
|
|
1144
|
+
if (plan.gitignoreRel !== null && gitIgnoredArtifacts.includes('.gitignore')) {
|
|
1145
|
+
const msg =
|
|
1146
|
+
`.gitignore 자체가 무시되고 있어(.git/info/exclude 또는 전역 ignore) 설치 커밋에 담을 수 없습니다.\n` +
|
|
1147
|
+
` node_modules 무시 규칙이 팀원의 fresh clone에 따라가지 않아 그쪽 req:new 가 막힙니다.\n` +
|
|
1148
|
+
` 로컬 exclude에서 .gitignore 를 빼고 저장소에 커밋하십시오.`
|
|
1149
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1150
|
+
console.warn(`⚠️ ${msg}\n (설치는 계속 — 커밋 안내는 생략됩니다)`)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// workflow/.gitignore가 ignored∧untracked면 설치 커밋에 못 담겨 팀·CI에 scratch 정책이 없다(phase-2 리뷰 R3·R6).
|
|
1154
|
+
// ⚠️ `workflowGitignorePolicyAtRisk`로 통합 판정 — created/ownedSkip(산출물)뿐 아니라 **user-differs**(artifacts에 없고
|
|
1155
|
+
// porcelain에도 안 나타나는 사용자 파일)도 포함한다. 이것을 빼면 --strict가 그 상태에서 쓰기 전에 안 막는다(R6 gap).
|
|
1156
|
+
if (workflowGitignorePolicyAtRisk) {
|
|
1157
|
+
const msg =
|
|
1158
|
+
`${KIT_GITIGNORE.dest} 가 무시되고 있어(예: 루트 규칙의 \`**/.gitignore\`) 설치 커밋에 담을 수 없습니다.\n` +
|
|
1159
|
+
` 티켓 scratch 무시 규칙이 팀원의 fresh clone·CI에 따라가지 않습니다.\n` +
|
|
1160
|
+
` 그 파일을 무시하는 규칙을 걷어내고 저장소에 커밋하십시오.`
|
|
1161
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1162
|
+
console.warn(`⚠️ ${msg}\n (설치는 계속 — 안전한 커밋 안내는 생략됩니다)`)
|
|
1163
|
+
}
|
|
1164
|
+
// companion skills가 ignored∧untracked면 설치 커밋에 못 담겨 팀원 fresh clone에 전달되지 않는다(REQ-2026-021 D1).
|
|
1165
|
+
//
|
|
1166
|
+
// 🔴 **`artifacts`로 판정하면 안 된다.** `userDiffers`는 `skips`로 가서 `planArtifactPaths`
|
|
1167
|
+
// (= `copies + ownedSkips + extras`)에 **없다** → `findIgnoredArtifacts`가 그 파일을 보지 못한다.
|
|
1168
|
+
// 그러면 나머지가 전부 추적된 상태에서 사용자가 skill 하나만 고쳤을 때 **경고 없이 `--strict`가 통과**한다.
|
|
1169
|
+
// 계획 3분류(create·ownedSkips·userDiffers)를 **전부** 본다 — 소유든 사용자 파일이든, 팀에 전달되지
|
|
1170
|
+
// 못하면 안전한 설치 안내를 낼 수 없다. `workflowGitignorePolicyAtRisk`와 같은 축이다(D1).
|
|
1171
|
+
//
|
|
1172
|
+
// ⚠️ `CONTRACT_POINTER_RELPATHS`에 섞지 않는다(REQ-2026-020 D6) — companion은 계약 포인터가 아니다.
|
|
1173
|
+
// 같은 WARN/strict **동작**만 별도 판정으로 준다. 기존 포인터 경고는 손대지 않는다(추가만).
|
|
1174
|
+
const companionAtRisk = [
|
|
1175
|
+
...companionSkills.create.map((c) => c.destRel),
|
|
1176
|
+
...companionSkills.ownedSkips,
|
|
1177
|
+
...companionSkills.userDiffers,
|
|
1178
|
+
].filter((p) => gitIsIgnored(targetRoot, p) && !gitIsTracked(targetRoot, p))
|
|
1179
|
+
|
|
1180
|
+
if (companionAtRisk.length > 0) {
|
|
1181
|
+
const msg =
|
|
1182
|
+
`companion skills가 무시되고 있어 설치 커밋에 담을 수 없습니다:\n` +
|
|
1183
|
+
companionAtRisk.map((p) => ` ${p}`).join('\n') +
|
|
1184
|
+
`\n 팀원의 fresh clone·CI에는 이 스킬들이 없습니다(그쪽 Builder는 방법론 보조를 못 받습니다).\n` +
|
|
1185
|
+
` 추적하려면: git add -f <위 경로> 또는 .gitignore 에서 해당 규칙을 걷어내십시오.`
|
|
1186
|
+
if (opts.strict) throw new Error(`[--strict] ${msg}`)
|
|
1187
|
+
console.warn(`⚠️ ${msg}\n (설치는 계속 — 강제 중단하려면 --strict)`)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// staged·overlapping이 있으면 **안전한 커밋 안내를 만들 수 없다**(커밋이 인덱스 전체를 담고, 겹침은 사후 분리 불가).
|
|
1191
|
+
// 기본 모드는 설치를 막지 않는다(비파괴·비-breaking) — 안내를 내지 않을 뿐. `--strict`는 쓰기 전에 중단한다.
|
|
1192
|
+
if (opts.strict && (preexistingDirty.staged.length > 0 || preexistingDirty.overlapping.length > 0)) {
|
|
1193
|
+
const lines = [
|
|
1194
|
+
...preexistingDirty.staged.map((p) => ` staged ${p}`),
|
|
1195
|
+
...preexistingDirty.overlapping.map((p) => ` 설치분과 겹침 ${p}`),
|
|
1196
|
+
]
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`[--strict] 설치 전 워킹트리에 변경이 있어 안전한 설치 커밋을 만들 수 없습니다:\n${lines.join('\n')}\n` +
|
|
1199
|
+
` staged 변경은 설치 커밋에 함께 들어가고, 겹치는 변경은 사후 분리가 불가능합니다. 먼저 커밋하거나 되돌리세요.`,
|
|
1200
|
+
)
|
|
1201
|
+
}
|
|
473
1202
|
|
|
474
1203
|
// ══ Apply: 여기부터 쓰기(preflight 전부 통과 후에만) ═════════════════
|
|
475
|
-
const copied
|
|
476
|
-
const skipped
|
|
477
|
-
copyInto(walkFiles(join(PACKAGE_ROOT, KIT_SOURCE_DIR_REL)), PACKAGE_ROOT, targetRoot, opts, copied, skipped)
|
|
478
|
-
// ⚠️ KIT_COPY_RELPATHS는 패키지-상대 = 대상-상대(리터럴 `workflow/`). ticketRoot/schemaPath 설정과 무관 — uninstall planner와 공유하는 SSOT.
|
|
479
|
-
const kitFiles = KIT_COPY_RELPATHS.map((rel) => join(PACKAGE_ROOT, rel))
|
|
480
|
-
copyInto(kitFiles, PACKAGE_ROOT, targetRoot, opts, copied, skipped)
|
|
481
|
-
if (!agentEntrypointsSkipped) copyEntrypoints(targetRoot, opts, copied, skipped)
|
|
1204
|
+
const copied = plan.copies.map((c) => c.destRel)
|
|
1205
|
+
const skipped = plan.skips
|
|
482
1206
|
|
|
483
1207
|
if (!opts.dryRun) {
|
|
1208
|
+
applyCopies(targetRoot, plan)
|
|
484
1209
|
if (configToWrite) writeFileSync(cfgPath, JSON.stringify(configToWrite, null, 2) + '\n', 'utf8')
|
|
485
1210
|
if (packageJsonAdded.length > 0) {
|
|
486
1211
|
pkg.scripts = scripts
|
|
487
|
-
pkg.devDependencies
|
|
1212
|
+
// ⚠️ Stage B(R3): `pkg.devDependencies`를 **재대입하지 않는다**. 주입이 없으므로 파싱된 원본이 그대로 직렬화되고,
|
|
1213
|
+
// devDependencies가 없던 package.json에 빈 `{}`를 새로 만들어 넣는 부작용도 없다.
|
|
488
1214
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
|
|
489
1215
|
}
|
|
490
1216
|
if (agentsCreated) copyFileSync(join(PACKAGE_ROOT, 'AGENTS.template.md'), agentsPath)
|
|
491
1217
|
// CLAUDE.md는 AGENTS.md와 동일 정책: **부재 시에만** 생성(--force로도 덮어쓰지 않는다 — 사용자 파일일 수 있다).
|
|
492
1218
|
if (claudeMdCreated) copyFileSync(join(PACKAGE_ROOT, KIT_CLAUDE_TEMPLATE_REL), claudeMdPath)
|
|
493
1219
|
if (agentsContractCopyCreated) copyFileSync(join(PACKAGE_ROOT, 'AGENTS.template.md'), contractCopyPath)
|
|
1220
|
+
// workflow/.gitignore도 부재 시에만 생성(--force로도 안 덮음, D12). workflow/는 KIT_COPY_RELPATHS 복사로 이미 존재.
|
|
1221
|
+
if (workflowGitignoreCreated) {
|
|
1222
|
+
mkdirSync(dirname(workflowGitignorePath), { recursive: true })
|
|
1223
|
+
copyFileSync(workflowGitignoreSrcAbs, workflowGitignorePath)
|
|
1224
|
+
}
|
|
494
1225
|
}
|
|
495
1226
|
|
|
1227
|
+
if (workflowGitignoreUserDiffers)
|
|
1228
|
+
console.warn(
|
|
1229
|
+
`⚠️ 기존 ${KIT_GITIGNORE.dest} 가 kit 템플릿과 다릅니다(보존 — --force로도 덮지 않음).\n` +
|
|
1230
|
+
` 아래 규칙이 (뒤에 \`!\` 부정 override 없이) 있는지 확인·병합하십시오 — 없으면 티켓 scratch가 git status에 계속 잡힙니다:\n` +
|
|
1231
|
+
kitGitignoreRules().map((r) => ` ${r}`).join('\n'),
|
|
1232
|
+
)
|
|
1233
|
+
|
|
496
1234
|
if (agentsMarkerMissing) {
|
|
497
1235
|
// phase-3a R1 observation: 사본을 **새로 놓았을 때**와 **기존 것을 보존했을 때**의 문구가 달라야 한다.
|
|
498
1236
|
// "설치했습니다"가 두 경우 모두에 나오면, 편집된 사본을 그대로 둔 사용자가 새 템플릿을 받았다고 오해한다.
|
|
@@ -511,6 +1249,11 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
511
1249
|
targetRoot,
|
|
512
1250
|
copied,
|
|
513
1251
|
skipped,
|
|
1252
|
+
artifacts,
|
|
1253
|
+
gitIgnoredArtifacts,
|
|
1254
|
+
lockfileRel: plan.lockfileRel,
|
|
1255
|
+
nodeModulesWillDirty: nmWillDirty,
|
|
1256
|
+
preexistingDirty,
|
|
514
1257
|
configAction,
|
|
515
1258
|
configKeysAdded,
|
|
516
1259
|
packageJsonAdded,
|
|
@@ -522,9 +1265,131 @@ export function runInit(opts: InitOptions): InitResult {
|
|
|
522
1265
|
agentsMarkerMissing,
|
|
523
1266
|
agentsContractCopyCreated,
|
|
524
1267
|
agentEntrypointsSkipped,
|
|
1268
|
+
workflowGitignoreCreated,
|
|
1269
|
+
workflowGitignoreUserDiffers,
|
|
1270
|
+
workflowGitignorePolicyAtRisk,
|
|
525
1271
|
}
|
|
526
1272
|
}
|
|
527
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* 설치 후 `git add` 대상. 산출물 전수에서 **무시되고 untracked인 것**만 뺀다 — `git add <ignored>`는 fatal이다.
|
|
1276
|
+
* `planArtifactPaths`(preflight)와 같은 목록을 소비하므로 두 축이 갈라질 수 없다(DEC-011-9).
|
|
1277
|
+
*/
|
|
1278
|
+
export function stageList(artifacts: readonly string[], gitIgnoredArtifacts: readonly string[]): string[] {
|
|
1279
|
+
const ignored = new Set(gitIgnoredArtifacts)
|
|
1280
|
+
return artifacts.filter((p) => !ignored.has(p))
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** 인용 없이 안전한 경로(영숫자 + 경로 구분자 + 흔한 구두점). */
|
|
1284
|
+
const SHELL_SAFE_PATH = /^[A-Za-z0-9._+\-/\\:@]+$/
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* 안내에 넣을 경로를 셸에 안전하게 인용한다(phase-6 리뷰 R5).
|
|
1288
|
+
*
|
|
1289
|
+
* 공백이 든 경로를 그대로 인쇄하면 `cd C:\Work\My Repo`가 PowerShell에서 인자 두 개로 쪼개지고,
|
|
1290
|
+
* `git stash push -u -- notes today.txt`는 pathspec 두 개가 되어 그 파일이 dirty로 남는다.
|
|
1291
|
+
* 큰따옴표는 sh·PowerShell·cmd가 모두 경로 묶음으로 인정한다.
|
|
1292
|
+
*
|
|
1293
|
+
* ⚠️ 경로에 `"`·`` ` ``·`$`가 있으면 셸마다 이스케이프 규칙이 달라 **단일 안전 표기가 없다.**
|
|
1294
|
+
* 그럴 땐 묶기만 하고 `pathNeedsManualQuoting`이 사용자에게 수동 확인을 지시하게 한다.
|
|
1295
|
+
*/
|
|
1296
|
+
export function quoteForShell(p: string): string {
|
|
1297
|
+
return SHELL_SAFE_PATH.test(p) ? p : `"${p}"`
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* 큰따옴표로도 셸 간 안전을 보장할 수 없는 경로인가.
|
|
1302
|
+
*
|
|
1303
|
+
* - `"` : 인용을 닫는다.
|
|
1304
|
+
* - `` ` ``·`$` : PowerShell이 큰따옴표 **안에서** 확장·이스케이프한다.
|
|
1305
|
+
* - `%`·`!` : **cmd.exe가 큰따옴표 안에서도** 환경변수(`%VAR%`)와 지연확장(`!VAR!`)을 치환한다.
|
|
1306
|
+
* `notes %USERPROFILE%.txt` 같은 경로는 다른 pathspec으로 바뀌어 엉뚱한 파일을 stash할 수 있다.
|
|
1307
|
+
*
|
|
1308
|
+
* 이런 경로가 하나라도 있으면 복붙 명령을 **내지 않는다** — 잘못된 명령보다 명령 없음이 낫다.
|
|
1309
|
+
*/
|
|
1310
|
+
export function pathNeedsManualQuoting(p: string): boolean {
|
|
1311
|
+
return /["`$%!]/.test(p)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* 설치 직후 "다음:" 안내(D4). **순수 함수** — `InitResult`만 보고 줄 배열을 만든다(테스트 가능).
|
|
1316
|
+
*
|
|
1317
|
+
* 세 가지 규칙:
|
|
1318
|
+
* 1. **`git add -A` 금지**(DEC-011-7). brownfield의 무관한 변경과 `.env`가 함께 커밋되고,
|
|
1319
|
+
* 이어지는 `req:review-codex`가 staged diff 전문을 외부로 전송한다.
|
|
1320
|
+
* 2. **shell 연산자 금지**(DEC-011-8). `&&`는 Windows PowerShell 5.1·cmd.exe에 없다.
|
|
1321
|
+
* 3. **안전한 안내를 만들 수 없으면 내지 않는다**(DEC-011-11). staged 변경은 커밋이 삼키고,
|
|
1322
|
+
* 산출물과 겹치는 tracked 변경은 사후 분리가 불가능하다. 잘못된 안내보다 안내 없음이 낫다.
|
|
1323
|
+
*/
|
|
1324
|
+
export function installGuidance(r: InitResult): string[] {
|
|
1325
|
+
const { staged, overlapping, unrelated } = r.preexistingDirty
|
|
1326
|
+
// `.gitignore`를 담아야 하는데 그 파일 자신이 무시되면 `git add`가 fatal이고 규칙이 커밋되지 않는다 →
|
|
1327
|
+
// 이식 가능한 clean-tree를 보장할 수 없다. 안내를 내지 않는다(phase-6 리뷰 R6).
|
|
1328
|
+
const gitignoreUnstageable = r.artifacts.includes('.gitignore') && r.gitIgnoredArtifacts.includes('.gitignore')
|
|
1329
|
+
// 어떤 셸 인용으로도 안전하지 않은 경로 — cmd.exe는 큰따옴표 안에서도 `%VAR%`·`!VAR!`를 치환한다(R7).
|
|
1330
|
+
const risky = [r.targetRoot, ...r.artifacts, ...unrelated].filter(pathNeedsManualQuoting)
|
|
1331
|
+
const unsafe =
|
|
1332
|
+
staged.length > 0 || overlapping.length > 0 || gitignoreUnstageable || risky.length > 0 || r.workflowGitignorePolicyAtRisk
|
|
1333
|
+
|
|
1334
|
+
const cdLine = risky.includes(r.targetRoot) ? ` 1. 저장소 루트로 이동: ${r.targetRoot}` : ` 1. cd ${quoteForShell(r.targetRoot)}`
|
|
1335
|
+
const out: string[] = ['', '다음:', cdLine, ` 2. ${r.packageManager} install`]
|
|
1336
|
+
out.push(` 3. codex --version # 리뷰 실호출 전제(미설치면 review-codex --run이 fail-closed)`)
|
|
1337
|
+
out.push(` 4. req.config.json 확인(branchPrefix 등)`)
|
|
1338
|
+
|
|
1339
|
+
if (unsafe) {
|
|
1340
|
+
out.push('')
|
|
1341
|
+
out.push(' ⚠️ 안전한 커밋 안내를 만들 수 없습니다.')
|
|
1342
|
+
for (const p of staged) out.push(` staged (커밋에 함께 들어갑니다) ${p}`)
|
|
1343
|
+
for (const p of overlapping) out.push(` 설치분과 겹침 (사후 분리 불가) ${p}`)
|
|
1344
|
+
if (gitignoreUnstageable)
|
|
1345
|
+
out.push(' .gitignore 자체가 무시됨 (규칙이 clone에 따라가지 않음)')
|
|
1346
|
+
if (r.workflowGitignorePolicyAtRisk)
|
|
1347
|
+
out.push(` ${KIT_GITIGNORE.dest} 가 무시됨 — 설치 커밋에 못 담겨 fresh clone·CI에 scratch 정책이 없습니다`)
|
|
1348
|
+
for (const p of risky)
|
|
1349
|
+
out.push(` 셸 특수문자(" \` $ % !) 포함 — 어떤 인용으로도 복붙이 안전하지 않음: ${p}`)
|
|
1350
|
+
out.push(' 위를 커밋하거나 되돌린 뒤, `git status` 로 직접 확인하며 설치분만 커밋하십시오.')
|
|
1351
|
+
out.push(` 그다음: ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
|
|
1352
|
+
return out
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const toStage = stageList(r.artifacts, r.gitIgnoredArtifacts)
|
|
1356
|
+
let n = 5
|
|
1357
|
+
if (r.nodeModulesWillDirty) {
|
|
1358
|
+
// `<pm> install`이 만든 `?? node_modules/`가 clean-tree 게이트를 막는다. README의 `git init && npm init -y`
|
|
1359
|
+
// 경로에는 .gitignore가 없어 **반드시** 걸린다. `.gitignore`는 이미 `artifacts`에 들어 있으므로
|
|
1360
|
+
// stage 목록에 자동으로 포함되고, tracked인데 이미 dirty하면 위의 unsafe 분기가 먼저 막는다.
|
|
1361
|
+
out.push(` ${n++}. \`node_modules\` 가 .gitignore 되어 있지 않습니다. 2단계 install 이 만든 그 디렉터리가`)
|
|
1362
|
+
out.push(` 워킹트리를 dirty 하게 만들어 req:new 가 막힙니다. .gitignore 에 \`node_modules/\` 를 추가하십시오.`)
|
|
1363
|
+
}
|
|
1364
|
+
out.push(` ${n++}. 설치분만 stage 하십시오. 전체를 담는 stage(-A / .)는 쓰지 마십시오 — 무관한 변경·.env 가`)
|
|
1365
|
+
out.push(` 함께 커밋되고, 이어지는 req:review-codex 가 staged diff 전문을 외부로 전송합니다.`)
|
|
1366
|
+
if (r.lockfileRel !== null)
|
|
1367
|
+
out.push(` (2단계 install 을 먼저 실행해야 ${r.lockfileRel} 이 존재합니다. lockfile 을 만들지 않는 설정이라면 그 경로는 빼십시오.)`)
|
|
1368
|
+
out.push(` git add -- ${toStage.map(quoteForShell).join(' ')}`)
|
|
1369
|
+
out.push(` git status # 의도한 것만 staged 인지 눈으로 확인`)
|
|
1370
|
+
out.push(` git commit -m "chore: install commitgate"`)
|
|
1371
|
+
|
|
1372
|
+
// ⚠️ 사용자 소유 workflow/.gitignore(differs)는 stash 대상에서 뺀다(phase-2 리뷰 P4). stash하면 그 정책 파일이
|
|
1373
|
+
// 사라져 fresh clone에서 scratch가 다시 나타나고 다음 req:new가 막힌다. 별도로 확인·커밋하도록 안내한다.
|
|
1374
|
+
const userGitignoreDirty = r.workflowGitignoreUserDiffers && unrelated.includes(KIT_GITIGNORE.dest)
|
|
1375
|
+
const stashUnrelated = userGitignoreDirty ? unrelated.filter((p) => p !== KIT_GITIGNORE.dest) : unrelated
|
|
1376
|
+
|
|
1377
|
+
if (userGitignoreDirty) {
|
|
1378
|
+
out.push(` ${n++}. ${KIT_GITIGNORE.dest} 는 당신의 파일입니다(미커밋). stash 하지 말고 —`)
|
|
1379
|
+
out.push(` kit 규칙이 있는지 확인한 뒤 **직접 커밋**하십시오(fresh clone·CI에 scratch 정책이 있어야 합니다).`)
|
|
1380
|
+
}
|
|
1381
|
+
if (stashUnrelated.length > 0) {
|
|
1382
|
+
// ⚠️ bare `git stash -u`는 너무 넓다 — `node_modules/`처럼 gitignore되지 않은 산출물까지 쓸어 가
|
|
1383
|
+
// 방금 설치한 tsx가 사라지고 req:new가 죽는다(실측). 경로를 명시한다(DEC-011-7과 같은 원칙).
|
|
1384
|
+
// `-u` 없이는 untracked가 남아 clean-tree 게이트가 여전히 실패한다(design 리뷰 R5).
|
|
1385
|
+
out.push(` ${n++}. 설치 커밋 뒤, 설치 전부터 있던 아래 무관한 변경을 커밋하거나 치우십시오`)
|
|
1386
|
+
out.push(` (req:new 는 clean 워킹트리를 요구합니다):`)
|
|
1387
|
+
out.push(` git stash push -u -- ${stashUnrelated.map(quoteForShell).join(' ')}`)
|
|
1388
|
+
}
|
|
1389
|
+
out.push(` ${n}. ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
|
|
1390
|
+
return out
|
|
1391
|
+
}
|
|
1392
|
+
|
|
528
1393
|
export function parseArgs(argv: string[]): InitOptions {
|
|
529
1394
|
let dir = process.cwd()
|
|
530
1395
|
let force = false
|
|
@@ -557,25 +1422,38 @@ export function parseArgs(argv: string[]): InitOptions {
|
|
|
557
1422
|
}
|
|
558
1423
|
|
|
559
1424
|
function printHelp(): void {
|
|
560
|
-
console.log(`commitgate — AI REQ workflow(커밋 게이트)
|
|
1425
|
+
console.log(`commitgate — AI REQ workflow(커밋 게이트) 설치
|
|
1426
|
+
|
|
1427
|
+
⚠️ commitgate 를 **먼저 devDependency 로 설치**해야 합니다:
|
|
1428
|
+
npm install -D commitgate
|
|
1429
|
+
그다음 이 명령을 실행합니다. 실행 코드는 대상 repo 에 복사되지 않고
|
|
1430
|
+
node_modules/commitgate 에서 돕니다(req:* 스크립트가 그리로 dispatch).
|
|
561
1431
|
|
|
562
1432
|
사용법:
|
|
563
|
-
npx commitgate [--dir <대상repo>] [--force] [--dry-run] [--strict]
|
|
564
|
-
npx commitgate
|
|
1433
|
+
npx commitgate [init] [--dir <대상repo>] [--force] [--dry-run] [--strict]
|
|
1434
|
+
npx commitgate migrate [--apply] [--dir <대상repo>] # 예전 vendored 설치본 → 런타임 패키지(기본: 계획만)
|
|
1435
|
+
npx commitgate uninstall [--dir <대상repo>] # 제거 계획만 출력(아무것도 지우지 않음)
|
|
565
1436
|
|
|
566
1437
|
옵션:
|
|
567
1438
|
--dir <path> 대상 repo 루트(기본: 현재 디렉터리)
|
|
568
|
-
--force
|
|
1439
|
+
--force 덮어쓰기 가능한 kit 항목만 갱신(기본: 스킵).
|
|
1440
|
+
AGENTS.md · CLAUDE.md · workflow/.gitignore · companion skills(.claude/skills/commitgate-*)는
|
|
1441
|
+
기존 파일을 **보존**합니다 — --force 로도 덮어쓰지 않습니다.
|
|
569
1442
|
--dry-run 변경 없이 수행 예정 목록만 출력
|
|
570
|
-
--strict
|
|
1443
|
+
--strict 정합성 경고를 설치 실패로 취급(fail-closed)
|
|
571
1444
|
--no-agent-entrypoints
|
|
572
1445
|
.claude/·.cursor/·CLAUDE.md 진입점 설치를 건너뛴다
|
|
573
1446
|
-h, --help 도움말
|
|
574
1447
|
|
|
1448
|
+
설치하는 것: workflow 스키마 2종 · reviewer persona · req.config.json ·
|
|
1449
|
+
AGENTS.md/CLAUDE.md·에이전트 진입점 · package.json 의 req:* 스크립트.
|
|
1450
|
+
설치하지 않는 것: scripts/req/** 실행 코드 · tsx/ajv/cross-spawn devDependency
|
|
1451
|
+
(전부 commitgate 패키지에 들어 있습니다).
|
|
1452
|
+
|
|
575
1453
|
설치 후:
|
|
576
|
-
1.
|
|
577
|
-
2.
|
|
578
|
-
3.
|
|
1454
|
+
1. codex CLI 설치 확인(리뷰 실호출용)
|
|
1455
|
+
2. req.config.json 조정(branchPrefix/ticketRoot 등)
|
|
1456
|
+
3. 설치분 커밋(안내가 stage 할 경로를 알려 줍니다)
|
|
579
1457
|
4. 첫 티켓 생성:
|
|
580
1458
|
npm → npm run req:new -- <slug> --run
|
|
581
1459
|
pnpm → pnpm req:new <slug> --run
|
|
@@ -607,14 +1485,17 @@ export function main(argv: string[]): void {
|
|
|
607
1485
|
console.log(`${tag} CLAUDE.md: ${r.claudeMdCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
|
|
608
1486
|
if (r.agentsContractCopyCreated) console.log(`${tag} ${KIT_AGENTS_CONTRACT_COPY_REL}: 계약 템플릿 사본 생성(AGENTS.md에 병합 후 삭제)`)
|
|
609
1487
|
}
|
|
1488
|
+
// workflow/.gitignore는 --no-agent-entrypoints와 무관하게 보고(D13).
|
|
1489
|
+
console.log(`${tag} ${KIT_GITIGNORE.dest}: ${r.workflowGitignoreCreated ? '생성' : '이미 존재(유지)'}`)
|
|
1490
|
+
if (r.gitIgnoredArtifacts.length > 0)
|
|
1491
|
+
console.log(`${tag} .gitignore로 제외되는 산출물: ${r.gitIgnoredArtifacts.join(', ')}`)
|
|
1492
|
+
const dirtyCount = r.preexistingDirty.staged.length + r.preexistingDirty.overlapping.length + r.preexistingDirty.unrelated.length
|
|
1493
|
+
if (dirtyCount > 0)
|
|
1494
|
+
console.log(
|
|
1495
|
+
`${tag} 설치 전 워킹트리: staged ${r.preexistingDirty.staged.length} / 설치분과 겹침 ${r.preexistingDirty.overlapping.length} / 무관 ${r.preexistingDirty.unrelated.length}`,
|
|
1496
|
+
)
|
|
610
1497
|
if (r.crossSpawnFloorWarned) console.log(`${tag} ⚠️ cross-spawn 버전 하한 경고(위 참조) — 강제 중단은 --strict`)
|
|
611
|
-
if (!r.dryRun)
|
|
612
|
-
console.log(`\n다음:`)
|
|
613
|
-
console.log(` 1. cd ${r.targetRoot} && ${r.packageManager} install`)
|
|
614
|
-
console.log(` 2. codex --version # 리뷰 실호출 전제(미설치면 review-codex --run이 fail-closed)`)
|
|
615
|
-
console.log(` 3. req.config.json 확인(branchPrefix 등)`)
|
|
616
|
-
console.log(` 4. ${runScriptCmd(r.packageManager, 'req:new', '<slug> --run')}`)
|
|
617
|
-
}
|
|
1498
|
+
if (!r.dryRun) for (const line of installGuidance(r)) console.log(line)
|
|
618
1499
|
}
|
|
619
1500
|
|
|
620
1501
|
/**
|