commitgate 0.9.8 → 0.9.10

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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * req:new intake gate (REQ-2026-052 phase-3b, DEC-C) — 새 REQ 생성 전, **HEAD-committed durable 증거만으로**
3
+ * 각 기존 티켓의 기본 상태를 파생해 미종결(developing/needs-recovery) durable 티켓이 있으면 fail-closed로 막는다.
4
+ *
5
+ * 🔴 판정 입력은 **오직 HEAD blob**: scaffold marker(`state.json`)·`approvals.jsonl`·`ticket-close.jsonl`·
6
+ * `review-ledger.jsonl`. 워킹 `state.json`·워킹트리·미커밋 원장은 절대 보지 않는다(DEC-B4). 스캔은 git
7
+ * **조회만**(read-only) — write-tree·commit·state 수정 없음.
8
+ * 🔴 **corrupt/부분 HEAD 증거**(매니페스트·close-proof 손상)는 pass 조건(dev-complete/series-terminal)이 읽는
9
+ * 아티팩트이므로, 손상 시 통과시키지 않고 **block**한다(fail-closed). 손상된 신호로 완료를 위장할 수 없다.
10
+ * 🔴 오버레이(`integrated`·`reconstructed`)는 기본 상태 판정을 바꾸지 않는다 — 게이트는 `baseStateBlocksIntake`
11
+ * (기본 상태만) 으로 결정한다. legacy(durability marker 없음)는 차단하지 않고 표시만 한다.
12
+ *
13
+ * 순수 판정(`classifyIntake`)과 IO 수집(`scanTicketIntake`·`scanIntake`)을 분리한다.
14
+ */
15
+ import {
16
+ deriveBaseState,
17
+ baseStateBlocksIntake,
18
+ isReconstructed,
19
+ parseCloseProof,
20
+ type CloseBaseState,
21
+ type CloseProofRow,
22
+ } from './close-proof'
23
+ import {
24
+ isDurabilityRequired,
25
+ verifyCommittedEvidenceIntegrity,
26
+ validateManifest,
27
+ evidencedPhaseIdsFromManifest,
28
+ designHashFromManifest,
29
+ } from './evidence'
30
+ import { parseLedger } from './review-ledger'
31
+ import { createEvidencePorts } from './evidence-ports'
32
+
33
+ export type IntakeVerdict = 'pass' | 'block' | 'legacy'
34
+
35
+ export interface IntakeTicketResult {
36
+ ticketId: string
37
+ ticketRel: string
38
+ /** 파생 기본 상태. 매니페스트/close-proof 손상은 5-상태 밖의 `corrupt`(항상 block). */
39
+ baseState: CloseBaseState | 'corrupt'
40
+ verdict: IntakeVerdict
41
+ reason: string
42
+ /** `reconstructed` 오버레이(표시용 — 게이트 판정엔 영향 없음). */
43
+ reconstructed: boolean
44
+ }
45
+
46
+ export interface IntakeFacts {
47
+ ticketId: string
48
+ ticketRel: string
49
+ /** HEAD scaffold marker(`isDurabilityRequired`). false면 legacy. */
50
+ durabilityRequired: boolean
51
+ /** HEAD approvals.jsonl 본문(없으면 null). */
52
+ manifestText: string | null
53
+ /** `validateManifest` 결과(빈 배열=정상). 비어있지 않으면 corrupt block. */
54
+ manifestProblems: string[]
55
+ /** HEAD ticket-close.jsonl 파싱 결과. problems 비어있지 않으면 corrupt block. */
56
+ closeParsed: { rows: CloseProofRow[]; problems: string[] }
57
+ /** 🔴 committed 증거(design+phase) 무결성 문제(DEC-B6·B7). 비어있지 않으면 corrupt block(삭제/변조). */
58
+ evidenceIntegrityProblems: string[]
59
+ ledgerHasApprovedClose: boolean
60
+ committedEvidenceComplete: boolean
61
+ committedDesignRef: string | null
62
+ /** **design-bound**(현재 committed design_ref에 결속된) phase evidence의 phase id. */
63
+ evidencedPhaseIds: string[]
64
+ }
65
+
66
+ /**
67
+ * HEAD 사실 → intake 판정(순수). corrupt(매니페스트/close-proof 손상)는 기본 상태 밖의 별도 block 사유다.
68
+ */
69
+ export function classifyIntake(facts: IntakeFacts): IntakeTicketResult {
70
+ const head = { ticketId: facts.ticketId, ticketRel: facts.ticketRel }
71
+ // legacy(durability marker 없음)는 차단하지 않고 표시만(DEC-C·요구).
72
+ if (!facts.durabilityRequired)
73
+ return { ...head, baseState: 'legacy', verdict: 'legacy', reason: 'legacy 티켓(durability marker 없음) — 표시만, 차단 안 함', reconstructed: false }
74
+ // 🔴 pass 조건(dev-complete/series-terminal)이 읽는 아티팩트가 손상됐으면 통과 금지(fail-closed).
75
+ if (facts.manifestText !== null && facts.manifestProblems.length)
76
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `HEAD approvals.jsonl 손상 — 통과 불가(fail-closed): ${facts.manifestProblems.slice(0, 3).join('; ')}`, reconstructed: false }
77
+ if (facts.closeParsed.problems.length)
78
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `HEAD ticket-close.jsonl 손상 — 통과 불가(fail-closed): ${facts.closeParsed.problems.slice(0, 3).join('; ')}`, reconstructed: false }
79
+ // 🔴 DEC-B6·B7: committed 증거(design·phase archive) blob 부재/변조도 통과 조건이 읽는 증거의 손상 →
80
+ // corrupt block(dev-complete·series-terminal 위장 차단). design 행이 없는 미완 티켓은 손상 대상 아님(불완전≠손상).
81
+ if (facts.evidenceIntegrityProblems.length)
82
+ return { ...head, baseState: 'corrupt', verdict: 'block', reason: `committed 증거 손상 — 통과 불가(fail-closed): ${facts.evidenceIntegrityProblems.slice(0, 3).join('; ')}`, reconstructed: false }
83
+ const state = deriveBaseState({
84
+ durabilityRequired: true,
85
+ closeProofRows: facts.closeParsed.rows,
86
+ ledgerHasApprovedClose: facts.ledgerHasApprovedClose,
87
+ committedEvidenceComplete: facts.committedEvidenceComplete,
88
+ evidencedPhaseIds: facts.evidencedPhaseIds,
89
+ committedDesignRef: facts.committedDesignRef,
90
+ })
91
+ const blocked = baseStateBlocksIntake(state) // 기본 상태만 본다 — 오버레이 무관(요구).
92
+ const reason = blocked
93
+ ? state === 'developing'
94
+ ? '미종결 durable 티켓(developing) — 모든 phase 완료·커밋 또는 종결 후 재시도'
95
+ : 'HEAD 증거 불일치(needs-recovery) — 승인 흔적은 있으나 커밋된 증거 불완전, 복구 필요'
96
+ : state === 'dev-complete'
97
+ ? '개발 완료(dev-complete)'
98
+ : state === 'series-terminal'
99
+ ? 'series 종결(replace/human-resolution)'
100
+ : // 🔴 REQ-2026-053: migrated-complete는 **phase-1(커밋 3ed1b95 close-proof.ts)**에서 event·base-state·
101
+ // 파서·deriveBaseState(dev-complete 아래·needs-recovery 위 비차단)로 이미 확장됐다. 이 phase-2 diff는
102
+ // 명령(req:close)과 이 reason 케이스만 추가한다. 종결→pass 전 파이프라인은 req-close.test.ts ⑮가 실증.
103
+ state === 'migrated-complete'
104
+ ? '개발 완료(마이그레이션 종결·migrated-complete)'
105
+ : String(state)
106
+ return { ...head, baseState: state, verdict: blocked ? 'block' : 'pass', reason, reconstructed: isReconstructed(facts.closeParsed.rows) }
107
+ }
108
+
109
+ /**
110
+ * 한 티켓의 HEAD 사실을 모아 판정(IO — git 조회만, read-only). `ports`가 HEAD blob 접근 정본.
111
+ */
112
+ export function scanTicketIntake(root: string, ticketRel: string, ticketId: string): IntakeTicketResult {
113
+ const ports = createEvidencePorts(root, `${ticketRel}/responses`)
114
+ const durabilityRequired = isDurabilityRequired(ports.headText(`${ticketRel}/state.json`))
115
+ if (!durabilityRequired)
116
+ return classifyIntake({ ticketId, ticketRel, durabilityRequired: false, manifestText: null, manifestProblems: [], closeParsed: { rows: [], problems: [] }, evidenceIntegrityProblems: [], ledgerHasApprovedClose: false, committedEvidenceComplete: false, committedDesignRef: null, evidencedPhaseIds: [] })
117
+ const manifestText = ports.headText(`${ticketRel}/responses/approvals.jsonl`)
118
+ const closeText = ports.headText(`${ticketRel}/responses/ticket-close.jsonl`)
119
+ const ledgerText = ports.headText(`${ticketRel}/responses/review-ledger.jsonl`)
120
+ // validPhaseIds는 매니페스트 자신의 phase 행 id로 만든다(HEAD state.phases는 설계상 []이므로 멤버십 검사만 무효화 —
121
+ // verifyCommittedDesignEvidence와 동일 기법). 나머지 구조·경로·sha·주입·kind 격리·phase_design_ref 형식은 강제된다.
122
+ const manifestPhaseIds = manifestText ? evidencedPhaseIdsFromManifest(manifestText) : []
123
+ const manifestProblems = manifestText ? validateManifest(manifestText, { ticketRel, validPhaseIds: manifestPhaseIds }) : []
124
+ const closeParsed = closeText ? parseCloseProof(closeText) : { rows: [], problems: [] }
125
+ const ledgerParsed = ledgerText ? parseLedger(ledgerText) : { rows: [], problems: [] }
126
+ const ledgerHasApprovedClose = ledgerParsed.rows.some((r) => r.event === 'attempt-closed' && r.outcome === 'approved')
127
+ const committedDesignRef = manifestText ? designHashFromManifest(manifestText) : null
128
+ const evidencedPhaseIds = manifestText ? evidencedPhaseIdsFromManifest(manifestText, committedDesignRef) : []
129
+ // 🔴 DEC-B7: committed 증거(design+phase) 무결성 종합 — intake·req:commit 공유 모듈. designEvidenceComplete로
130
+ // needs-recovery 판정 입력(committedEvidenceComplete)을 같은 조회에서 얻는다(중복 조회 없음).
131
+ const integrity = verifyCommittedEvidenceIntegrity({ ticketRel, manifestText, ports })
132
+ return classifyIntake({ ticketId, ticketRel, durabilityRequired: true, manifestText, manifestProblems, closeParsed, evidenceIntegrityProblems: integrity.problems, ledgerHasApprovedClose, committedEvidenceComplete: integrity.designEvidenceComplete, committedDesignRef, evidencedPhaseIds })
133
+ }
134
+
135
+ /**
136
+ * HEAD tree의 `workflow/REQ-*` 디렉터리 이름(정렬). 🔴 **워킹 디렉터리를 읽지 않는다**(HEAD only).
137
+ *
138
+ * 🔴 **후행 슬래시가 load-bearing이다**: `git ls-tree -d --name-only HEAD workflow/`(슬래시 有)는 `workflow/`의
139
+ * **직계 자식 tree**(= `workflow/REQ-*`)를 열거한다. 슬래시가 **없으면**(`workflow`) ls-tree는 그 항목 자신
140
+ * (`workflow`) 한 줄만 낸다 → 자식을 못 보고 스캔이 통째로 비어 **게이트가 우회된다**. 그래서 아래에서 항상
141
+ * `${dir}/`로 슬래시를 붙인다. 이 정상 경로는 `req-new-intake.test.ts`의 실 git 열거·생성 전 차단 e2e가 고정한다
142
+ * (열거가 비면 developing 티켓이 차단되지 않아 그 테스트가 실패한다).
143
+ */
144
+ export function listHeadTicketIds(workflowDirRel: string, gitFn: (a: string[]) => string): string[] {
145
+ const dir = workflowDirRel.replace(/\\/g, '/').replace(/\/+$/, '')
146
+ let out: string
147
+ try {
148
+ out = gitFn(['ls-tree', '-d', '--name-only', 'HEAD', `${dir}/`]) // 🔴 후행 슬래시 필수(직계 자식 열거).
149
+ } catch {
150
+ return [] // workflow/ 가 HEAD에 없음(첫 REQ 등) → 스캔 대상 없음.
151
+ }
152
+ return out
153
+ .split('\n')
154
+ .map((l) => l.trim())
155
+ .filter(Boolean)
156
+ .map((p) => p.split('/').pop() ?? '')
157
+ .filter((n) => /^REQ-\d{4}-\d+$/.test(n))
158
+ .sort()
159
+ }
160
+
161
+ /**
162
+ * HEAD의 `workflow/REQ-*` 티켓 전부를 스캔(read-only). `excludeTicketId`(대체될 부모 등)는 스캔에서 제외한다 —
163
+ * 그 티켓은 지금 정규 replace 흐름으로 종결되므로 후속 생성을 막아선 안 된다(제외는 부모의 replace 검증이 이미 끝난 뒤에만 쓴다).
164
+ */
165
+ export function scanIntake(
166
+ root: string,
167
+ workflowDirRel: string,
168
+ gitFn: (a: string[]) => string,
169
+ excludeTicketId?: string | null,
170
+ ): { tickets: IntakeTicketResult[]; blocked: IntakeTicketResult[] } {
171
+ const ticketIds = listHeadTicketIds(workflowDirRel, gitFn).filter((id) => id !== excludeTicketId)
172
+ const dir = workflowDirRel.replace(/\\/g, '/').replace(/\/+$/, '')
173
+ const tickets = ticketIds.map((id) => scanTicketIntake(root, `${dir}/${id}`, id))
174
+ return { tickets, blocked: tickets.filter((t) => t.verdict === 'block') }
175
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * lockfile diff **요약** (REQ-2026-056·DEC-E1).
3
+ *
4
+ * `git diff --cached` 전문이 리뷰 프롬프트에 들어가는데, lockfile(`package-lock.json`·`pnpm-lock.yaml` 등)은
5
+ * 수천 줄 기계생성이라 토큰을 낭비하고 사람/리뷰어 신호를 묻는다. lockfile 구획의 hunk를 **요약**(경로·헤더
6
+ * 보존 + ±N/M + 생략분 sha256)으로 대체한다.
7
+ *
8
+ * 🔴 **프롬프트만 바꾼다** — 승인 바인딩(reviewTree = git write-tree)은 전체 index라 이 문자열 변형과 무관하다.
9
+ * 승인은 여전히 전체 lockfile을 결속한다(이 모듈은 그 사실을 강제하지 않지만, 호출부가 stagedDiff 문자열에만
10
+ * 이 변형을 적용한다).
11
+ *
12
+ * 순수 모듈 — fs·git을 모른다(deterministic·`createHash`만).
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+
16
+ /** lockfile로 인식할 basename(정확 일치만 — 경로 부분문자열 오탐 방지). */
17
+ export const LOCKFILE_NAMES: ReadonlySet<string> = new Set([
18
+ 'package-lock.json',
19
+ 'npm-shrinkwrap.json',
20
+ 'pnpm-lock.yaml',
21
+ 'yarn.lock',
22
+ 'bun.lockb',
23
+ ])
24
+
25
+ /** 경로 접두(`a/`·`b/`)·git C-quote(따옴표)를 벗겨 정규화한다. */
26
+ function stripPathDecoration(raw: string): string {
27
+ let t = raw.trim()
28
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1) // git quoted path
29
+ if (t.startsWith('a/') || t.startsWith('b/')) t = t.slice(2)
30
+ return t
31
+ }
32
+
33
+ /**
34
+ * 한 구획의 **모든 후보 경로**를 헤더 줄에서 모은다(r01 P1). `diff --git`의 양쪽 경로는 공백에서 모호하므로
35
+ * `---`/`+++`/`rename from|to`/`copy from|to`/`Binary files … differ`의 명시 경로도 함께 본다 —
36
+ * rename(한쪽만 lockfile)·공백/따옴표 경로에서도 인식되게.
37
+ */
38
+ function sectionPaths(section: string[]): string[] {
39
+ const paths: string[] = []
40
+ const add = (p: string | undefined): void => {
41
+ if (p === undefined) return
42
+ const s = stripPathDecoration(p)
43
+ if (s && s !== '/dev/null') paths.push(s)
44
+ }
45
+ for (const line of section) {
46
+ if (line.startsWith('--- ')) add(line.slice(4))
47
+ else if (line.startsWith('+++ ')) add(line.slice(4))
48
+ else if (line.startsWith('rename from ')) add(line.slice(12))
49
+ else if (line.startsWith('rename to ')) add(line.slice(10))
50
+ else if (line.startsWith('copy from ')) add(line.slice(10))
51
+ else if (line.startsWith('copy to ')) add(line.slice(8))
52
+ else if (line.startsWith('Binary files ')) {
53
+ const m = /^Binary files (.+) and (.+) differ$/.exec(line)
54
+ if (m) { add(m[1]); add(m[2]) }
55
+ } else if (line.startsWith('diff --git ')) {
56
+ const m = /^diff --git (.+) (.+)$/.exec(line) // best-effort(공백 경로면 ---/+++가 보완)
57
+ if (m) { add(m[1]); add(m[2]) }
58
+ }
59
+ }
60
+ return paths
61
+ }
62
+
63
+ /** 경로 basename이 lockfile인가(정확 일치). */
64
+ export function isLockfilePath(path: string): boolean {
65
+ const base = path.replace(/\\/g, '/').split('/').pop() ?? ''
66
+ return LOCKFILE_NAMES.has(base)
67
+ }
68
+
69
+ /** 구획이 lockfile을 다루는가(양쪽 경로·rename·binary 헤더 모두 검사). */
70
+ function sectionIsLockfile(section: string[]): { yes: boolean; display: string } {
71
+ const paths = sectionPaths(section)
72
+ const lock = paths.find(isLockfilePath)
73
+ return { yes: lock !== undefined, display: lock ?? paths[paths.length - 1] ?? '(lockfile)' }
74
+ }
75
+
76
+ /** 한 파일 구획(`diff --git`으로 시작하는 줄 배열)을 요약(lockfile일 때만). 비-lockfile은 그대로. */
77
+ function summarizeSection(section: string[]): string[] {
78
+ const { yes, display: path } = sectionIsLockfile(section)
79
+ if (!yes) return section
80
+
81
+ const hunkIdx = section.findIndex((l) => l.startsWith('@@ '))
82
+ if (hunkIdx >= 0) {
83
+ const header = section.slice(0, hunkIdx)
84
+ const hunks = section.slice(hunkIdx)
85
+ // `+++`/`---`는 파일 헤더라 hunkIdx 이후엔 없지만 방어적으로 제외한다.
86
+ const plus = hunks.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length
87
+ const minus = hunks.filter((l) => l.startsWith('-') && !l.startsWith('---')).length
88
+ const sha = createHash('sha256').update(hunks.join('\n'), 'utf8').digest('hex').slice(0, 12)
89
+ return [
90
+ ...header,
91
+ `# [lockfile 전문 생략 — 요약 모드] ${path}: +${plus}/-${minus} lines · sha256(생략분)=${sha} · 전문은 config lockfilePromptFull:true`,
92
+ ]
93
+ }
94
+ const binaryIdx = section.findIndex((l) => l.startsWith('Binary files ') && l.includes(' differ'))
95
+ if (binaryIdx >= 0) {
96
+ return [...section.slice(0, binaryIdx), `# [lockfile binary 변경 — 요약 모드] ${path} · 전문은 config lockfilePromptFull:true`]
97
+ }
98
+ // hunk도 binary도 없음(순수 rename/mode 변경 등) — 헤더만 작으니 그대로 둔다.
99
+ return section
100
+ }
101
+
102
+ /**
103
+ * staged diff의 lockfile 구획을 요약한다(순수). `opts.full`이면 원문 그대로.
104
+ * 🔴 lockfile 구획이 하나도 없으면 **완전 no-op**(입력===출력) — 기존 byte-identical near-e2e 무회귀.
105
+ */
106
+ export function summarizeLockfileDiff(stagedDiff: string, opts: { full: boolean }): string {
107
+ if (opts.full || stagedDiff === '') return stagedDiff
108
+ const lines = stagedDiff.split('\n')
109
+ const starts: number[] = []
110
+ for (let i = 0; i < lines.length; i++) if (lines[i]!.startsWith('diff --git ')) starts.push(i)
111
+ if (starts.length === 0) return stagedDiff // 표준 파일 구획 없음 → 손대지 않는다.
112
+
113
+ // 구획별로 요약(비-lockfile은 그대로). 요약이 하나도 일어나지 않으면 원문 반환(no-op 보장).
114
+ const result: string[] = []
115
+ if (starts[0]! > 0) result.push(...lines.slice(0, starts[0]!)) // 첫 구획 前 preamble(있으면) 보존.
116
+ let changed = false
117
+ for (let s = 0; s < starts.length; s++) {
118
+ const from = starts[s]!
119
+ const to = s + 1 < starts.length ? starts[s + 1]! : lines.length
120
+ const section = lines.slice(from, to)
121
+ const summarized = summarizeSection(section)
122
+ if (summarized !== section) changed = true // summarizeSection은 요약 시에만 새 배열을 반환한다.
123
+ result.push(...summarized)
124
+ }
125
+ if (!changed) return stagedDiff // lockfile 구획 없음(또는 요약 대상 없음) → 원문 그대로.
126
+ return result.join('\n')
127
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * req:reconstruct 복원 가능성 매트릭스 (REQ-2026-052 phase-4·DEC-D2) — **순수** 산출.
3
+ *
4
+ * 🔴 close-proof lifecycle event는 `dev-complete`·`series-terminal` 둘뿐이다. 이 모듈은 **HEAD-committed
5
+ * immutable evidence가 행의 모든 필드를 명확·모호없이 결정할 때만** 복원 후보를 낸다:
6
+ * - **dev-complete**: 🔴 **절대 산출하지 않는다.** `phase_inventory`를 approvals.jsonl의 phase_id 집합으로
7
+ * 합성하면 계획됐으나 미커밋인 phase를 조용히 빼는 DEC-B5 P1이 재발한다. inventory 독립 기록이 없으므로
8
+ * self-verifying 행이 없으면 복원 근거가 없다 → 재검토·재내구화가 유일 경로.
9
+ * - **series-terminal(replace)**: 커밋된 **successor** 티켓의 `successor_of`(parent_series_id 포함)가
10
+ * 이 티켓 replace 종결을 완전히 증명할 때만.
11
+ * - **series-terminal(terminate)**: successor를 만들지 않아 증거가 없다 → 불가.
12
+ *
13
+ * fs·git·review-codex를 모르는 leaf(부작용·evidence 추출은 CLI가 한다).
14
+ */
15
+ import { type CloseProofRow, closeProofRowKey } from './close-proof'
16
+
17
+ /** 이 티켓을 replace 부모로 지목하는 committed successor의 추출 증거(CLI가 HEAD blob에서 뽑아 넣는다). */
18
+ export interface SuccessorEvidence {
19
+ /** successor 티켓 id(진단용). */
20
+ successorTicketId: string
21
+ /** successor `state.json`의 repo-상대 경로 — evidence_basis. */
22
+ successorStatePath: string
23
+ /** `successor_of.parent_series_id` — 부모의 replace 종결 series_id. */
24
+ parentSeriesId: string
25
+ /** 종결 사유. successor는 항상 `replace`(collect가 그렇게 거른다) — material field로 명시 검사한다. */
26
+ resolution: 'replace'
27
+ /** `successor_of.parent_replace_resolution.decided_at` — 종결 시점(부모 값). */
28
+ at: string
29
+ }
30
+
31
+ export interface ReconstructCandidate {
32
+ row: CloseProofRow
33
+ evidenceBasis: string[]
34
+ }
35
+
36
+ export interface ReconstructPlan {
37
+ /** 복원 예정(신규) 행. */
38
+ candidates: ReconstructCandidate[]
39
+ /** 복원 불가·불필요·모호 사유(진단 표시용 — write는 안 하지만 명령은 계속). */
40
+ refusals: string[]
41
+ /** 🔴 fail-closed conflict(HEAD 모순) — 하나라도 있으면 CLI는 **전체를 write 0으로 중단**한다. */
42
+ conflicts: string[]
43
+ }
44
+
45
+ /**
46
+ * 이 티켓의 복원 가능한 close-proof 행을 매트릭스(DEC-D2 multi-witness)대로 산출(순수). dev-complete는 절대 산출 안 함.
47
+ *
48
+ * 🔴 **parent_series_id별 그룹화**: 같은 series를 가리키는 복수 witness의 material field(resolution·at)가 **전부 일치**할
49
+ * 때만 후보 1개(evidence_basis=모든 successor state 경로 정렬·중복제거). 불일치=ambiguity refusal(그 series write 0).
50
+ * 🔴 **HEAD 정합**: 같은 자연키 행이 HEAD에 있으면 material 일치=멱등 no-op, 모순=**conflict**(숨기지 않고 fail-closed).
51
+ *
52
+ * @param existingRows 이 티켓 HEAD close-proof를 파싱한 행들. 손상 티켓은 CLI가 이 함수 **전에** 거른다.
53
+ * @param successors 이 티켓을 부모로 지목하는 committed successor 증거들.
54
+ */
55
+ export function planReconstruction(args: {
56
+ ticketId: string
57
+ existingRows: readonly CloseProofRow[]
58
+ successors: readonly SuccessorEvidence[]
59
+ }): ReconstructPlan {
60
+ const candidates: ReconstructCandidate[] = []
61
+ const refusals: string[] = []
62
+ const conflicts: string[] = []
63
+
64
+ // ① parent_series_id별 그룹화(빈 필드는 미결정으로 개별 refusal).
65
+ const groups = new Map<string, SuccessorEvidence[]>()
66
+ for (const s of args.successors) {
67
+ if (!s.parentSeriesId || !s.at || !s.resolution) {
68
+ refusals.push(`successor ${s.successorTicketId}: parent_series_id·resolution·at 중 미결정 → 복원 불가(모호)`)
69
+ continue
70
+ }
71
+ const g = groups.get(s.parentSeriesId)
72
+ if (g) g.push(s)
73
+ else groups.set(s.parentSeriesId, [s])
74
+ }
75
+
76
+ for (const seriesId of [...groups.keys()].sort()) {
77
+ const witnesses = groups.get(seriesId)!
78
+ // ② material field(resolution·at) 전부 일치해야 후보. 불일치=ambiguity(그 series 후보 없음).
79
+ const ats = new Set(witnesses.map((w) => w.at))
80
+ const resolutions = new Set(witnesses.map((w) => w.resolution))
81
+ if (ats.size > 1 || resolutions.size > 1) {
82
+ refusals.push(
83
+ `series ${seriesId}: 복수 successor의 material field 불일치(at=[${[...ats].sort().join(', ')}] resolution=[${[...resolutions].sort().join(', ')}]) → ambiguity 복원 불가(write 0)`,
84
+ )
85
+ continue
86
+ }
87
+ const at = witnesses[0]!.at
88
+ const resolution = witnesses[0]!.resolution
89
+ // 완전 일치하는 복수 witness → evidence_basis에 모든 경로 정렬·중복제거.
90
+ const evidenceBasis = [...new Set(witnesses.map((w) => w.successorStatePath))].sort()
91
+ const row: CloseProofRow = {
92
+ ticket_id: args.ticketId,
93
+ event: 'series-terminal',
94
+ series_id: seriesId,
95
+ resolution,
96
+ phase_inventory: null,
97
+ design_ref: null,
98
+ at,
99
+ reconstructed: true,
100
+ evidence_basis: evidenceBasis,
101
+ }
102
+ // ③ HEAD 정합: 같은 자연키 행이 있으면 material 일치=멱등 no-op, 모순=conflict(fail-closed).
103
+ const key = closeProofRowKey(row)
104
+ const prior = args.existingRows.find((r) => closeProofRowKey(r) === key)
105
+ if (prior) {
106
+ if (prior.resolution === resolution && prior.at === at) {
107
+ refusals.push(`series ${seriesId}: 동일 series-terminal 행이 HEAD에 존재 → 복원 불필요(멱등 no-op)`)
108
+ } else {
109
+ conflicts.push(
110
+ `series ${seriesId}: HEAD 행과 모순(HEAD at=${prior.at} resolution=${prior.resolution} ≠ 후보 at=${at} resolution=${resolution}) → fail-closed(숨기지 않음)`,
111
+ )
112
+ }
113
+ continue
114
+ }
115
+ candidates.push({ row, evidenceBasis })
116
+ }
117
+ return { candidates, refusals, conflicts }
118
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * 커밋되는 **리뷰 예외 부여 원장** (REQ-2026-055·DEC-RE3).
3
+ *
4
+ * needs-exception 구간에서 사람이 부여한 예외의 **구조화 rationale**을 durable하게 남긴다. `review_exception_confirmed`
5
+ * (state.json scratch)는 소비 후 null이 되지만 이 파일은 남아 "왜 예산을 넘겼나"의 감사가 된다.
6
+ *
7
+ * 🔴 **B1 review-ledger 스키마를 건드리지 않는다**(전용 sibling 파일) — 릴리스된 원장에 필수 키를 더하면 기존
8
+ * 커밋 원장이 "필수 키 누락"으로 D5 fail-closed된다(B1 자체 경고). 이 파일은 자체 스키마다.
9
+ * 🔴 review-ledger가 본문을 뺀 것과 달리 이 파일은 rationale **본문을 담는다** — 이 파일의 목적이 그 내용이다.
10
+ *
11
+ * 순수 모듈 — fs·git을 모른다. 부작용은 호출부가 낸다(`lib/review-ledger`·`lib/close-proof`와 같은 태도).
12
+ */
13
+ import { isValidIsoInstant } from './evidence'
14
+ import type { ReviewKind } from '../review-codex'
15
+
16
+ /** 예외 부여 파일의 basename. `review-ledger.jsonl`·`ticket-close.jsonl`과 같은 `responses/` 디렉터리. */
17
+ export const EXCEPTION_BASENAME = 'review-exceptions.jsonl'
18
+
19
+ /** 티켓 `responses/` 기준 예외 원장의 repo-상대 경로. */
20
+ export function exceptionsPath(ticketRel: string): string {
21
+ return `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/${EXCEPTION_BASENAME}`
22
+ }
23
+
24
+ /** 구조화 rationale 4섹션(전부 비어있지 않아야 함). */
25
+ export interface Rationale {
26
+ prev_findings: string
27
+ changes: string
28
+ unresolved: string
29
+ retry_justification: string
30
+ }
31
+ export const RATIONALE_KEYS = ['prev_findings', 'changes', 'unresolved', 'retry_justification'] as const
32
+
33
+ export interface ExceptionGrantRow {
34
+ ticket_id: string
35
+ review_kind: ReviewKind
36
+ phase_id: string | null
37
+ series_id: string
38
+ /** 이 예외가 유효한 회차(= consumeReviewException의 for_attempt·checkReviewBudget의 attempt). */
39
+ for_attempt: number
40
+ /** 받은 승인 문장 그대로. */
41
+ method: string
42
+ confirmed_at: string
43
+ rationale: Rationale
44
+ /** 사후 복원 행인지(원본과 구별). 정상 부여는 false. */
45
+ reconstructed: boolean
46
+ }
47
+
48
+ /** 직렬화 키 순서(고정) + 허용키 화이트리스트. */
49
+ export const EXCEPTION_KEYS = [
50
+ 'ticket_id',
51
+ 'review_kind',
52
+ 'phase_id',
53
+ 'series_id',
54
+ 'for_attempt',
55
+ 'method',
56
+ 'confirmed_at',
57
+ 'rationale',
58
+ 'reconstructed',
59
+ ] as const
60
+
61
+ /** 한 줄 직렬화(JSONL): 고정 키 순서(rationale 내부도 고정) + 끝 개행. */
62
+ export function serializeExceptionGrantRow(row: ExceptionGrantRow): string {
63
+ const rationale: Record<string, unknown> = {}
64
+ for (const k of RATIONALE_KEYS) rationale[k] = row.rationale[k]
65
+ const o: Record<string, unknown> = {}
66
+ for (const k of EXCEPTION_KEYS) o[k] = k === 'rationale' ? rationale : row[k]
67
+ return `${JSON.stringify(o)}\n`
68
+ }
69
+
70
+ /** 자연키 구분자 — US(0x1F). 식별자에 나타날 수 없는 제어문자(review-ledger와 같은 기법·소스에 리터럴 금지). */
71
+ const KEY_SEP = String.fromCharCode(31)
72
+
73
+ /** 자연키 — `(ticket, series, for_attempt)`. 한 series의 한 회차에 예외 1개. */
74
+ export function exceptionGrantRowKey(row: Pick<ExceptionGrantRow, 'ticket_id' | 'series_id' | 'for_attempt'>): string {
75
+ return [row.ticket_id, row.series_id, String(row.for_attempt)].join(KEY_SEP)
76
+ }
77
+
78
+ /** rationale 객체 형식 문제(순수). 빈 배열=정상. 4섹션 전부 비어있지 않은 문자열. */
79
+ function rationaleProblems(raw: unknown): string[] {
80
+ const p: string[] = []
81
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['rationale가 객체가 아님']
82
+ const r = raw as Record<string, unknown>
83
+ const allowed = new Set<string>(RATIONALE_KEYS)
84
+ for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`rationale 알 수 없는 키: ${k}`)
85
+ for (const k of RATIONALE_KEYS) {
86
+ const v = r[k]
87
+ if (typeof v !== 'string' || v.trim() === '') p.push(`rationale.${k}가 비어 있음`)
88
+ }
89
+ return p
90
+ }
91
+
92
+ /** 행 하나의 형식 문제 목록(순수). 빈 배열 = 정상. 모르는 top-level 키는 거부(주입 방어). */
93
+ export function exceptionGrantRowProblems(raw: unknown): string[] {
94
+ const p: string[] = []
95
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['객체가 아님']
96
+ const r = raw as Record<string, unknown>
97
+ const allowed = new Set<string>(EXCEPTION_KEYS)
98
+ for (const k of Object.keys(r)) if (!allowed.has(k)) p.push(`알 수 없는 키: ${k}`)
99
+ for (const k of EXCEPTION_KEYS) if (!(k in r)) p.push(`필수 키 누락: ${k}`)
100
+ if (p.length) return p
101
+
102
+ if (typeof r.ticket_id !== 'string' || r.ticket_id === '') p.push('ticket_id가 비어 있음')
103
+ if (r.review_kind !== 'design' && r.review_kind !== 'phase') p.push(`review_kind 부적합: ${String(r.review_kind)}`)
104
+ if (r.phase_id !== null && (typeof r.phase_id !== 'string' || r.phase_id === '')) p.push('phase_id는 null이거나 비지 않은 문자열')
105
+ if (typeof r.series_id !== 'string' || r.series_id === '') p.push('series_id가 비어 있음')
106
+ if (typeof r.for_attempt !== 'number' || !Number.isInteger(r.for_attempt) || r.for_attempt < 1) p.push('for_attempt는 1 이상 정수')
107
+ if (typeof r.method !== 'string' || r.method.trim() === '') p.push('method가 비어 있음')
108
+ if (!isValidIsoInstant(r.confirmed_at)) p.push('confirmed_at이 ISO instant가 아님')
109
+ if (typeof r.reconstructed !== 'boolean') p.push('reconstructed는 boolean')
110
+ p.push(...rationaleProblems(r.rationale))
111
+ return p
112
+ }
113
+
114
+ export interface ParsedExceptions {
115
+ rows: ExceptionGrantRow[]
116
+ problems: string[]
117
+ }
118
+
119
+ /** 본문 파싱(순수). 빈 줄 무시, 파싱 불가·형식 위반·자연키 중복은 problems로 드러낸다(조용히 건너뛰지 않음). */
120
+ export function parseExceptions(content: string): ParsedExceptions {
121
+ const rows: ExceptionGrantRow[] = []
122
+ const problems: string[] = []
123
+ const seen = new Set<string>()
124
+ content.split('\n').forEach((line, i) => {
125
+ if (line.trim() === '') return
126
+ let raw: unknown
127
+ try {
128
+ raw = JSON.parse(line)
129
+ } catch {
130
+ problems.push(`line ${i + 1}: JSON 파싱 실패`)
131
+ return
132
+ }
133
+ const ps = exceptionGrantRowProblems(raw)
134
+ if (ps.length) {
135
+ problems.push(...ps.map((m) => `line ${i + 1}: ${m}`))
136
+ return
137
+ }
138
+ const row = raw as ExceptionGrantRow
139
+ const key = exceptionGrantRowKey(row)
140
+ if (seen.has(key)) problems.push(`line ${i + 1}: 자연키 중복(${row.series_id} for_attempt=${row.for_attempt})`)
141
+ seen.add(key)
142
+ rows.push(row)
143
+ })
144
+ return { rows, problems }
145
+ }
146
+
147
+ /** material 동일성(순수) — method + rationale만 비교(confirmed_at 제외). 재실행 멱등 판정용. */
148
+ export function materialEqual(a: ExceptionGrantRow, b: ExceptionGrantRow): boolean {
149
+ if (a.method !== b.method) return false
150
+ return RATIONALE_KEYS.every((k) => a.rationale[k] === b.rationale[k])
151
+ }
152
+
153
+ /** 자연키로 기존 부여 행 조회(순수). 없으면 null. 호출부가 confirmed_at 재사용에 쓴다(재실행 복구). */
154
+ export function findExistingGrant(content: string, key: Pick<ExceptionGrantRow, 'ticket_id' | 'series_id' | 'for_attempt'>): ExceptionGrantRow | null {
155
+ const parsed = parseExceptions(content)
156
+ if (parsed.problems.length) return null // 손상은 append 단계가 conflict로 처리한다.
157
+ const k = exceptionGrantRowKey(key)
158
+ return parsed.rows.find((r) => exceptionGrantRowKey(r) === k) ?? null
159
+ }
160
+
161
+ export type ExceptionAppendOutcome = 'appended' | 'duplicate' | 'conflict'
162
+ export interface ExceptionAppendResult {
163
+ outcome: ExceptionAppendOutcome
164
+ content: string
165
+ problems: string[]
166
+ }
167
+
168
+ /**
169
+ * 멱등 append(순수 — 새 본문 반환만). 🔴 **material 멱등**(DEC-RE3): 같은 자연키 + method·rationale 같으면
170
+ * duplicate(confirmed_at 달라도), material 다르면 conflict(같은 회차 다른 예외 — 덮지 않음·fail-closed).
171
+ * 기존 본문 손상이면 그대로 올리고 append하지 않는다(D5 태도).
172
+ */
173
+ export function appendExceptionGrant(existingContent: string, row: ExceptionGrantRow): ExceptionAppendResult {
174
+ const rowProblems = exceptionGrantRowProblems(row)
175
+ if (rowProblems.length) return { outcome: 'conflict', content: existingContent, problems: rowProblems }
176
+
177
+ const parsed = parseExceptions(existingContent)
178
+ if (parsed.problems.length) return { outcome: 'conflict', content: existingContent, problems: parsed.problems }
179
+
180
+ const key = exceptionGrantRowKey(row)
181
+ const prior = parsed.rows.find((r) => exceptionGrantRowKey(r) === key)
182
+ if (prior) {
183
+ return materialEqual(prior, row)
184
+ ? { outcome: 'duplicate', content: existingContent, problems: [] }
185
+ : {
186
+ outcome: 'conflict',
187
+ content: existingContent,
188
+ problems: [`같은 회차(${row.series_id} #${row.for_attempt})에 다른 예외가 이미 있음 — 덮지 않는다`],
189
+ }
190
+ }
191
+ const base = existingContent === '' || existingContent.endsWith('\n') ? existingContent : `${existingContent}\n`
192
+ return { outcome: 'appended', content: base + serializeExceptionGrantRow(row), problems: [] }
193
+ }
194
+
195
+ // ───────────────────────────────── rationale 파일 파서(순수) ──
196
+
197
+ /** rationale 마크다운 섹션 헤더 → 필드. */
198
+ const RATIONALE_SECTIONS: ReadonlyArray<{ header: string; field: keyof Rationale }> = [
199
+ { header: '직전 findings', field: 'prev_findings' },
200
+ { header: '이번 변경', field: 'changes' },
201
+ { header: '미해결', field: 'unresolved' },
202
+ { header: '재시도 근거', field: 'retry_justification' },
203
+ ]
204
+
205
+ export type RationaleParse = { ok: true; rationale: Rationale } | { ok: false; problems: string[] }
206
+
207
+ /**
208
+ * rationale 마크다운 파싱(순수). `## <헤더>` 4섹션의 본문을 뽑아 전부 비어있지 않으면 ok.
209
+ * 형식은 자유(마크다운) — 존재·비-빔만 강제한다. 누락·빈 섹션은 어느 것인지 알린다.
210
+ */
211
+ export function parseRationale(text: string): RationaleParse {
212
+ const lines = text.split('\n')
213
+ const bodies = new Map<keyof Rationale, string[]>()
214
+ let current: keyof Rationale | null = null
215
+ for (const line of lines) {
216
+ const m = /^##\s+(.+?)\s*$/.exec(line)
217
+ if (m) {
218
+ const sec = RATIONALE_SECTIONS.find((s) => m[1] === s.header)
219
+ current = sec ? sec.field : null // 알 수 없는 헤더는 어느 섹션에도 안 담는다.
220
+ if (current && !bodies.has(current)) bodies.set(current, [])
221
+ continue
222
+ }
223
+ if (current) bodies.get(current)!.push(line)
224
+ }
225
+ const problems: string[] = []
226
+ const rationale = {} as Rationale
227
+ for (const { header, field } of RATIONALE_SECTIONS) {
228
+ const body = (bodies.get(field) ?? []).join('\n').trim()
229
+ if (body === '') problems.push(`rationale 섹션 "## ${header}"가 없거나 비어 있음`)
230
+ rationale[field] = body
231
+ }
232
+ return problems.length ? { ok: false, problems } : { ok: true, rationale }
233
+ }