@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,347 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const schema = require("./schema");
4
+ const { OKF_REQUIRED, TYPES, ID_PREFIX, STATUS_ENUM, detectLegacyFormat, KIND_TO_TYPE, SCALE_ENUM, isValidSupersedes, DEPENDENCY_GATE_ENUM, isValidDependsOn, isValidExclusiveResources, isValidAuthoredLineList, executionKindOf, planReviewContractOf, } = schema;
5
+ const paths = require("./paths");
6
+ const { parsePathIds, toPosix, isNumericContextId, } = paths;
7
+ const contextIds = require("./context-ids");
8
+ const { parseContextPath, validateSlug } = contextIds;
9
+ const layout = require("./layout");
10
+ const { isValidBlueprintOrder, listBlueprintOrders } = layout;
11
+ const verification = require("./verification");
12
+ const { isValidVerifyCommand } = verification;
13
+ const tasksDocs = require("./tasks-docs");
14
+ const { expectedTasksId, expectedTaskDocIds, TASK_UNIT_BASENAMES, unitDocKind, } = tasksDocs;
15
+ const planPrerequisites = require("./plan-prerequisites");
16
+ const { prerequisiteFormatIssues } = planPrerequisites;
17
+ const config = require("./config");
18
+ const { DEFAULT_VERIFY_ALLOWLIST } = config;
19
+ // review-dispatch Execute 경로와 같은 허용 목록(review-policy 정본). 정본이 하나라
20
+ // S30과 분류기 거절이 어긋나 malformed 위험이 한쪽에만 통과하는 일이 없다.
21
+ const reviewPolicy = require("./review-policy");
22
+ const { REVIEW_RISK_ENUM } = reviewPolicy;
23
+ /**
24
+ * 경로가 요구하는 bouncer type. 위치 규칙이 없으면 null — S19를 내지 않는다.
25
+ * task 묶음 basename은 TASK_UNIT_BASENAMES만 순회하고 문자열을 여기 두지 않는다.
26
+ */
27
+ function expectedTypeForPath(rel) {
28
+ const norm = toPosix(rel);
29
+ const parsed = parsePathIds(norm);
30
+ const base = path.posix.basename(norm);
31
+ // epic/blueprint index는 basename이 같아 blueprintId 유무로만 가른다.
32
+ if (base === 'index.md') {
33
+ if (parsed.blueprintId)
34
+ return KIND_TO_TYPE.blueprint;
35
+ if (parsed.epicId)
36
+ return KIND_TO_TYPE.epic;
37
+ return null;
38
+ }
39
+ // 루트 tasks.md·알 수 없는 basename은 규칙 밖. 번호 묶음만 대조한다.
40
+ const unitM = /\/tasks\/(\d{3})\//.exec(norm);
41
+ if (unitM) {
42
+ for (const name of TASK_UNIT_BASENAMES) {
43
+ if (base === name) {
44
+ const kind = unitDocKind(name);
45
+ return kind ? KIND_TO_TYPE[kind] : null;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ // explain.md / context-review.md는 FILE_KIND(paths) → parsePathIds.kind.
51
+ // blueprint 아래만 기대. 게이트 판정은 여기 두지 않는다(S19 매핑만).
52
+ if (parsed.blueprintId && parsed.kind === 'explain') {
53
+ return KIND_TO_TYPE.explain;
54
+ }
55
+ if (parsed.blueprintId && parsed.kind === 'context_review') {
56
+ return KIND_TO_TYPE.context_review;
57
+ }
58
+ // 루트 review.md는 tasks/<NNN>/ 매핑 밖에 있다. 위치 규칙을 주지 않으면
59
+ // 모드 문서가 S19를 건너뛰고 임의의 type으로 남을 수 있다. unit 경로는
60
+ // 위에서 이미 반환했다.
61
+ if (parsed.blueprintId && parsed.kind === 'review') {
62
+ return KIND_TO_TYPE.review;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * 문서 하나(프론트매터)의 구조 코드를 기록한다. S12는 호출자가 넘긴
68
+ * 프로젝트 allowlist로 `tasks.bouncer.verify`를 검사한다. 목록을 생략하면
69
+ * 기본 목록을 쓰는데, 직접 호출 테스트와 정책 부재 폴백을 맞추기 위함이다.
70
+ * 파손된 config의 기본 목록 폴백은 `validateBlueprint`가 막는다.
71
+ *
72
+ * @param {unknown} doc - `{ data, rel }` 문서
73
+ * @param {FailureEntry[]} failures - 실패를 누적할 배열
74
+ * @param {readonly string[]} [verifyAllowlist] - S12 argv0 허용 목록
75
+ * @returns {void}
76
+ */
77
+ function checkStructural(doc, failures, verifyAllowlist = DEFAULT_VERIFY_ALLOWLIST) {
78
+ const { data, rel } = doc;
79
+ const add = (code, message) => failures.push({ code, message, file: rel });
80
+ const rec = data;
81
+ const legacy = detectLegacyFormat({ data });
82
+ if (legacy.legacy) {
83
+ add('S2', legacy.reason);
84
+ return;
85
+ }
86
+ for (const f of OKF_REQUIRED) {
87
+ const v = rec[f];
88
+ if (v === undefined || v === null || v === '')
89
+ add('S1', `OKF field missing: ${f}`);
90
+ }
91
+ if (!TYPES.includes(rec.type)) {
92
+ add('S2', `unknown type: ${rec.type}`);
93
+ return; // type에 의존하는 검사는 진행할 수 없음
94
+ }
95
+ const docType = rec.type;
96
+ // S19: 알려진 type만 위치와 대조. 기대값이 null이면 위치 규칙이 없는 경로.
97
+ const expectedType = expectedTypeForPath(rel);
98
+ if (expectedType && rec.type !== expectedType) {
99
+ add('S19', `type ${rec.type} does not match expected ${expectedType} for path`);
100
+ }
101
+ if (rec.resource !== rel) {
102
+ add('S3', `resource path mismatch: ${rec.resource} != ${rel}`);
103
+ }
104
+ const bouncer = (rec.bouncer || {});
105
+ const prefix = Object.prototype.hasOwnProperty.call(ID_PREFIX, docType)
106
+ ? ID_PREFIX[docType]
107
+ : undefined;
108
+ // migration 이후에는 검증기가 구형 접두를 보정하지 않는다. 정본 형태가 아니면
109
+ // S4/S5에서 그대로 거절해 일부만 migrate된 저장소가 통과하지 못하게 한다.
110
+ const id = bouncer.id;
111
+ // 문서 경로의 형식(번호/slug)이 id 모양을 정한다. slug 경로의 epic·blueprint 문서는 id가 경로 slug이고
112
+ // (값 일치는 아래 S5가 본다), 루트 review·explain·context-review는 접두 뒤가 blueprint slug다.
113
+ // task 묶음 문서(TASKS-NNN 등)는 형식과 무관하게 task 번호라 숫자 판정을 그대로 쓴다.
114
+ const slugPath = parseContextPath(rel)?.format === 'slug';
115
+ if (docType === 'bouncer.epic' || docType === 'bouncer.blueprint') {
116
+ if (slugPath ? validateSlug(id) !== null : !isNumericContextId(id)) {
117
+ add('S4', slugPath
118
+ ? `id "${bouncer.id}" must be a valid slug`
119
+ : `id "${bouncer.id}" must be a zero-padded three-digit id`);
120
+ }
121
+ }
122
+ else if (typeof prefix !== 'string'
123
+ || typeof id !== 'string'
124
+ || !id.startsWith(prefix)
125
+ || !(isNumericContextId(id.slice(prefix.length)) || (slugPath && validateSlug(id.slice(prefix.length)) === null))) {
126
+ add('S4', `id "${bouncer.id}" missing prefix ${prefix} or invalid digits`);
127
+ }
128
+ const parsed = parsePathIds(rel);
129
+ if (parsed.epicId && bouncer.epic_id !== parsed.epicId) {
130
+ add('S5', `epic_id ${bouncer.epic_id} != path ${parsed.epicId}`);
131
+ }
132
+ if (docType !== 'bouncer.epic'
133
+ && parsed.blueprintId
134
+ && bouncer.blueprint_id !== parsed.blueprintId) {
135
+ add('S5', `blueprint_id ${bouncer.blueprint_id} != path ${parsed.blueprintId}`);
136
+ }
137
+ let expectedId = null;
138
+ // tasks/<NNN>/… 새 레이아웃은 디렉터리 번호가 id 숫자. basename만 보면
139
+ // 전부 tasks.md → TASKS-{blueprintId}로 잘못 접혀 002가 S5에 걸린다.
140
+ const dirDigitsMatch = /\/tasks\/(\d{3})\//.exec(toPosix(rel));
141
+ if (dirDigitsMatch) {
142
+ const ids = expectedTaskDocIds(dirDigitsMatch[1]);
143
+ if (docType === 'bouncer.tasks')
144
+ expectedId = ids.tasks;
145
+ else if (docType === 'bouncer.verification')
146
+ expectedId = ids.verification;
147
+ else if (docType === 'bouncer.review')
148
+ expectedId = ids.review;
149
+ }
150
+ else if (docType === 'bouncer.epic')
151
+ expectedId = parsed.epicId;
152
+ else if (docType === 'bouncer.blueprint')
153
+ expectedId = parsed.blueprintId;
154
+ else if (docType === 'bouncer.tasks') {
155
+ // task id는 파일 이름에서 유도 — 레거시는 blueprint id, 번호 문서는 NNN.
156
+ expectedId = expectedTasksId(path.posix.basename(rel), parsed.blueprintId);
157
+ }
158
+ else if (parsed.blueprintId)
159
+ expectedId = `${prefix}${parsed.blueprintId}`;
160
+ if (expectedId && bouncer.id !== expectedId) {
161
+ add('S5', `id ${bouncer.id} != expected ${expectedId} from path`);
162
+ }
163
+ if (!(Object.prototype.hasOwnProperty.call(STATUS_ENUM, docType)
164
+ ? STATUS_ENUM[docType]
165
+ : []).includes(bouncer.status)) {
166
+ add('S6', `status "${bouncer.status}" not in enum for ${docType}`);
167
+ }
168
+ // S20: blueprint만. 부재는 0.7 문서 통과용으로 허용; 잘못된 값만 거절.
169
+ if (docType === 'bouncer.blueprint'
170
+ && bouncer.scale !== undefined
171
+ && !SCALE_ENUM.includes(bouncer.scale)) {
172
+ add('S20', `scale "${bouncer.scale}" not in enum for ${docType}`);
173
+ }
174
+ // S31: review_scope는 켜져 있으면 값 'blueprint'만 받는다. 부재는 구형
175
+ // task별 리뷰 계약이므로 통과한다. 파일 존재·scale로 모드를 추론하지 않는다.
176
+ if (docType === 'bouncer.blueprint'
177
+ && bouncer.review_scope !== undefined
178
+ && bouncer.review_scope !== 'blueprint') {
179
+ add('S31', 'review_scope must be blueprint');
180
+ }
181
+ // S38: review_contract는 켜져 있으면 값 'brief'만 받는다. 부재는 legacy 계약이므로
182
+ // 통과한다. 잘못된 표식을 legacy로 오인하면 리뷰 관점이 조용히 바뀌므로 값과
183
+ // review_scope 전제를 각각 거절한다. light도 표식을 받는다(plan 리뷰가 verifiability
184
+ // 한 관점으로 돈다). light를 접는 reviewContractOf가 아니라 planReviewContractOf를
185
+ // 써야 light의 잘못된 값도 여기서 거절된다.
186
+ if (docType === 'bouncer.blueprint' && planReviewContractOf(bouncer) !== 'legacy') {
187
+ if (planReviewContractOf(bouncer) === null) {
188
+ add('S38', 'review_contract must be brief');
189
+ }
190
+ else if (bouncer.review_scope !== 'blueprint') {
191
+ add('S38', 'review_contract requires review_scope blueprint');
192
+ }
193
+ }
194
+ // S39: prerequisites는 선택 키라 부재/[]는 통과하고 있는 값의 모양만 거절한다. 존재·순환·병합 여부는
195
+ // 구조 검사가 아니라 plan-prerequisites helper의 판정 데이터이며 이 task에서는 gate에 연결하지 않는다.
196
+ if (docType === 'bouncer.blueprint') {
197
+ const selfDir = toPosix(rel).replace(/\/index\.md$/, '');
198
+ for (const message of prerequisiteFormatIssues(bouncer.prerequisites, selfDir))
199
+ add('S39', message);
200
+ }
201
+ // S36: area·attribution은 선택 키라 부재는 통과하고 있는 값의 모양만 거절한다.
202
+ // 등록부 대조와 필수 판정은 구조 검사가 아니라 gate 소관이다.
203
+ if (docType === 'bouncer.blueprint') {
204
+ const { area, attribution } = bouncer;
205
+ if (area !== undefined && !(typeof area === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(area))) {
206
+ add('S36', 'area must be a lowercase slug string');
207
+ }
208
+ if (attribution !== undefined
209
+ && !(typeof attribution === 'string' && attribution.trim() !== '' && !/[\r\n]/.test(attribution))) {
210
+ add('S36', 'attribution must be a non-empty single-line string');
211
+ }
212
+ }
213
+ // S27: epic·blueprint만. 부재는 통과(소급 없음). 형식만 schema.isValidSupersedes —
214
+ // 경로 존재·자기참조·순환·중복은 검사하지 않는다.
215
+ if ((docType === 'bouncer.epic' || docType === 'bouncer.blueprint')
216
+ && !isValidSupersedes(bouncer.supersedes)) {
217
+ add('S27', 'supersedes must be an array of non-empty document paths');
218
+ }
219
+ if (docType === 'bouncer.tasks') {
220
+ const ap = bouncer.affected_paths;
221
+ const executionKind = executionKindOf(bouncer);
222
+ if (executionKind !== 'verification' && (!Array.isArray(ap) || ap.length === 0)) {
223
+ add('S7', 'tasks.affected_paths missing or empty');
224
+ }
225
+ // 선택 필드: 없으면 기존 tasks.md가 모두 유효하게 유지됨. S12와
226
+ // VERIFY_COMMAND_INVALID가 같은 allowlist를 써야 두 경로가 어긋나지 않는다.
227
+ if (bouncer.verify !== undefined && !isValidVerifyCommand(bouncer.verify, verifyAllowlist)) {
228
+ add('S12', 'tasks.verify must be a single executable command');
229
+ }
230
+ // S28: DAG 필드 shape·enum만. 부재는 빈 depends_on / false / integrated /
231
+ // 빈 exclusive_resources로 읽히므로 통과. 참조 무결성·cycle은 G19.
232
+ if (!isValidDependsOn(bouncer.depends_on)) {
233
+ add('S28', 'depends_on must be an array of TASKS-NNN ids');
234
+ }
235
+ if (bouncer.parallel_safe !== undefined && typeof bouncer.parallel_safe !== 'boolean') {
236
+ add('S28', 'parallel_safe must be a boolean');
237
+ }
238
+ if (bouncer.dependency_gate !== undefined
239
+ && !DEPENDENCY_GATE_ENUM.includes(bouncer.dependency_gate)) {
240
+ add('S28', `dependency_gate "${bouncer.dependency_gate}" not in enum`);
241
+ }
242
+ if (!isValidExclusiveResources(bouncer.exclusive_resources)) {
243
+ add('S28', 'exclusive_resources must be an array of unique resource ids');
244
+ }
245
+ // verification node는 구현 범위를 갖지 않고 선행 fan-in 뒤 단일 argv만
246
+ // 실행한다. 이 불변조건을 한 코드로 묶어 부분 선언이 commit task처럼
247
+ // 흘러가는 것을 막는다. graph의 존재·terminal 관계는 plan G20의 몫이다.
248
+ if (executionKind === null) {
249
+ add('S29', 'execution_kind must be commit or verification');
250
+ }
251
+ else if (executionKind === 'verification') {
252
+ if (!Array.isArray(ap) || ap.length !== 0) {
253
+ add('S29', 'verification task affected_paths must be empty');
254
+ }
255
+ if (!Array.isArray(bouncer.depends_on) || bouncer.depends_on.length === 0) {
256
+ add('S29', 'verification task depends_on must be non-empty');
257
+ }
258
+ if (bouncer.parallel_safe !== false) {
259
+ add('S29', 'verification task parallel_safe must be false');
260
+ }
261
+ if (bouncer.dependency_gate !== 'integrated') {
262
+ add('S29', 'verification task dependency_gate must be integrated');
263
+ }
264
+ if (!isValidVerifyCommand(bouncer.verify, verifyAllowlist)) {
265
+ add('S29', 'verification task verify must be a single executable command');
266
+ }
267
+ }
268
+ else if (bouncer.status === 'verifying' || bouncer.status === 'integrated') {
269
+ add('S29', `commit task cannot use verification status ${bouncer.status}`);
270
+ }
271
+ // S30: review_risk는 승인 enum의 중복 없는 배열만 받는다. 부재는 legacy []
272
+ // 로 읽히므로 통과 — 새 문서의 malformed 값만 dispatch 전에 막는다.
273
+ // S32: commit_intent·commit_summary는 YAML 리스트만. 스칼라 문자열은
274
+ // `bouncer commit`이 스테이징 전에 같은 이유로 죽던 형태라 plan에서 거절한다.
275
+ // 부재·[]는 통과(작성 전 scaffold). 한국어 종결형은 커밋 정규화가 맡는다.
276
+ for (const field of ['commit_intent', 'commit_summary']) {
277
+ if (!isValidAuthoredLineList(bouncer[field])) {
278
+ add('S32', `${field} must be a YAML list of 0-2 strings`);
279
+ }
280
+ }
281
+ if (bouncer.review_risk !== undefined) {
282
+ if (!Array.isArray(bouncer.review_risk)) {
283
+ add('S30', 'review_risk must be an array');
284
+ }
285
+ else {
286
+ const seen = new Set();
287
+ for (const entry of bouncer.review_risk) {
288
+ if (typeof entry !== 'string' || !REVIEW_RISK_ENUM.includes(entry)) {
289
+ add('S30', `review_risk value invalid: ${String(entry)}`);
290
+ continue;
291
+ }
292
+ if (seen.has(entry)) {
293
+ add('S30', `review_risk duplicate: ${entry}`);
294
+ continue;
295
+ }
296
+ seen.add(entry);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+ /**
303
+ * slug 형식 blueprint의 S37을 판정한다. 대상은 blueprint 디렉터리 경로가 slug 형식으로 읽히는 경우뿐이며
304
+ * 번호 형식은 건드리지 않는다. 보는 것은 `order` 존재·양의 정수·epic 안 유일성, 그리고 같은 epic 안에서
305
+ * 같은 slug를 id로 쓰는 다른 문서(복사된 문서)다. 경로 자체의 형식 혼용·slug 규칙 위반은 경로가 읽히지 않아
306
+ * 이 함수에 오기 전에 validate가 S37로 보고한다. 승인 뒤 slug 불변은 gate가 판정하지 않고(`order`도 닫힌
307
+ * blueprint에서 바꿀 수 있다), 여기서도 값 모양과 유일성만 본다.
308
+ *
309
+ * @param {object} opts - 판정 입력
310
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
311
+ * @param {string} opts.blueprintDir - 저장소 상대 blueprint 디렉터리
312
+ * @param {unknown} opts.blueprintIndex - 로드된 blueprint index 문서(`{ data, rel }`). 없으면 판정하지 않는다
313
+ * @returns {FailureEntry[]} S37 실패 목록(없으면 빈 배열). 메시지는 위반 종류로 시작한다
314
+ */
315
+ function checkSlugBlueprint({ repoRoot, blueprintDir, blueprintIndex }) {
316
+ const ids = parseContextPath(blueprintDir);
317
+ if (!ids || ids.format !== 'slug' || ids.blueprintKey === null || !blueprintIndex)
318
+ return [];
319
+ const { data, rel } = blueprintIndex;
320
+ const failures = [];
321
+ const add = (kind, message) => {
322
+ failures.push({ code: 'S37', message: `${kind}: ${message}`, file: rel });
323
+ };
324
+ const bouncer = (data?.bouncer ?? {});
325
+ const epicDir = toPosix(blueprintDir).split('/blueprints/')[0];
326
+ const siblings = listBlueprintOrders(repoRoot, epicDir).filter((entry) => entry.name !== ids.blueprintKey);
327
+ if (bouncer.order === undefined) {
328
+ add('order-missing', 'a slug blueprint needs a positive integer bouncer.order');
329
+ }
330
+ else if (!isValidBlueprintOrder(bouncer.order)) {
331
+ add('order-invalid', `bouncer.order must be a positive integer, got ${JSON.stringify(bouncer.order)}`);
332
+ }
333
+ else {
334
+ const clash = siblings.find((entry) => entry.order === bouncer.order);
335
+ if (clash)
336
+ add('order-duplicate', `order ${bouncer.order} is also used by ${clash.name}`);
337
+ }
338
+ const copy = siblings.find((entry) => entry.id === ids.blueprintKey);
339
+ if (copy)
340
+ add('slug-duplicate', `slug ${ids.blueprintKey} is also the id of ${copy.name}`);
341
+ return failures;
342
+ }
343
+ module.exports = {
344
+ expectedTypeForPath,
345
+ checkStructural,
346
+ checkSlugBlueprint,
347
+ };