@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,595 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const layout = require("./layout");
5
+ const { CONTEXT_ROOT, isCanonicalBlueprintDir, normalizeRepoPath } = layout;
6
+ const contextIds = require("./context-ids");
7
+ const { parseContextPath } = contextIds;
8
+ const paths = require("./paths");
9
+ const { toPosix, parsePathIds } = paths;
10
+ const frontmatter = require("./frontmatter");
11
+ const { readDoc } = frontmatter;
12
+ const tasksDocs = require("./tasks-docs");
13
+ const { listTasksDocs, expectedTaskDocIds } = tasksDocs;
14
+ const validateDocs = require("./validate-docs");
15
+ const { loadBlueprintDocs, statusOf } = validateDocs;
16
+ const finalizeCollect = require("./finalize-collect");
17
+ const finalizeExplain = require("./finalize-explain");
18
+ const { collectTransientRels, } = finalizeCollect;
19
+ const { buildTaskContext, writeExplainTaskContext, } = finalizeExplain;
20
+ /**
21
+ * 디렉터리 이름이 번호 형식(`ddd-slug`)인지 본다. 보존 정책 이관은 번호 형식 Blueprint만 대상이다 —
22
+ * slug 형식은 새 형식이라 소급할 구형 산출물이 없다. 판정은 context-ids 한 곳에 맡기고, blueprint 이름도
23
+ * epic 자리에 끼워 같은 규칙으로 읽는다.
24
+ *
25
+ * @param {string} name - epic 또는 blueprint 디렉터리 이름
26
+ * @returns {boolean} 번호 형식이면 true
27
+ */
28
+ function isNumberedDirName(name) {
29
+ return parseContextPath(`epics/${name}`)?.format === 'numeric';
30
+ }
31
+ /**
32
+ * context 트리에서 closed Blueprint 상대 경로를 경로순으로 모은다.
33
+ * filesystem 열거 순서를 결과에 실지 않기 위해 마지막에 localeCompare 한다.
34
+ *
35
+ * @param {string} repoRoot - 저장소 루트
36
+ * @returns {string[]} closed Blueprint의 posix 상대 경로
37
+ */
38
+ function listClosedBlueprintDirs(repoRoot) {
39
+ const found = [];
40
+ const epicsRoot = path.join(repoRoot, CONTEXT_ROOT, 'epics');
41
+ if (!fs.existsSync(epicsRoot))
42
+ return found;
43
+ let epicNames;
44
+ try {
45
+ epicNames = fs.readdirSync(epicsRoot);
46
+ }
47
+ catch (_e) {
48
+ // 읽기 불가 epics 루트는 빈 감사로 끝낸다 — 부분 목록을 성공처럼 돌리지 않는다.
49
+ return found;
50
+ }
51
+ for (const epicName of epicNames) {
52
+ if (!isNumberedDirName(epicName))
53
+ continue;
54
+ const blueprintsRoot = path.join(epicsRoot, epicName, 'blueprints');
55
+ if (!fs.existsSync(blueprintsRoot))
56
+ continue;
57
+ let bpNames;
58
+ try {
59
+ bpNames = fs.readdirSync(blueprintsRoot);
60
+ }
61
+ catch (_e) {
62
+ continue;
63
+ }
64
+ for (const bpName of bpNames) {
65
+ if (!isNumberedDirName(bpName))
66
+ continue;
67
+ const bpAbs = path.join(blueprintsRoot, bpName);
68
+ let st;
69
+ try {
70
+ st = fs.statSync(bpAbs);
71
+ }
72
+ catch (_e) {
73
+ continue;
74
+ }
75
+ if (!st.isDirectory())
76
+ continue;
77
+ const rel = toPosix(path.relative(repoRoot, bpAbs));
78
+ if (!isCanonicalBlueprintDir(rel))
79
+ continue;
80
+ const indexAbs = path.join(bpAbs, 'index.md');
81
+ if (!fs.existsSync(indexAbs))
82
+ continue;
83
+ try {
84
+ const { data } = readDoc(indexAbs);
85
+ if (statusOf({ data, body: '', rel: `${rel}/index.md` }) !== 'closed')
86
+ continue;
87
+ found.push(rel);
88
+ }
89
+ catch (_e) {
90
+ // 깨진 index는 closed 목록에 넣지 않는다. 감사 대상이 되려면 상태를 읽을 수 있어야 한다.
91
+ }
92
+ }
93
+ }
94
+ found.sort((a, b) => a.localeCompare(b));
95
+ return found;
96
+ }
97
+ /**
98
+ * absPath의 실경로가 repoReal 안인지 본다. symlink로 저장소 밖을 가리키면 거절한다.
99
+ * realpath를 못 열면(부재·루프·권한) 탈출 여부를 증명할 수 없어 같이 거절한다.
100
+ *
101
+ * @param {string} absPath - 검사할 절대 경로(링크 포함 가능)
102
+ * @param {string} repoReal - fs.realpathSync(repoRoot)
103
+ * @returns {{ ok: true, real: string } | { ok: false }}
104
+ */
105
+ function containedRealpath(absPath, repoReal) {
106
+ let real;
107
+ try {
108
+ real = fs.realpathSync(absPath);
109
+ }
110
+ catch (_e) {
111
+ // ENOENT·ELOOP·EACCES — 실경로를 확정하지 못하면 쓰기를 허용하지 않는다.
112
+ return { ok: false };
113
+ }
114
+ if (real === repoReal)
115
+ return { ok: true, real };
116
+ const prefix = repoReal.endsWith(path.sep) ? repoReal : `${repoReal}${path.sep}`;
117
+ if (!real.startsWith(prefix))
118
+ return { ok: false };
119
+ return { ok: true, real };
120
+ }
121
+ /**
122
+ * 적용·단일 감사에 쓸 Blueprint 경로를 저장소 상대 정본으로 닫는다.
123
+ * 절대 경로·`..` 탈출·정본 레이아웃이 아니거나, symlink 실경로가 repoRoot 밖이면 거절.
124
+ *
125
+ * @param {string} repoRoot - 저장소 루트
126
+ * @param {unknown} blueprintDir - 호출자가 준 경로
127
+ * @returns {{ ok: true, blueprint: string } | { ok: false, reason: string, code: string }}
128
+ */
129
+ function resolveBlueprintDir(repoRoot, blueprintDir) {
130
+ if (typeof blueprintDir !== 'string' || blueprintDir.trim() === '') {
131
+ return { ok: false, reason: 'blueprintDir is required', code: 'INVALID_PATH' };
132
+ }
133
+ const raw = blueprintDir.trim();
134
+ // 절대 경로는 저장소 밖을 가리켜도 상대처럼 보일 수 있어 먼저 거절한다.
135
+ if (path.isAbsolute(raw)) {
136
+ return { ok: false, reason: 'blueprintDir must be a repo-relative path', code: 'INVALID_PATH' };
137
+ }
138
+ const normalized = normalizeRepoPath(raw);
139
+ if (normalized.split('/').includes('..')) {
140
+ return { ok: false, reason: 'blueprintDir must not contain ..', code: 'INVALID_PATH' };
141
+ }
142
+ // 정본 경로여도 slug 형식은 이관 대상이 아니라 같은 INVALID_PATH로 거절한다(범위·출력 불변).
143
+ if (!isCanonicalBlueprintDir(normalized) || parseContextPath(normalized)?.format !== 'numeric') {
144
+ return {
145
+ ok: false,
146
+ reason: `blueprintDir must be under ${CONTEXT_ROOT}/epics`,
147
+ code: 'INVALID_PATH',
148
+ };
149
+ }
150
+ const abs = path.resolve(repoRoot, normalized);
151
+ const rootAbs = path.resolve(repoRoot);
152
+ if (abs !== rootAbs && !abs.startsWith(`${rootAbs}${path.sep}`)) {
153
+ return { ok: false, reason: 'blueprintDir resolves outside the repository', code: 'INVALID_PATH' };
154
+ }
155
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
156
+ return { ok: false, reason: 'blueprintDir does not exist', code: 'INVALID_PATH' };
157
+ }
158
+ // lexical 봉쇄만으로는 symlink blueprint dir이 저장소 밖으로 쓰기를 열 수 있다.
159
+ let repoReal;
160
+ try {
161
+ repoReal = fs.realpathSync(repoRoot);
162
+ }
163
+ catch (_e) {
164
+ return { ok: false, reason: 'repoRoot is not resolvable', code: 'INVALID_PATH' };
165
+ }
166
+ const contained = containedRealpath(abs, repoReal);
167
+ if (!contained.ok) {
168
+ return { ok: false, reason: 'blueprintDir resolves outside the repository', code: 'INVALID_PATH' };
169
+ }
170
+ return { ok: true, blueprint: normalized };
171
+ }
172
+ /**
173
+ * Explain이 승격에 쓸 수 있는 형태로 있는지 본다.
174
+ * 파일 부재·frontmatter 파싱 실패·부모 ID 부재는 모두 장기 증적 부재로 본다.
175
+ *
176
+ * @param {string} repoRoot - 저장소 루트
177
+ * @param {string} blueprintDir - Blueprint 상대 경로
178
+ * @returns {{ ok: true, rel: string } | { ok: false, reason: string }}
179
+ */
180
+ function inspectExplain(repoRoot, blueprintDir) {
181
+ const rel = `${toPosix(blueprintDir)}/explain.md`;
182
+ const abs = path.join(repoRoot, rel);
183
+ if (!fs.existsSync(abs)) {
184
+ return { ok: false, reason: 'explain.md is missing' };
185
+ }
186
+ try {
187
+ const { data } = readDoc(abs);
188
+ if (!data || typeof data !== 'object') {
189
+ return { ok: false, reason: 'explain.md frontmatter is unreadable' };
190
+ }
191
+ const bouncer = data.bouncer;
192
+ if (!bouncer || typeof bouncer !== 'object') {
193
+ return { ok: false, reason: 'explain.md bouncer block is missing' };
194
+ }
195
+ const epicId = bouncer.epic_id;
196
+ const blueprintId = bouncer.blueprint_id;
197
+ if (typeof epicId !== 'string' || typeof blueprintId !== 'string') {
198
+ return { ok: false, reason: 'explain.md parent ids are missing' };
199
+ }
200
+ return { ok: true, rel };
201
+ }
202
+ catch (_e) {
203
+ // 깨진 YAML만 흡수한다. 다른 I/O는 상위에서 실패로 보고한다.
204
+ return { ok: false, reason: 'explain.md could not be parsed' };
205
+ }
206
+ }
207
+ /**
208
+ * task 묶음이 안전한 승격 입력인지 검사한다.
209
+ * 파싱 실패·비정본 ID·부모 ID 불일치·중복 번호·invalidDirs를 거절한다.
210
+ *
211
+ * @param {string} repoRoot - 저장소 루트
212
+ * @param {string} blueprintDir - Blueprint 상대 경로
213
+ * @returns {{ ok: true, taskUnits: NonNullable<ReturnType<typeof loadBlueprintDocs>['docs']['taskUnits']> }
214
+ * | { ok: false, reason: string }}
215
+ */
216
+ function inspectTasks(repoRoot, blueprintDir) {
217
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
218
+ if (listing.invalidDirs.length > 0) {
219
+ return {
220
+ ok: false,
221
+ reason: `invalid task directories: ${listing.invalidDirs.join(', ')}`,
222
+ };
223
+ }
224
+ const { docs, parseErrors } = loadBlueprintDocs({ repoRoot, blueprintDir });
225
+ const taskParseErrors = parseErrors.filter((entry) => /\/tasks\//.test(entry.file));
226
+ if (taskParseErrors.length > 0) {
227
+ return {
228
+ ok: false,
229
+ reason: `task document parse failed: ${taskParseErrors.map((e) => e.file).join(', ')}`,
230
+ };
231
+ }
232
+ const taskUnits = Array.isArray(docs.taskUnits) ? docs.taskUnits : [];
233
+ const pathIds = parsePathIds(blueprintDir);
234
+ const seenNumbers = new Set();
235
+ for (const unit of taskUnits) {
236
+ if (typeof unit.number === 'number') {
237
+ if (seenNumbers.has(unit.number)) {
238
+ return { ok: false, reason: `duplicate task number ${unit.number}` };
239
+ }
240
+ seenNumbers.add(unit.number);
241
+ }
242
+ const tasksLeaf = unit.tasks;
243
+ if (!tasksLeaf || !tasksLeaf.data || typeof tasksLeaf.data !== 'object') {
244
+ return {
245
+ ok: false,
246
+ reason: `tasks.md missing or unreadable for unit ${String(unit.number)}`,
247
+ };
248
+ }
249
+ const bouncer = tasksLeaf.data.bouncer || {};
250
+ const digits = typeof unit.number === 'number'
251
+ ? String(unit.number).padStart(3, '0')
252
+ : null;
253
+ if (!digits) {
254
+ return { ok: false, reason: 'task unit number is missing' };
255
+ }
256
+ const expected = expectedTaskDocIds(digits);
257
+ if (bouncer.id !== expected.tasks) {
258
+ return {
259
+ ok: false,
260
+ reason: `task id ${String(bouncer.id)} != expected ${expected.tasks}`,
261
+ };
262
+ }
263
+ if (bouncer.epic_id !== pathIds.epicId) {
264
+ return {
265
+ ok: false,
266
+ reason: `task epic_id ${String(bouncer.epic_id)} != path ${String(pathIds.epicId)}`,
267
+ };
268
+ }
269
+ if (bouncer.blueprint_id !== pathIds.blueprintId) {
270
+ return {
271
+ ok: false,
272
+ reason: `task blueprint_id ${String(bouncer.blueprint_id)} != path ${String(pathIds.blueprintId)}`,
273
+ };
274
+ }
275
+ }
276
+ return { ok: true, taskUnits };
277
+ }
278
+ /**
279
+ * closed Blueprint 하나를 다섯 상태 중 하나로 분류한다.
280
+ * already-compacted보다 blocked-missing-explain을 우선해 장기 증적 부재를 숨기지 않는다.
281
+ *
282
+ * @param {string} repoRoot - 저장소 루트
283
+ * @param {string} blueprintDir - Blueprint 상대 경로
284
+ * @returns {RetentionRow} 분류 결과
285
+ */
286
+ function classifyClosedBlueprint(repoRoot, blueprintDir) {
287
+ const deleteRels = collectTransientRels({ repoRoot, blueprintDir });
288
+ const explain = inspectExplain(repoRoot, blueprintDir);
289
+ // transient가 없어도 Explain 부재면 compacted로 끝내지 않는다.
290
+ if (!explain.ok) {
291
+ return {
292
+ blueprint: blueprintDir,
293
+ status: 'blocked-missing-explain',
294
+ reason: explain.reason,
295
+ promote: [],
296
+ delete: [],
297
+ };
298
+ }
299
+ if (deleteRels.length === 0) {
300
+ return {
301
+ blueprint: blueprintDir,
302
+ status: 'already-compacted',
303
+ reason: 'no transient documents remain',
304
+ promote: [],
305
+ delete: [],
306
+ };
307
+ }
308
+ const tasks = inspectTasks(repoRoot, blueprintDir);
309
+ if (!tasks.ok) {
310
+ return {
311
+ blueprint: blueprintDir,
312
+ status: 'blocked-invalid-task',
313
+ reason: tasks.reason,
314
+ promote: [],
315
+ delete: [],
316
+ };
317
+ }
318
+ // 승격 본문이 비면 삭제만 하고 Explain을 비우는 적용을 막는다.
319
+ const taskContext = buildTaskContext(tasks.taskUnits);
320
+ if (!taskContext.trim()) {
321
+ return {
322
+ blueprint: blueprintDir,
323
+ status: 'blocked-insufficient-intent',
324
+ reason: 'no durable task sections to promote',
325
+ promote: [],
326
+ delete: [],
327
+ };
328
+ }
329
+ return {
330
+ blueprint: blueprintDir,
331
+ status: 'eligible',
332
+ reason: 'closed blueprint has promotable intent and transient documents',
333
+ promote: [explain.rel],
334
+ delete: deleteRels.slice().sort((a, b) => a.localeCompare(b)),
335
+ };
336
+ }
337
+ /**
338
+ * closed Blueprint retention을 쓰기 없이 감사한다.
339
+ * blueprintDir가 있으면 그 한 경로만, 없으면 정본 context 트리의 모든 closed를 경로순으로 분류한다.
340
+ *
341
+ * @param {object} opts
342
+ * @param {string} opts.repoRoot - 저장소 루트
343
+ * @param {string} [opts.blueprintDir] - 선택적 단일 Blueprint 상대 경로
344
+ * @returns {AuditResult} 분류 목록. 경로 오류면 ok:false
345
+ */
346
+ function auditRetention({ repoRoot, blueprintDir }) {
347
+ if (blueprintDir !== undefined) {
348
+ const resolved = resolveBlueprintDir(repoRoot, blueprintDir);
349
+ if (!resolved.ok) {
350
+ return { ok: false, results: [], reason: resolved.reason, code: resolved.code };
351
+ }
352
+ const indexAbs = path.join(repoRoot, resolved.blueprint, 'index.md');
353
+ if (!fs.existsSync(indexAbs)) {
354
+ return {
355
+ ok: false,
356
+ results: [],
357
+ reason: 'blueprint index.md is missing',
358
+ code: 'INVALID_PATH',
359
+ };
360
+ }
361
+ let status;
362
+ try {
363
+ status = statusOf({
364
+ data: readDoc(indexAbs).data,
365
+ body: '',
366
+ rel: `${resolved.blueprint}/index.md`,
367
+ });
368
+ }
369
+ catch (_e) {
370
+ return {
371
+ ok: false,
372
+ results: [],
373
+ reason: 'blueprint index.md could not be parsed',
374
+ code: 'INVALID_PATH',
375
+ };
376
+ }
377
+ if (status !== 'closed') {
378
+ return {
379
+ ok: false,
380
+ results: [],
381
+ reason: `blueprint status is ${String(status)}, expected closed`,
382
+ code: 'NOT_CLOSED',
383
+ };
384
+ }
385
+ return { ok: true, results: [classifyClosedBlueprint(repoRoot, resolved.blueprint)] };
386
+ }
387
+ const dirs = listClosedBlueprintDirs(repoRoot);
388
+ return {
389
+ ok: true,
390
+ results: dirs.map((dir) => classifyClosedBlueprint(repoRoot, dir)),
391
+ };
392
+ }
393
+ /**
394
+ * 적용 전 바이트를 복구한다. 삭제됐던 파일은 다시 쓰고, 없었던 Explain은 지우지 않는다
395
+ * (적용은 기존 Explain만 대상으로 한다).
396
+ *
397
+ * @param {FileSnapshot[]} snapshots - 절대 경로와 원본 바이트
398
+ * @param {(abs: string, content: string | Buffer) => void} writeFileSync - 쓰기 구현
399
+ */
400
+ function restoreSnapshots(snapshots, writeFileSync) {
401
+ for (const snap of snapshots) {
402
+ if (snap.content === null)
403
+ continue;
404
+ fs.mkdirSync(path.dirname(snap.abs), { recursive: true });
405
+ writeFileSync(snap.abs, snap.content);
406
+ }
407
+ }
408
+ /**
409
+ * 복구를 최선을 다해 시도한다. 복구 자체가 던져도 호출자가 ok:false를 반환할 수 있게
410
+ * 예외를 삼키고 사유만 돌려준다.
411
+ *
412
+ * @param {FileSnapshot[]} snapshots - 적용 전 스냅샷
413
+ * @param {(abs: string, content: string | Buffer) => void} writeFileSync - 쓰기 구현
414
+ * @returns {string | null} 복구 실패 사유. 성공이면 null
415
+ */
416
+ function tryRestoreSnapshots(snapshots, writeFileSync) {
417
+ try {
418
+ restoreSnapshots(snapshots, writeFileSync);
419
+ return null;
420
+ }
421
+ catch (restoreError) {
422
+ // 부분 적용 뒤 복구 실패를 다시 throw하면 호출자가 ok:false를 못 받고
423
+ // 디스크가 half-applied인 채 uncaught로 끝난다. 사유만 붙인다.
424
+ return restoreError.message || 'restoreSnapshots failed';
425
+ }
426
+ }
427
+ /**
428
+ * 감사에서 eligible인 단일 closed Blueprint에 Explain 승격을 먼저 쓰고 transient를 삭제한다.
429
+ * task_commits는 읽거나 쓰지 않는다. 어느 단계든 실패하면 적용 전 바이트로 되돌린다.
430
+ *
431
+ * @param {object} opts
432
+ * @param {string} opts.repoRoot - 저장소 루트
433
+ * @param {string} opts.blueprintDir - 저장소 상대 Blueprint 경로
434
+ * @param {MigrateDeps} [opts.deps] - 테스트용 fs 주입
435
+ * @returns {MigrateResult} 성공 시 적용 요약, 실패 시 ok:false와 사유
436
+ */
437
+ function migrateRetention({ repoRoot, blueprintDir, deps }) {
438
+ const unlinkSync = deps && deps.unlinkSync ? deps.unlinkSync : fs.unlinkSync.bind(fs);
439
+ const writeFileSync = deps && deps.writeFileSync
440
+ ? deps.writeFileSync
441
+ : fs.writeFileSync.bind(fs);
442
+ const readFileSync = deps && deps.readFileSync
443
+ ? deps.readFileSync
444
+ : ((abs) => fs.readFileSync(abs));
445
+ const resolved = resolveBlueprintDir(repoRoot, blueprintDir);
446
+ if (!resolved.ok) {
447
+ return { ok: false, reason: resolved.reason, code: resolved.code };
448
+ }
449
+ const audited = auditRetention({ repoRoot, blueprintDir: resolved.blueprint });
450
+ if (!audited.ok || audited.results.length !== 1) {
451
+ return {
452
+ ok: false,
453
+ blueprint: resolved.blueprint,
454
+ reason: audited.reason || 'audit failed',
455
+ code: audited.code || 'AUDIT_FAILED',
456
+ };
457
+ }
458
+ const row = audited.results[0];
459
+ if (row.status !== 'eligible') {
460
+ return {
461
+ ok: false,
462
+ blueprint: row.blueprint,
463
+ status: row.status,
464
+ reason: row.reason,
465
+ promote: row.promote,
466
+ delete: row.delete,
467
+ code: 'NOT_ELIGIBLE',
468
+ };
469
+ }
470
+ let repoReal;
471
+ try {
472
+ repoReal = fs.realpathSync(repoRoot);
473
+ }
474
+ catch (_e) {
475
+ return {
476
+ ok: false,
477
+ blueprint: resolved.blueprint,
478
+ reason: 'repoRoot is not resolvable',
479
+ code: 'INVALID_PATH',
480
+ };
481
+ }
482
+ const explainRel = row.promote[0];
483
+ const explainAbs = path.join(repoRoot, explainRel);
484
+ // explain.md가 저장소 밖을 가리키는 symlink면 writeExplainTaskContext가
485
+ // 링크를 따라 외부 파일에 승격 본문을 쓴다. 삭제 후보도 같은 봉쇄를 적용한다.
486
+ if (!containedRealpath(explainAbs, repoReal).ok) {
487
+ return {
488
+ ok: false,
489
+ blueprint: resolved.blueprint,
490
+ reason: 'explain.md resolves outside the repository',
491
+ code: 'INVALID_PATH',
492
+ };
493
+ }
494
+ const deleteAbs = row.delete.map((rel) => ({
495
+ rel,
496
+ abs: path.join(repoRoot, rel),
497
+ }));
498
+ for (const entry of deleteAbs) {
499
+ if (!fs.existsSync(entry.abs))
500
+ continue;
501
+ if (!containedRealpath(entry.abs, repoReal).ok) {
502
+ return {
503
+ ok: false,
504
+ blueprint: resolved.blueprint,
505
+ reason: `${entry.rel} resolves outside the repository`,
506
+ code: 'INVALID_PATH',
507
+ };
508
+ }
509
+ }
510
+ // Explain과 삭제 후보만 스냅샷한다. index.md는 적용이 건드리지 않는다.
511
+ const snapshots = [
512
+ { abs: explainAbs, content: readFileSync(explainAbs) },
513
+ ...deleteAbs.map(({ abs }) => ({
514
+ abs,
515
+ content: fs.existsSync(abs) ? readFileSync(abs) : null,
516
+ })),
517
+ ];
518
+ const { docs } = loadBlueprintDocs({ repoRoot, blueprintDir: resolved.blueprint });
519
+ const taskContext = buildTaskContext(docs.taskUnits);
520
+ if (!taskContext.trim()) {
521
+ return {
522
+ ok: false,
523
+ blueprint: resolved.blueprint,
524
+ status: 'blocked-insufficient-intent',
525
+ reason: 'no durable task sections to promote',
526
+ code: 'NOT_ELIGIBLE',
527
+ };
528
+ }
529
+ try {
530
+ const wrote = writeExplainTaskContext({
531
+ repoRoot,
532
+ blueprintDir: resolved.blueprint,
533
+ taskContext,
534
+ });
535
+ if (!wrote) {
536
+ const restoreFail = tryRestoreSnapshots(snapshots, writeFileSync);
537
+ return {
538
+ ok: false,
539
+ blueprint: resolved.blueprint,
540
+ reason: restoreFail
541
+ ? `failed to write Explain task context; restore also failed: ${restoreFail}`
542
+ : 'failed to write Explain task context',
543
+ code: 'PROMOTE_FAILED',
544
+ };
545
+ }
546
+ // 앞 32자만 보면 승격이 잘리거나 접두만 겹쳐도 통과한다. trim한 taskContext
547
+ // 전체가 Explain에 들어갔는지 확인해 삭제 전에 승격을 잠근다.
548
+ const afterPromote = readFileSync(explainAbs).toString('utf8');
549
+ const expectedContext = taskContext.trim();
550
+ if (!afterPromote.includes('## Tasks') || !afterPromote.includes(expectedContext)) {
551
+ const restoreFail = tryRestoreSnapshots(snapshots, writeFileSync);
552
+ return {
553
+ ok: false,
554
+ blueprint: resolved.blueprint,
555
+ reason: restoreFail
556
+ ? `Explain promote could not be re-read; restore also failed: ${restoreFail}`
557
+ : 'Explain promote could not be re-read',
558
+ code: 'PROMOTE_FAILED',
559
+ };
560
+ }
561
+ for (const entry of deleteAbs) {
562
+ if (!fs.existsSync(entry.abs))
563
+ continue;
564
+ unlinkSync(entry.abs);
565
+ }
566
+ }
567
+ catch (error) {
568
+ const applyReason = error.message || 'migrateRetention failed';
569
+ const restoreFail = tryRestoreSnapshots(snapshots, writeFileSync);
570
+ return {
571
+ ok: false,
572
+ blueprint: resolved.blueprint,
573
+ reason: restoreFail
574
+ ? `${applyReason}; restore also failed: ${restoreFail}`
575
+ : applyReason,
576
+ code: 'APPLY_FAILED',
577
+ };
578
+ }
579
+ return {
580
+ ok: true,
581
+ blueprint: resolved.blueprint,
582
+ status: 'eligible',
583
+ reason: row.reason,
584
+ promote: row.promote,
585
+ delete: row.delete,
586
+ };
587
+ }
588
+ module.exports = {
589
+ auditRetention,
590
+ migrateRetention,
591
+ // 테스트·진단용. 공개 CLI는 audit/migrate만 노출한다.
592
+ listClosedBlueprintDirs,
593
+ classifyClosedBlueprint,
594
+ resolveBlueprintDir,
595
+ };