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/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
+ }