@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,392 @@
1
+ 'use strict';
2
+ const nodeFs = require('node:fs');
3
+ const path = require('node:path');
4
+ const layout = require("./layout");
5
+ const { CONTEXT_ROOT } = layout;
6
+ const epicRegistry = require("./epic-registry");
7
+ const frontmatter = require("./frontmatter");
8
+ const { parseFrontmatter } = frontmatter;
9
+ const guards = require("./guards");
10
+ const { isRecord, catchMessage } = guards;
11
+ const rewriteModule = require("./context-migration-rewrite");
12
+ const { defaultRunGit, defaultListInProgress, computeRewrites, writeRegistryAtomically } = rewriteModule;
13
+ const validate = require("./validate");
14
+ const { validateBlueprint } = validate;
15
+ const EPICS_ROOT = `${CONTEXT_ROOT}/epics`;
16
+ const LIST_HEADING = '## Legacy epic 귀속';
17
+ const TABLE_HEADING = '### 다른 epic에서 이 영역으로 귀속되는 blueprint';
18
+ const LIST_LINE_RE = /^- \[[^\]\n]+\]\(\.\.\/([^/)\s]+)\/index\.md\)(?: △)?\s*$/;
19
+ /** 읽기 seam 기본 구현. 파일이 없을 때(ENOENT·ENOTDIR)만 null이고 권한 오류 등은 던진다. */
20
+ function defaultReadFile(repoRoot) {
21
+ return (rel) => {
22
+ try {
23
+ return nodeFs.readFileSync(path.join(repoRoot, rel), 'utf8');
24
+ }
25
+ catch (error) {
26
+ // 없는 문서는 "그 epic에 index가 없음"이라는 정상 입력이다. 그 밖의 오류를 숨기면 계획이 틀어진다.
27
+ const code = error.code;
28
+ if (code === 'ENOENT' || code === 'ENOTDIR')
29
+ return null;
30
+ throw error;
31
+ }
32
+ };
33
+ }
34
+ /** 디렉터리 이름 목록 seam 기본 구현. 디렉터리가 없으면 빈 목록이고, 이름순으로 고정한다. */
35
+ function defaultListDirs(repoRoot) {
36
+ return (rel) => {
37
+ try {
38
+ return nodeFs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true })
39
+ .filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
40
+ }
41
+ catch (error) {
42
+ const code = error.code;
43
+ if (code === 'ENOENT' || code === 'ENOTDIR')
44
+ return [];
45
+ throw error;
46
+ }
47
+ };
48
+ }
49
+ /** `## ` 제목 한 줄을 시작으로 같은 단계 이상의 다음 제목 전까지의 줄을 돌려준다. 제목이 없으면 null이다. */
50
+ function sectionLines(text, heading) {
51
+ const lines = text.split('\n');
52
+ const start = lines.findIndex((line) => line.trim() === heading);
53
+ if (start === -1)
54
+ return null;
55
+ const level = heading.match(/^#+/)[0].length;
56
+ const out = [];
57
+ for (const line of lines.slice(start + 1)) {
58
+ const hit = /^(#+)\s/.exec(line);
59
+ if (hit !== null && hit[1].length <= level)
60
+ break;
61
+ out.push(line);
62
+ }
63
+ return out;
64
+ }
65
+ /** blueprint index frontmatter의 `bouncer.order`. 문서·값이 없으면 null이다(frontmatter 부재만 흡수). */
66
+ function orderOf(readFile, indexRel) {
67
+ const text = readFile(indexRel);
68
+ if (text === null)
69
+ return null;
70
+ let data;
71
+ try {
72
+ data = parseFrontmatter(text).data;
73
+ }
74
+ catch (error) {
75
+ // frontmatter가 없는 문서만 "order 없음"으로 본다. YAML 오류는 계획이 틀어지므로 던진다.
76
+ if (error instanceof Error && error.message === 'missing frontmatter block')
77
+ return null;
78
+ throw error;
79
+ }
80
+ const bouncer = isRecord(data) ? data.bouncer : undefined;
81
+ return isRecord(bouncer) && typeof bouncer.order === 'number' ? bouncer.order : null;
82
+ }
83
+ /**
84
+ * legacy epic 병합 이동 계획을 계산한다. 디스크를 쓰지 않으며 같은 입력에 같은 출력을 낸다.
85
+ *
86
+ * 규칙: legacy epic의 blueprint는 그 legacy가 `## Legacy epic 귀속` 목록에 오른 도메인 epic으로 가되,
87
+ * 예외 표에 오른 blueprint는 그 표가 있는 도메인 epic으로 간다. `order`는 대상 epic의 기존 최댓값 다음부터
88
+ * (legacy_id 오름차순, 원래 order 오름차순)으로 1씩 붙는다. 자유 서술 예외 행은 `unresolved_rows`로 보고하고
89
+ * 그 행의 blueprint는 목록 규칙으로 계획한다(적용 단계가 거절하는 근거).
90
+ *
91
+ * @param {object} opts - 입력
92
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로(기본 seam이 읽을 때만 쓴다)
93
+ * @param {EpicRegistry | null} opts.registry - v2 등록부
94
+ * @param {(rel: string) => string | null} [opts.readFile] - 저장소 상대 경로 문서 읽기. 없으면 null
95
+ * @param {(rel: string) => string[]} [opts.listDirs] - 저장소 상대 경로 하위 디렉터리 이름. 없으면 빈 목록
96
+ * @returns {MergePlanResult} 성공(moves·unresolved_rows·legacyEpics·byDomain) 또는
97
+ * 실패(`merge-mapping-invalid`·`migrate-slug-conflict`와 details). 첫 실패에서 계산을 멈춘다
98
+ */
99
+ function planLegacyEpicMerge(opts) {
100
+ const readFile = opts.readFile ?? defaultReadFile(opts.repoRoot);
101
+ const listDirs = opts.listDirs ?? defaultListDirs(opts.repoRoot);
102
+ const invalid = (...details) => ({ ok: false, code: 'merge-mapping-invalid', details });
103
+ const registry = opts.registry;
104
+ if (registry === null || registry.version !== 2)
105
+ return invalid({ reason: 'registry is not version 2' });
106
+ const legacyBySlug = new Map(registry.epics.filter((e) => e.kind === 'legacy').map((e) => [e.slug, e]));
107
+ const legacyByNumber = new Map();
108
+ for (const e of legacyBySlug.values())
109
+ if (e.legacy_id !== undefined)
110
+ legacyByNumber.set(e.legacy_id, e);
111
+ // 도메인은 slug 순으로 읽어 같은 입력이 항상 같은 오류·순서를 내게 한다.
112
+ const domains = registry.epics.filter((e) => e.kind === 'domain').map((e) => e.slug).sort();
113
+ // 1. 귀속 목록과 예외 표를 도메인 index에서 읽는다.
114
+ const owner = new Map(); // legacy slug → 목록을 가진 domain
115
+ const exceptions = [];
116
+ const unresolved = [];
117
+ for (const domain of domains) {
118
+ const text = readFile(`${EPICS_ROOT}/${domain}/index.md`);
119
+ if (text === null)
120
+ continue;
121
+ for (const line of sectionLines(text, LIST_HEADING) ?? []) {
122
+ if (!line.startsWith('- '))
123
+ continue;
124
+ const hit = LIST_LINE_RE.exec(line);
125
+ if (hit === null)
126
+ return invalid({ domain, line, reason: 'list line is not a legacy epic link' });
127
+ const legacy = legacyBySlug.get(hit[1]);
128
+ if (legacy === undefined)
129
+ return invalid({ domain, epic: hit[1], reason: 'linked epic is not a legacy epic' });
130
+ const previous = owner.get(legacy.slug);
131
+ if (previous !== undefined) {
132
+ return invalid({ legacy: legacy.slug, domains: [previous, domain], reason: 'legacy epic is listed twice' });
133
+ }
134
+ owner.set(legacy.slug, domain);
135
+ }
136
+ for (const line of sectionLines(text, TABLE_HEADING) ?? []) {
137
+ if (!line.trim().startsWith('|'))
138
+ continue;
139
+ const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
140
+ // 머리글·구분선은 행이 아니다.
141
+ if (cells[0] === 'legacy epic' || /^:?-+:?$/.test(cells[0]))
142
+ continue;
143
+ if (cells.length !== 3)
144
+ return invalid({ domain, line, reason: 'exception row must have three cells' });
145
+ const legacy = legacyByNumber.get(cells[0]);
146
+ if (legacy === undefined)
147
+ return invalid({ domain, legacy_id: cells[0], reason: 'unknown legacy number' });
148
+ const parsed = parseCell(cells[1], legacy);
149
+ if (parsed.error !== undefined) {
150
+ return invalid({ domain, legacy_id: cells[0], cell: cells[1], reason: parsed.error });
151
+ }
152
+ if (parsed.slugs.length === 0)
153
+ unresolved.push({ domain, legacy_id: cells[0], cell: cells[1] });
154
+ else
155
+ exceptions.push({ domain, legacy, slugs: parsed.slugs });
156
+ }
157
+ }
158
+ for (const legacy of legacyBySlug.values()) {
159
+ if (!owner.has(legacy.slug))
160
+ return invalid({ legacy: legacy.slug, reason: 'legacy epic is in no list' });
161
+ }
162
+ // 2. 예외 대상 지도. 같은 blueprint가 서로 다른 도메인의 표에 있으면 어느 쪽인지 정할 수 없다.
163
+ const exceptionTarget = new Map(); // `${legacy slug}/${bp slug}` → domain
164
+ for (const row of exceptions) {
165
+ for (const slug of row.slugs) {
166
+ const key = `${row.legacy.slug}/${slug}`;
167
+ const previous = exceptionTarget.get(key);
168
+ if (previous !== undefined && previous !== row.domain) {
169
+ return invalid({ legacy: row.legacy.slug, blueprint: slug, domains: [previous, row.domain] });
170
+ }
171
+ exceptionTarget.set(key, row.domain);
172
+ }
173
+ }
174
+ // 3. legacy epic별 실제 blueprint 디렉터리를 이동 후보로 모은다. 이미 옮겨진 것은 디렉터리가 없어 빠진다.
175
+ const candidates = [];
176
+ for (const legacy of [...legacyBySlug.values()].sort(compareLegacy)) {
177
+ const aliasNumberOf = new Map(Object.entries(legacy.blueprint_aliases ?? {}).map(([n, s]) => [s, Number(n)]));
178
+ for (const slug of listDirs(`${EPICS_ROOT}/${legacy.slug}/blueprints`)) {
179
+ const fromFrontmatter = orderOf(readFile, `${EPICS_ROOT}/${legacy.slug}/blueprints/${slug}/index.md`);
180
+ // frontmatter `order`가 정본이고, 없으면 옛 번호 별칭, 그것도 없으면 맨 뒤(이름순)로 둔다.
181
+ const original = fromFrontmatter ?? aliasNumberOf.get(slug) ?? Number.MAX_SAFE_INTEGER;
182
+ const domain = exceptionTarget.get(`${legacy.slug}/${slug}`) ?? owner.get(legacy.slug);
183
+ candidates.push({ legacy, slug, original, domain });
184
+ }
185
+ }
186
+ candidates.sort((a, b) => compareLegacy(a.legacy, b.legacy) || a.original - b.original
187
+ || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
188
+ // 4. 충돌 검사와 order 배정.
189
+ const nextOrder = new Map();
190
+ const taken = new Map();
191
+ const moves = [];
192
+ for (const c of candidates) {
193
+ if (!taken.has(c.domain)) {
194
+ const existing = listDirs(`${EPICS_ROOT}/${c.domain}/blueprints`);
195
+ taken.set(c.domain, new Set(existing));
196
+ const orders = existing.map((s) => orderOf(readFile, `${EPICS_ROOT}/${c.domain}/blueprints/${s}/index.md`));
197
+ nextOrder.set(c.domain, orders.reduce((max, o) => Math.max(max, o ?? 0), 0) + 1);
198
+ }
199
+ const slugs = taken.get(c.domain);
200
+ if (slugs.has(c.slug)) {
201
+ return { ok: false, code: 'migrate-slug-conflict', details: [{
202
+ legacy: c.legacy.slug, blueprint: c.slug, domain: c.domain,
203
+ }] };
204
+ }
205
+ slugs.add(c.slug);
206
+ const order = nextOrder.get(c.domain);
207
+ nextOrder.set(c.domain, order + 1);
208
+ moves.push({
209
+ from: `${EPICS_ROOT}/${c.legacy.slug}/blueprints/${c.slug}`,
210
+ to: `${EPICS_ROOT}/${c.domain}/blueprints/${c.slug}`,
211
+ legacyEpic: c.legacy.slug, domainEpic: c.domain, slug: c.slug, order,
212
+ });
213
+ }
214
+ const byDomain = {};
215
+ for (const move of moves)
216
+ (byDomain[move.domainEpic] ??= []).push(move);
217
+ const legacyEpics = [...legacyBySlug.values()].sort(compareLegacy).map((e) => ({
218
+ slug: e.slug, legacy_id: e.legacy_id, domain: owner.get(e.slug),
219
+ }));
220
+ return { ok: true, moves, unresolved_rows: unresolved, legacyEpics, byDomain };
221
+ }
222
+ /** legacy epic을 `legacy_id` 오름차순(없으면 slug)으로 비교한다. 번호는 3자리 0패딩이라 문자열 비교로 충분하다. */
223
+ function compareLegacy(a, b) {
224
+ const left = a.legacy_id ?? a.slug;
225
+ const right = b.legacy_id ?? b.slug;
226
+ return left < right ? -1 : left > right ? 1 : 0;
227
+ }
228
+ /**
229
+ * 예외 표 blueprint 셀을 쉼표로 나눈 항목(`NNN`·`NNN~MMM`·`NNN <slug>`)으로 풀어 현재 slug 목록을 낸다.
230
+ * 항목을 하나도 얻지 못한 셀(자유 서술)은 빈 목록이고, 일부만 해석되면 오류다.
231
+ *
232
+ * @param {string} cell - 표의 blueprint 셀
233
+ * @param {RegistryEpic} legacy - 행의 legacy epic(`blueprint_aliases`를 쥔다)
234
+ * @returns {{ slugs: string[]; error?: string }} slug 목록 또는 거절 사유
235
+ */
236
+ function parseCell(cell, legacy) {
237
+ const aliases = legacy.blueprint_aliases ?? {};
238
+ const slugs = [];
239
+ const parts = cell.split(',').map((p) => p.trim()).filter((p) => p !== '');
240
+ let matched = 0;
241
+ for (const part of parts) {
242
+ const single = /^(\d{3})$/.exec(part);
243
+ const range = /^(\d{3})~(\d{3})$/.exec(part);
244
+ const named = /^(\d{3})\s+(\S+)$/.exec(part);
245
+ if (single === null && range === null && named === null)
246
+ continue;
247
+ matched += 1;
248
+ if (single !== null) {
249
+ if (aliases[single[1]] === undefined)
250
+ return { slugs, error: `blueprint number not in aliases: ${single[1]}` };
251
+ slugs.push(aliases[single[1]]);
252
+ }
253
+ else if (range !== null) {
254
+ const [low, high] = [Number(range[1]), Number(range[2])];
255
+ if (low > high)
256
+ return { slugs, error: `range is reversed: ${part}` };
257
+ for (let n = low; n <= high; n += 1) {
258
+ const key = String(n).padStart(3, '0');
259
+ if (aliases[key] === undefined)
260
+ return { slugs, error: `blueprint number not in aliases: ${key}` };
261
+ slugs.push(aliases[key]);
262
+ }
263
+ }
264
+ else if (named !== null) {
265
+ if (aliases[named[1]] === undefined)
266
+ return { slugs, error: `blueprint number not in aliases: ${named[1]}` };
267
+ if (aliases[named[1]] !== named[2]) {
268
+ return { slugs, error: `slug differs from alias: ${named[1]} is ${aliases[named[1]]}, not ${named[2]}` };
269
+ }
270
+ slugs.push(named[2]);
271
+ }
272
+ }
273
+ if (matched > 0 && matched !== parts.length)
274
+ return { slugs, error: 'cell has unparseable items' };
275
+ return { slugs: matched === 0 ? [] : slugs };
276
+ }
277
+ // `migrate context-slugs`와 같은 복구 명령. 병합이 만지는 것도 `.bouncer` 아래뿐이다.
278
+ const RESTORE_NEXT = 'git restore --staged --worktree .bouncer && git clean -fd .bouncer/context';
279
+ function failure(code, message, next, extra = {}) {
280
+ return { ok: false, code, message, next, ...extra };
281
+ }
282
+ /**
283
+ * legacy epic의 blueprint를 도메인 epic으로 병합한다. `yes`가 없으면 계획과 재작성 결과만 보고한다(dry-run).
284
+ * 계획·가드 거절은 어떤 쓰기·`git mv` 전에 끝나고, 쓰기 단계의 예외는 자동 롤백 없이 복구 명령을 `next`에 담는다.
285
+ * 적용 순서는 `git mv` → 문서 쓰기 → 등록부 `moved_to` 원자 쓰기 → 옮긴 blueprint 검증이다.
286
+ *
287
+ * @param {object} opts - 입력
288
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
289
+ * @param {boolean} [opts.yes] - true면 실제로 옮긴다
290
+ * @param {MergeDeps} [opts.deps] - 테스트 seam. `runGit`·`fs`(쓰기)·`listInProgress`
291
+ * @returns {MergeSuccess | MergeFailure} 성공(moved·rewritten·movedTo·unresolved_rows·validate) 또는
292
+ * 거절(code·message·next와 details·blockers). 이미 옮겨졌다면 `moved: []`인 쓰기 없는 성공이다
293
+ */
294
+ function migrateMergeLegacyEpics({ repoRoot, yes = false, deps }) {
295
+ const fsApi = deps?.fs ?? nodeFs;
296
+ const runGit = deps?.runGit ?? defaultRunGit(repoRoot);
297
+ const read = epicRegistry.readEpicRegistry(repoRoot);
298
+ if (read.status === 'invalid') {
299
+ return failure('S35', `epic registry is invalid: ${read.message}`, `fix ${epicRegistry.REGISTRY_REL}, then retry`);
300
+ }
301
+ if (read.status === 'missing') {
302
+ return failure('migrate-registry-missing', `${epicRegistry.REGISTRY_REL} is missing; the registry records where each blueprint moved`, 'run bouncer init to create the registry, then retry');
303
+ }
304
+ const registry = read.registry;
305
+ const plan = planLegacyEpicMerge({ repoRoot, registry });
306
+ if (!plan.ok) {
307
+ return failure(plan.code, `legacy epic merge plan is invalid: ${plan.code}`, 'fix the listed details, then retry', {
308
+ details: plan.details,
309
+ });
310
+ }
311
+ // 옮길 blueprint가 없으면 거절 검사 없이 성공이다. 병합 직후(작업 트리가 더러운 상태)의 재실행이
312
+ // dirty 거절에 걸리면 "두 번째 실행은 moved: []인 성공"이라는 멱등 계약이 깨진다.
313
+ if (plan.moves.length === 0) {
314
+ return {
315
+ ok: true, dryRun: !yes, moved: [], rewritten: 0, movedTo: {}, unresolved_rows: plan.unresolved_rows, validate: [],
316
+ };
317
+ }
318
+ // 자유 서술 예외 행은 사람이 결정해야 하는 귀속이다. dry-run은 보고만 하지만 적용은 쓰기 전에 거절한다.
319
+ if (yes && plan.unresolved_rows.length > 0) {
320
+ return failure('merge-mapping-invalid', 'exception table rows are free text and cannot be applied automatically', 'rewrite the listed rows as blueprint numbers (NNN, NNN~MMM, NNN <slug>) in the domain epic index, then retry', { details: plan.unresolved_rows });
321
+ }
322
+ if (runGit(['status', '--porcelain']).stdout.trim() !== '') {
323
+ return failure('migrate-dirty-tree', 'the working tree has uncommitted changes', 'commit or stash every change (git status --porcelain must be empty), then retry');
324
+ }
325
+ const inProgress = (deps?.listInProgress ?? (() => defaultListInProgress({ repoRoot, runGit, fsApi })))({ repoRoot });
326
+ if (inProgress.length > 0) {
327
+ return failure('migrate-in-progress', `${inProgress.length} in-progress runtime item(s) hold the old path keys`, 'finish or discard the pointer, approvals, coordinator ledger and linked worktrees listed in blockers, '
328
+ + 'then retry', { blockers: inProgress });
329
+ }
330
+ // 등록부: 이동한 legacy epic마다 `moved_to`를 기존 값에 병합한다. 다른 필드는 건드리지 않는다.
331
+ const movedTo = {};
332
+ for (const move of plan.moves)
333
+ (movedTo[move.legacyEpic] ??= {})[move.slug] = move.domainEpic;
334
+ const candidate = {
335
+ ...registry,
336
+ epics: registry.epics.map((epic) => (movedTo[epic.slug] === undefined
337
+ ? epic : { ...epic, moved_to: { ...epic.moved_to, ...movedTo[epic.slug] } })),
338
+ };
339
+ const parsed = epicRegistry.parseEpicRegistry(candidate);
340
+ if (!parsed.ok) {
341
+ return failure('merge-mapping-invalid', `registry with moved_to would be invalid: ${parsed.message}`, 'fix the legacy epic mapping, then retry');
342
+ }
343
+ // 모든 문서 변경을 쓰기 전에 계산한다. 여기서 던지면(깨진 YAML 등) 디스크는 그대로다.
344
+ const moved = plan.moves.map((m) => ({ from: m.from, to: m.to, order: m.order }));
345
+ const rewrites = computeRewrites({
346
+ repoRoot,
347
+ plan: {
348
+ moved: plan.moves.map((m) => ({ from: m.from, to: m.to })),
349
+ nextRegistry: parsed.registry,
350
+ // 병합은 대상 epic 안의 새 순번이 정본이라 order를 덮어쓴다. legacy epic 직속 문서(index.md)는 제자리에
351
+ // 남고 링크만 새 경로로 바뀌므로 epic 맥락을 두지 않는다.
352
+ contexts: plan.moves.map((m) => ({
353
+ fromEpicDir: `${EPICS_ROOT}/${m.legacyEpic}`,
354
+ toEpicDir: `${EPICS_ROOT}/${m.domainEpic}`,
355
+ epicSlug: m.domainEpic,
356
+ blueprint: { from: m.from, to: m.to, slug: m.slug, order: m.order },
357
+ orderMode: 'overwrite',
358
+ })),
359
+ },
360
+ runGit,
361
+ fsApi,
362
+ });
363
+ const changed = rewrites.filter((r) => r.text !== r.original);
364
+ const base = { moved, rewritten: changed.length, movedTo, unresolved_rows: plan.unresolved_rows };
365
+ if (!yes)
366
+ return { ok: true, dryRun: true, ...base, validate: [] };
367
+ try {
368
+ for (const move of plan.moves) {
369
+ // git mv는 대상의 부모 디렉터리를 만들지 않는다. 도메인 epic에 blueprints/가 아직 없을 수 있다.
370
+ fsApi.mkdirSync(path.join(repoRoot, path.posix.dirname(move.to)), { recursive: true });
371
+ runGit(['mv', move.from, move.to]);
372
+ }
373
+ // 문서 내용. git mv는 내용을 바꾸지 않으므로 이동한 자리(newRel)에 새 내용을 쓴다.
374
+ for (const r of changed)
375
+ fsApi.writeFileSync(path.join(repoRoot, r.newRel), r.text);
376
+ // 등록부는 문서 뒤에 쓴다 — 실패하면 문서만 새 상태가 남지만 restore 한 줄로 되돌린다.
377
+ writeRegistryAtomically(fsApi, repoRoot, parsed.registry);
378
+ }
379
+ catch (error) {
380
+ // 자동 롤백은 하지 않는다. 어느 단계에서 던졌든 일부만 적용된 상태이므로 모든 예외를 같은 복구 안내로 접는다.
381
+ return failure('migrate-failed', catchMessage(error), RESTORE_NEXT, { moved });
382
+ }
383
+ const validation = plan.moves.map((m) => {
384
+ const result = validateBlueprint({ repoRoot, blueprintDir: m.to });
385
+ return { blueprint: m.to, ok: result.ok, failures: result.failures };
386
+ });
387
+ if (validation.some((v) => !v.ok)) {
388
+ return failure('migrate-validate-failed', 'bouncer validate reported failures for moved blueprints; the moved files are kept', `fix the failures listed in validate (or restore with: ${RESTORE_NEXT})`, { moved, validate: validation });
389
+ }
390
+ return { ok: true, dryRun: false, ...base, validate: validation };
391
+ }
392
+ module.exports = { planLegacyEpicMerge, migrateMergeLegacyEpics };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const frontmatter = require("./frontmatter");
5
+ const { parseFrontmatter } = frontmatter;
6
+ const render = require("./render");
7
+ const { renderDoc } = render;
8
+ const templates = require("./templates");
9
+ const { templateBody } = templates;
10
+ const paths = require("./paths");
11
+ const { parsePathIds } = paths;
12
+ const tasksDocs = require("./tasks-docs");
13
+ const { listTasksDocs } = tasksDocs;
14
+ const retentionMigration = require("./retention-migration");
15
+ const { resolveBlueprintDir } = retentionMigration;
16
+ const time = require("./time");
17
+ const { nowIsoKst } = time;
18
+ /**
19
+ * 레거시 blueprint(`review_scope` 없음)를 blueprint 단일 최종 리뷰 범위로 명시 전환한다.
20
+ * `index.md`에 `review_scope: blueprint`를 쓰고, scaffold와 같은 metadata의 root pending
21
+ * `review.md`를 만든다. 이미 전환됐고 root review가 있으면 아무것도 쓰지 않는 noop이다.
22
+ * task 증적·기존 task 리뷰·pointer는 읽지도 옮기지도 않는다.
23
+ *
24
+ * 쓰기 전에 모든 거절 조건(경로 이탈, 문서 부재, flat layout, root review 충돌)을 먼저
25
+ * 검증하고, 쓰기 도중 실패하면 이미 쓴 파일을 되돌려 부분 변경을 남기지 않는다.
26
+ *
27
+ * @param {object} opts
28
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
29
+ * @param {string} opts.blueprintDir - 저장소 상대 blueprint 경로
30
+ * @param {string} [opts.timestamp] - 새 review.md의 timestamp(테스트 주입용, 기본 현재 KST)
31
+ * @returns {MigrateReviewScopeResult} 성공 시 migrated/noop 요약, 거절 시 ok:false와 code
32
+ */
33
+ function migrateReviewScope({ repoRoot, blueprintDir, timestamp }) {
34
+ // 1. 경로 봉쇄: 절대·`..`·symlink 탈출은 어떤 파일도 읽기 전에 거절한다.
35
+ const resolved = resolveBlueprintDir(repoRoot, blueprintDir);
36
+ if (!resolved.ok) {
37
+ return { ok: false, blueprint: null, reason: resolved.reason, code: resolved.code };
38
+ }
39
+ const bp = resolved.blueprint;
40
+ const fail = (reason, code, next) => (next ? { ok: false, blueprint: bp, reason, code, next } : { ok: false, blueprint: bp, reason, code });
41
+ // 2. index.md를 읽는다. 읽지 못하면 어떤 blueprint인지 증명할 수 없어 거절한다.
42
+ const indexRel = `${bp}/index.md`;
43
+ const indexAbs = path.join(repoRoot, indexRel);
44
+ let original;
45
+ let data;
46
+ let body;
47
+ try {
48
+ original = fs.readFileSync(indexAbs, 'utf8');
49
+ }
50
+ catch (error) {
51
+ // ENOENT 등 읽기 실패만 문서 부재로 접는다. 그 밖의 예외는 버그이므로 그대로 던진다.
52
+ if (error.code === undefined)
53
+ throw error;
54
+ return fail(`${indexRel} is not readable`, 'INVALID_DOC');
55
+ }
56
+ try {
57
+ const parsed = parseFrontmatter(original);
58
+ data = parsed.data;
59
+ body = parsed.body;
60
+ }
61
+ catch (_error) {
62
+ // YAML 파싱 실패는 문서 충돌로 보고하고 파일은 건드리지 않는다.
63
+ return fail(`${indexRel} frontmatter is not parseable`, 'INVALID_DOC');
64
+ }
65
+ const bouncer = data && typeof data === 'object' ? data.bouncer : undefined;
66
+ if (!bouncer || typeof bouncer !== 'object' || Array.isArray(bouncer)) {
67
+ return fail(`${indexRel} has no bouncer block`, 'INVALID_DOC');
68
+ }
69
+ const meta = bouncer;
70
+ // 3. flat layout(루트 tasks-NNN.md / tasks.md)은 root review를 task 쪽으로 옮기는
71
+ // task-layout migration과 계약이 겹친다. 먼저 그쪽을 하도록 안내하고 아무것도 쓰지 않는다.
72
+ if (listTasksDocs({ repoRoot, blueprintDir: bp }).legacy) {
73
+ return fail('flat task layout: run task-layout migration first', 'FLAT_LAYOUT', 'bouncer migrate task-layout');
74
+ }
75
+ const reviewRel = `${bp}/review.md`;
76
+ const reviewAbs = path.join(repoRoot, reviewRel);
77
+ const reviewExists = fs.existsSync(reviewAbs);
78
+ const scoped = meta.review_scope === 'blueprint';
79
+ if (meta.review_scope !== undefined && !scoped) {
80
+ return fail('review_scope must be blueprint', 'INVALID_DOC');
81
+ }
82
+ // 4. 이미 전환됨 + root review 존재 → noop(원문 보존).
83
+ if (scoped && reviewExists) {
84
+ return { ok: true, blueprint: bp, status: 'noop', created: [], updated: [] };
85
+ }
86
+ // review_scope가 없는데 root review가 있으면 사람이 쓴 finding일 수 있어 덮어쓰지 않는다.
87
+ if (!scoped && reviewExists) {
88
+ return fail(`${reviewRel} already exists without review_scope`, 'REVIEW_CONFLICT');
89
+ }
90
+ // 5. 쓰기. review.md를 먼저 만들고 index를 갱신한다. 둘째 쓰기가 실패하면 첫째를 되돌린다.
91
+ const ids = parsePathIds(bp);
92
+ const epicId = typeof meta.epic_id === 'string' ? meta.epic_id : ids.epicId;
93
+ const blueprintId = typeof meta.blueprint_id === 'string' ? meta.blueprint_id : ids.blueprintId;
94
+ const reviewDoc = {
95
+ type: 'bouncer.review',
96
+ title: `${blueprintId} review`,
97
+ description: `Review for ${blueprintId}`,
98
+ resource: reviewRel,
99
+ tags: ['bouncer', 'review'],
100
+ timestamp: timestamp || nowIsoKst(),
101
+ bouncer: {
102
+ id: `REVIEW-${blueprintId}`,
103
+ epic_id: epicId,
104
+ blueprint_id: blueprintId,
105
+ status: 'pending',
106
+ review: { required: true },
107
+ },
108
+ };
109
+ const created = [];
110
+ const updated = [];
111
+ try {
112
+ fs.writeFileSync(reviewAbs, renderDoc(reviewDoc, templateBody('review.md', {
113
+ epicId, blueprintId, name: '',
114
+ })));
115
+ created.push(reviewRel);
116
+ if (!scoped) {
117
+ // scaffold와 키 순서를 맞추려고 scale 바로 뒤에 넣고, scale이 없으면 끝에 붙인다.
118
+ const next = {};
119
+ for (const [key, value] of Object.entries(meta)) {
120
+ next[key] = value;
121
+ if (key === 'scale')
122
+ next.review_scope = 'blueprint';
123
+ }
124
+ if (!('review_scope' in next))
125
+ next.review_scope = 'blueprint';
126
+ fs.writeFileSync(indexAbs, renderDoc({ ...data, bouncer: next }, body));
127
+ updated.push(indexRel);
128
+ }
129
+ }
130
+ catch (error) {
131
+ // 부분 변경을 남기지 않는다. 되돌리기 자체가 실패해도 원래 오류를 가리지 않게 삼킨다.
132
+ try {
133
+ fs.writeFileSync(indexAbs, original);
134
+ if (created.length)
135
+ fs.rmSync(reviewAbs, { force: true });
136
+ }
137
+ catch (_rollbackError) {
138
+ // 롤백 실패는 원래 쓰기 오류를 다시 던지는 것으로 충분히 드러난다.
139
+ }
140
+ throw error;
141
+ }
142
+ return { ok: true, blueprint: bp, status: 'migrated', created, updated };
143
+ }
144
+ module.exports = { migrateReviewScope };