@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 guards = require("./guards");
3
+ const { isRecord } = guards;
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const layout = require("./layout");
7
+ const { CONTEXT_ROOT } = layout;
8
+ const frontmatter = require("./frontmatter");
9
+ const contextIds = require("./context-ids");
10
+ const { parseContextPath } = contextIds;
11
+ const paths = require("./paths");
12
+ const { isNumericContextId } = paths;
13
+ const CONTEXT_INDEX_REL = `${CONTEXT_ROOT}/index.md`;
14
+ // init CONTEXT_INDEX와 동일 frontmatter. 파일이 없을 때만 쓰며 기존 index를
15
+ // 소급 수정하지 않는다 — 이미 있는 저장소는 사람이 bouncer_schema를 넣는다.
16
+ const EMPTY_CONTEXT_INDEX = `---
17
+ okf_version: "0.1"
18
+ bouncer_schema: "0.1"
19
+ ---
20
+ # Epics
21
+
22
+ `;
23
+ // OKF §6: bundle index와 실제 디렉터리는 정본 경로(번호 `ddd-slug` 또는 slug)만 비교한다.
24
+ // 구형 EPIC- 접두는 목록에서 조용히 빼지 않고 S13으로 거절한다 — 전이 종료 후
25
+ // “구형만 남은 트리”가 빈 dirs로 통과하면 안 된다.
26
+ const EPIC_LINK_RE = /\]\(epics\/([^/)]+)\/index\.md\)/g;
27
+ /**
28
+ * epic 디렉터리 이름이 정본(번호 `ddd-slug` 또는 slug)인지 본다. 세그먼트 해석은 context-ids 한 곳에 맡긴다.
29
+ *
30
+ * @param {string} name - `epics/` 바로 아래 이름
31
+ * @returns {boolean} 정본 형식이면 true
32
+ */
33
+ function isCanonicalEpicDirName(name) {
34
+ return parseContextPath(`epics/${name}`) !== null;
35
+ }
36
+ /**
37
+ * 구형 `EPIC-ddd-<이름>` 접두 이름인지 본다. 이 접두는 번호 정본이 아니라 S13 거절 대상이다.
38
+ * context-ids에 접두 판정 함수가 없고 그 파일은 이 task의 쓰기 범위 밖이라, 새 정규식 대신
39
+ * paths의 세 자리 판정을 조합해 같은 모양(`EPIC-` + 세 자리 + `-` + 이름)만 읽는다.
40
+ *
41
+ * @param {string} name - `epics/` 바로 아래 이름
42
+ * @returns {boolean} 구형 접두 이름이면 true
43
+ */
44
+ function isLegacyEpicDirName(name) {
45
+ return name.startsWith('EPIC-') && isNumericContextId(name.slice(5, 8)) && name[8] === '-' && name.length > 9;
46
+ }
47
+ /**
48
+ * epic 디렉터리 이름에서 "placeholder description" 판정에 쓰는 키를 꺼낸다.
49
+ * 번호 epic은 세 자리 번호, slug epic은 slug 자체다(`Epic <키>`가 scaffold의 자리표시 문구).
50
+ *
51
+ * @param {string} dirName - canonical epic 디렉터리 이름
52
+ * @returns {string} 번호 또는 slug
53
+ */
54
+ function epicKeyOfDirName(dirName) {
55
+ const parsed = parseContextPath(`epics/${dirName}`);
56
+ return parsed ? parsed.epicKey : dirName;
57
+ }
58
+ /**
59
+ * canonical epic 문서에서 색인 파생값인 description을 읽는다.
60
+ * 파일 읽기·frontmatter·값 검증 실패를 모두 호출자에게 전달해 scaffold와
61
+ * S13이 손상된 정본을 조용히 다른 문구로 대체하지 않게 한다.
62
+ *
63
+ * @param {string} repoRoot - 저장소 루트 절대 경로
64
+ * @param {string} dirName - canonical epic 디렉터리 이름
65
+ * @returns {string} 공백을 제거한 비어 있지 않은 description
66
+ * @throws {Error} 파일을 읽거나 frontmatter를 해석할 수 없거나 값이 유효하지 않을 때
67
+ */
68
+ function readEpicDescription(repoRoot, dirName) {
69
+ const abs = path.join(repoRoot, CONTEXT_ROOT, 'epics', dirName, 'index.md');
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(abs, 'utf8');
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ throw new Error(`epic frontmatter unreadable: ${abs} (${message})`, { cause: error });
77
+ }
78
+ let data;
79
+ try {
80
+ // frontmatter는 최상단 import = require로 읽어 순환 없이 단일 YAML 정책을 재사용한다.
81
+ // 함수 안 require+시그니처 캐스트는 경계 타입 검사를 우회하므로 쓰지 않는다.
82
+ data = frontmatter.parseFrontmatter(raw).data;
83
+ }
84
+ catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ throw new Error(`epic frontmatter parse failed: ${abs} (${message})`, { cause: error });
87
+ }
88
+ const description = isRecord(data) ? data.description : undefined;
89
+ if (typeof description !== 'string' || !description.trim()) {
90
+ throw new Error(`epic description must be a non-empty string: ${abs}`);
91
+ }
92
+ const trimmed = description.trim();
93
+ if (trimmed === `Epic ${epicKeyOfDirName(dirName)}`) {
94
+ throw new Error(`epic description must not be a placeholder: ${abs}`);
95
+ }
96
+ return trimmed;
97
+ }
98
+ function escapedRegExp(value) {
99
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ }
101
+ function indexRowsForDir(text, dirName) {
102
+ const pathPart = escapedRegExp(`epics/${dirName}/index.md`);
103
+ const re = new RegExp(`^(\\* \\[[^\\r\\n]*\\]\\(${pathPart}\\) - )(.*)$`, 'gm');
104
+ const rows = [];
105
+ let match;
106
+ while ((match = re.exec(text)) !== null) {
107
+ rows.push({ line: match[0], prefix: match[1], summary: match[2] });
108
+ }
109
+ return rows;
110
+ }
111
+ function listEpicDirNames(repoRoot) {
112
+ const epicsRoot = path.join(repoRoot, CONTEXT_ROOT, 'epics');
113
+ if (!fs.existsSync(epicsRoot))
114
+ return [];
115
+ let names;
116
+ try {
117
+ names = fs.readdirSync(epicsRoot);
118
+ }
119
+ catch (_e) {
120
+ return [];
121
+ }
122
+ const dirs = [];
123
+ for (const name of names) {
124
+ if (!isCanonicalEpicDirName(name))
125
+ continue;
126
+ try {
127
+ if (fs.statSync(path.join(epicsRoot, name)).isDirectory())
128
+ dirs.push(name);
129
+ }
130
+ catch (_e) {
131
+ // skip unreadable entries
132
+ }
133
+ }
134
+ dirs.sort();
135
+ return dirs;
136
+ }
137
+ /** 구형 EPIC- 접두 epic 디렉터리 이름. S13 거절 대상. */
138
+ function listLegacyEpicDirNames(repoRoot) {
139
+ const epicsRoot = path.join(repoRoot, CONTEXT_ROOT, 'epics');
140
+ if (!fs.existsSync(epicsRoot))
141
+ return [];
142
+ let names;
143
+ try {
144
+ names = fs.readdirSync(epicsRoot);
145
+ }
146
+ catch (_e) {
147
+ return [];
148
+ }
149
+ const dirs = [];
150
+ for (const name of names) {
151
+ if (!isLegacyEpicDirName(name))
152
+ continue;
153
+ try {
154
+ if (fs.statSync(path.join(epicsRoot, name)).isDirectory())
155
+ dirs.push(name);
156
+ }
157
+ catch (_e) {
158
+ // skip unreadable entries
159
+ }
160
+ }
161
+ dirs.sort();
162
+ return dirs;
163
+ }
164
+ /** 색인의 epic 링크 대상 이름 중 `keep`이 받아들이는 것만 모은다. HTML 주석 안의 예시 링크는 목록이 아니다. */
165
+ function collectIndexEpicDirs(text, keep) {
166
+ const listed = new Set();
167
+ const visible = text.replace(/<!--[\s\S]*?-->/g, '');
168
+ EPIC_LINK_RE.lastIndex = 0;
169
+ let m;
170
+ while ((m = EPIC_LINK_RE.exec(visible)) !== null) {
171
+ if (keep(m[1]))
172
+ listed.add(m[1]);
173
+ }
174
+ return listed;
175
+ }
176
+ function parseIndexEpicDirs(text) {
177
+ return collectIndexEpicDirs(text, isCanonicalEpicDirName);
178
+ }
179
+ function parseLegacyIndexEpicDirs(text) {
180
+ return collectIndexEpicDirs(text, isLegacyEpicDirName);
181
+ }
182
+ /**
183
+ * 색인 한 행을 만든다. `epicId`가 있으면 번호 epic(`ddd-slug`), 없으면 slug epic(`slug`)이다 —
184
+ * 형식은 호출자가 이미 정했고 여기서는 디렉터리 이름과 라벨만 형식에 맞춘다.
185
+ *
186
+ * @param {object} options - epic 식별 정보
187
+ * @param {string} [options.epicId] - zero-padded epic id. slug epic이면 생략
188
+ * @param {string} options.name - epic slug
189
+ * @param {unknown} options.description - 비어 있지 않은 non-placeholder 문자열
190
+ * @returns {string} 색인 행
191
+ * @throws {Error} description이 비었거나 placeholder일 때
192
+ */
193
+ function formatEpicIndexLine({ epicId, name, description }) {
194
+ const dirName = epicId === undefined ? name : `${epicId}-${name}`;
195
+ if (typeof description !== 'string' || !description.trim() || description.trim() === `Epic ${epicId ?? name}`) {
196
+ throw new Error(`epic description must be a non-placeholder string for ${dirName}`);
197
+ }
198
+ const label = epicId === undefined ? name : `${epicId} ${name}`;
199
+ return `* [${label}](epics/${dirName}/index.md) - ${description.trim()}`;
200
+ }
201
+ /**
202
+ * frontmatter description으로 bundle index의 한 행을 append·replace·no-op한다.
203
+ * 기존 행은 label과 링크를 보존하고 요약만 교체하며, 변경이 없으면 파일을
204
+ * 쓰지 않는다. 정본을 읽지 못하면 append도 거절한다.
205
+ *
206
+ * @param {object} options - 저장소와 epic 식별 정보
207
+ * @param {string} options.repoRoot - 저장소 루트 절대 경로
208
+ * @param {string} [options.epicId] - zero-padded epic id. slug epic이면 생략한다
209
+ * @param {string} options.name - epic slug
210
+ * @param {unknown} options.description - 신규 생성 시 사용할 description
211
+ * @returns {string|null} 변경한 index 경로 또는 no-op이면 null
212
+ */
213
+ function ensureEpicIndexEntry({ repoRoot, epicId, name, description }) {
214
+ const abs = path.join(repoRoot, CONTEXT_INDEX_REL);
215
+ const dirName = epicId === undefined ? name : `${epicId}-${name}`;
216
+ const epicPath = path.join(repoRoot, CONTEXT_ROOT, 'epics', dirName, 'index.md');
217
+ const canonicalDescription = fs.existsSync(epicPath)
218
+ ? readEpicDescription(repoRoot, dirName)
219
+ : (typeof description === 'string' && description.trim() && description.trim() !== `Epic ${epicId ?? name}`
220
+ ? description.trim()
221
+ : (() => { throw new Error(`epic description must be provided for ${dirName}`); })());
222
+ let text;
223
+ if (!fs.existsSync(abs)) {
224
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
225
+ text = EMPTY_CONTEXT_INDEX;
226
+ }
227
+ else {
228
+ text = fs.readFileSync(abs, 'utf8');
229
+ }
230
+ const rows = indexRowsForDir(text, dirName);
231
+ if (rows.length > 0) {
232
+ if (rows[0].summary === canonicalDescription && rows.length === 1)
233
+ return null;
234
+ const next = text.replace(rows[0].line, `${rows[0].prefix}${canonicalDescription}`);
235
+ fs.writeFileSync(abs, next);
236
+ return CONTEXT_INDEX_REL;
237
+ }
238
+ const trimmed = text.replace(/\s*$/, '');
239
+ const next = `${trimmed}\n${formatEpicIndexLine({ epicId, name, description: canonicalDescription })}\n`;
240
+ fs.writeFileSync(abs, next);
241
+ return CONTEXT_INDEX_REL;
242
+ }
243
+ /** epic 디렉터리 ↔ `.bouncer/context/index.md` 목록 일치. S13 실패 목록. */
244
+ function checkEpicIndexConsistency({ repoRoot }) {
245
+ const failures = [];
246
+ const legacyDirs = listLegacyEpicDirNames(repoRoot);
247
+ for (const d of legacyDirs) {
248
+ failures.push({
249
+ code: 'S13',
250
+ message: `legacy-prefixed epic directory is not canonical: ${d}`,
251
+ file: `${CONTEXT_ROOT}/epics/${d}`,
252
+ });
253
+ }
254
+ const dirs = listEpicDirNames(repoRoot);
255
+ const abs = path.join(repoRoot, CONTEXT_INDEX_REL);
256
+ const hasEpics = dirs.length > 0 || legacyDirs.length > 0;
257
+ if (!hasEpics)
258
+ return failures;
259
+ if (!fs.existsSync(abs)) {
260
+ failures.push({
261
+ code: 'S13',
262
+ message: 'bundle context index.md missing while epics exist',
263
+ file: CONTEXT_INDEX_REL,
264
+ });
265
+ return failures;
266
+ }
267
+ let text;
268
+ try {
269
+ text = fs.readFileSync(abs, 'utf8');
270
+ }
271
+ catch (_e) {
272
+ failures.push({
273
+ code: 'S13',
274
+ message: 'bundle context index.md unreadable',
275
+ file: CONTEXT_INDEX_REL,
276
+ });
277
+ return failures;
278
+ }
279
+ for (const d of [...parseLegacyIndexEpicDirs(text)].sort()) {
280
+ failures.push({
281
+ code: 'S13',
282
+ message: `legacy-prefixed epic link is not canonical: ${d}`,
283
+ file: CONTEXT_INDEX_REL,
284
+ });
285
+ }
286
+ // 정본 디렉터리가 하나도 없으면 구형 거절만으로 충분하다.
287
+ if (dirs.length === 0)
288
+ return failures;
289
+ const listed = parseIndexEpicDirs(text);
290
+ for (const d of dirs) {
291
+ const rows = indexRowsForDir(text, d);
292
+ if (rows.length === 0) {
293
+ failures.push({
294
+ code: 'S13',
295
+ message: `epic directory not listed in context index: ${d}`,
296
+ file: CONTEXT_INDEX_REL,
297
+ });
298
+ continue;
299
+ }
300
+ if (rows.length > 1) {
301
+ failures.push({
302
+ code: 'S13',
303
+ message: `duplicate epic index rows for directory: ${d}`,
304
+ file: CONTEXT_INDEX_REL,
305
+ });
306
+ }
307
+ let description;
308
+ try {
309
+ description = readEpicDescription(repoRoot, d);
310
+ }
311
+ catch (error) {
312
+ failures.push({
313
+ code: 'S13',
314
+ message: error.message,
315
+ file: `${CONTEXT_ROOT}/epics/${d}/index.md`,
316
+ });
317
+ continue;
318
+ }
319
+ if (rows[0].summary !== description) {
320
+ failures.push({
321
+ code: 'S13',
322
+ message: `epic summary mismatch for ${d}: index summary does not match frontmatter description`,
323
+ file: CONTEXT_INDEX_REL,
324
+ });
325
+ }
326
+ }
327
+ for (const d of [...listed].sort()) {
328
+ if (!dirs.includes(d)) {
329
+ failures.push({
330
+ code: 'S13',
331
+ message: `context index lists missing epic directory: ${d}`,
332
+ file: CONTEXT_INDEX_REL,
333
+ });
334
+ }
335
+ }
336
+ return failures;
337
+ }
338
+ module.exports = {
339
+ CONTEXT_INDEX_REL,
340
+ listEpicDirNames,
341
+ listLegacyEpicDirNames,
342
+ parseIndexEpicDirs,
343
+ parseLegacyIndexEpicDirs,
344
+ formatEpicIndexLine,
345
+ ensureEpicIndexEntry,
346
+ checkEpicIndexConsistency,
347
+ };