@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
package/dist/layout.js ADDED
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+ const paths = require("./paths");
3
+ const { toPosix } = paths;
4
+ const contextIds = require("./context-ids");
5
+ const { parseContextPath } = contextIds;
6
+ const frontmatter = require("./frontmatter");
7
+ const { readDoc } = frontmatter;
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const CONTEXT_ROOT = '.bouncer/context';
11
+ // Codex는 플러그인 `agents/*.md`를 named role로 읽지 않는다. 프로젝트
12
+ // `.codex/agents/*.toml`만 스캔하므로 init이 여기에 심는다.
13
+ const CODEX_AGENTS_DIR = '.codex/agents';
14
+ // context 디렉터리의 위치(모양)만 정규식으로 본다. 세그먼트가 번호 형식(`ddd-slug`)인지 slug 형식인지는
15
+ // 숫자 정규식을 여기 새로 두지 않고 context-ids.parseContextPath 한 곳에 맡긴다 — 호출부마다 정규식이
16
+ // 있으면 두 형식의 경계가 갈라진다.
17
+ const EPIC_DIR_SHAPE = /^\.bouncer\/context\/epics\/[^/]+$/;
18
+ const BLUEPRINT_DIR_SHAPE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+$/;
19
+ function normalizeRepoPath(value) {
20
+ return toPosix(value);
21
+ }
22
+ /**
23
+ * 정본 epic 디렉터리인지 본다. 번호(`ddd-slug`)와 slug 두 형식 모두 정본이다.
24
+ *
25
+ * @param {unknown} value - 저장소 상대 경로(백슬래시 허용)
26
+ * @returns {boolean} 위치가 `.bouncer/context/epics/<세그먼트>`이고 세그먼트가 해석되면 true
27
+ */
28
+ function isCanonicalEpicDir(value) {
29
+ const norm = normalizeRepoPath(value);
30
+ return EPIC_DIR_SHAPE.test(norm) && parseContextPath(norm) !== null;
31
+ }
32
+ /**
33
+ * 정본 blueprint 디렉터리인지 본다. epic과 blueprint 세그먼트가 같은 형식이어야 한다 —
34
+ * 형식이 섞이면 parseContextPath가 null을 주므로 정본이 아니다.
35
+ *
36
+ * @param {unknown} value - 저장소 상대 경로(백슬래시 허용)
37
+ * @returns {boolean} 정본 blueprint 경로면 true
38
+ */
39
+ function isCanonicalBlueprintDir(value) {
40
+ const norm = normalizeRepoPath(value);
41
+ return BLUEPRINT_DIR_SHAPE.test(norm) && parseContextPath(norm) !== null;
42
+ }
43
+ /**
44
+ * blueprint 경로가 정본이 아닌 이유가 slug 체계 위반인지 가린다. validate가 S10(일반 경로 오류)
45
+ * 대신 S37로 구체적인 사유를 내려 한다. 위치 모양이 맞지 않으면 null이다.
46
+ *
47
+ * - `format-mixed`: epic은 slug인데 blueprint는 번호 접두이거나 그 반대
48
+ * - `slug-invalid`: epic은 slug인데 blueprint 세그먼트가 slug 규칙(길이·문자)을 어김
49
+ * epic 세그먼트 자체가 해석되지 않는 경로(구형 `EPIC-` 접두 등)는 null로 두어 S10을 유지한다.
50
+ *
51
+ * @param {unknown} value - 저장소 상대 경로(백슬래시 허용)
52
+ * @returns {'format-mixed' | 'slug-invalid' | null} 위반 종류. 해당 없으면 null
53
+ */
54
+ function blueprintPathProblem(value) {
55
+ const norm = normalizeRepoPath(value);
56
+ if (!BLUEPRINT_DIR_SHAPE.test(norm) || parseContextPath(norm) !== null)
57
+ return null;
58
+ const marker = '/blueprints/';
59
+ const cut = norm.lastIndexOf(marker);
60
+ const epic = parseContextPath(norm.slice(0, cut));
61
+ if (epic === null)
62
+ return null;
63
+ // blueprint 세그먼트를 epic 자리에 끼워 읽으면 같은 규칙으로 형식을 얻는다(규칙 사본을 두지 않기 위함).
64
+ const blueprint = parseContextPath(`${CONTEXT_ROOT}/epics/${norm.slice(cut + marker.length)}`);
65
+ if (blueprint !== null)
66
+ return blueprint.format === epic.format ? null : 'format-mixed';
67
+ return epic.format === 'slug' ? 'slug-invalid' : null;
68
+ }
69
+ /**
70
+ * blueprint `order`가 쓸 수 있는 값(양의 정수)인지 본다. scaffold·inspect·S37이 같은 판정을 쓰게 한 곳에 둔다.
71
+ *
72
+ * @param {unknown} value - frontmatter `bouncer.order` 값
73
+ * @returns {boolean} 양의 정수면 true
74
+ */
75
+ function isValidBlueprintOrder(value) {
76
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
77
+ }
78
+ /**
79
+ * epic 아래 blueprint 디렉터리마다 index.md의 `order`·`id`를 읽어 돌려준다. 읽기 전용이다.
80
+ * 이름순으로 돌려줘 호출자의 판정(중복 검사 등)이 파일 시스템 열거 순서에 기대지 않게 한다.
81
+ * `blueprints/`가 없으면(ENOENT) 빈 목록이다. 개별 index.md를 읽거나 파싱하지 못하면 그 항목만
82
+ * order/id를 undefined로 둔다 — 문서 자체의 오류는 구조 검사(S1·S2·S37)가 따로 보고하므로,
83
+ * 형제 하나의 손상이 다른 blueprint의 order 판정을 막으면 안 된다.
84
+ *
85
+ * @param {string} repoRoot - 저장소 루트 절대 경로
86
+ * @param {string} epicDirRel - 저장소 상대 epic 디렉터리
87
+ * @returns {BlueprintOrderEntry[]} 이름순 blueprint 목록
88
+ * @throws {Error} `blueprints/`를 ENOENT 외 이유로 읽지 못할 때
89
+ */
90
+ function listBlueprintOrders(repoRoot, epicDirRel) {
91
+ const epicRel = normalizeRepoPath(epicDirRel);
92
+ let names;
93
+ try {
94
+ names = fs.readdirSync(path.join(repoRoot, epicRel, 'blueprints'), { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory())
96
+ .map((entry) => entry.name);
97
+ }
98
+ catch (error) {
99
+ if (error.code === 'ENOENT')
100
+ return [];
101
+ throw error;
102
+ }
103
+ names.sort();
104
+ return names.map((name) => {
105
+ const rel = `${epicRel}/blueprints/${name}`;
106
+ let order;
107
+ let id;
108
+ try {
109
+ const data = readDoc(path.join(repoRoot, rel, 'index.md')).data;
110
+ const bouncer = data && typeof data === 'object' ? data.bouncer : undefined;
111
+ if (bouncer && typeof bouncer === 'object') {
112
+ order = bouncer.order;
113
+ id = bouncer.id;
114
+ }
115
+ }
116
+ catch (_error) {
117
+ // 부재·YAML 오류는 "읽을 수 있는 order 없음"으로만 접는다(위 docstring 사유).
118
+ }
119
+ return { name, rel, order, id };
120
+ });
121
+ }
122
+ module.exports = {
123
+ CONTEXT_ROOT,
124
+ CODEX_AGENTS_DIR,
125
+ normalizeRepoPath,
126
+ isCanonicalEpicDir,
127
+ isCanonicalBlueprintDir,
128
+ blueprintPathProblem,
129
+ isValidBlueprintOrder,
130
+ listBlueprintOrders,
131
+ };
package/dist/lease.js ADDED
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+ /**
3
+ * task에 새 active lease를 발급한다. ledger.leaseSeq를 1 올리고 generation은
4
+ * 직전 lease(없으면 0)에서 1 증가시킨다 — revoke 후 재배정이 같은 id를 쓰지
5
+ * 않게 하고, scope 충돌 시 seq로 후발을 고르기 위함이다.
6
+ *
7
+ * @param {LeaseLedger} ledger - leaseSeq를 올리는 원장
8
+ * @param {LeaseTask} task - lease를 받을 task
9
+ * @param {() => string} makeId - lease id 생성기(테스트 seam은 고정값)
10
+ * @returns {Lease} 발급된 active lease
11
+ */
12
+ function issueLease(ledger, task, makeId) {
13
+ const previousGeneration = task.lease && Number.isInteger(task.lease.generation)
14
+ ? task.lease.generation
15
+ : 0;
16
+ const nextSeq = (Number.isInteger(ledger.leaseSeq) ? ledger.leaseSeq : 0) + 1;
17
+ ledger.leaseSeq = nextSeq;
18
+ const lease = {
19
+ id: makeId(),
20
+ generation: previousGeneration + 1,
21
+ seq: nextSeq,
22
+ status: 'active',
23
+ };
24
+ task.lease = lease;
25
+ return lease;
26
+ }
27
+ /**
28
+ * 배정을 무효화한다. lease가 있으면 revoked로 표시하고, 없으면 legacy로 남겨
29
+ * 새 lease를 만들지 않는다. dispatch·sha를 비우고 status를 pending으로 돌린다.
30
+ *
31
+ * @param {LeaseLedger} ledger - decision을 append할 원장
32
+ * @param {LeaseTask} task - revoke 대상
33
+ * @param {{ reason: string, previousHead: string | null }} opts - 감사 필드
34
+ * @returns {RevokeDecision} 원장·task decisions에 붙인 revoke 기록
35
+ */
36
+ function revokeLease(ledger, task, opts) {
37
+ const previousStatus = task.status || 'pending';
38
+ const hadLease = task.lease !== undefined && task.lease !== null;
39
+ // legacy(lease 없음)는 필드를 새로 만들지 않는다 — 재개 경로가 lease 검사
40
+ // 없이 attempt·brief hash만 보게 하려는 보존 계약이다.
41
+ const leaseId = hadLease ? task.lease.id : null;
42
+ const generation = hadLease ? task.lease.generation : null;
43
+ if (hadLease) {
44
+ task.lease.status = 'revoked';
45
+ }
46
+ delete task.dispatch;
47
+ delete task.sha;
48
+ task.status = 'pending';
49
+ const decision = {
50
+ task: task.id,
51
+ kind: 'revoke',
52
+ lease_id: leaseId,
53
+ generation,
54
+ reason: opts.reason,
55
+ previous_status: previousStatus,
56
+ previous_head: opts.previousHead,
57
+ };
58
+ task.decisions = [...(task.decisions || []), decision];
59
+ ledger.decisions = [...(Array.isArray(ledger.decisions) ? ledger.decisions : []), decision];
60
+ return decision;
61
+ }
62
+ /**
63
+ * 호출자가 보낸 lease_id·generation을 활성 lease와 맞는지 본다.
64
+ * 둘 다 없으면 검사를 건너뛴다(레거시·플래그 생략 경로). 하나만 있으면
65
+ * lease-required. 값이 다르거나 lease가 revoked면 stale-lease.
66
+ *
67
+ * @param {LeaseTask} task - 원장 task(lease 없으면 항상 ok)
68
+ * @param {LeaseReceived} received - 호출자 플래그
69
+ * @returns {LeaseCheckOk | LeaseCheckFail} 통과 또는 거절 reason
70
+ */
71
+ function checkLease(task, received) {
72
+ const hasId = received.lease_id !== undefined;
73
+ const hasGeneration = received.generation !== undefined;
74
+ // 1. 둘 다 생략 — lease 검사 없이 기존 attempt·상태 검사로 넘긴다.
75
+ if (!hasId && !hasGeneration)
76
+ return { ok: true };
77
+ // 2. 하나만 주면 짝이 안 맞는다. 부분 플래그는 활성 lease와 비교하지 않는다.
78
+ if (hasId !== hasGeneration) {
79
+ return {
80
+ ok: false,
81
+ reason: 'lease-required',
82
+ expected: task.lease
83
+ ? { lease_id: task.lease.id, generation: task.lease.generation }
84
+ : null,
85
+ received: {
86
+ ...(hasId ? { lease_id: received.lease_id } : {}),
87
+ ...(hasGeneration ? { generation: received.generation } : {}),
88
+ },
89
+ };
90
+ }
91
+ // 3. lease 필드가 없는 legacy task는 플래그가 있어도 검사하지 않는다.
92
+ if (!task.lease)
93
+ return { ok: true };
94
+ const expected = { lease_id: task.lease.id, generation: task.lease.generation };
95
+ const got = {
96
+ lease_id: received.lease_id,
97
+ generation: received.generation,
98
+ };
99
+ // revoked이거나 id/generation 불일치면 상태 전이 없이 stale로만 거절한다.
100
+ if (task.lease.status === 'revoked'
101
+ || got.lease_id !== expected.lease_id
102
+ || got.generation !== expected.generation) {
103
+ return { ok: false, reason: 'stale-lease', expected, received: got };
104
+ }
105
+ return { ok: true };
106
+ }
107
+ /**
108
+ * 원장·task에 실린 lease / leaseSeq shape가 유효한지 본다.
109
+ * 손상된 lease가 재개·fence 기준으로 쓰이지 않게 validateCoordinatorLedger가 호출한다.
110
+ *
111
+ * @param {unknown} lease - task.lease 후보
112
+ * @returns {boolean} Lease shape이면 true
113
+ */
114
+ function isValidLease(lease) {
115
+ if (!lease || typeof lease !== 'object' || Array.isArray(lease))
116
+ return false;
117
+ const value = lease;
118
+ if (typeof value.id !== 'string' || value.id === '')
119
+ return false;
120
+ if (!Number.isInteger(value.generation) || value.generation < 1)
121
+ return false;
122
+ if (!Number.isInteger(value.seq) || value.seq < 1)
123
+ return false;
124
+ if (value.status !== 'active' && value.status !== 'revoked')
125
+ return false;
126
+ return true;
127
+ }
128
+ /**
129
+ * ledger.leaseSeq가 0 이상 정수인지 본다. 필드 부재는 legacy로 허용한다.
130
+ *
131
+ * @param {unknown} leaseSeq - 원장 leaseSeq 후보
132
+ * @returns {boolean} 유효하거나 부재면 true
133
+ */
134
+ function isValidLeaseSeq(leaseSeq) {
135
+ if (leaseSeq === undefined)
136
+ return true;
137
+ return Number.isInteger(leaseSeq) && leaseSeq >= 0;
138
+ }
139
+ module.exports = {
140
+ issueLease,
141
+ revokeLease,
142
+ checkLease,
143
+ isValidLease,
144
+ isValidLeaseSeq,
145
+ };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const templates = require("./templates");
5
+ const { findLegacyScaffoldComments } = templates;
6
+ const paths = require("./paths");
7
+ const { epicDirOf, toPosix } = paths;
8
+ /**
9
+ * blueprint 디렉터리 아래 .md를 재귀로 모은다.
10
+ * 디렉터리가 없으면 빈 목록을 돌려 판정을 만들지 않는다 — 그 부재는 G2·G3이 판정한다.
11
+ *
12
+ * @param {string} absDir - 절대 경로 디렉터리
13
+ * @param {string} repoRoot - 저장소 루트 절대 경로
14
+ * @returns {string[]} 저장소 상대 POSIX .md 경로
15
+ */
16
+ function collectBlueprintMarkdown(absDir, repoRoot) {
17
+ if (!fs.existsSync(absDir))
18
+ return [];
19
+ const found = [];
20
+ const stack = [absDir];
21
+ while (stack.length > 0) {
22
+ const current = stack.pop();
23
+ // 권한 오류 등은 삼키지 않는다. plan gate·bootstrap이 잡지 않아 CLI가 기존 예외 경로로 끝난다.
24
+ const entries = fs.readdirSync(current, { withFileTypes: true });
25
+ for (const entry of entries) {
26
+ const abs = path.join(current, entry.name);
27
+ if (entry.isDirectory()) {
28
+ stack.push(abs);
29
+ }
30
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
31
+ found.push(toPosix(path.relative(repoRoot, abs)));
32
+ }
33
+ }
34
+ }
35
+ return found;
36
+ }
37
+ /**
38
+ * epic index와 blueprint 트리의 .md에서 옛 스캐폴드 안내 주석이 남은 경로를 모은다.
39
+ * 판정은 findLegacyScaffoldComments에 맡기고, 저자 주석만 있는 파일은 빠진다.
40
+ * 존재하지 않는 파일은 조용히 제외하고, 존재하지만 읽기에 실패한 파일은 예외를 그대로 올린다.
41
+ *
42
+ * @param {{ repoRoot: string, blueprintDir: string }} opts - 스캔 범위
43
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
44
+ * @param {string} opts.blueprintDir - blueprint 디렉터리의 저장소 상대 POSIX 경로
45
+ * @returns {string[]} 옛 주석이 있는 파일의 정렬된 저장소 상대 POSIX 경로
46
+ */
47
+ function scanLegacyScaffoldComments({ repoRoot, blueprintDir }) {
48
+ const leftover = [];
49
+ const epicIndexRel = `${toPosix(epicDirOf(blueprintDir))}/index.md`;
50
+ const candidates = [
51
+ epicIndexRel,
52
+ ...collectBlueprintMarkdown(path.join(repoRoot, blueprintDir), repoRoot),
53
+ ];
54
+ // 같은 파일이 두 번 나오지 않게 한다. epic index는 blueprint 트리 밖이지만
55
+ // 잘못된 blueprintDir가 epic 자신을 가리키면 겹칠 수 있다.
56
+ const seen = new Set();
57
+ for (const rel of candidates) {
58
+ if (seen.has(rel))
59
+ continue;
60
+ seen.add(rel);
61
+ const abs = path.join(repoRoot, rel);
62
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
63
+ continue;
64
+ const body = fs.readFileSync(abs, 'utf8');
65
+ if (findLegacyScaffoldComments(body).length > 0)
66
+ leftover.push(rel);
67
+ }
68
+ leftover.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
69
+ return leftover;
70
+ }
71
+ module.exports = { scanLegacyScaffoldComments };
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+ const nodeFs = require('node:fs');
3
+ const path = require('node:path');
4
+ const guards = require("./guards");
5
+ const { catchMessage } = guards;
6
+ const layout = require("./layout");
7
+ const { CONTEXT_ROOT } = layout;
8
+ const contextIds = require("./context-ids");
9
+ const { parseContextPath, validateSlug } = contextIds;
10
+ const epicRegistry = require("./epic-registry");
11
+ const { readEpicRegistry, parseEpicRegistry, REGISTRY_REL } = epicRegistry;
12
+ const rewriteModule = require("./context-migration-rewrite");
13
+ const { defaultRunGit, defaultListInProgress, computeRewrites, writeRegistryAtomically, } = rewriteModule;
14
+ const validate = require("./validate");
15
+ const { validateBlueprint } = validate;
16
+ const RESTORE_NEXT = 'git restore --staged --worktree .bouncer && git clean -fd .bouncer/context';
17
+ function failure(code, message, next, extra = {}) {
18
+ return { ok: false, code, message, next, ...extra };
19
+ }
20
+ /**
21
+ * slug 이관 계획을 문서 재작성 공용 모듈의 이동 맥락 목록으로 바꾼다. epic마다 직속 문서용 맥락 하나와
22
+ * blueprint마다 맥락 하나를 내며, `order`는 번호에서 온 값을 frontmatter에 없을 때만 채운다(`fill-missing`).
23
+ *
24
+ * @param {MigrationPlan} plan - 충돌 없는 이동 계획
25
+ * @returns 공용 모듈 `computeRewrites`의 `plan.contexts`
26
+ */
27
+ function rewriteContextsOf(plan) {
28
+ return plan.epics.flatMap((epic) => {
29
+ const base = {
30
+ fromEpicDir: epic.from, toEpicDir: epic.to, epicSlug: epic.slug, orderMode: 'fill-missing',
31
+ };
32
+ return [
33
+ { ...base, blueprint: null },
34
+ ...epic.blueprints.map((bp) => ({
35
+ ...base, blueprint: { from: bp.from, to: bp.to, slug: bp.slug, order: bp.order },
36
+ })),
37
+ ];
38
+ });
39
+ }
40
+ /** 등록부 항목을 v2 모양(`id` 제거, 옛 번호는 `legacy_id`)으로 복사한다. 키 순서를 고정한다. */
41
+ function toV2Epic(epic) {
42
+ const legacyId = epic.legacy_id ?? epic.id;
43
+ return {
44
+ slug: epic.slug,
45
+ ...(legacyId === undefined ? {} : { legacy_id: legacyId }),
46
+ kind: epic.kind,
47
+ active: epic.active,
48
+ ...(epic.approval === undefined ? {} : { approval: epic.approval }),
49
+ ...(epic.blueprint_aliases === undefined ? {} : { blueprint_aliases: { ...epic.blueprint_aliases } }),
50
+ };
51
+ }
52
+ /**
53
+ * 번호 형식 epic·blueprint 디렉터리를 slug 형식으로 옮기는 계획을 만든다. 파일을 쓰지 않는다.
54
+ * 겹침·규칙 위반은 `conflicts`에 모으고 계획은 계속 만든다(호출자가 한 번에 보고하도록).
55
+ *
56
+ * @param {object} opts - 입력
57
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
58
+ * @param {EpicRegistry | null} opts.registry - 현재 등록부. 없으면 `nextRegistry`는 null이다
59
+ * @param {Partial<Deps>} [opts.deps] - `fs` 주입(읽기)
60
+ * @returns {MigrationPlan} 이동 목록·충돌·다음 등록부·별칭 표
61
+ */
62
+ function planContextSlugMigration({ repoRoot, registry, deps }) {
63
+ const fsApi = deps?.fs ?? nodeFs;
64
+ const epicsRel = `${CONTEXT_ROOT}/epics`;
65
+ const conflicts = [];
66
+ const epics = [];
67
+ const epicNames = fsApi.existsSync(path.join(repoRoot, epicsRel))
68
+ ? fsApi.readdirSync(path.join(repoRoot, epicsRel)).sort() : [];
69
+ const claimedEpicSlugs = new Set();
70
+ for (const name of epicNames) {
71
+ const ids = parseContextPath(`epics/${name}`);
72
+ // 이미 slug인 디렉터리와 해석되지 않는 이름은 건너뛴다(재실행·비정본 이름은 이관 대상이 아니다).
73
+ if (!ids || ids.format !== 'numeric')
74
+ continue;
75
+ if (!fsApi.statSync(path.join(repoRoot, epicsRel, name)).isDirectory())
76
+ continue;
77
+ const slugViolation = validateSlug(ids.epicSlug);
78
+ if (slugViolation !== null)
79
+ conflicts.push(`${epicsRel}/${name}: ${slugViolation}`);
80
+ if (claimedEpicSlugs.has(ids.epicSlug)) {
81
+ conflicts.push(`${epicsRel}/${name}: epic slug is also claimed by another numbered epic: ${ids.epicSlug}`);
82
+ }
83
+ claimedEpicSlugs.add(ids.epicSlug);
84
+ if (fsApi.existsSync(path.join(repoRoot, epicsRel, ids.epicSlug))) {
85
+ conflicts.push(`${epicsRel}/${name}: ${epicsRel}/${ids.epicSlug} already exists`);
86
+ }
87
+ const move = {
88
+ from: `${epicsRel}/${name}`,
89
+ to: `${epicsRel}/${ids.epicSlug}`,
90
+ legacyId: ids.epicKey,
91
+ slug: ids.epicSlug,
92
+ blueprints: [],
93
+ };
94
+ const bpRoot = path.join(repoRoot, move.from, 'blueprints');
95
+ const bpNames = fsApi.existsSync(bpRoot) ? fsApi.readdirSync(bpRoot).sort() : [];
96
+ const seen = new Set();
97
+ for (const bpName of bpNames) {
98
+ if (!fsApi.statSync(path.join(bpRoot, bpName)).isDirectory())
99
+ continue;
100
+ const bpIds = parseContextPath(`epics/${name}/blueprints/${bpName}`);
101
+ if (!bpIds || bpIds.format !== 'numeric' || bpIds.blueprintKey === null || bpIds.blueprintSlug === null) {
102
+ conflicts.push(`${move.from}/blueprints/${bpName}: blueprint directory is not numbered (ddd-slug)`);
103
+ continue;
104
+ }
105
+ const bpViolation = validateSlug(bpIds.blueprintSlug);
106
+ if (bpViolation !== null)
107
+ conflicts.push(`${move.from}/blueprints/${bpName}: ${bpViolation}`);
108
+ if (seen.has(bpIds.blueprintSlug)) {
109
+ conflicts.push(`${move.from}/blueprints/${bpName}: blueprint slug repeats in this epic: `
110
+ + `${bpIds.blueprintSlug}`);
111
+ }
112
+ seen.add(bpIds.blueprintSlug);
113
+ move.blueprints.push({
114
+ from: `${move.from}/blueprints/${bpName}`,
115
+ to: `${move.to}/blueprints/${bpIds.blueprintSlug}`,
116
+ number: bpIds.blueprintKey,
117
+ slug: bpIds.blueprintSlug,
118
+ order: Number(bpIds.blueprintKey),
119
+ });
120
+ }
121
+ epics.push(move);
122
+ }
123
+ // 등록부: v1 항목은 `id`를 `legacy_id`로 옮기고, 번호 디렉터리를 가졌던 epic은 legacy 여부와 관계없이
124
+ // `legacy_id`·`blueprint_aliases`를 가진다. 이미 기록된 같은 별칭은 건너뛰고 다른 값이면 충돌이다.
125
+ const aliases = {};
126
+ let nextRegistry = null;
127
+ if (registry !== null) {
128
+ const nextEpics = registry.epics.map(toV2Epic);
129
+ for (const move of epics) {
130
+ let entry = nextEpics.find((e) => e.slug === move.slug);
131
+ if (entry === undefined) {
132
+ // 등록부에 없는 번호 epic은 init의 planRegistry와 같이 legacy로 기록한다(번호 별칭을 잃지 않기 위함).
133
+ entry = { slug: move.slug, legacy_id: move.legacyId, kind: 'legacy', active: true };
134
+ nextEpics.push(entry);
135
+ }
136
+ else if (entry.legacy_id !== move.legacyId) {
137
+ conflicts.push(`${move.from}: registry records legacy id ${String(entry.legacy_id)} for ${move.slug}`);
138
+ }
139
+ const table = { ...entry.blueprint_aliases };
140
+ for (const bp of move.blueprints) {
141
+ if (table[bp.number] !== undefined && table[bp.number] !== bp.slug) {
142
+ conflicts.push(`${move.slug}: alias ${bp.number} already points to ${table[bp.number]}, not ${bp.slug}`);
143
+ }
144
+ table[bp.number] = bp.slug;
145
+ }
146
+ entry.blueprint_aliases = Object.fromEntries(Object.entries(table).sort(([a], [b]) => a.localeCompare(b)));
147
+ aliases[move.slug] = {
148
+ legacy_id: move.legacyId, blueprint_aliases: entry.blueprint_aliases,
149
+ };
150
+ }
151
+ const candidate = {
152
+ version: 2,
153
+ profile: registry.profile,
154
+ domainEpicLimit: registry.domainEpicLimit,
155
+ areas: registry.areas,
156
+ ...(registry.areaPromotionThreshold === undefined
157
+ ? {} : { areaPromotionThreshold: registry.areaPromotionThreshold }),
158
+ epics: nextEpics,
159
+ };
160
+ const parsed = parseEpicRegistry(candidate);
161
+ if (parsed.ok)
162
+ nextRegistry = parsed.registry;
163
+ else
164
+ conflicts.push(`${REGISTRY_REL}: migrated registry would be invalid: ${parsed.message}`);
165
+ }
166
+ else {
167
+ for (const move of epics) {
168
+ aliases[move.slug] = {
169
+ legacy_id: move.legacyId,
170
+ blueprint_aliases: Object.fromEntries(move.blueprints.map((bp) => [bp.number, bp.slug])),
171
+ };
172
+ }
173
+ }
174
+ const moved = [
175
+ ...epics.map((e) => ({ from: e.from, to: e.to })),
176
+ ...epics.flatMap((e) => e.blueprints.map((bp) => ({ from: bp.from, to: bp.to }))),
177
+ ];
178
+ return { epics, moved, conflicts, nextRegistry, aliases };
179
+ }
180
+ /**
181
+ * 번호 형식 epic·blueprint를 slug 형식으로 이관한다. `yes`가 없으면 계획만 돌려준다(dry-run).
182
+ * 거절(dirty 트리·진행 중 상태·slug 충돌·등록부 문제)은 어떤 쓰기·`git mv` 전에 끝난다.
183
+ * 쓰기 단계의 예외는 자동 롤백 없이 되돌리는 명령을 `next`에 담아 돌려준다.
184
+ *
185
+ * @param {object} opts - 입력
186
+ * @param {string} opts.repoRoot - 저장소 루트 절대 경로
187
+ * @param {boolean} [opts.yes] - true면 실제로 옮긴다
188
+ * @param {Partial<Deps>} [opts.deps] - 테스트 seam. `runGit`·`fs`·`listInProgress`
189
+ * @returns {MigrationResult} 성공(moved·rewritten·aliases·validate) 또는 거절(code·message·next·blockers)
190
+ */
191
+ function migrateContextSlugs({ repoRoot, yes = false, deps }) {
192
+ const fsApi = deps?.fs ?? nodeFs;
193
+ const runGit = deps?.runGit ?? defaultRunGit(repoRoot);
194
+ const read = readEpicRegistry(repoRoot);
195
+ if (read.status === 'invalid') {
196
+ return failure('S35', `epic registry is invalid: ${read.message}`, `fix ${REGISTRY_REL}, then retry`);
197
+ }
198
+ const registry = read.status === 'ok' ? read.registry : null;
199
+ const plan = planContextSlugMigration({ repoRoot, registry, deps: { fs: fsApi } });
200
+ // 옮길 번호 디렉터리가 없으면 거절 검사 없이 성공이다. 이관 직후(작업 트리가 더러운 상태)의 재실행이
201
+ // dirty 거절에 걸리면 "두 번째 실행은 moved: []인 성공"이라는 멱등 계약이 깨진다.
202
+ if (plan.epics.length === 0) {
203
+ return { ok: true, dryRun: !yes, moved: [], rewritten: [], aliases: {}, validate: [] };
204
+ }
205
+ if (registry === null) {
206
+ return failure('migrate-registry-missing', `${REGISTRY_REL} is missing; the registry records the aliases the migration must write`, 'run bouncer init to create the registry, then retry');
207
+ }
208
+ if (runGit(['status', '--porcelain']).stdout.trim() !== '') {
209
+ return failure('migrate-dirty-tree', 'the working tree has uncommitted changes', 'commit or stash every change (git status --porcelain must be empty), then retry');
210
+ }
211
+ const inProgress = (deps?.listInProgress ?? (() => defaultListInProgress({ repoRoot, runGit, fsApi })))();
212
+ if (inProgress.length > 0) {
213
+ 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, '
214
+ + 'then retry', { blockers: inProgress });
215
+ }
216
+ if (plan.conflicts.length > 0) {
217
+ return failure('migrate-slug-conflict', plan.conflicts.join('; '), 'rename the conflicting directories, then retry');
218
+ }
219
+ // 모든 문서 변경을 쓰기 전에 계산한다. 여기서 던지면(깨진 YAML 등) 디스크는 그대로다.
220
+ const rewrites = computeRewrites({
221
+ repoRoot,
222
+ plan: { moved: plan.moved, nextRegistry: plan.nextRegistry, contexts: rewriteContextsOf(plan) },
223
+ runGit,
224
+ fsApi,
225
+ });
226
+ const changed = rewrites.filter((r) => r.text !== r.original);
227
+ const rewritten = changed.map((r) => r.newRel).sort();
228
+ if (!yes) {
229
+ return { ok: true, dryRun: true, moved: plan.moved, rewritten, aliases: plan.aliases, validate: [] };
230
+ }
231
+ try {
232
+ // 1. epic 디렉터리를 먼저 옮기고, 그 안의 blueprint를 옮긴다(epic 이동 뒤의 경로 기준).
233
+ for (const epic of plan.epics)
234
+ runGit(['mv', epic.from, epic.to]);
235
+ for (const epic of plan.epics) {
236
+ for (const bp of epic.blueprints)
237
+ runGit(['mv', `${epic.to}/blueprints/${path.posix.basename(bp.from)}`, bp.to]);
238
+ }
239
+ // 2. 문서 내용. git mv는 내용을 바꾸지 않으므로 이동한 자리(newRel)에 새 내용을 쓴다.
240
+ for (const r of changed)
241
+ fsApi.writeFileSync(path.join(repoRoot, r.newRel), r.text);
242
+ // 3. 등록부(v2)는 문서 뒤에 쓴다 — 실패하면 문서만 새 형식인 상태가 남지만 restore 한 줄로 되돌린다.
243
+ if (plan.nextRegistry !== null)
244
+ writeRegistryAtomically(fsApi, repoRoot, plan.nextRegistry);
245
+ }
246
+ catch (error) {
247
+ // 자동 롤백은 하지 않는다(답변 16). 어느 단계에서 던졌든 일부만 적용된 상태이므로, 모든 예외를
248
+ // 같은 복구 안내로 접는다. 원인은 message에 그대로 싣는다.
249
+ return failure('migrate-failed', catchMessage(error), RESTORE_NEXT, { moved: plan.moved, rewritten });
250
+ }
251
+ const validation = plan.epics.flatMap((e) => e.blueprints).map((bp) => {
252
+ const result = validateBlueprint({ repoRoot, blueprintDir: bp.to });
253
+ return { blueprint: bp.to, ok: result.ok, failures: result.failures };
254
+ });
255
+ const summary = { moved: plan.moved, rewritten, aliases: plan.aliases, validate: validation };
256
+ if (validation.some((v) => !v.ok)) {
257
+ return failure('migrate-validate-failed', 'bouncer validate reported failures for migrated blueprints; the moved files are kept', 'fix the failures listed in validate (or restore with: git restore --staged --worktree .bouncer '
258
+ + '&& git clean -fd .bouncer/context)', summary);
259
+ }
260
+ return { ok: true, dryRun: false, ...summary };
261
+ }
262
+ module.exports = { planContextSlugMigration, migrateContextSlugs, defaultListInProgress };