commitgate 0.1.2 → 0.2.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.
Files changed (2) hide show
  1. package/bin/init.ts +115 -5
  2. package/package.json +66 -63
package/bin/init.ts CHANGED
@@ -25,6 +25,7 @@ import { resolve, join, dirname, relative } from 'node:path'
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url'
26
26
  import { loadConfig, stripBom, type PackageManager } from '../scripts/req/lib/config'
27
27
  import { createGitAdapter } from '../scripts/req/lib/adapters'
28
+ import * as semver from 'semver'
28
29
 
29
30
  /** 이 패키지 루트(bin/ 기준 1단계 위). 복사 원본. */
30
31
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -37,10 +38,13 @@ const REQ_SCRIPTS: Record<string, string> = {
37
38
  'req:commit': 'tsx scripts/req/req-commit.ts',
38
39
  }
39
40
 
41
+ /** cross-spawn 주입 spec(= 보안 하한 SSOT). 진단(#1)과 주입이 이 값을 공유. */
42
+ const CROSS_SPAWN_SPEC = '^7.0.6'
43
+
40
44
  /** 대상 package.json에 주입할 devDeps(워크플로 실행 전제). cross-spawn = 복사된 adapters.ts의 안전 spawn(P1) 런타임 의존. */
41
45
  const REQ_DEV_DEPS: Record<string, string> = {
42
46
  ajv: '^8.20.0',
43
- 'cross-spawn': '^7.0.6',
47
+ 'cross-spawn': CROSS_SPAWN_SPEC,
44
48
  tsx: '^4.19.1',
45
49
  }
46
50
 
@@ -48,6 +52,7 @@ export interface InitOptions {
48
52
  dir: string
49
53
  force: boolean
50
54
  dryRun: boolean
55
+ strict: boolean // cross-spawn 하한 미만이면 WARN 대신 throw(#1)
51
56
  }
52
57
 
