@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,746 @@
1
+ 'use strict';
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const node_child_process_1 = require("node:child_process");
5
+ const frontmatter = require("./frontmatter");
6
+ const { parseFrontmatter } = frontmatter;
7
+ const contextIds = require("./context-ids");
8
+ const { parseContextPath, formatStableTaskId, formatLegacyEpicId, formatLegacyBlueprintId } = contextIds;
9
+ // 경로 모양만 거른다. 세그먼트가 번호(`NNN-slug`)인지 slug인지, 두 세그먼트의 형식이 같은지는
10
+ // parseContextPath 한 곳에서 판정한다(`layoutOf`). 정규식에 형식 규칙을 복제하면 두 곳이 갈라진다.
11
+ const EPIC_SHAPE = /^\.bouncer\/context\/epics\/[^/]+\/index\.md$/;
12
+ const BP_SHAPE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/index\.md$/;
13
+ const EXPLAIN_SHAPE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/explain\.md$/;
14
+ const TASK_SHAPE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/tasks\/(\d{3})\/tasks\.md$/;
15
+ /**
16
+ * 경로가 모양 정규식과 세그먼트 규칙을 모두 만족하면 key와 형식을 돌려준다.
17
+ *
18
+ * @param {string} p - 저장소 상대 posix 경로
19
+ * @param {RegExp} shape - 문서 종류별 모양 정규식(task는 첫 캡처가 task 번호)
20
+ * @returns {Layout | null} 해석 결과. 모양이 다르거나 세그먼트 규칙을 어기면 null
21
+ */
22
+ function layoutOf(p, shape) {
23
+ const match = shape.exec(p);
24
+ const ids = match ? parseContextPath(p) : null;
25
+ if (!match || !ids)
26
+ return null;
27
+ return { format: ids.format, epicKey: ids.epicKey, blueprintKey: ids.blueprintKey, taskNum: match[1] ?? null };
28
+ }
29
+ // 문서 경로 목록에서 같은 epic·blueprint key를 가진 경로를 찾는다. 번호/slug 디렉터리 접두 비교 대신
30
+ // 해석된 key로 비교해 두 형식이 같은 코드를 탄다.
31
+ function sameBlueprint(p, epicKey, blueprintKey) {
32
+ const ids = parseContextPath(p);
33
+ return ids !== null && ids.epicKey === epicKey && ids.blueprintKey === blueprintKey;
34
+ }
35
+ const SKIP = new Set(['.git', 'node_modules', '.worktrees', 'review', 'reviews', 'verification', 'verifications']);
36
+ const TOKEN = /`([^`\n]+)`/g;
37
+ /**
38
+ * 현재 Git checkout의 canonical 컨텍스트 문서에서 출처를 보존한 검색 색인을 만든다.
39
+ * 읽기 실패는 숨기지 않으며, 문서별 형식 오류는 해당 문서만 경고 후 제외한다.
40
+ *
41
+ * @param {{repoRoot: string}} input - 현재 checkout의 절대 경로
42
+ * @returns {Ontology} 문서, 연결, 출처, 경고를 담은 새 색인
43
+ */
44
+ function loadContextOntology(input) {
45
+ const rootArg = typeof input?.repoRoot === 'string' ? input.repoRoot : '';
46
+ if (!rootArg || !path.isAbsolute(rootArg))
47
+ throw inputError('repoRoot must be an absolute path');
48
+ let root;
49
+ try {
50
+ root = fs.realpathSync(rootArg);
51
+ }
52
+ catch (error) {
53
+ throw ioError('cannot resolve repoRoot', error);
54
+ }
55
+ const git = runGit(root, ['rev-parse', '--show-toplevel']);
56
+ if (git.status !== 0)
57
+ throw inputError('repoRoot is not a Git checkout');
58
+ let gitRoot;
59
+ try {
60
+ gitRoot = fs.realpathSync(git.stdout.trim());
61
+ }
62
+ catch (error) {
63
+ throw ioError('cannot resolve Git root', error);
64
+ }
65
+ if (root !== gitRoot)
66
+ throw inputError('repoRoot must be the current checkout root');
67
+ const index = {
68
+ version: 1,
69
+ repoRoot: root,
70
+ documents: [],
71
+ entities: new Map(),
72
+ files: new Map(),
73
+ entityEvidence: new Map(),
74
+ fileEvidence: new Map(),
75
+ warnings: [],
76
+ };
77
+ const paths = walkContext(root, index.warnings);
78
+ const epicPaths = paths.filter((p) => layoutOf(p, EPIC_SHAPE));
79
+ const bpPaths = paths.filter((p) => layoutOf(p, BP_SHAPE));
80
+ const explainPaths = paths.filter((p) => layoutOf(p, EXPLAIN_SHAPE));
81
+ const taskPaths = paths.filter((p) => layoutOf(p, TASK_SHAPE));
82
+ const acceptedPaths = new Set([...epicPaths, ...bpPaths, ...explainPaths, ...taskPaths]);
83
+ for (const source of paths) {
84
+ if (!acceptedPaths.has(source)) {
85
+ warn(index, 'path-rejected', source, 0, 'document path is outside the canonical context layout');
86
+ }
87
+ }
88
+ const tasksByBp = new Map();
89
+ for (const taskPath of taskPaths) {
90
+ const at = layoutOf(taskPath, TASK_SHAPE);
91
+ const bp = bpPaths.find((p) => sameBlueprint(p, at.epicKey, at.blueprintKey));
92
+ if (bp)
93
+ tasksByBp.set(bp, [...(tasksByBp.get(bp) ?? []), taskPath]);
94
+ }
95
+ const docs = new Map();
96
+ for (const source of [...epicPaths, ...bpPaths, ...[...tasksByBp.values()].flat(), ...explainPaths].sort(compare)) {
97
+ const parsed = readContextDoc(root, source, index.warnings);
98
+ if (parsed)
99
+ docs.set(source, parsed);
100
+ }
101
+ const epics = new Map();
102
+ for (const source of epicPaths) {
103
+ const doc = docs.get(source);
104
+ if (!doc)
105
+ continue;
106
+ const at = layoutOf(source, EPIC_SHAPE);
107
+ const id = at.epicKey;
108
+ // 번호 epic의 bouncer.id는 `EPIC-NNN`, slug epic은 epic slug 자체다.
109
+ const expectedId = at.format === 'numeric' ? formatLegacyEpicId(id) : id;
110
+ if (doc.data.type !== 'bouncer.epic' || nested(doc.data, 'bouncer', 'id') !== expectedId) {
111
+ warn(index, 'document-invalid', source, 0, 'epic identity does not match canonical path');
112
+ continue;
113
+ }
114
+ epics.set(id, { ...doc, id: source, kind: 'epic' });
115
+ }
116
+ const blueprintDocs = new Map();
117
+ for (const source of bpPaths) {
118
+ const doc = docs.get(source);
119
+ if (!doc)
120
+ continue;
121
+ const at = layoutOf(source, BP_SHAPE);
122
+ const epicId = at.epicKey;
123
+ const bpId = at.blueprintKey;
124
+ // 번호 blueprint의 bouncer.id는 `EPIC-NNN/BP-NNN`, slug blueprint는 blueprint slug 자체다.
125
+ const expectedId = at.format === 'numeric' ? formatLegacyBlueprintId(epicId, bpId) : bpId;
126
+ if (doc.data.type !== 'bouncer.blueprint' ||
127
+ nested(doc.data, 'bouncer', 'id') !== expectedId ||
128
+ !epics.has(epicId)) {
129
+ warn(index, 'document-invalid', source, 0, 'blueprint identity does not match canonical path or epic');
130
+ continue;
131
+ }
132
+ blueprintDocs.set(source, { ...doc, id: source, kind: 'blueprint' });
133
+ index.documents.push({ ...doc, id: source, kind: 'blueprint' });
134
+ }
135
+ index.documents.push(...epics.values());
136
+ for (const [source, doc] of docs)
137
+ if (layoutOf(source, TASK_SHAPE)) {
138
+ const at = layoutOf(source, TASK_SHAPE);
139
+ const epicId = at.epicKey;
140
+ const bpId = at.blueprintKey;
141
+ const taskNum = at.taskNum;
142
+ const parent = [...blueprintDocs.keys()].find((p) => sameBlueprint(p, epicId, bpId));
143
+ const stable = formatStableTaskId({ format: at.format, epicKey: epicId, blueprintKey: bpId, task: taskNum });
144
+ if (!parent ||
145
+ doc.data.type !== 'bouncer.tasks' ||
146
+ nested(doc.data, 'bouncer', 'id') !== `TASKS-${taskNum}` ||
147
+ nested(doc.data, 'bouncer', 'epic_id') !== epicId ||
148
+ nested(doc.data, 'bouncer', 'blueprint_id') !== bpId) {
149
+ warn(index, 'document-invalid', source, 0, 'task identity does not match canonical path');
150
+ continue;
151
+ }
152
+ const taskDoc = { ...doc, id: source, kind: 'task' };
153
+ index.documents.push(taskDoc);
154
+ const affected = nested(doc.data, 'bouncer', 'affected_paths');
155
+ if (Array.isArray(affected)) {
156
+ for (const item of affected) {
157
+ if (typeof item === 'string') {
158
+ addPath(index, parent, item, source, lineOfValue(doc.body, item, 0) + doc.bodyLineOffset, 'affected_path');
159
+ }
160
+ }
161
+ }
162
+ extractSectionPaths(index, parent, source, doc.body, '#### Touch', 'touch', doc.bodyLineOffset);
163
+ void stable;
164
+ }
165
+ for (const source of explainPaths) {
166
+ const doc = docs.get(source);
167
+ if (!doc)
168
+ continue;
169
+ const at = layoutOf(source, EXPLAIN_SHAPE);
170
+ const epicId = at.epicKey;
171
+ const bpId = at.blueprintKey;
172
+ const parent = [...blueprintDocs.keys()].find((p) => sameBlueprint(p, epicId, bpId));
173
+ if (!parent)
174
+ continue;
175
+ extractSectionPaths(index, parent, source, doc.body, '## Code', 'touch', doc.bodyLineOffset);
176
+ extractContract(index, parent, source, doc.body, doc.bodyLineOffset);
177
+ parseCommitRows(index, parent, source, doc, epicId, bpId, at.format);
178
+ }
179
+ for (const [bpPath, doc] of blueprintDocs) {
180
+ extractContract(index, bpPath, bpPath, doc.body, doc.bodyLineOffset);
181
+ }
182
+ index.documents.sort((a, b) => compare(a.id, b.id));
183
+ const relationships = [];
184
+ for (const doc of index.documents) {
185
+ const parent = doc.kind === 'blueprint'
186
+ ? [...epics.entries()].find(([id]) => parseContextPath(doc.id)?.epicKey === id)?.[1].id
187
+ : doc.kind === 'task'
188
+ ? doc.id.replace(/\/tasks\/\d{3}\/tasks\.md$/, '/index.md')
189
+ : undefined;
190
+ if (parent) {
191
+ relationships.push({
192
+ kind: 'contains',
193
+ from: parent,
194
+ to: doc.id,
195
+ evidence: [evidence(doc.id, 1, 'affected_path')],
196
+ });
197
+ }
198
+ }
199
+ for (const [bp, values] of index.entityEvidence) {
200
+ for (const [token, ev] of values) {
201
+ relationships.push({ kind: 'mentions', from: bp, to: token, evidence: uniqueEvidence(ev) });
202
+ }
203
+ }
204
+ for (const [bp, values] of index.fileEvidence) {
205
+ for (const [file, ev] of values) {
206
+ relationships.push({ kind: 'touches', from: bp, to: file, evidence: uniqueEvidence(ev) });
207
+ }
208
+ }
209
+ for (const [kind, map] of [['shares_entity', index.entities], ['shares_file', index.files]]) {
210
+ for (const [value, bps] of map) {
211
+ const ordered = [...bps].sort(compare);
212
+ for (let i = 0; i < ordered.length; i++) {
213
+ for (let j = i + 1; j < ordered.length; j++) {
214
+ const sourceMap = kind === 'shares_entity' ? index.entityEvidence : index.fileEvidence;
215
+ const evidenceItems = [
216
+ ...(sourceMap.get(ordered[i])?.get(value) ?? []),
217
+ ...(sourceMap.get(ordered[j])?.get(value) ?? []),
218
+ ];
219
+ relationships.push({
220
+ kind,
221
+ from: ordered[i],
222
+ to: ordered[j],
223
+ evidence: uniqueEvidence(evidenceItems),
224
+ });
225
+ }
226
+ }
227
+ }
228
+ }
229
+ index.nodes = {
230
+ documents: index.documents,
231
+ entities: [...index.entities.keys()].sort(compare).map((token) => ({
232
+ token,
233
+ kind: token.startsWith('G') && /^G\d+$/.test(token)
234
+ ? 'gate'
235
+ : token.startsWith('--')
236
+ ? 'flag'
237
+ : token.includes('/') ? 'path' : 'identifier',
238
+ })),
239
+ files: [...index.files.keys()].sort(compare).map((file) => ({ path: file })),
240
+ };
241
+ index.relationships = relationships.sort((a, b) => compare(a.kind, b.kind) || compare(a.from, b.from) || compare(a.to, b.to));
242
+ index.warnings = uniqueWarnings(index.warnings);
243
+ return index;
244
+ }
245
+ /**
246
+ * 색인에서 정확 엔티티·파일 일치 또는 한 단계 영향 결과를 결정적으로 반환한다.
247
+ * limit과 질의 값을 먼저 검증하고 입력 색인을 변경하지 않는다.
248
+ *
249
+ * @param {Ontology} index - loadContextOntology가 반환한 색인
250
+ * @param {Query} query - entity, file, impact 중 하나와 검색 값
251
+ * @returns {object} 정렬 및 limit 처리된 응답과 경고
252
+ */
253
+ function queryContextOntology(index, query) {
254
+ if (!index || index.version !== 1 || !Array.isArray(index.documents)) {
255
+ throw inputError('invalid ontology index');
256
+ }
257
+ if (!query || !['entity', 'file', 'impact'].includes(query.mode)) {
258
+ throw inputError('unknown query mode');
259
+ }
260
+ const limit = query.limit === undefined ? 20 : query.limit;
261
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
262
+ throw inputError('limit must be an integer from 1 to 100');
263
+ }
264
+ const hasNonAscii = typeof query.value === 'string' &&
265
+ [...query.value].some((char) => char.charCodeAt(0) > 0x7f);
266
+ if (typeof query.value !== 'string' || !query.value ||
267
+ (query.mode === 'entity' && (hasNonAscii || /\s/.test(query.value))))
268
+ throw inputError('invalid entity token');
269
+ if (query.mode !== 'entity')
270
+ validateFileQuery(query.value);
271
+ const matching = query.mode === 'entity' ? index.entities.get(query.value) : index.files.get(query.value);
272
+ const direct = new Set(matching ?? []);
273
+ let selected = direct;
274
+ const via = new Map();
275
+ const evidenceByBp = new Map();
276
+ if (query.mode === 'impact') {
277
+ const starts = [...direct];
278
+ for (const bp of starts) {
279
+ via.set(bp, [bp]);
280
+ evidenceByBp.set(bp, [...(index.fileEvidence.get(bp)?.get(query.value) ?? [])]);
281
+ }
282
+ const expanded = new Set(starts);
283
+ const sharedEntities = new Set();
284
+ const sharedFiles = new Set();
285
+ for (const bp of starts) {
286
+ for (const [token, bps] of index.entities) {
287
+ if (bps.has(bp))
288
+ sharedEntities.add(token);
289
+ }
290
+ for (const [file, bps] of index.files) {
291
+ if (bps.has(bp))
292
+ sharedFiles.add(file);
293
+ }
294
+ }
295
+ for (const bp of starts) {
296
+ for (const token of sharedEntities) {
297
+ for (const other of index.entities.get(token) ?? []) {
298
+ if (expanded.has(other))
299
+ continue;
300
+ expanded.add(other);
301
+ via.set(other, [bp]);
302
+ evidenceByBp.set(other, [
303
+ ...(index.entityEvidence.get(bp)?.get(token) ?? []),
304
+ ...(index.entityEvidence.get(other)?.get(token) ?? []),
305
+ ]);
306
+ }
307
+ }
308
+ }
309
+ for (const bp of starts) {
310
+ for (const file of sharedFiles) {
311
+ for (const other of index.files.get(file) ?? []) {
312
+ if (expanded.has(other))
313
+ continue;
314
+ expanded.add(other);
315
+ via.set(other, [bp]);
316
+ evidenceByBp.set(other, [
317
+ ...(index.fileEvidence.get(bp)?.get(file) ?? []),
318
+ ...(index.fileEvidence.get(other)?.get(file) ?? []),
319
+ ]);
320
+ }
321
+ }
322
+ }
323
+ selected = expanded;
324
+ }
325
+ const all = [...selected].sort(compare);
326
+ const results = all.map((bp) => ({
327
+ blueprint: bp,
328
+ status: index.documents.find((d) => d.id === bp)?.status ?? 'unknown',
329
+ via: query.mode === 'impact' ? (via.get(bp) ?? []).sort(compare) : [bp],
330
+ entities: [...index.entities].filter(([, bps]) => bps.has(bp)).map(([token]) => token).sort(compare),
331
+ files: [...index.files].filter(([, bps]) => bps.has(bp)).map(([file]) => file).sort(compare),
332
+ evidence: uniqueEvidence(query.mode === 'impact'
333
+ ? evidenceByBp.get(bp) ?? []
334
+ : query.mode === 'entity'
335
+ ? index.entityEvidence.get(bp)?.get(query.value) ?? []
336
+ : index.fileEvidence.get(bp)?.get(query.value) ?? []),
337
+ }));
338
+ const limited = results.slice(0, limit);
339
+ return {
340
+ version: 1,
341
+ query: { mode: query.mode, value: query.value, limit },
342
+ results: limited,
343
+ total: results.length,
344
+ truncated: results.length > limited.length,
345
+ warnings: uniqueWarnings(index.warnings),
346
+ };
347
+ }
348
+ /**
349
+ * canonical context 아래의 Markdown만 정렬 순회하며 symlink 경계를 경고로 보존한다.
350
+ * 디렉터리 읽기 실패는 입력 결손으로 오인하지 않고 I/O 오류로 올린다.
351
+ *
352
+ * @param {string} root - canonical Git checkout
353
+ * @param {Warning[]} warnings - 순회 중 발견한 경고를 누적하는 배열
354
+ * @returns {string[]} 저장소 상대 Markdown 경로
355
+ */
356
+ function walkContext(root, warnings) {
357
+ const base = path.join(root, '.bouncer', 'context');
358
+ let entries;
359
+ try {
360
+ entries = fs.readdirSync(base, { withFileTypes: true });
361
+ }
362
+ catch (error) {
363
+ if (isMissing(error)) {
364
+ warnings.push({
365
+ code: 'context-missing',
366
+ source: '.bouncer/context',
367
+ line: 0,
368
+ detail: 'context directory is absent',
369
+ });
370
+ return [];
371
+ }
372
+ throw ioError('cannot read context directory', error);
373
+ }
374
+ const found = [];
375
+ function descend(abs, rel) {
376
+ let items;
377
+ try {
378
+ items = fs.readdirSync(abs, { withFileTypes: true });
379
+ }
380
+ catch (error) {
381
+ throw ioError('cannot read context directory', error);
382
+ }
383
+ for (const item of items.sort((a, b) => compare(a.name, b.name))) {
384
+ const child = `${rel}/${item.name}`;
385
+ if (SKIP.has(item.name)) {
386
+ if (['review', 'reviews', 'verification', 'verifications'].includes(item.name)) {
387
+ warnings.push({
388
+ code: 'path-rejected',
389
+ source: child,
390
+ line: 0,
391
+ detail: 'review and verification documents are outside the retrieval index',
392
+ });
393
+ }
394
+ continue;
395
+ }
396
+ if (item.isSymbolicLink()) {
397
+ warnings.push({ code: 'symlink-skipped', source: child, line: 0, detail: 'symbolic link skipped' });
398
+ continue;
399
+ }
400
+ if (item.isDirectory())
401
+ descend(path.join(abs, item.name), child);
402
+ else if (item.isFile() && item.name.endsWith('.md'))
403
+ found.push(child);
404
+ }
405
+ }
406
+ for (const item of entries.sort((a, b) => compare(a.name, b.name))) {
407
+ const rel = `.bouncer/context/${item.name}`;
408
+ const abs = path.join(base, item.name);
409
+ if (SKIP.has(item.name)) {
410
+ if (['review', 'reviews', 'verification', 'verifications'].includes(item.name)) {
411
+ warnings.push({
412
+ code: 'path-rejected',
413
+ source: rel,
414
+ line: 0,
415
+ detail: 'review and verification documents are outside the retrieval index',
416
+ });
417
+ }
418
+ continue;
419
+ }
420
+ if (item.isSymbolicLink()) {
421
+ warnings.push({ code: 'symlink-skipped', source: rel, line: 0, detail: 'symbolic link skipped' });
422
+ continue;
423
+ }
424
+ if (item.isDirectory())
425
+ descend(abs, rel);
426
+ else if (item.isFile() && item.name.endsWith('.md'))
427
+ found.push(rel);
428
+ }
429
+ return found;
430
+ }
431
+ /**
432
+ * 문서 하나를 읽고 frontmatter와 필수 shape를 검증한다.
433
+ * 예상 가능한 문서 형식 오류만 경고로 접고 권한 등 I/O 실패는 전달한다.
434
+ *
435
+ * @param {string} root - canonical Git checkout
436
+ * @param {string} source - 문서의 저장소 상대 경로
437
+ * @param {Warning[]} warnings - 문서 오류 경고 누적 배열
438
+ * @returns {Doc | null} 유효 문서 또는 제외한 문서
439
+ */
440
+ function readContextDoc(root, source, warnings) {
441
+ const abs = path.join(root, ...source.split('/'));
442
+ let raw;
443
+ try {
444
+ raw = fs.readFileSync(abs, 'utf8');
445
+ }
446
+ catch (error) {
447
+ if (isMissing(error)) {
448
+ warnArray(warnings, 'document-invalid', source, 0, 'document disappeared during scan');
449
+ return null;
450
+ }
451
+ throw ioError('cannot read context document', error);
452
+ }
453
+ let parsed;
454
+ try {
455
+ parsed = parseFrontmatter(raw);
456
+ }
457
+ catch {
458
+ warnArray(warnings, 'document-invalid', source, 0, 'frontmatter is invalid');
459
+ return null;
460
+ }
461
+ if (!isRecord(parsed.data) || typeof parsed.body !== 'string') {
462
+ warnArray(warnings, 'document-invalid', source, 0, 'document shape is invalid');
463
+ return null;
464
+ }
465
+ if (typeof parsed.data.type !== 'string' || typeof nested(parsed.data, 'bouncer', 'status') !== 'string') {
466
+ warnArray(warnings, 'document-invalid', source, 0, 'document type or status is missing');
467
+ return null;
468
+ }
469
+ const bodyStart = raw.indexOf(parsed.body);
470
+ const bodyLineOffset = raw.slice(0, bodyStart).split(/\r?\n/).length;
471
+ return {
472
+ id: source,
473
+ kind: '',
474
+ status: nested(parsed.data, 'bouncer', 'status'),
475
+ data: parsed.data,
476
+ body: parsed.body,
477
+ bodyLineOffset,
478
+ };
479
+ }
480
+ /**
481
+ * Contract의 코드 펜스 밖 단일 백틱 토큰을 종류와 출처를 붙여 연결한다.
482
+ * 범위 표기는 펼치지 않으며 비ASCII 토큰과 공백 토큰은 인덱스에 넣지 않는다.
483
+ *
484
+ * @param {Ontology} index - 누적 색인
485
+ * @param {string} bp - 연결할 canonical BP 경로
486
+ * @param {string} source - 토큰을 읽은 문서 경로
487
+ * @param {string} body - frontmatter 뒤 본문
488
+ * @param {number} offset - 본문 첫 줄의 문서 전체 기준 오프셋
489
+ * @returns {void} 색인 관계를 누적한다
490
+ */
491
+ function extractContract(index, bp, source, body, offset) {
492
+ const section = getSection(body, '## Contract');
493
+ if (!section)
494
+ return;
495
+ let fence = false;
496
+ const lines = body.split(/\r?\n/);
497
+ let inSection = false;
498
+ for (let i = 0; i < lines.length; i++) {
499
+ if (lines[i].trim() === '## Contract') {
500
+ inSection = true;
501
+ continue;
502
+ }
503
+ if (inSection && /^## /.test(lines[i]))
504
+ break;
505
+ if (!inSection)
506
+ continue;
507
+ if (/^\s*```/.test(lines[i])) {
508
+ fence = !fence;
509
+ continue;
510
+ }
511
+ if (fence)
512
+ continue;
513
+ TOKEN.lastIndex = 0;
514
+ let match;
515
+ while ((match = TOKEN.exec(lines[i]))) {
516
+ const token = match[1];
517
+ const hasNonAscii = [...token].some((char) => char.charCodeAt(0) > 0x7f);
518
+ if (!token || /\s/.test(token) || hasNonAscii)
519
+ continue;
520
+ const kind = token.startsWith('G') && /^G\d+$/.test(token)
521
+ ? 'gate'
522
+ : token.startsWith('--')
523
+ ? 'flag'
524
+ : token.includes('/') ? 'path' : 'identifier';
525
+ addEntity(index, bp, token, evidence(source, i + 1 + offset, 'contract'));
526
+ if (kind === 'path')
527
+ addPath(index, bp, token, source, i + 1 + offset, 'contract');
528
+ }
529
+ }
530
+ void section;
531
+ }
532
+ function extractSectionPaths(index, bp, source, body, heading, kind, offset) {
533
+ const lines = body.split(/\r?\n/);
534
+ let active = false;
535
+ for (let i = 0; i < lines.length; i++) {
536
+ if (lines[i].trim() === heading) {
537
+ active = true;
538
+ continue;
539
+ }
540
+ if (active && /^#{1,4} /.test(lines[i]))
541
+ break;
542
+ if (!active)
543
+ continue;
544
+ TOKEN.lastIndex = 0;
545
+ let match;
546
+ while ((match = TOKEN.exec(lines[i]))) {
547
+ const val = match[1];
548
+ if (!val.includes('/'))
549
+ continue;
550
+ addPath(index, bp, val, source, i + 1 + offset, kind);
551
+ }
552
+ }
553
+ }
554
+ /**
555
+ * Explain의 새 형식과 legacy task_commits를 검증하고 확정 커밋의 파일 diff를 연결한다.
556
+ * Git 인자 배열을 유지해 경로와 SHA가 셸 문법으로 해석되지 않게 한다.
557
+ *
558
+ * @param {Ontology} index - 누적 색인
559
+ * @param {string} bp - commit diff를 연결할 BP 경로
560
+ * @param {string} source - Explain 문서 경로
561
+ * @param {Doc} doc - 이미 파싱된 Explain
562
+ * @param {string} epicId - 부모 epic 번호
563
+ * @param {string} bpId - 부모 blueprint 번호
564
+ * @param {'numeric' | 'slug'} format - 부모 key 형식(행의 task ID 형식 기대값을 정한다)
565
+ * @returns {void} 유효 diff 경로와 경고를 색인에 누적한다
566
+ */
567
+ function parseCommitRows(index, bp, source, doc, epicId, bpId, format) {
568
+ const rows = nested(doc.data, 'bouncer', 'task_commits');
569
+ if (rows === undefined)
570
+ return;
571
+ if (!Array.isArray(rows)) {
572
+ warn(index, 'document-invalid', source, 0, 'task_commits must be an array');
573
+ return;
574
+ }
575
+ let sourceLines;
576
+ try {
577
+ sourceLines = fs.readFileSync(path.join(index.repoRoot, ...source.split('/')), 'utf8').split(/\r?\n/);
578
+ }
579
+ catch (error) {
580
+ throw ioError('cannot reread Explain source lines', error);
581
+ }
582
+ for (let i = 0; i < rows.length; i++) {
583
+ const row = rows[i];
584
+ if (!isRecord(row)) {
585
+ warn(index, 'document-invalid', source, 0, 'task_commits row is invalid');
586
+ continue;
587
+ }
588
+ const rowLine = sourceLines.findIndex((text) => /\bsha\s*:/.test(text) && typeof row.sha === 'string' && text.includes(row.sha));
589
+ const line = rowLine < 0 ? 0 : rowLine + 1;
590
+ const hasNew = Object.hasOwn(row, 'task') || Object.hasOwn(row, 'intent_anchor');
591
+ const task = row.task;
592
+ // 접두는 형식별 task ID에서 끝 세 자리(번호)만 뗀 값이다. key는 번호 또는 kebab slug라 정규식 특수문자가 없다.
593
+ const expectedPrefix = formatStableTaskId({ format, epicKey: epicId, blueprintKey: bpId, task: '000' })
594
+ .slice(0, -3);
595
+ const taskPattern = new RegExp(`^${expectedPrefix}\\d{3}$`);
596
+ const newValid = hasNew && typeof task === 'string' && taskPattern.test(task) &&
597
+ typeof row.intent_anchor === 'string' && row.intent_anchor === `task-${task.slice(-3)}`;
598
+ const oldValid = !hasNew && typeof row.id === 'string' &&
599
+ (/^TASKS?[-/]\d{3}$/.test(row.id) || taskPattern.test(row.id));
600
+ if (!(newValid || oldValid) || (typeof task === 'string' && !task.startsWith(expectedPrefix))) {
601
+ warn(index, 'document-invalid', source, line, 'task_commits task identity is invalid');
602
+ continue;
603
+ }
604
+ if (typeof row.sha !== 'string' || !/^[0-9a-fA-F]{8,40}$/.test(row.sha)) {
605
+ warn(index, 'document-invalid', source, line, 'task_commits SHA is invalid');
606
+ continue;
607
+ }
608
+ const full = runGit(index.repoRoot, ['rev-parse', '--verify', `${row.sha}^{commit}`]);
609
+ if (full.status !== 0 || !/^[0-9a-f]{40}$/i.test(full.stdout.trim())) {
610
+ warn(index, 'commit-unavailable', source, line, 'commit is missing or ambiguous');
611
+ continue;
612
+ }
613
+ const sha = full.stdout.trim().toLowerCase();
614
+ const diffArgs = [
615
+ 'diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-M',
616
+ ...(hasParent(index.repoRoot, sha) ? [`${sha}^`, sha] : [sha]),
617
+ ];
618
+ const diff = runGit(index.repoRoot, diffArgs);
619
+ if (diff.status !== 0)
620
+ throw ioError(`git diff failed for ${sha}`, new Error(diff.stderr));
621
+ const seen = new Set();
622
+ for (const record of diff.stdout.split(/\r?\n/)) {
623
+ const cols = record.split('\t');
624
+ if (cols.length < 2)
625
+ continue;
626
+ const changed = cols[0].startsWith('R') ? [cols[1], cols[2]] : [cols[1]];
627
+ for (const file of changed) {
628
+ if (!seen.has(file)) {
629
+ seen.add(file);
630
+ addPath(index, bp, file, source, line, 'commit_diff', sha);
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ function hasParent(root, sha) {
637
+ const result = runGit(root, ['rev-list', '--parents', '-n', '1', sha]);
638
+ return result.status === 0 && result.stdout.trim().split(/\s+/).length > 1;
639
+ }
640
+ function addPath(index, bp, value, source, line, kind, sha) {
641
+ const norm = normalizePath(value);
642
+ if (!norm) {
643
+ warn(index, 'path-rejected', source, line, 'file path is unsafe or contains a glob');
644
+ return;
645
+ }
646
+ addFile(index, bp, norm, evidence(source, line, kind, sha));
647
+ }
648
+ function addFile(index, bp, file, ev) {
649
+ addRelation(index.files, file, bp);
650
+ addEvidence(index.fileEvidence, bp, file, ev);
651
+ }
652
+ function addEntity(index, bp, token, ev) {
653
+ addRelation(index.entities, token, bp);
654
+ addEvidence(index.entityEvidence, bp, token, ev);
655
+ }
656
+ function addRelation(map, token, bp) {
657
+ if (!map.has(token))
658
+ map.set(token, new Set());
659
+ map.get(token).add(bp);
660
+ }
661
+ function addEvidence(map, bp, key, ev) {
662
+ if (!map.has(bp))
663
+ map.set(bp, new Map());
664
+ const inner = map.get(bp);
665
+ inner.set(key, [...(inner.get(key) ?? []), ev]);
666
+ }
667
+ function validateFileQuery(value) {
668
+ if (!value || value.includes('\0') || value.includes('\\') || value.startsWith('/') ||
669
+ value.split('/').includes('..') || /[*?{}[\]]/.test(value))
670
+ throw inputError('file path must be a safe repository-relative path');
671
+ }
672
+ function normalizePath(value) {
673
+ if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\') ||
674
+ value.startsWith('/') || /[*?{}[\]]/.test(value))
675
+ return null;
676
+ const clean = value.startsWith('./') ? value.slice(2) : value;
677
+ if (clean.split('/').includes('..') || clean.split('/').some((part) => !part))
678
+ return null;
679
+ return clean;
680
+ }
681
+ function getSection(body, title) {
682
+ const at = body.indexOf(`${title}\n`);
683
+ return at < 0 ? null : body.slice(at);
684
+ }
685
+ function lineOfValue(body, value, fallback) {
686
+ const lines = body.split(/\r?\n/);
687
+ const idx = lines.findIndex((line) => line.includes(value));
688
+ return idx < 0 ? fallback : idx + 1;
689
+ }
690
+ function evidence(source, line, kind, sha) {
691
+ return sha ? { source, line, kind, sha } : { source, line, kind };
692
+ }
693
+ function warn(index, code, source, line, detail) {
694
+ warnArray(index.warnings, code, source, line, detail);
695
+ }
696
+ function warnArray(target, code, source, line, detail) {
697
+ target.push({ code, source, line, detail });
698
+ }
699
+ function uniqueWarnings(items) {
700
+ const map = new Map(items.map((x) => [`${x.code}\0${x.source}\0${x.line}\0${x.detail}`, x]));
701
+ return [...map.values()].sort((a, b) => compare(a.code, b.code) || compare(a.source, b.source) || a.line - b.line || compare(a.detail, b.detail));
702
+ }
703
+ function uniqueEvidence(items) {
704
+ const map = new Map(items.map((x) => [`${x.source}\0${x.line}\0${x.kind}\0${x.sha ?? ''}`, x]));
705
+ return [...map.values()].sort((a, b) => compare(a.source, b.source) || a.line - b.line || compare(a.kind, b.kind) || compare(a.sha ?? '', b.sha ?? ''));
706
+ }
707
+ function compare(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
708
+ function nested(value, parent, key) {
709
+ return isRecord(value) && isRecord(value[parent]) ? value[parent][key] : undefined;
710
+ }
711
+ function isRecord(value) {
712
+ return !!value && typeof value === 'object' && !Array.isArray(value);
713
+ }
714
+ function isMissing(error) {
715
+ return !!error && typeof error === 'object' &&
716
+ ['ENOENT', 'ENOTDIR'].includes(String(error.code));
717
+ }
718
+ function inputError(message) {
719
+ const error = new Error(message);
720
+ error.code = 'ontology-input-invalid';
721
+ return error;
722
+ }
723
+ function ioError(message, cause) {
724
+ const error = new Error(message, { cause });
725
+ error.code = 'ontology-io-failed';
726
+ return error;
727
+ }
728
+ /**
729
+ * 셸 없이 Git 하위 명령을 실행하고 시작 실패를 명시적 I/O 오류로 전달한다.
730
+ * 명령이 시작된 뒤의 비정상 종료는 호출자가 명령별 계약에 따라 처리한다.
731
+ *
732
+ * @param {string} root - Git 실행 cwd
733
+ * @param {string[]} args - Git에 전달할 인자 배열
734
+ * @returns {{status: number; stdout: string; stderr: string}} 프로세스 결과
735
+ */
736
+ function runGit(root, args) {
737
+ const result = (0, node_child_process_1.spawnSync)('git', args, { cwd: root, encoding: 'utf8', windowsHide: true });
738
+ if (result.error)
739
+ throw ioError('cannot execute git', result.error);
740
+ return {
741
+ status: result.status ?? 1,
742
+ stdout: result.stdout ?? '',
743
+ stderr: result.stderr ?? '',
744
+ };
745
+ }
746
+ module.exports = { loadContextOntology, queryContextOntology };