@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,575 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const layout = require("./layout");
5
+ const { isCanonicalEpicDir, isCanonicalBlueprintDir, isValidBlueprintOrder, listBlueprintOrders, } = layout;
6
+ const contextIds = require("./context-ids");
7
+ const { parseContextPath } = contextIds;
8
+ const paths = require("./paths");
9
+ const { toPosix } = paths;
10
+ const current = require("./current");
11
+ const { resolveCurrent } = current;
12
+ const frontmatter = require("./frontmatter");
13
+ const { readDoc } = frontmatter;
14
+ const tasksDocs = require("./tasks-docs");
15
+ const { listTasksDocs } = tasksDocs;
16
+ const epicRegistry = require("./epic-registry");
17
+ const { readEpicRegistry, COMMON_EPICS } = epicRegistry;
18
+ const BOUNCER_DIR = '.bouncer';
19
+ const EPICS_REL = '.bouncer/context/epics';
20
+ const VERIFY_FILES = [
21
+ 'docker-compose.yml',
22
+ 'docker-compose.yaml',
23
+ 'compose.yml',
24
+ 'compose.yaml',
25
+ 'Makefile',
26
+ 'Taskfile.yml',
27
+ ];
28
+ // light/full 추천 신호용 경로 규칙. 설정 파일로 열지 않는 코드 상수 —
29
+ // review_risk(frontmatter)와 별개 계약이라 여기만 소유한다.
30
+ const SECURITY_SEGMENTS = new Set([
31
+ 'auth',
32
+ 'credential',
33
+ 'credentials',
34
+ 'secret',
35
+ 'secrets',
36
+ 'token',
37
+ 'tokens',
38
+ 'permission',
39
+ 'permissions',
40
+ ]);
41
+ const MANIFEST_EXACT = new Set([
42
+ 'package.json',
43
+ 'package-lock.json',
44
+ 'bun.lock',
45
+ 'yarn.lock',
46
+ 'pnpm-lock.yaml',
47
+ 'go.mod',
48
+ ]);
49
+ const MODULE_EXCLUDED = new Set(['test', 'tests', 'docs']);
50
+ /**
51
+ * `\d{3}-<slug>` 디렉터리 이름만 모아 최댓값 + 1을 세 자리로 채운다.
52
+ * 구형 `EPIC-009` / `BP-001` 이름은 정규식에서 빠지므로 번호가 그 쪽으로
53
+ * 점프하지 않는다. 후보가 없으면 `001` — 빈 트리의 첫 id. blueprint id 제안에 쓴다.
54
+ *
55
+ * @param {string[]} names - 한 부모 아래의 디렉터리 이름
56
+ * @returns {string} zero-pad 세 자리 다음 id
57
+ */
58
+ function nextNumericId(names) {
59
+ let max = 0;
60
+ for (const name of names) {
61
+ // 정본 번호 이름(`ddd-slug`)만 센다. EPIC-/BP- 접두와 slug 이름은 최댓값에 넣지 않아 구형 디렉터리가
62
+ // 다음 번호를 건너뛰게 하지 않는다. 세그먼트 해석은 context-ids 한 곳에 맡기고, blueprint 이름도
63
+ // epic 자리에 끼워 같은 규칙으로 읽는다.
64
+ const parsed = parseContextPath(`epics/${name}`);
65
+ if (!parsed || parsed.format !== 'numeric')
66
+ continue;
67
+ const n = Number(parsed.epicKey);
68
+ if (Number.isFinite(n) && n > max)
69
+ max = n;
70
+ }
71
+ return String(max + 1).padStart(3, '0');
72
+ }
73
+ /**
74
+ * 활성 `features` epic의 blueprint를 `bouncer.area`별로 세어 승격 후보를 낸다.
75
+ * 읽기 전용이며 승격은 실행하지 않는다 — 사용자 승인 뒤 스킬이 명령을 돌린다.
76
+ *
77
+ * @param {string} repoRoot - 저장소 루트 절대 경로
78
+ * @param {string | null} featuresDir - 활성 features epic의 repo-relative 디렉터리(없으면 null)
79
+ * @param {number | undefined} threshold - 등록부 `areaPromotionThreshold`
80
+ * @returns {{ area: string, count: number }[]} count가 threshold 이상인 area(이름순)
81
+ */
82
+ function countAreaPromotions(repoRoot, featuresDir, threshold) {
83
+ if (threshold === undefined || featuresDir === null)
84
+ return [];
85
+ const bpRoot = path.join(repoRoot, featuresDir, 'blueprints');
86
+ const counts = new Map();
87
+ for (const name of listDirNames(bpRoot)) {
88
+ try {
89
+ const doc = readDoc(path.join(bpRoot, name, 'index.md'));
90
+ const data = doc.data && typeof doc.data === 'object' ? doc.data : {};
91
+ const bouncer = data.bouncer && typeof data.bouncer === 'object'
92
+ ? data.bouncer : null;
93
+ const area = bouncer ? bouncer.area : undefined;
94
+ if (typeof area === 'string' && area !== '')
95
+ counts.set(area, (counts.get(area) ?? 0) + 1);
96
+ }
97
+ catch (_e) {
98
+ // index.md가 없거나 파싱 불가한 blueprint는 집계에서 뺀다. inspect는 읽기 전용이라
99
+ // 문서를 고치지 않고, 한 문서의 오류가 나머지 집계를 막지 않게 한다.
100
+ }
101
+ }
102
+ return [...counts.entries()]
103
+ .filter(([, count]) => count >= threshold)
104
+ .map(([area, count]) => ({ area, count }))
105
+ .sort((a, b) => (a.area < b.area ? -1 : a.area > b.area ? 1 : 0));
106
+ }
107
+ /**
108
+ * 등록부(`.bouncer/epics.json`)를 한 번 읽어 inspect용 후보 목록으로 바꾼다.
109
+ * 귀속은 판단하지 않고 사실(활성 epic, 디렉터리 유무, legacy, area)만 낸다.
110
+ * 형식 위반도 실패로 올리지 않고 `invalid`로 접어 plan이 안내를 이어가게 한다.
111
+ *
112
+ * @param {string} repoRoot - 저장소 루트 절대 경로
113
+ * @param {string[]} dirNames - epics 디렉터리 직계 이름(v1 `<id>-<slug>`, v2 `<slug>`)
114
+ * @returns {{ registry: InspectRegistry, nextEpicId: string | null }} 등록부 요약과
115
+ * 승인됐으나 디렉터리가 없는 도메인 epic 중 가장 작은 id(없거나 등록부 불량이면 null, v2는 번호가 없어 항상 null)
116
+ */
117
+ function summarizeRegistry(repoRoot, dirNames) {
118
+ const read = readEpicRegistry(repoRoot);
119
+ if (read.status === 'missing') {
120
+ return {
121
+ registry: { status: 'missing', next: 'Run `bouncer init` to create .bouncer/epics.json.' },
122
+ nextEpicId: null,
123
+ };
124
+ }
125
+ if (read.status === 'invalid') {
126
+ return {
127
+ registry: {
128
+ status: 'invalid',
129
+ code: 'S35',
130
+ message: `epic registry is invalid: ${read.message}`,
131
+ next: 'Fix .bouncer/epics.json so `bouncer validate` no longer reports S35.',
132
+ },
133
+ nextEpicId: null,
134
+ };
135
+ }
136
+ const { registry } = read;
137
+ // v1은 `<id>-<slug>` 디렉터리와 `id`를, v2는 `<slug>` 디렉터리와 선택 `legacy_id`를 쓴다.
138
+ // 버전은 등록부 `version`으로만 가른다(디렉터리 이름 모양으로 추측하지 않는다).
139
+ const isV2 = registry.version === 2;
140
+ const idOf = (entry) => (isV2 ? entry.legacy_id ?? null : entry.id);
141
+ const dirOf = (entry) => {
142
+ const name = isV2 ? entry.slug : `${entry.id}-${entry.slug}`;
143
+ return dirNames.includes(name) ? `${EPICS_REL}/${name}` : null;
144
+ };
145
+ const epics = [];
146
+ const legacy = [];
147
+ const pendingDomainIds = [];
148
+ for (const entry of registry.epics) {
149
+ if (!entry.active)
150
+ continue;
151
+ const dir = dirOf(entry);
152
+ const id = idOf(entry);
153
+ if (entry.kind === 'legacy') {
154
+ // legacy는 디렉터리가 있어야 의미가 있다. 없는 항목은 후보로 내지 않는다.
155
+ if (dir !== null)
156
+ legacy.push({ slug: entry.slug, id, dir });
157
+ continue;
158
+ }
159
+ if (entry.kind === 'domain') {
160
+ // v2 slug epic은 번호가 없어 다음 번호 후보에 넣지 않는다.
161
+ if (dir === null && !isV2)
162
+ pendingDomainIds.push(entry.id);
163
+ epics.push({
164
+ slug: entry.slug, id, dir, kind: 'domain',
165
+ description: null, include: entry.approval ? entry.approval.reason : '', exclude: '',
166
+ });
167
+ continue;
168
+ }
169
+ const common = COMMON_EPICS.find((c) => c.slug === entry.slug);
170
+ epics.push({
171
+ slug: entry.slug, id, dir, kind: 'common',
172
+ description: common ? common.description : null,
173
+ include: common ? common.include : '',
174
+ exclude: common ? common.exclude : '',
175
+ });
176
+ }
177
+ pendingDomainIds.sort();
178
+ // 상한이 남아 있을 때만 후보를 낸다: 소진되면 add-domain이 어차피 거절된다.
179
+ const domainCount = registry.epics.filter((e) => e.kind === 'domain').length;
180
+ const features = epics.find((e) => e.slug === 'features');
181
+ const promotions = domainCount < registry.domainEpicLimit
182
+ ? countAreaPromotions(repoRoot, features ? features.dir : null, registry.areaPromotionThreshold)
183
+ : [];
184
+ return {
185
+ registry: {
186
+ status: 'ok', profile: registry.profile, epics, legacy, areas: registry.areas, promotions,
187
+ },
188
+ nextEpicId: pendingDomainIds.length > 0 ? pendingDomainIds[0] : null,
189
+ };
190
+ }
191
+ /**
192
+ * 한 부모의 직계 디렉터리 이름만 나열한다. 읽기 실패는 빈 목록 — inspect는
193
+ * 추천값만 계산하므로 권한 오류를 종료로 승격하지 않는다.
194
+ *
195
+ * @param {string} absDir - 절대 경로
196
+ * @returns {string[]} 디렉터리 이름
197
+ */
198
+ function listDirNames(absDir) {
199
+ try {
200
+ if (!fs.existsSync(absDir))
201
+ return [];
202
+ const entries = fs.readdirSync(absDir, { withFileTypes: true });
203
+ const names = [];
204
+ for (const entry of entries) {
205
+ if (entry.isDirectory())
206
+ names.push(entry.name);
207
+ }
208
+ return names;
209
+ }
210
+ catch (_e) {
211
+ // EACCES 등만 접는다. 추천 id를 못 구하면 빈 목록으로 `001`이 나온다.
212
+ return [];
213
+ }
214
+ }
215
+ /**
216
+ * epic/blueprint `index.md`의 `bouncer.status`만 읽는다. 파싱 실패는 null —
217
+ * inspect가 문서를 고치지 않고, 없는 상태를 지어내지 않기 위함.
218
+ *
219
+ * @param {string} absIndex - index.md 절대 경로
220
+ * @returns {string | null} 문자열 status, 없거나 읽을 수 없으면 null
221
+ */
222
+ function readStatus(absIndex) {
223
+ try {
224
+ const doc = readDoc(absIndex);
225
+ const data = doc.data && typeof doc.data === 'object'
226
+ ? doc.data : {};
227
+ const bouncer = data.bouncer && typeof data.bouncer === 'object'
228
+ ? data.bouncer : null;
229
+ return bouncer && typeof bouncer.status === 'string' ? bouncer.status : null;
230
+ }
231
+ catch (_e) {
232
+ // 부재·YAML 오류는 상태 없음으로만 접는다. inspect는 문서를 복구하지 않는다.
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * slug epic의 다음 blueprint 순서를 구한다. 쓸 수 있는 `order`(양의 정수)의 최댓값 + 1이다.
238
+ * 간격은 메우지 않는다(1·2·5 → 6) — 순서는 디렉터리 이름이 아니라 frontmatter가 가지므로
239
+ * 중간 값을 비워 둔 채 계속 추가해도 기존 순서가 흔들리지 않는다. 읽을 수 없거나 모양이 틀린 값은
240
+ * 최댓값에서 뺀다(그 문서의 오류는 S37이 따로 보고한다). 후보가 없으면 1이다.
241
+ *
242
+ * @param {string} repoRoot - 저장소 루트 절대 경로
243
+ * @param {string} dirRel - 저장소 상대 epic 디렉터리
244
+ * @returns {number} 다음 순서(양의 정수)
245
+ */
246
+ function nextBlueprintOrder(repoRoot, dirRel) {
247
+ let max = 0;
248
+ for (const entry of listBlueprintOrders(repoRoot, dirRel)) {
249
+ if (isValidBlueprintOrder(entry.order) && entry.order > max)
250
+ max = entry.order;
251
+ }
252
+ return max + 1;
253
+ }
254
+ function epicInfo(repoRoot, dirRel) {
255
+ const status = readStatus(path.join(repoRoot, dirRel, 'index.md'));
256
+ // 형식은 경로로 판정한다(호출자가 정본 검사를 통과시켰다).
257
+ if (parseContextPath(dirRel)?.format === 'slug') {
258
+ return { dir: dirRel, status, nextBlueprintId: null, nextBlueprintOrder: nextBlueprintOrder(repoRoot, dirRel) };
259
+ }
260
+ const nextBlueprintId = nextNumericId(listDirNames(path.join(repoRoot, dirRel, 'blueprints')));
261
+ return { dir: dirRel, status, nextBlueprintId, nextBlueprintOrder: Number(nextBlueprintId) };
262
+ }
263
+ function presentCurrent(repoRoot) {
264
+ const resolved = resolveCurrent({ repoRoot });
265
+ if (resolved.status === 'selected') {
266
+ return {
267
+ status: 'selected',
268
+ blueprint: resolved.current.blueprint,
269
+ task: resolved.current.task,
270
+ base: resolved.current.base,
271
+ };
272
+ }
273
+ // empty·ambiguous·invalid 모두 포인터 필드를 비운다. 워크플로 중단은
274
+ // 스킬이 status를 보고 결정하며, inspect는 후보를 고르지 않는다.
275
+ return {
276
+ status: resolved.status,
277
+ blueprint: null,
278
+ task: null,
279
+ base: null,
280
+ };
281
+ }
282
+ /**
283
+ * 루트 파일 존재와 `package.json`의 `scripts` 키 존재만 본다.
284
+ * compose/Makefile 내용은 읽지 않는다. JSON 파싱은 키 존재 확인에만 쓴다.
285
+ *
286
+ * @param {string} repoRoot - 저장소 루트 절대 경로
287
+ * @returns {string[]} Interface 나열 순의 신호 토큰
288
+ */
289
+ function collectVerifySignals(repoRoot) {
290
+ const signals = [];
291
+ for (const name of VERIFY_FILES) {
292
+ if (fs.existsSync(path.join(repoRoot, name)))
293
+ signals.push(name);
294
+ }
295
+ const pkgAbs = path.join(repoRoot, 'package.json');
296
+ if (!fs.existsSync(pkgAbs))
297
+ return signals;
298
+ try {
299
+ const parsed = JSON.parse(fs.readFileSync(pkgAbs, 'utf8'));
300
+ if (parsed
301
+ && typeof parsed === 'object'
302
+ && !Array.isArray(parsed)
303
+ && Object.hasOwn(parsed, 'scripts')) {
304
+ signals.push('package.json#scripts');
305
+ }
306
+ }
307
+ catch (_e) {
308
+ // 깨진 JSON은 scripts 키를 확인할 수 없으므로 신호에 넣지 않는다.
309
+ }
310
+ return signals;
311
+ }
312
+ function normalizeRelDir(value) {
313
+ if (typeof value !== 'string' || value === '')
314
+ return null;
315
+ return toPosix(value).replace(/\/+$/, '');
316
+ }
317
+ /**
318
+ * basename이 `prefix*suffix` 형태(단일 `*`)와 맞는지 본다.
319
+ * 설정/외부 glob 엔진 없이 상수 규칙만 쓰기 위함.
320
+ *
321
+ * @param {string} name - 파일 basename
322
+ * @param {string} prefix - `*` 앞
323
+ * @param {string} suffix - `*` 뒤
324
+ * @returns {boolean} 매칭 여부
325
+ */
326
+ function matchStar(name, prefix, suffix) {
327
+ return name.startsWith(prefix) && name.endsWith(suffix)
328
+ && name.length >= prefix.length + suffix.length;
329
+ }
330
+ /**
331
+ * 경로를 `/` 조각으로 나누고 마지막 조각의 확장자를 벗긴다.
332
+ * `tokenizer`≠`token`처럼 부분 문자열 오탐을 막으려면 확장자 없는
333
+ * 조각과 정확히 비교해야 한다.
334
+ *
335
+ * @param {string} relPath - repo-relative posix 경로
336
+ * @returns {string[]} 확장자 없는 경로 조각
337
+ */
338
+ function pathSegments(relPath) {
339
+ const parts = toPosix(relPath).split('/').filter(Boolean);
340
+ return parts.map((part, index) => {
341
+ if (index !== parts.length - 1)
342
+ return part;
343
+ // `.github` 같은 leading-dot 디렉터리는 확장자가 아니다.
344
+ if (part.startsWith('.'))
345
+ return part;
346
+ const dot = part.lastIndexOf('.');
347
+ return dot > 0 ? part.slice(0, dot) : part;
348
+ });
349
+ }
350
+ /**
351
+ * 한 경로가 걸리는 첫 risk kind를 고른다. 우선순위는 security → manifest →
352
+ * build → migration — 한 path에 여러 kind를 쌓지 않아 recommendation 근거가
353
+ * 중복되지 않게 한다.
354
+ *
355
+ * @param {string} relPath - repo-relative 경로
356
+ * @returns {RiskKind | null} 매칭 kind, 없으면 null
357
+ */
358
+ function classifyOnePath(relPath) {
359
+ const posix = toPosix(relPath);
360
+ const base = path.posix.basename(posix);
361
+ const segments = pathSegments(posix);
362
+ if (segments.some((seg) => SECURITY_SEGMENTS.has(seg)))
363
+ return 'security';
364
+ if (MANIFEST_EXACT.has(base)
365
+ || matchStar(base, 'requirements', '.txt')) {
366
+ return 'manifest';
367
+ }
368
+ if (matchStar(base, 'Dockerfile', '')
369
+ || base === 'Makefile'
370
+ || matchStar(base, 'tsconfig', '.json')
371
+ || posix === '.github/workflows'
372
+ || posix.startsWith('.github/workflows/')) {
373
+ return 'build';
374
+ }
375
+ // migrations/ 접두·조각, schema 조각. `schemas` 복수형은 규칙에 없어 제외.
376
+ if (segments.includes('migrations')
377
+ || segments.includes('schema')) {
378
+ return 'migration';
379
+ }
380
+ return null;
381
+ }
382
+ /**
383
+ * 경로 목록을 risk kind로 분류한다. I/O 없음 — 단위 테스트가 규칙만
384
+ * 고정하고 planInspect는 집계 전에 같은 헬퍼를 재사용한다.
385
+ *
386
+ * @param {string[]} pathList - repo-relative 경로 목록
387
+ * @returns {Array<{ path: string, kind: RiskKind }>} 매칭된 {path, kind}만, 입력 순
388
+ */
389
+ function classifyRoutingPaths(pathList) {
390
+ const out = [];
391
+ for (const relPath of pathList) {
392
+ if (typeof relPath !== 'string' || relPath === '')
393
+ continue;
394
+ const kind = classifyOnePath(relPath);
395
+ if (kind !== null)
396
+ out.push({ path: toPosix(relPath), kind });
397
+ }
398
+ return out;
399
+ }
400
+ /**
401
+ * 모듈 토큰: 첫 경로 조각. `test`/`tests`/`docs`와 루트 파일(슬래시 없음)은
402
+ * 접촉 모듈이 아니므로 세지 않는다 — light 후보를 path count로 키우지 않기 위함.
403
+ *
404
+ * @param {string[]} pathList - affected_paths 합집합
405
+ * @returns {string[]} 정렬·중복 제거된 모듈 이름
406
+ */
407
+ function collectModules(pathList) {
408
+ const modules = new Set();
409
+ for (const relPath of pathList) {
410
+ const posix = toPosix(relPath);
411
+ if (!posix.includes('/'))
412
+ continue;
413
+ const head = posix.split('/')[0];
414
+ if (!head || MODULE_EXCLUDED.has(head))
415
+ continue;
416
+ modules.add(head);
417
+ }
418
+ return [...modules].sort();
419
+ }
420
+ /**
421
+ * commit task 요약으로 advisory routing을 만든다. 선택·승인은 하지 않고
422
+ * recommendation/reasons만 낸다 — light 선언은 사용자 소유다.
423
+ *
424
+ * @param {Array<{ paths: string[], dependsOn: string[] }>} taskList - 이미
425
+ * commit-only로 걸러진 task 입력. paths는 affected_paths, dependsOn은 depends_on
426
+ * @returns {Routing} advisory routing 객체
427
+ */
428
+ function summarizeRouting(taskList) {
429
+ const tasks = taskList.length;
430
+ let dependencies = 0;
431
+ const allPaths = [];
432
+ for (const task of taskList) {
433
+ dependencies += Array.isArray(task.dependsOn) ? task.dependsOn.length : 0;
434
+ if (Array.isArray(task.paths)) {
435
+ for (const p of task.paths) {
436
+ if (typeof p === 'string' && p !== '')
437
+ allPaths.push(p);
438
+ }
439
+ }
440
+ }
441
+ const modules = collectModules(allPaths);
442
+ const riskPaths = classifyRoutingPaths(allPaths);
443
+ const reasons = [];
444
+ // 조건 이름 = 필드명. 스킬이 reasons를 그대로 인용할 수 있게 짧게 둔다.
445
+ if (tasks >= 2)
446
+ reasons.push('tasks');
447
+ if (dependencies > 0)
448
+ reasons.push('dependencies');
449
+ if (modules.length >= 3)
450
+ reasons.push('modules');
451
+ if (riskPaths.length > 0)
452
+ reasons.push('riskPaths');
453
+ // empty는 full 트리거가 아니다. 근거만 남기고 light-candidate를 유지한다.
454
+ if (allPaths.length === 0)
455
+ reasons.push('affected-paths-empty');
456
+ const recommendation = (tasks >= 2
457
+ || dependencies > 0
458
+ || modules.length >= 3
459
+ || riskPaths.length > 0) ? 'full-candidate' : 'light-candidate';
460
+ return {
461
+ advisory: true,
462
+ tasks,
463
+ dependencies,
464
+ modules,
465
+ riskPaths,
466
+ recommendation,
467
+ reasons,
468
+ };
469
+ }
470
+ /**
471
+ * blueprint의 commit task에서 paths/dependsOn만 모은다. verification task는
472
+ * tasks 카운트·모듈·위험 경로에 넣지 않는다 — light 신호는 구현 커밋 범위다.
473
+ *
474
+ * @param {string} repoRoot - 저장소 루트
475
+ * @param {string} blueprintDir - 정본 blueprint 상대 경로
476
+ * @returns {RoutingTask[]} summarizeRouting 입력
477
+ */
478
+ function loadRoutingTasks(repoRoot, blueprintDir) {
479
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
480
+ const out = [];
481
+ for (const entry of listing.entries) {
482
+ if (entry.executionKind !== 'commit')
483
+ continue;
484
+ let paths = [];
485
+ let dependsOn = [];
486
+ try {
487
+ const doc = readDoc(path.join(repoRoot, entry.tasks.rel));
488
+ const data = doc.data && typeof doc.data === 'object'
489
+ ? doc.data : {};
490
+ const bouncer = data.bouncer && typeof data.bouncer === 'object'
491
+ ? data.bouncer : null;
492
+ if (bouncer) {
493
+ if (Array.isArray(bouncer.affected_paths)) {
494
+ paths = bouncer.affected_paths.filter((p) => typeof p === 'string' && p !== '');
495
+ }
496
+ if (Array.isArray(bouncer.depends_on)) {
497
+ dependsOn = bouncer.depends_on.filter((p) => typeof p === 'string' && p !== '');
498
+ }
499
+ }
500
+ }
501
+ catch (_e) {
502
+ // 파싱 실패 task는 빈 paths로만 센다. inspect가 draft를 고치지 않는다.
503
+ }
504
+ out.push({ paths, dependsOn });
505
+ }
506
+ return out;
507
+ }
508
+ /**
509
+ * 계획 스킬이 본문에서 반복하던 등록부 후보·다음 도메인 id·verify 신호·포인터 상태와
510
+ * (선택) light 추천 근거를 한 번 계산한다. 추천값만 반환하며 어떤 경로에도
511
+ * 파일을 쓰지 않는다. `--blueprint`가 없으면 `routing: null`.
512
+ *
513
+ * @param {{ repoRoot: string, epicDir?: unknown, blueprintDir?: unknown }} opts -
514
+ * repoRoot는 대상 저장소, epicDir/blueprintDir은 선택적 정본 상대 경로
515
+ * @returns {InspectResult} 성공 JSON 또는 not-initialized / invalid-*-dir
516
+ */
517
+ function planInspect({ repoRoot, epicDir, blueprintDir }) {
518
+ const bouncerAbs = path.join(repoRoot, BOUNCER_DIR);
519
+ // 1. 초기화 여부. epic-dir 형식보다 앞선다 — 없는 트리의 경로 오류로
520
+ // 위장하면 `/bouncer-init` 안내가 빗나간다.
521
+ if (!fs.existsSync(bouncerAbs) || !fs.statSync(bouncerAbs).isDirectory()) {
522
+ return { ok: false, reason: 'not-initialized' };
523
+ }
524
+ let epic = null;
525
+ if (epicDir !== undefined) {
526
+ // 2. --epic-dir는 정본 상대 경로만. 짧은 이름·EPIC- 접두·부재는 같은
527
+ // 거절 코드라서 호출자가 형식을 추측해 재시도하지 않게 한다.
528
+ const rel = normalizeRelDir(epicDir);
529
+ if (rel === null || !isCanonicalEpicDir(rel)) {
530
+ return { ok: false, reason: 'invalid-epic-dir' };
531
+ }
532
+ const abs = path.join(repoRoot, rel);
533
+ try {
534
+ // ENOENT·ENOTDIR·EACCES는 모두 같은 거절 코드다. 형식이 맞아도
535
+ // 없는 디렉터리를 다음 id 계산에 쓰면 없는 epic을 추천하게 된다.
536
+ if (!fs.statSync(abs).isDirectory()) {
537
+ return { ok: false, reason: 'invalid-epic-dir' };
538
+ }
539
+ }
540
+ catch (_e) {
541
+ return { ok: false, reason: 'invalid-epic-dir' };
542
+ }
543
+ epic = epicInfo(repoRoot, rel);
544
+ }
545
+ let routing = null;
546
+ if (blueprintDir !== undefined) {
547
+ // 3. --blueprint는 정본 blueprint 경로만. routing은 읽기 전용 신호다.
548
+ const rel = normalizeRelDir(blueprintDir);
549
+ if (rel === null || !isCanonicalBlueprintDir(rel)) {
550
+ return { ok: false, reason: 'invalid-blueprint-dir' };
551
+ }
552
+ const abs = path.join(repoRoot, rel);
553
+ try {
554
+ if (!fs.statSync(abs).isDirectory()) {
555
+ return { ok: false, reason: 'invalid-blueprint-dir' };
556
+ }
557
+ }
558
+ catch (_e) {
559
+ return { ok: false, reason: 'invalid-blueprint-dir' };
560
+ }
561
+ routing = summarizeRouting(loadRoutingTasks(repoRoot, rel));
562
+ }
563
+ const epicNames = listDirNames(path.join(repoRoot, EPICS_REL));
564
+ const { registry, nextEpicId } = summarizeRegistry(repoRoot, epicNames);
565
+ return {
566
+ ok: true,
567
+ nextEpicId,
568
+ registry,
569
+ epic,
570
+ verifySignals: collectVerifySignals(repoRoot),
571
+ current: presentCurrent(repoRoot),
572
+ routing,
573
+ };
574
+ }
575
+ module.exports = { planInspect, classifyRoutingPaths, summarizeRouting };