@cheongfish/bouncer 0.0.0-stage → 0.1.13

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 (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,595 @@
1
+ 'use strict';
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateBlueprintIntent = validateBlueprintIntent;
4
+ exports.buildBlueprintSnapshot = buildBlueprintSnapshot;
5
+ exports.buildTaskSnapshots = buildTaskSnapshots;
6
+ exports.applyTaskTrailers = applyTaskTrailers;
7
+ exports.collectWorkerNotes = collectWorkerNotes;
8
+ exports.parseCommitSubjects = parseCommitSubjects;
9
+ exports.extractSymbols = extractSymbols;
10
+ exports.summarizeDiffStat = summarizeDiffStat;
11
+ exports.normalizeChangedPaths = normalizeChangedPaths;
12
+ exports.assessDigestSnapshot = assessDigestSnapshot;
13
+ exports.assembleDigestBody = assembleDigestBody;
14
+ exports.summarizeDrive = summarizeDrive;
15
+ exports.summarizeDriveIssues = summarizeDriveIssues;
16
+ exports.collectDigestWarnings = collectDigestWarnings;
17
+ const executionContracts = require("./execution-contracts");
18
+ const { validOutsideScope } = executionContracts;
19
+ const pathOverlap = require("./path-overlap");
20
+ const { normalizeOverlapPath, pathsOverlap } = pathOverlap;
21
+ const templates = require("./templates");
22
+ const { parseIntentBody } = templates;
23
+ const schema = require("./schema");
24
+ const { executionKindOf } = schema;
25
+ const commitSha = require("./commit-sha");
26
+ const { normalizeCommitSha, buildStableProvenance } = commitSha;
27
+ const posixPath = require("./posix-path");
28
+ const { toPosix } = posixPath;
29
+ const contextIds = require("./context-ids");
30
+ const { validateSlug, formatLegacyBlueprintId } = contextIds;
31
+ const SYMBOL_FN_RE = new RegExp([
32
+ '^(?:export\\s+(?:default\\s+)?)?(?:async\\s+)?',
33
+ '(?:function(?:\\s*\\*\\s*|\\s+)|class\\s+)([A-Za-z_$][\\w$]*)',
34
+ ].join(''));
35
+ const SYMBOL_VAR_RE = /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
36
+ const SHORT_SHA_NUL_RE = /([0-9a-f]{7,40})\0/gi;
37
+ const CONTEXT_EXCLUDED = '.bouncer/context/';
38
+ const PER_FILE_SYMBOL_CAP = 10;
39
+ const TOTAL_SYMBOL_CAP = 60;
40
+ const COMMIT_LIST_CAP = 50;
41
+ const DIFF_PER_FILE_CAP = 30;
42
+ /**
43
+ * blueprint Intent를 순수하게 검사한다. 헤딩 부재는 구형 문서와 같이 빈 배열이다.
44
+ *
45
+ * @param {string} body - index.md 본문
46
+ * @returns {{ ok: true; intent: string[] } | { ok: false; message: string }} Intent 또는 거절 사유
47
+ */
48
+ function validateBlueprintIntent(body) {
49
+ try {
50
+ return { ok: true, intent: parseIntentBody(body) };
51
+ }
52
+ catch (error) {
53
+ const message = error.message || String(error);
54
+ if (/blueprint Intent is missing or malformed/.test(message))
55
+ return { ok: true, intent: [] };
56
+ return { ok: false, message };
57
+ }
58
+ }
59
+ /**
60
+ * blueprint index snapshot을 공개 blueprint 필드와 intent 검증 결과로 정규화한다.
61
+ *
62
+ * @param {object} snapshot - 이미 읽힌 frontmatter 데이터와 본문
63
+ * @returns {object} digest blueprint 값, 또는 S33으로 반환할 Intent 오류
64
+ */
65
+ function buildBlueprintSnapshot(snapshot) {
66
+ const data = asRecord(snapshot.data);
67
+ const bouncer = asRecord(data.bouncer);
68
+ const epicId = typeof bouncer.epic_id === 'string' ? bouncer.epic_id : null;
69
+ const blueprintId = typeof bouncer.blueprint_id === 'string'
70
+ ? bouncer.blueprint_id
71
+ : (typeof bouncer.id === 'string' ? bouncer.id : null);
72
+ const intent = validateBlueprintIntent(snapshot.body);
73
+ if (!intent.ok)
74
+ return intent;
75
+ let stableId = '';
76
+ if (epicId && blueprintId) {
77
+ stableId = validateSlug(epicId) === null && validateSlug(blueprintId) === null
78
+ ? `${epicId}/${blueprintId}`
79
+ : formatLegacyBlueprintId(epicId, blueprintId);
80
+ }
81
+ return {
82
+ ok: true,
83
+ title: typeof data.title === 'string' ? data.title : '',
84
+ commit_type: typeof bouncer.commit_type === 'string' ? bouncer.commit_type : 'feat',
85
+ scale: typeof bouncer.scale === 'string' ? bouncer.scale : null,
86
+ epicId,
87
+ blueprintId,
88
+ stable_id: stableId,
89
+ reviewMode: bouncer.review_scope === 'blueprint',
90
+ intent: intent.intent,
91
+ outOfScope: parseOutOfScopeSnapshot(snapshot.body),
92
+ };
93
+ }
94
+ function parseOutOfScopeSnapshot(body) {
95
+ const text = body.replace(/<!--[\s\S]*?-->/g, '');
96
+ const lines = text.split('\n');
97
+ const starts = lines
98
+ .map((line, index) => /^##\s+Out\s+of\s+scope\s*$/i.test(line.trim()) ? index : -1)
99
+ .filter((index) => index >= 0);
100
+ const start = starts[starts.length - 1] ?? -1;
101
+ if (start < 0)
102
+ return [];
103
+ const end = lines.findIndex((line, index) => index > start && /^##\s/.test(line));
104
+ return lines.slice(start + 1, end < 0 ? lines.length : end)
105
+ .map((line) => line.trim())
106
+ .filter(Boolean)
107
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
108
+ .filter(Boolean);
109
+ }
110
+ /**
111
+ * 이미 읽힌 task snapshot을 version two task digest 행으로 정규화한다.
112
+ *
113
+ * @param {object} snapshot - 수집된 문서 값과 blueprint·worker-note context
114
+ * @returns {object} task digest 행과 trailer 매칭 stable id 정보
115
+ */
116
+ function buildTaskSnapshots(snapshot) {
117
+ const workerNotes = collectWorkerNotes(snapshot.ledger);
118
+ return snapshot.rows.map((row) => {
119
+ const taskData = asRecord(row.data);
120
+ const bouncer = asRecord(taskData.bouncer);
121
+ const id = typeof bouncer.id === 'string'
122
+ ? bouncer.id
123
+ : `TASKS-${String(row.number).padStart(3, '0')}`;
124
+ const kind = executionKindOf(bouncer) || row.executionKind || 'commit';
125
+ let stableId = id;
126
+ try {
127
+ stableId = buildStableProvenance({
128
+ epicId: bouncer.epic_id || snapshot.blueprintEpicId,
129
+ blueprintId: bouncer.blueprint_id || snapshot.blueprintId,
130
+ taskId: id,
131
+ }).task;
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof Error ? error.message : '';
135
+ const invalidInputs = new Set([
136
+ 'blueprint_id must be a three-digit id',
137
+ 'blueprint_id must be a slug',
138
+ 'epic_id must be a three-digit id or a slug',
139
+ 'task id must be TASKS-NNN',
140
+ ]);
141
+ if (!invalidInputs.has(message)) {
142
+ throw error;
143
+ }
144
+ // 문서 ID 형식 오류만 흡수해 id는 보존하고 trailer 매칭만 생략한다.
145
+ }
146
+ const affected = stringList(bouncer.affected_paths);
147
+ const constraints = parseTaskConstraints(row.body);
148
+ const commit = normalizeCommitSha(bouncer.commit_sha);
149
+ const digest = {
150
+ stable_id: stableId,
151
+ id,
152
+ title: typeof taskData.title === 'string' ? taskData.title : '',
153
+ execution_kind: kind,
154
+ status: typeof bouncer.status === 'string' ? bouncer.status : '',
155
+ commit: commit ? { sha: commit, sha8: commit, source: 'commit_sha', worker_sha: null } : null,
156
+ affected_paths: affected,
157
+ verification: row.verification,
158
+ review: row.review,
159
+ constraints,
160
+ ...(workerNotes.has(id) ? { worker_notes: workerNotes.get(id) } : {}),
161
+ };
162
+ return { digest, affected, kind };
163
+ });
164
+ }
165
+ /**
166
+ * Git trailer map을 task digest commit 필드에 적용한다. trailer가 있으면 원장·문서 SHA보다 우선한다.
167
+ *
168
+ * @param {Array<object>} built - 모델이 만든 task snapshot
169
+ * @param {Map<string, object>} trailerMap - collector가 읽은 commit provenance
170
+ * @returns {Array<object>} commit 필드가 정규화된 task snapshot 복사본
171
+ */
172
+ function applyTaskTrailers(built, trailerMap) {
173
+ return built.map((item) => {
174
+ const hit = trailerMap.get(item.digest.stable_id);
175
+ if (hit) {
176
+ return {
177
+ ...item,
178
+ digest: {
179
+ ...item.digest,
180
+ commit: { sha: hit.sha, sha8: hit.sha8, source: 'trailer', worker_sha: hit.workerSha },
181
+ },
182
+ };
183
+ }
184
+ if (item.digest.commit?.source === 'commit_sha') {
185
+ const short = item.digest.commit.sha8;
186
+ return {
187
+ ...item,
188
+ digest: {
189
+ ...item.digest,
190
+ commit: { sha: short, sha8: short, source: 'commit_sha', worker_sha: null },
191
+ },
192
+ };
193
+ }
194
+ return { ...item, digest: { ...item.digest } };
195
+ });
196
+ }
197
+ /**
198
+ * ledger에서 task별 마지막 accepted 보고의 열린 선택과 약점을 수집한다.
199
+ *
200
+ * @param {unknown} ledger - 읽힌 원장. 없거나 light 모드면 null과 같다
201
+ * @returns {Map<string, WorkerNotes>} 내용이 있는 TASKS-NNN 항목만 포함
202
+ */
203
+ function collectWorkerNotes(ledger) {
204
+ const notes = new Map();
205
+ const root = asRecord(ledger);
206
+ if (root.mode === 'light' || !Array.isArray(root.decisions))
207
+ return notes;
208
+ const latest = new Map();
209
+ for (const raw of root.decisions) {
210
+ const decision = asRecord(raw);
211
+ if (decision.kind !== 'report' || decision.outcome !== 'accepted')
212
+ continue;
213
+ if (typeof decision.task !== 'string' || !decision.task)
214
+ continue;
215
+ const id = decision.task.startsWith('TASKS-') ? decision.task : `TASKS-${decision.task}`;
216
+ latest.set(id, decision);
217
+ }
218
+ for (const [id, decision] of latest) {
219
+ const open_choices = stringList(decision.open_choices);
220
+ const known_weaknesses = stringList(decision.known_weaknesses);
221
+ if (open_choices.length || known_weaknesses.length)
222
+ notes.set(id, { open_choices, known_weaknesses });
223
+ }
224
+ return notes;
225
+ }
226
+ function stringList(value) {
227
+ return (Array.isArray(value) ? value : [])
228
+ .filter((entry) => typeof entry === 'string' && entry !== '');
229
+ }
230
+ /**
231
+ * task 본문에서 Constraints 절만 읽는다. 경량 모델은 validate-sections의 I/O 그래프에 묶이지 않는다.
232
+ *
233
+ * @param {string} body - tasks.md 본문
234
+ * @returns {string | null} 주석 제거 후 비어 있지 않은 Constraints 본문
235
+ */
236
+ function parseTaskConstraints(body) {
237
+ const text = body.replace(/<!--[\s\S]*?-->/g, '');
238
+ const lines = text.split('\n');
239
+ const headings = [
240
+ /^##\s+(Goal\s*&\s*intent|목적[·・.]?의도)\s*$/i,
241
+ /^##\s+(Current\s+behavior|현재\s*동작)\s*$/i,
242
+ /^##\s+(Target\s+behavior|목표\s*동작)\s*$/i,
243
+ /^##\s+(Interface|인터페이스)\s*$/i,
244
+ /^##\s+(Touch|수정할\s*부분)\s*$/i,
245
+ /^##\s+(Do\s+not\s+touch|절대\s*수정\s*금지)\s*$/i,
246
+ /^##\s+(Constraints|제약)\s*$/i,
247
+ /^##\s+(Checklist|체크리스트)\s*$/i,
248
+ ];
249
+ const isHeading = (line) => headings.some((heading) => heading.test(line.trim()));
250
+ const starts = lines
251
+ .map((line, index) => /^##\s+(Constraints|제약)\s*$/i.test(line.trim()) ? index : -1)
252
+ .filter((index) => index >= 0);
253
+ const start = starts[starts.length - 1] ?? -1;
254
+ if (start < 0)
255
+ return null;
256
+ const next = lines.findIndex((line, index) => index > start && isHeading(line));
257
+ const content = lines.slice(start + 1, next < 0 ? lines.length : next).join('\n').trim();
258
+ return content || null;
259
+ }
260
+ /**
261
+ * `git log --format=%h%x00%s` 출력을 최신순 commit 요약으로 접는다.
262
+ *
263
+ * @param {string} stdout - git log 결과
264
+ * @returns {Array<{ sha8: string; subject: string }>} 제한된 commit 목록
265
+ */
266
+ function parseCommitSubjects(stdout) {
267
+ const starts = [];
268
+ SHORT_SHA_NUL_RE.lastIndex = 0;
269
+ let match;
270
+ while ((match = SHORT_SHA_NUL_RE.exec(stdout)) !== null) {
271
+ starts.push({
272
+ sha: match[1].toLowerCase(),
273
+ bodyStart: match.index + match[0].length,
274
+ shaStart: match.index,
275
+ });
276
+ }
277
+ const result = [];
278
+ for (let index = 0; index < starts.length; index += 1) {
279
+ const end = index + 1 < starts.length ? starts[index + 1].shaStart : stdout.length;
280
+ const subject = stdout.slice(starts[index].bodyStart, end).replace(/^\n|\n$/g, '').trim();
281
+ const sha8 = normalizeCommitSha(starts[index].sha) || starts[index].sha.slice(0, 8);
282
+ result.push({ sha8, subject });
283
+ }
284
+ return result.slice(0, COMMIT_LIST_CAP);
285
+ }
286
+ /**
287
+ * unified diff의 파일별 최상위 선언 이름을 줄 머리 규칙으로 추출한다.
288
+ *
289
+ * @param {string} diffText - `git diff -U0` 결과
290
+ * @returns {Array<{ path: string; names: string[] }>} 파일당 10개, 전체 60개 이하
291
+ */
292
+ function extractSymbols(diffText) {
293
+ const byPath = new Map();
294
+ let currentPath = null;
295
+ let total = 0;
296
+ const pushName = (relPath, name) => {
297
+ if (total >= TOTAL_SYMBOL_CAP)
298
+ return;
299
+ let list = byPath.get(relPath);
300
+ if (!list) {
301
+ list = [];
302
+ byPath.set(relPath, list);
303
+ }
304
+ if (list.length >= PER_FILE_SYMBOL_CAP || list.includes(name))
305
+ return;
306
+ list.push(name);
307
+ total += 1;
308
+ };
309
+ const scanLine = (relPath, text) => {
310
+ const fn = SYMBOL_FN_RE.exec(text);
311
+ if (fn?.[1]) {
312
+ pushName(relPath, fn[1]);
313
+ return;
314
+ }
315
+ const variable = SYMBOL_VAR_RE.exec(text);
316
+ if (variable?.[1])
317
+ pushName(relPath, variable[1]);
318
+ };
319
+ for (const line of diffText.split('\n')) {
320
+ const gitPath = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
321
+ if (gitPath) {
322
+ currentPath = toPosix(gitPath[2]);
323
+ continue;
324
+ }
325
+ if (!currentPath || currentPath.startsWith(CONTEXT_EXCLUDED))
326
+ continue;
327
+ const hunk = /^@@[^@]*@@(.*)$/.exec(line);
328
+ if (hunk) {
329
+ const context = hunk[1] || '';
330
+ scanLine(currentPath, context.startsWith(' ') ? context.slice(1) : context);
331
+ continue;
332
+ }
333
+ if (line.startsWith('+') && !line.startsWith('+++'))
334
+ scanLine(currentPath, line.slice(1));
335
+ }
336
+ return [...byPath.entries()].map(([filePath, names]) => ({ path: filePath, names }));
337
+ }
338
+ /**
339
+ * 수집된 numstat 결과를 digest용 변경 통계로 정규화한다.
340
+ *
341
+ * @param {{ status: number; stdout: string }} result - `git diff --numstat -z` 결과
342
+ * @returns {DiffSummary | null} Git 실패면 null, 성공이면 안정 정렬된 요약
343
+ */
344
+ function summarizeDiffStat(result) {
345
+ if (result.status !== 0)
346
+ return null;
347
+ const rows = [];
348
+ let insertions = 0;
349
+ let deletions = 0;
350
+ for (const record of result.stdout.split('\0')) {
351
+ if (!record.trim())
352
+ continue;
353
+ const parts = record.split('\t');
354
+ if (parts.length < 3)
355
+ continue;
356
+ const filePath = toPosix(parts.slice(2).join('\t'));
357
+ if (filePath.startsWith(CONTEXT_EXCLUDED))
358
+ continue;
359
+ const added = /^\d+$/.test(parts[0]) ? Number(parts[0]) : 0;
360
+ const deleted = /^\d+$/.test(parts[1]) ? Number(parts[1]) : 0;
361
+ insertions += added;
362
+ deletions += deleted;
363
+ rows.push({ path: filePath, added, deleted });
364
+ }
365
+ const perFile = rows
366
+ .map((row, index) => ({ row, index }))
367
+ .sort((a, b) => (b.row.added + b.row.deleted) - (a.row.added + a.row.deleted) || a.index - b.index)
368
+ .slice(0, DIFF_PER_FILE_CAP)
369
+ .map((entry) => entry.row);
370
+ return { files: rows.length, insertions, deletions, per_file: perFile };
371
+ }
372
+ /**
373
+ * Git name-only 결과를 governance 경로 없이 정규화한다.
374
+ *
375
+ * @param {{ status: number; stdout: string }} result - `git diff --name-only` 결과
376
+ * @returns {string[]} 성공 경로의 입력 순서 목록. Git 실패면 빈 배열
377
+ */
378
+ function normalizeChangedPaths(result) {
379
+ if (result.status !== 0)
380
+ return [];
381
+ return result.stdout.split('\n')
382
+ .map((line) => line.trim())
383
+ .filter(Boolean)
384
+ .map((filePath) => toPosix(filePath))
385
+ .filter((filePath) => filePath !== CONTEXT_EXCLUDED.slice(0, -1)
386
+ && !filePath.startsWith(CONTEXT_EXCLUDED));
387
+ }
388
+ /**
389
+ * 수집 snapshot에 들어온 task·review·ledger 값만 검증해 unverified 결과를 만든다.
390
+ * 실패한 terminal을 outside_scope로 분류해도 verification 상태는 실패로 남긴다.
391
+ *
392
+ * @param {object} snapshot - collector가 읽은 task와 ledger 값
393
+ * @returns {object} 복제된 task digest, root review 요약, unverified 목록
394
+ */
395
+ function assessDigestSnapshot(snapshot) {
396
+ const built = snapshot.built.map((item) => ({
397
+ ...item,
398
+ digest: { ...item.digest },
399
+ }));
400
+ const unverified = [];
401
+ if (snapshot.diffSummaryUnavailable)
402
+ unverified.push({ kind: 'diff-summary-unavailable' });
403
+ let terminalOk = false;
404
+ let outsideScopeTerminal = null;
405
+ for (const item of built) {
406
+ const task = item.digest;
407
+ if (item.kind === 'verification') {
408
+ if (task.verification && task.verification.status === 'passed') {
409
+ terminalOk = true;
410
+ continue;
411
+ }
412
+ const accepted = validOutsideScope(snapshot.ledger, task.id);
413
+ if (accepted) {
414
+ const failure = asRecord(asRecord(snapshot.ledger).terminalFailure);
415
+ task.verification = {
416
+ status: task.verification ? task.verification.status : 'failed',
417
+ command: task.verification?.command ?? (typeof failure.command === 'string' ? failure.command : null),
418
+ exit_code: task.verification?.exit_code ?? (typeof failure.exitCode === 'number' ? failure.exitCode : null),
419
+ evidence_id: task.verification?.evidence_id ?? accepted.evidence_id,
420
+ reused: task.verification ? task.verification.reused : null,
421
+ outside_scope: accepted,
422
+ };
423
+ outsideScopeTerminal = { stableId: task.stable_id, accepted };
424
+ }
425
+ continue;
426
+ }
427
+ if (item.kind === 'commit' && (!task.verification || task.verification.status !== 'passed')) {
428
+ unverified.push({ kind: 'verification-not-passed', task: task.stable_id });
429
+ }
430
+ if (!snapshot.blueprintReviewMode && task.review && task.review.required === false) {
431
+ unverified.push({ kind: 'review-skipped', task: task.stable_id });
432
+ }
433
+ for (const finding of task.review?.findings || []) {
434
+ if (finding.status === 'deferred' || finding.status === 'accepted') {
435
+ unverified.push({
436
+ kind: `finding-${finding.status}`,
437
+ task: task.stable_id,
438
+ detail: finding.note || finding.id,
439
+ });
440
+ }
441
+ }
442
+ }
443
+ const blueprintReview = snapshot.blueprintReviewMode
444
+ ? { findings: snapshot.rootReview ? snapshot.rootReview.findings : [] }
445
+ : null;
446
+ for (const finding of blueprintReview?.findings || []) {
447
+ if (finding.status === 'deferred' || finding.status === 'accepted') {
448
+ unverified.push({
449
+ kind: `finding-${finding.status}`,
450
+ task: null,
451
+ detail: finding.note || finding.id,
452
+ });
453
+ }
454
+ }
455
+ if (!terminalOk && outsideScopeTerminal) {
456
+ unverified.push({
457
+ kind: 'terminal-outside-scope',
458
+ task: outsideScopeTerminal.stableId,
459
+ detail: `${outsideScopeTerminal.accepted.reason} (evidence ${outsideScopeTerminal.accepted.evidence_id})`,
460
+ });
461
+ }
462
+ else if (!terminalOk) {
463
+ unverified.push({ kind: 'terminal-missing' });
464
+ }
465
+ const scopes = built.map((item) => item.affected);
466
+ for (const changed of snapshot.changedPaths) {
467
+ if (pathOutsideScope(changed, scopes))
468
+ unverified.push({ kind: 'path-outside-scope', path: changed });
469
+ }
470
+ return { tasks: built.map((item) => item.digest), blueprint_review: blueprintReview, unverified };
471
+ }
472
+ /**
473
+ * changed path가 task scope와 겹치지 않는지 순수하게 판정한다.
474
+ *
475
+ * @param {string} changedPath - 변경 경로
476
+ * @param {string[][]} scopes - task별 허용 scope
477
+ * @returns {boolean} 정당화된 scope가 없으면 true
478
+ */
479
+ function pathOutsideScope(changedPath, scopes) {
480
+ const normalized = normalizeOverlapPath(changedPath);
481
+ return !scopes.some((scope) => scope.some((declared) => (pathsOverlap(normalized, normalizeOverlapPath(declared)))));
482
+ }
483
+ /**
484
+ * 수집·검사된 snapshot을 version 2 digest 본문으로 조립한다.
485
+ * 전달받은 값만 사용해 파일, process, pointer 또는 Git을 조회하지 않는다.
486
+ *
487
+ * @param {object} snapshot - digest 계약에 이미 정규화된 값
488
+ * @returns {object} buildPrDraft 입력에 쓰이는 v2 digest 본문
489
+ */
490
+ function assembleDigestBody(snapshot) {
491
+ return {
492
+ ok: true,
493
+ version: 2,
494
+ blueprint: snapshot.blueprint,
495
+ range: snapshot.range,
496
+ git: snapshot.git,
497
+ tasks: snapshot.tasks,
498
+ changed_paths: snapshot.changed_paths,
499
+ symbols: snapshot.symbols,
500
+ commits: snapshot.commits,
501
+ diff: snapshot.diff,
502
+ evidence: {
503
+ verification: snapshot.tasks.flatMap((task) => (task.verification && task.verification.evidence_id
504
+ ? [{ task: task.stable_id, evidence_id: task.verification.evidence_id }]
505
+ : [])),
506
+ review: snapshot.reviewEvidence,
507
+ },
508
+ unverified: snapshot.unverified,
509
+ out_of_scope: snapshot.out_of_scope,
510
+ blueprint_review: snapshot.blueprint_review,
511
+ };
512
+ }
513
+ function asRecord(value) {
514
+ return value && typeof value === 'object' && !Array.isArray(value)
515
+ ? value
516
+ : {};
517
+ }
518
+ /**
519
+ * 읽힌 coordinator ledger를 공개용 drive 요약으로 정규화한다.
520
+ * 식별할 수 없는 task가 있으면 ready를 닫아 불완전한 목록을 완료로 오인하지 않게 한다.
521
+ *
522
+ * @param {unknown} ledger - 읽힌 원장. 없으면 null
523
+ * @returns {DriveSummary | null} 원장 부재면 null, 그 외에는 task 순서를 보존한 요약
524
+ */
525
+ function summarizeDrive(ledger) {
526
+ if (ledger === null || ledger === undefined)
527
+ return null;
528
+ const rawTasks = ledger.tasks;
529
+ if (!Array.isArray(rawTasks))
530
+ return { ready: false, tasks: [] };
531
+ const tasks = [];
532
+ let dropped = false;
533
+ for (const raw of rawTasks) {
534
+ if (!raw || typeof raw.id !== 'string') {
535
+ dropped = true;
536
+ continue;
537
+ }
538
+ tasks.push({
539
+ id: raw.id,
540
+ status: typeof raw.status === 'string' ? raw.status : null,
541
+ sha: typeof raw.sha === 'string' ? raw.sha : null,
542
+ });
543
+ }
544
+ const ready = !dropped && tasks.length > 0
545
+ && tasks.every((task) => task.status === 'integrated' || validOutsideScope(ledger, task.id) !== null);
546
+ return { ready, tasks };
547
+ }
548
+ /**
549
+ * 원장 event에서 Retro 판단용 통계를 계산한다. 같은 repair wave는 한 번만 센다.
550
+ *
551
+ * @param {unknown} ledger - 읽힌 원장. 없으면 모든 원장 통계는 0
552
+ * @param {number} deferredMustFix - 루트 리뷰 deferred must_fix 고유 id 개수
553
+ * @returns {DriveIssues} 여섯 실행 통계
554
+ */
555
+ function summarizeDriveIssues(ledger, deferredMustFix) {
556
+ const root = asRecord(ledger);
557
+ const decisions = (Array.isArray(root.decisions) ? root.decisions : []).map(asRecord);
558
+ const count = (pred) => decisions.filter(pred).length;
559
+ const uniqueWaves = (rows) => new Set(rows.map(asRecord).map((r) => r.wave).filter((w) => Number.isInteger(w))).size;
560
+ const fromRepairWaves = Array.isArray(root.repairWaves) ? uniqueWaves(root.repairWaves) : 0;
561
+ return {
562
+ rework_reports: count((d) => d.kind === 'report' && d.outcome === 'rework'),
563
+ debugger_calls: count((d) => d.kind === 'worker-model' && d.role === 'debugger'),
564
+ repair_waves: fromRepairWaves > 0
565
+ ? fromRepairWaves
566
+ : uniqueWaves(decisions.filter((d) => d.kind === 'repair')),
567
+ fanin_verification_failures: count((d) => d.kind === 'fanin-verification-failed'),
568
+ blocked: count((d) => (d.kind === 'report' || d.kind === 'critical-recovery')
569
+ && d.outcome === 'blocked'),
570
+ deferred_must_fix: deferredMustFix,
571
+ };
572
+ }
573
+ /**
574
+ * blueprint review finding에서 고유 deferred must_fix 경고를 계산한다.
575
+ *
576
+ * @param {Finding[]} findings - 읽힌 blueprint root findings
577
+ * @returns {DigestWarning[]} 처음 등장한 id 순서의 경고
578
+ */
579
+ function collectDigestWarnings(findings) {
580
+ const warnings = [];
581
+ const seen = new Set();
582
+ for (const finding of findings) {
583
+ if (finding.actionability !== 'must_fix' || finding.status !== 'deferred' || !finding.id)
584
+ continue;
585
+ if (seen.has(finding.id))
586
+ continue;
587
+ seen.add(finding.id);
588
+ warnings.push({
589
+ kind: 'must-fix-deferred',
590
+ id: finding.id,
591
+ note: typeof finding.note === 'string' ? finding.note : '',
592
+ });
593
+ }
594
+ return warnings;
595
+ }