commitgate 0.1.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 +48 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/bin/commitgate.mjs +21 -0
- package/bin/init.ts +328 -0
- package/package.json +62 -0
- package/req.config.json.sample +13 -0
- package/scripts/req/lib/adapters.ts +139 -0
- package/scripts/req/lib/config.ts +177 -0
- package/scripts/req/req-commit.ts +792 -0
- package/scripts/req/req-doctor.ts +526 -0
- package/scripts/req/req-new.ts +173 -0
- package/scripts/req/review-codex.ts +927 -0
- package/workflow/machine.schema.json +38 -0
- package/workflow/req.config.schema.json +21 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commitgate",
|
|
3
|
+
"version": "0.1.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
|
+
},
|
|
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
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.17"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"ajv": "^8.20.0",
|
|
53
|
+
"cross-spawn": "^7.0.6",
|
|
54
|
+
"tsx": "^4.19.1"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/cross-spawn": "^6.0.6",
|
|
58
|
+
"@types/node": "^22.7.4",
|
|
59
|
+
"typescript": "^5.6.2",
|
|
60
|
+
"vitest": "^2.1.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ticketRoot": "workflow",
|
|
3
|
+
"schemaPath": "workflow/machine.schema.json",
|
|
4
|
+
"handoffPath": null,
|
|
5
|
+
"branchPrefix": "feat/req-",
|
|
6
|
+
"packageManager": "npm",
|
|
7
|
+
"granularityMaxFiles": 8,
|
|
8
|
+
"designDocs": {
|
|
9
|
+
"requirement": "00-requirement.md",
|
|
10
|
+
"design": "01-design.md",
|
|
11
|
+
"plan": "02-plan.md"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* req 워크플로 어댑터 모듈 (REQ-2026-017 Phase 3, portability kit).
|
|
3
|
+
*
|
|
4
|
+
* 목적: git·codex(reviewer) 호출을 **얇은 경계 인터페이스**로 추상화 — plumbing 로직·승인 바인딩은 불변.
|
|
5
|
+
* - GitAdapter: 모든 git 호출을 단일 경계로(default = execFileSync('git', …, {cwd: root})). 비-git VCS는 후속 확장 여지.
|
|
6
|
+
* - ReviewerAdapter: codex 호출(exec/resume + thread 파싱 + --output-last-message)을 단일 경계로. 테스트는 FakeReviewerAdapter로 live codex 없이 검증.
|
|
7
|
+
*
|
|
8
|
+
* 안전(fail-closed): codex 미설치/실패는 default 구현이 그대로 throw(silent 금지, D-017-4·D-017-7).
|
|
9
|
+
* 본 모듈은 req 스크립트에 의존하지 않는 leaf(순환 의존 금지) — parseThreadId도 여기로 이동(codex CLI 전용 로직).
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { existsSync, readFileSync, mkdtempSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import spawn from 'cross-spawn'
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────── 안전 spawn (P1) ──
|
|
18
|
+
|
|
19
|
+
/** safeSpawnSync 옵션(필요분만). stdio 미지정 = 'pipe'(stdout 반환). */
|
|
20
|
+
export interface SafeSpawnOptions {
|
|
21
|
+
cwd?: string
|
|
22
|
+
input?: string
|
|
23
|
+
stdio?: 'pipe' | 'inherit'
|
|
24
|
+
maxBuffer?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 크로스플랫폼 **안전** 동기 spawn. cross-spawn으로 **shell 없이** 실행한다.
|
|
29
|
+
* - 인자의 shell 메타문자(`& | < > ^ " ' 백틱 $ ; ( ) ! %` 등)가 별도 명령으로 해석되지 않는다(명령 주입 차단).
|
|
30
|
+
* - 공백 포함 경로도 올바르게 전달된다(shell:true는 인용을 안 해 깨졌음).
|
|
31
|
+
* - Windows의 `.cmd` 래퍼(codex·npm·pnpm·yarn)도 안전하게 해소한다(과거 `shell:true`가 필요했던 이유를 대체).
|
|
32
|
+
* 실패(spawn 오류·exit≠0)면 throw(fail-closed — 기존 execFileSync 의미 보존).
|
|
33
|
+
*/
|
|
34
|
+
export function safeSpawnSync(file: string, args: readonly string[], opts: SafeSpawnOptions = {}): string {
|
|
35
|
+
const res = spawn.sync(file, args as string[], {
|
|
36
|
+
cwd: opts.cwd,
|
|
37
|
+
input: opts.input,
|
|
38
|
+
stdio: opts.stdio ?? 'pipe',
|
|
39
|
+
maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
|
|
40
|
+
})
|
|
41
|
+
if (res.error) throw res.error
|
|
42
|
+
if (res.status !== 0) {
|
|
43
|
+
const err = res.stderr ? res.stderr.toString('utf8') : ''
|
|
44
|
+
throw new Error(`명령 실패(exit=${res.status ?? 'null'}): ${file}\n${err}`.trim())
|
|
45
|
+
}
|
|
46
|
+
return res.stdout ? res.stdout.toString('utf8') : ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ──────────────────────────────────────────────────────────── Git ──
|
|
50
|
+
|
|
51
|
+
/** git 호출 경계(D-017-3). exec(args) = trim된 stdout, 실패 시 throw(fail-closed). */
|
|
52
|
+
export interface GitAdapter {
|
|
53
|
+
exec(args: string[]): string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** GitAdapter default 구현의 내부 실행자(주입 가능 — 테스트). encoding:'utf8'이라 string 반환. */
|
|
57
|
+
export type GitRunner = (file: string, args: string[], opts: { cwd: string; encoding: 'utf8' }) => string
|
|
58
|
+
const defaultGitRunner: GitRunner = (file, args, opts) => execFileSync(file, args, opts)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* default GitAdapter — `execFileSync('git', args, {cwd: root, encoding:'utf8'})` 후 **trailing 공백만 제거**.
|
|
62
|
+
* ⚠️ `.trim()`이 아니라 `.replace(/\s+$/, '')` — `git status --porcelain`의 **선행 공백(XY 코드)**을 보존(behavior-preserving).
|
|
63
|
+
*/
|
|
64
|
+
export function createGitAdapter(root: string, run: GitRunner = defaultGitRunner): GitAdapter {
|
|
65
|
+
return { exec: (args) => run('git', args, { cwd: root, encoding: 'utf8' }).replace(/\s+$/, '') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────── Reviewer (codex) ──
|
|
69
|
+
|
|
70
|
+
export interface ReviewRequest {
|
|
71
|
+
prompt: string
|
|
72
|
+
schemaPath: string
|
|
73
|
+
resumeThreadId: string | null
|
|
74
|
+
cwd: string
|
|
75
|
+
}
|
|
76
|
+
export interface ReviewResult {
|
|
77
|
+
rawStdout: string
|
|
78
|
+
lastMessage: string
|
|
79
|
+
threadId: string | null
|
|
80
|
+
}
|
|
81
|
+
/** codex 리뷰 경계(D-017-4). default=codex CLI, 테스트=FakeReviewerAdapter. */
|
|
82
|
+
export interface ReviewerAdapter {
|
|
83
|
+
review(req: ReviewRequest): ReviewResult
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `codex exec --json` JSONL에서 thread.started.thread_id 추출(0차 실측). 없으면 null. (review-codex에서 이동) */
|
|
87
|
+
export function parseThreadId(jsonl: string): string | null {
|
|
88
|
+
for (const line of jsonl.split('\n')) {
|
|
89
|
+
const t = line.trim()
|
|
90
|
+
if (!t) continue
|
|
91
|
+
try {
|
|
92
|
+
const ev = JSON.parse(t) as { type?: string; thread_id?: string }
|
|
93
|
+
if (ev.type === 'thread.started' && typeof ev.thread_id === 'string') return ev.thread_id
|
|
94
|
+
} catch {
|
|
95
|
+
// JSONL 외 라인 무시
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** codex 실행자(주입 가능 — 테스트). stdout 반환, 실패 시 throw. */
|
|
102
|
+
export type CodexRunner = (args: string[], input: string, cwd: string) => string
|
|
103
|
+
const defaultCodexRunner: CodexRunner = (args, input, cwd) =>
|
|
104
|
+
// shell 없이 안전 실행(safeSpawnSync/cross-spawn). 프롬프트는 stdin(input)으로 전달.
|
|
105
|
+
// ⚠️ 과거 `shell:true`는 args(schemaPath·resumeThreadId 등)의 메타문자로 **명령 주입**이 가능했고 공백 경로도 깨졌음 — P1 수정.
|
|
106
|
+
safeSpawnSync('codex', args, { cwd, input, maxBuffer: 64 * 1024 * 1024 })
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* default ReviewerAdapter(codex CLI). exec(신규)/resume(thread_id) 분기 + `--output-last-message`(임시파일) 캡처 + thread 파싱.
|
|
110
|
+
* - resume은 `--sandbox` 미수용(0차 실측) → 생략(정책 상속). exec은 `--sandbox read-only`.
|
|
111
|
+
* - threadId: resume이면 resumeThreadId, exec이면 parseThreadId(stdout)(없으면 null → 호출처가 fail-closed).
|
|
112
|
+
* - codex 미설치/실패 → runner가 throw(그대로 전파, fail-closed).
|
|
113
|
+
*/
|
|
114
|
+
export function createCodexReviewerAdapter(run: CodexRunner = defaultCodexRunner): ReviewerAdapter {
|
|
115
|
+
return {
|
|
116
|
+
review({ prompt, schemaPath, resumeThreadId, cwd }) {
|
|
117
|
+
const lastPath = join(mkdtempSync(join(tmpdir(), 'req-codex-')), 'last.json')
|
|
118
|
+
const args = resumeThreadId
|
|
119
|
+
? ['exec', 'resume', resumeThreadId, '--json', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
|
|
120
|
+
: ['exec', '--json', '--sandbox', 'read-only', '--output-schema', schemaPath, '--output-last-message', lastPath, '-']
|
|
121
|
+
const rawStdout = run(args, prompt, cwd)
|
|
122
|
+
const threadId = resumeThreadId ?? parseThreadId(rawStdout)
|
|
123
|
+
const lastMessage = existsSync(lastPath) ? readFileSync(lastPath, 'utf8') : ''
|
|
124
|
+
return { rawStdout, lastMessage, threadId }
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 테스트 전용 ReviewerAdapter — canned 응답 반환 + 받은 요청 기록(live codex 없이 review-codex 플로 검증, 수용기준 #4). */
|
|
130
|
+
export function createFakeReviewerAdapter(result: ReviewResult): ReviewerAdapter & { requests: ReviewRequest[] } {
|
|
131
|
+
const requests: ReviewRequest[] = []
|
|
132
|
+
return {
|
|
133
|
+
requests,
|
|
134
|
+
review(req: ReviewRequest): ReviewResult {
|
|
135
|
+
requests.push(req)
|
|
136
|
+
return result
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* req 워크플로 config 모듈 (REQ-2026-017 Phase 1, portability kit).
|
|
3
|
+
*
|
|
4
|
+
* 목적: 경로·이름·패키지매니저를 `req.config.json`으로 외부화하되, **파일 부재 시 현재 동작 100% 유지**(DEFAULTS=현재 하드코딩 값).
|
|
5
|
+
* 본 Phase는 **모듈만** 제공 — 4 스크립트 배선·`--root` CLI는 Phase 2, 어댑터는 Phase 3.
|
|
6
|
+
*
|
|
7
|
+
* 안전(fail-closed): config가 게이트를 무력화하거나 경로를 탈출하지 못하도록 AJV 스키마 + 해상도 confinement로 강제.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { resolve, join, dirname, sep } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import Ajv from 'ajv'
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
|
|
16
|
+
export type PackageManager = 'pnpm' | 'npm' | 'yarn'
|
|
17
|
+
|
|
18
|
+
export interface DesignDocs {
|
|
19
|
+
requirement: string
|
|
20
|
+
design: string
|
|
21
|
+
plan: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 사용자가 `req.config.json`에 줄 수 있는 부분 config(전부 선택). */
|
|
25
|
+
export interface RawConfig {
|
|
26
|
+
ticketRoot?: string
|
|
27
|
+
schemaPath?: string
|
|
28
|
+
handoffPath?: string | null
|
|
29
|
+
branchPrefix?: string
|
|
30
|
+
packageManager?: PackageManager
|
|
31
|
+
granularityMaxFiles?: number
|
|
32
|
+
designDocs?: Partial<DesignDocs>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 해소된 config(DEFAULTS 병합 + 파생 절대경로). */
|
|
36
|
+
export interface ResolvedConfig {
|
|
37
|
+
root: string
|
|
38
|
+
ticketRoot: string
|
|
39
|
+
schemaPath: string
|
|
40
|
+
handoffPath: string | null
|
|
41
|
+
branchPrefix: string
|
|
42
|
+
packageManager: PackageManager
|
|
43
|
+
granularityMaxFiles: number
|
|
44
|
+
designDocs: DesignDocs
|
|
45
|
+
// 파생(절대경로)
|
|
46
|
+
workflowDirAbs: string
|
|
47
|
+
schemaPathAbs: string
|
|
48
|
+
handoffPathAbs: string | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ⚠️ 현재 하드코딩 값 — 변경 시 behavior-preserving(수용기준 #1) 깨짐. */
|
|
52
|
+
export const DEFAULTS = {
|
|
53
|
+
ticketRoot: 'workflow',
|
|
54
|
+
schemaPath: 'workflow/machine.schema.json',
|
|
55
|
+
handoffPath: '../palm-kiosk/docs/evaluation/project-memory/ai-handoff.md',
|
|
56
|
+
branchPrefix: 'feat/req-',
|
|
57
|
+
packageManager: 'pnpm' as PackageManager,
|
|
58
|
+
granularityMaxFiles: 8,
|
|
59
|
+
designDocs: { requirement: '00-requirement.md', design: '01-design.md', plan: '02-plan.md' } as DesignDocs,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BASENAME_RE = '^[A-Za-z0-9][A-Za-z0-9._-]*$' // basename만(슬래시·백슬래시·선행 `.`(→`..`) 금지)
|
|
63
|
+
|
|
64
|
+
/** `req.config.json` AJV 스키마(fail-closed). 미지정 키는 DEFAULTS로 병합. */
|
|
65
|
+
export const CONFIG_SCHEMA = {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
ticketRoot: { type: 'string', minLength: 1 },
|
|
70
|
+
schemaPath: { type: 'string', minLength: 1 },
|
|
71
|
+
handoffPath: { type: ['string', 'null'] },
|
|
72
|
+
branchPrefix: { type: 'string', minLength: 1 }, // 빈 prefix는 D11 무력화 → 금지
|
|
73
|
+
packageManager: { type: 'string', enum: ['pnpm', 'npm', 'yarn'] },
|
|
74
|
+
granularityMaxFiles: { type: 'integer', minimum: 1 },
|
|
75
|
+
designDocs: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
properties: {
|
|
79
|
+
requirement: { type: 'string', pattern: BASENAME_RE },
|
|
80
|
+
design: { type: 'string', pattern: BASENAME_RE },
|
|
81
|
+
plan: { type: 'string', pattern: BASENAME_RE },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
} as const
|
|
86
|
+
|
|
87
|
+
const ajv = new Ajv({ allErrors: true })
|
|
88
|
+
const validateConfig = ajv.compile(CONFIG_SCHEMA)
|
|
89
|
+
|
|
90
|
+
/** kit 패키지 루트(= 현재 APP_ROOT와 동일 디렉터리). config.ts는 scripts/req/lib/ 이므로 3단계 상위. */
|
|
91
|
+
export function packageRoot(): string {
|
|
92
|
+
return resolve(__dirname, '..', '..', '..')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* root 해소(순수에 가깝게, IO=existsSync만). 우선순위: ① `--root`(opts.root) → ② cwd 상향탐색으로 `req.config.json` 발견 → ③ package-root fallback.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveRoot(opts: { root?: string | null; cwd?: string } = {}): string {
|
|
99
|
+
if (opts.root) return resolve(opts.root)
|
|
100
|
+
let dir = resolve(opts.cwd ?? process.cwd())
|
|
101
|
+
for (;;) {
|
|
102
|
+
if (existsSync(join(dir, 'req.config.json'))) return dir
|
|
103
|
+
const parent = dirname(dir)
|
|
104
|
+
if (parent === dir) break // 파일시스템 루트
|
|
105
|
+
dir = parent
|
|
106
|
+
}
|
|
107
|
+
return packageRoot()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 절대경로(POSIX `/`·Windows `C:\`·드라이브상대·UNC `\\`)면 throw — repo-내부 자원은 상대경로만(portable). */
|
|
111
|
+
function assertRelative(rel: string, name: string): void {
|
|
112
|
+
if (/^([/\\]|[A-Za-z]:)/.test(rel)) throw new Error(`req.config: ${name}는 절대경로 불가(repo-상대만): ${rel}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** abs가 rootAbs 하위인지(자기 자신 포함). 탈출 시 throw. */
|
|
116
|
+
function assertUnderRoot(rootAbs: string, rel: string, name: string): void {
|
|
117
|
+
const abs = resolve(rootAbs, rel)
|
|
118
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) throw new Error(`req.config: ${name}가 root 밖으로 탈출: ${rel}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** UTF-8 BOM(U+FEFF) 제거 — PowerShell 5 `Set-Content -Encoding UTF8` 등이 BOM을 붙여 JSON.parse가 실패하는 것 방지(P3). */
|
|
122
|
+
export function stripBom(s: string): string {
|
|
123
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* config 로드(fail-closed). root 결정 → `<root>/req.config.json` 있으면 파싱+AJV 검증+confinement → DEFAULTS 병합 → 파생경로.
|
|
128
|
+
* 파일 부재 시 DEFAULTS만(현재 동작). 위반은 명확한 throw(자동 보정·기본값 강등 금지).
|
|
129
|
+
*/
|
|
130
|
+
export function loadConfig(opts: { root?: string | null; cwd?: string } = {}): ResolvedConfig {
|
|
131
|
+
const rootAbs = resolveRoot(opts)
|
|
132
|
+
const cfgPath = join(rootAbs, 'req.config.json')
|
|
133
|
+
let raw: RawConfig = {}
|
|
134
|
+
if (existsSync(cfgPath)) {
|
|
135
|
+
let parsed: unknown
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(stripBom(readFileSync(cfgPath, 'utf8')))
|
|
138
|
+
} catch (e) {
|
|
139
|
+
throw new Error(`req.config.json 파싱 실패(${cfgPath}): ${(e as Error).message}`)
|
|
140
|
+
}
|
|
141
|
+
if (!validateConfig(parsed)) throw new Error(`req.config.json 스키마 위반: ${ajv.errorsText(validateConfig.errors)}`)
|
|
142
|
+
raw = parsed as RawConfig
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const merged = {
|
|
146
|
+
ticketRoot: raw.ticketRoot ?? DEFAULTS.ticketRoot,
|
|
147
|
+
schemaPath: raw.schemaPath ?? DEFAULTS.schemaPath,
|
|
148
|
+
handoffPath: raw.handoffPath !== undefined ? raw.handoffPath : DEFAULTS.handoffPath, // null = 명시적 비활성
|
|
149
|
+
branchPrefix: raw.branchPrefix ?? DEFAULTS.branchPrefix,
|
|
150
|
+
packageManager: raw.packageManager ?? DEFAULTS.packageManager,
|
|
151
|
+
granularityMaxFiles: raw.granularityMaxFiles ?? DEFAULTS.granularityMaxFiles,
|
|
152
|
+
designDocs: { ...DEFAULTS.designDocs, ...(raw.designDocs ?? {}) },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// repo-내부 자원(ticketRoot·schemaPath)은 **상대경로 + root 하위**만(절대경로·탈출 금지 → portable). handoffPath는 읽기 전용 참조라 면제.
|
|
156
|
+
assertRelative(merged.ticketRoot, 'ticketRoot')
|
|
157
|
+
assertRelative(merged.schemaPath, 'schemaPath')
|
|
158
|
+
assertUnderRoot(rootAbs, merged.ticketRoot, 'ticketRoot')
|
|
159
|
+
assertUnderRoot(rootAbs, merged.schemaPath, 'schemaPath')
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
root: rootAbs,
|
|
163
|
+
...merged,
|
|
164
|
+
workflowDirAbs: resolve(rootAbs, merged.ticketRoot),
|
|
165
|
+
schemaPathAbs: resolve(rootAbs, merged.schemaPath),
|
|
166
|
+
handoffPathAbs: merged.handoffPath ? resolve(rootAbs, merged.handoffPath) : null,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 패키지매니저별 스크립트 호출 argv 빌더(순수). 문자열 치환만으론 npm 불가.
|
|
172
|
+
* pnpm/yarn → `[pm, script, ...args]`, npm → `[npm, run, script, --, ...args]`.
|
|
173
|
+
*/
|
|
174
|
+
export function buildScriptInvocation(pm: PackageManager, scriptName: string, args: string[]): string[] {
|
|
175
|
+
if (pm === 'npm') return ['npm', 'run', scriptName, '--', ...args]
|
|
176
|
+
return [pm, scriptName, ...args]
|
|
177
|
+
}
|