53
58
  export interface InitResult {
@@ -59,6 +64,7 @@ export interface InitResult {
59
64
  packageJsonAdded: string[] // 추가된 script/devDep 키
60
65
  agentsCreated: boolean
61
66
  packageManager: PackageManager
67
+ crossSpawnFloorWarned: boolean // 기존 cross-spawn이 보안 하한 미만이라 경고(#1)
62
68
  dryRun: boolean
63
69
  }
64
70
 
@@ -142,6 +148,92 @@ function parseJsonObject(path: string, label: string): Record<string, unknown> {
142
148
  return parsed as Record<string, unknown>
143
149
  }
144
150
 
151
+ // ─────────────────────────────── cross-spawn 버전 하한 진단 (#1) ──
152
+
153
+ /** 보안 하한 = 주입 spec의 최소버전(SSOT — 하드코딩 이중화 금지). '^7.0.6' → 7.0.6. */
154
+ const CROSS_SPAWN_FLOOR = semver.minVersion(CROSS_SPAWN_SPEC)
155
+
156
+ /** obj가 plain object일 때 obj[key](문자열). 아니면 undefined. */
157
+ function stringField(obj: unknown, key: string): string | undefined {
158
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
159
+ const v = (obj as Record<string, unknown>)[key]
160
+ if (typeof v === 'string') return v
161
+ }
162
+ return undefined
163
+ }
164
+
165
+ /** 대상의 기존 cross-spawn spec(devDeps 우선, 없으면 deps). 없으면 null. */
166
+ function existingCrossSpawnSpec(pkg: Record<string, unknown>): string | null {
167
+ return stringField(pkg.devDependencies, 'cross-spawn') ?? stringField(pkg.dependencies, 'cross-spawn') ?? null
168
+ }
169
+
170
+ /** node_modules에 실제 설치된 cross-spawn 버전(valid semver). 없으면 null. */
171
+ function installedCrossSpawnVersion(targetRoot: string): string | null {
172
+ const p = join(targetRoot, 'node_modules', 'cross-spawn', 'package.json')
173
+ if (!existsSync(p)) return null
174
+ try {
175
+ const v = (JSON.parse(stripBom(readFileSync(p, 'utf8'))) as { version?: unknown }).version
176
+ return typeof v === 'string' && semver.valid(v) ? v : null
177
+ } catch {
178
+ return null
179
+ }
180
+ }
181
+
182
+ /** lockfile 해소 cross-spawn 버전(package-lock v2/v3 JSON 우선, pnpm/yarn best-effort). 없으면 null. */
183
+ function lockedCrossSpawnVersion(targetRoot: string): string | null {
184
+ const pl = join(targetRoot, 'package-lock.json')
185
+ if (existsSync(pl)) {
186
+ try {
187
+ const j = JSON.parse(stripBom(readFileSync(pl, 'utf8'))) as {
188
+ packages?: Record<string, { version?: unknown }>
189
+ dependencies?: Record<string, { version?: unknown }>
190
+ }
191
+ const v = j.packages?.['node_modules/cross-spawn']?.version ?? j.dependencies?.['cross-spawn']?.version
192
+ if (typeof v === 'string' && semver.valid(v)) return v
193
+ } catch {
194
+ /* best-effort */
195
+ }
196
+ }
197
+ for (const [file, re] of [
198
+ ['pnpm-lock.yaml', /cross-spawn@(\d+\.\d+\.\d+)/],
199
+ ['yarn.lock', /(?:^|\n)"?cross-spawn@[^\n]*:[\s\S]*?\n\s+version:?\s+"?(\d+\.\d+\.\d+)"?/],
200
+ ] as const) {
201
+ const fp = join(targetRoot, file)
202
+ if (!existsSync(fp)) continue
203
+ try {
204
+ const m = readFileSync(fp, 'utf8').match(re)
205
+ if (m?.[1] && semver.valid(m[1])) return m[1]
206
+ } catch {
207
+ /* best-effort */
208
+ }
209
+ }
210
+ return null
211
+ }
212
+
213
+ /**
214
+ * 기존 cross-spawn이 보안 하한 미만인지 판정(#1). 우선순위: **설치버전 → lockfile 해소버전 → range**.
215
+ * range fallback은 `>=floor`로 절대 해소 불가한 spec만 below로 본다(‘^7.0.0’·‘~7.0.1’ 오탐 방지 — R1 P2).
216
+ * 기존 cross-spawn 없으면 null(우리가 `^7.0.6` 주입 → 진단 불필요).
217
+ */
218
+ export function crossSpawnBelowFloor(
219
+ targetRoot: string,
220
+ pkg: Record<string, unknown>,
221
+ ): { below: boolean; detail: string } | null {
222
+ if (!CROSS_SPAWN_FLOOR) return null // 이론상 도달 불가(REQ_DEV_DEPS 고정값)
223
+ const floor = CROSS_SPAWN_FLOOR.version
224
+ const spec = existingCrossSpawnSpec(pkg)
225
+ if (!spec) return null
226
+
227
+ const installed = installedCrossSpawnVersion(targetRoot)
228
+ if (installed) return { below: semver.lt(installed, floor), detail: `설치버전 ${installed}` }
229
+
230
+ const locked = lockedCrossSpawnVersion(targetRoot)
231
+ if (locked) return { below: semver.lt(locked, floor), detail: `lockfile ${locked}` }
232
+
233
+ if (semver.validRange(spec)) return { below: !semver.intersects(spec, `>=${floor}`), detail: `범위 ${spec}` }
234
+ return { below: false, detail: `범위 ${spec}(파싱 불가 — 무경고)` }
235
+ }
236
+
145
237
  /**
146
238
  * 설치 코어. IO는 여기서만(테스트가 임시 repo로 직접 호출).
147
239
  * **Preflight(전 검증·파싱) → Apply(쓰기) 2단계** — malformed 입력에 대해 어떤 파일도 복사·수정하기 전에 실패한다(부분 설치 방지, design R2 P2).
@@ -161,11 +253,23 @@ export function runInit(opts: InitOptions): InitResult {
161
253
  scripts?: Record<string, string>
162
254
  devDependencies?: Record<string, string>
163
255
  }
164
- // scripts·devDependencies가 존재하면 반드시 plain object — 배열/원시면 patch 조용히 유실되어(성공 보고인데 req:* 미주입) fail-closed 위반(phase R1 P2).
165
- for (const field of ['scripts', 'devDependencies'] as const) {
256
+ // scripts·devDependencies·dependencies가 존재하면 반드시 plain object — 배열/원시면 patch 유실(scripts/devDeps, phase R1 P2)
257
+ // 또는 cross-spawn 진단(dependencies, design R1 P3) 오동작. 읽기 전에 shape 검증(fail-closed).
258
+ for (const field of ['scripts', 'devDependencies', 'dependencies'] as const) {
166
259
  const v = (pkg as Record<string, unknown>)[field]
167
260
  if (v !== undefined && (typeof v !== 'object' || v === null || Array.isArray(v)))
168
- throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — req:* 주입 불가(배열/원시 미지원).`)
261
+ throw new Error(`package.json의 ${field} 필드가 객체가 아님(${pkgPath}) — 배열/원시 미지원.`)
262
+ }
263
+
264
+ // cross-spawn 보안 하한 진단(#1): 기존 cross-spawn이 하한 미만이면 WARN(기본)/throw(--strict). preflight라 strict throw 시 부분 설치 없음.
265
+ let crossSpawnFloorWarned = false
266
+ const floorCheck = crossSpawnBelowFloor(targetRoot, pkg as Record<string, unknown>)
267
+ if (floorCheck?.below) {
268
+ const spec = CROSS_SPAWN_SPEC
269
+ const msg = `기존 cross-spawn(${floorCheck.detail})이 보안 하한 >=${CROSS_SPAWN_FLOOR?.version} 미만 — CommitGate 안전 경계(safeSpawnSync)는 ${spec} 검증분입니다. 'npm i -D cross-spawn@${spec}' 권장.`
270
+ if (opts.strict) throw new Error(`[--strict] ${msg}`)
271
+ console.warn(`⚠️ ${msg} (설치는 계속 — 강제 중단하려면 --strict)`)
272
+ crossSpawnFloorWarned = true
169
273
  }
170
274
 
171
275
  const cfgPath = join(targetRoot, 'req.config.json')
@@ -247,6 +351,7 @@ export function runInit(opts: InitOptions): InitResult {
247
351
  packageJsonAdded,
248
352
  agentsCreated,
249
353
  packageManager,
354
+ crossSpawnFloorWarned,
250
355
  dryRun: opts.dryRun,
251
356
  }
252
357
  }
@@ -255,6 +360,7 @@ export function parseArgs(argv: string[]): InitOptions {
255
360
  let dir = process.cwd()
256
361
  let force = false
257
362
  let dryRun = false
363
+ let strict = false
258
364
  for (let i = 0; i < argv.length; i++) {
259
365
  const a = argv[i]
260
366
  if (a === '--dir') {
@@ -266,6 +372,8 @@ export function parseArgs(argv: string[]): InitOptions {
266
372
  force = true
267
373
  } else if (a === '--dry-run') {
268
374
  dryRun = true
375
+ } else if (a === '--strict') {
376
+ strict = true
269
377
  } else if (a === '-h' || a === '--help') {
270
378
  printHelp()
271
379
  process.exit(0)
@@ -273,7 +381,7 @@ export function parseArgs(argv: string[]): InitOptions {
273
381
  throw new Error(`알 수 없는 인자: ${a}`)
274
382
  }
275
383
  }
276
- return { dir: resolve(dir), force, dryRun }
384
+ return { dir: resolve(dir), force, dryRun, strict }
277
385
  }
278
386
 
279
387
  function printHelp(): void {
@@ -286,6 +394,7 @@ function printHelp(): void {
286
394
  --dir <path> 대상 repo 루트(기본: 현재 디렉터리)
287
395
  --force 기존 kit 파일 덮어쓰기(기본: 스킵)
288
396
  --dry-run 변경 없이 수행 예정 목록만 출력
397
+ --strict 기존 cross-spawn이 보안 하한(>=7.0.6) 미만이면 경고 대신 중단(fail-closed)
289
398
  -h, --help 도움말
290
399
 
291
400
  설치 후:
@@ -315,6 +424,7 @@ export function main(argv: string[]): void {
315
424
  `${tag} package.json: ${r.packageJsonAdded.length > 0 ? '추가 ' + r.packageJsonAdded.join(', ') : '변경 없음'}`,
316
425
  )
317
426
  console.log(`${tag} AGENTS.md: ${r.agentsCreated ? '템플릿 생성' : '이미 존재(유지)'}`)
427
+ if (r.crossSpawnFloorWarned) console.log(`${tag} ⚠️ cross-spawn 버전 하한 경고(위 참조) — 강제 중단은 --strict`)
318
428
  if (!r.dryRun) {
319
429
  console.log(`\n다음:`)
320
430
  console.log(` 1. cd ${r.targetRoot} && ${r.packageManager} install`)
package/package.json CHANGED
@@ -1,63 +1,66 @@
1
- {
2
- "name": "commitgate",
3
- "version": "0.1.2",
4
- "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "sol5288",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/sol5288/commitgate.git"
11
- },
12
- "homepage": "https://github.com/sol5288/commitgate#readme",
13
- "bugs": {
14
- "url": "https://github.com/sol5288/commitgate/issues"
15
- },
16
- "keywords": [
17
- "ai",
18
- "code-review",
19
- "workflow",
20
- "codex",
21
- "claude",
22
- "git",
23
- "commit-gate",
24
- "fail-closed",
25
- "review-gate",
26
- "cli"
27
- ],
28
- "bin": {
29
- "commitgate": "bin/commitgate.mjs"
30
- },
31
- "scripts": {
32
- "req:new": "tsx scripts/req/req-new.ts",
33
- "req:review-codex": "tsx scripts/req/review-codex.ts",
34
- "req:doctor": "tsx scripts/req/req-doctor.ts",
35
- "req:commit": "tsx scripts/req/req-commit.ts",
36
- "test": "vitest run",
37
- "typecheck": "tsc --noEmit"
38
- },
39
- "files": [
40
- "scripts",
41
- "workflow/machine.schema.json",
42
- "workflow/req.config.schema.json",
43
- "bin",
44
- "AGENTS.template.md",
45
- "req.config.json.sample",
46
- "README.md",
47
- "README.en.md"
48
- ],
49
- "engines": {
50
- "node": ">=18.17"
51
- },
52
- "dependencies": {
53
- "ajv": "^8.20.0",
54
- "cross-spawn": "^7.0.6",
55
- "tsx": "^4.19.1"
56
- },
57
- "devDependencies": {
58
- "@types/cross-spawn": "^6.0.6",
59
- "@types/node": "^22.7.4",
60
- "typescript": "^5.6.2",
61
- "vitest": "^2.1.2"
62
- }
63
- }
1
+ {
2
+ "name": "commitgate",
3
+ "version": "0.2.0",
4
+ "description": "CommitGate — Builder↔Reviewer(Claude↔Codex) fail-closed 커밋 게이트: 리뷰·승인·증거 없인 커밋을 통과시키지 않는 AI REQ 워크플로 kit (req:new/review-codex/doctor/commit)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "sol5288",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sol5288/commitgate.git"
11
+ },
12
+ "homepage": "https://github.com/sol5288/commitgate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sol5288/commitgate/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "code-review",
19
+ "workflow",
20
+ "codex",
21
+ "claude",
22
+ "git",
23
+ "commit-gate",
24
+ "fail-closed",
25
+ "review-gate",
26
+ "cli"
27
+ ],
28
+ "bin": {
29
+ "commitgate": "bin/commitgate.mjs"
30
+ },
31
+ "scripts": {
32
+ "req:new": "tsx scripts/req/req-new.ts",
33
+ "req:review-codex": "tsx scripts/req/review-codex.ts",
34
+ "req:doctor": "tsx scripts/req/req-doctor.ts",
35
+ "req:commit": "tsx scripts/req/req-commit.ts",
36
+ "test": "vitest run",
37
+ "typecheck": "tsc --noEmit",
38
+ "smoke": "node scripts/smoke.mjs"
39
+ },
40
+ "files": [
41
+ "scripts/req",
42
+ "workflow/machine.schema.json",
43
+ "workflow/req.config.schema.json",
44
+ "bin",
45
+ "AGENTS.template.md",
46
+ "req.config.json.sample",
47
+ "README.md",
48
+ "README.en.md"
49
+ ],
50
+ "engines": {
51
+ "node": ">=18.17"
52
+ },
53
+ "dependencies": {
54
+ "ajv": "^8.20.0",
55
+ "cross-spawn": "^7.0.6",
56
+ "semver": "^7.6.3",
57
+ "tsx": "^4.19.1"
58
+ },
59
+ "devDependencies": {
60
+ "@types/cross-spawn": "^6.0.6",
61
+ "@types/node": "^22.7.4",
62
+ "@types/semver": "^7.5.8",
63
+ "typescript": "^5.6.2",
64
+ "vitest": "^2.1.2"
65
+ }
66
+ }