@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,352 @@
1
+ 'use strict';
2
+ const cliUsage = require("./cli-usage");
3
+ function cmdValidate(rest, io) {
4
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
5
+ const { parseFlags } = require('./cli-flags');
6
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
7
+ const { validateBlueprint } = require('./validate');
8
+ const f = parseFlags(rest);
9
+ // 게이트 실패(1)와 구분: 필수 플래그 누락은 호출 형식 오류(2). 없으면
10
+ // validate가 빈 blueprint로 구조 실패를 내어 사용법 문제를 위장한다.
11
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
12
+ io.err('validate: --blueprint is required\n');
13
+ return 2;
14
+ }
15
+ const repoRoot = (f.repo || process.cwd());
16
+ // 빈 문자열을 넘기면 validate가 알 수 없는 gate로 본다. 생략일 때만
17
+ // 라이브러리 기본(게이트 없이 구조 검사)이 살아 있다.
18
+ const gate = typeof f.gate === 'string' ? f.gate : undefined;
19
+ const result = validateBlueprint({
20
+ repoRoot,
21
+ blueprintDir: f.blueprint,
22
+ gate,
23
+ });
24
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
25
+ // ok=false는 실패 코드가 있는 검사 결과 — 사용법이 아니므로 1.
26
+ return result.ok ? 0 : 1;
27
+ }
28
+ /**
29
+ * `--blueprint` 대상의 verify 명령을 실행하고 증적을 기록한다.
30
+ * 통과하면 lease·pointer commit task를 `ready`에서 `verified`로 올린다.
31
+ *
32
+ * @param {string[]} rest - CLI 나머지 인자(`--blueprint`, `--repo`)
33
+ * @param {CliIo} io - stdout/stderr 기록기
34
+ * @returns {number} 성공 0, 실행 실패 1, 플래그 누락 2
35
+ */
36
+ function cmdVerify(rest, io) {
37
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
38
+ const { catchMessage } = require('./guards');
39
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
40
+ const { parseFlags } = require('./cli-flags');
41
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
42
+ const { runSelectedVerification } = require('./verification-target');
43
+ const f = parseFlags(rest);
44
+ // validate와 같은 2: 대상 없이 돌리면 게이트 실패로 위장된다.
45
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
46
+ io.err('verify: --blueprint is required\n');
47
+ return 2;
48
+ }
49
+ try {
50
+ const result = runSelectedVerification({
51
+ repoRoot: (f.repo || process.cwd()),
52
+ blueprintDir: f.blueprint,
53
+ markTaskVerified: true,
54
+ });
55
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
56
+ return result.ok ? 0 : 1;
57
+ }
58
+ catch (error) {
59
+ // VERIFY_DOCUMENT_MISSING 등은 throw. 플래그는 이미 통과했으므로
60
+ // 사용법(2)이 아니라 실행 실패(1).
61
+ io.err(`verify: ${catchMessage(error)}\n`);
62
+ return 1;
63
+ }
64
+ }
65
+ function cmdScaffold(rest, io) {
66
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
67
+ const { catchMessage } = require('./guards');
68
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
69
+ const { parseFlags } = require('./cli-flags');
70
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
71
+ const { AREA_SLUG_RE, scaffoldEpic, scaffoldBlueprint, scaffoldExplain, scaffoldTask, scaffoldContextReview } = require('./scaffold');
72
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
73
+ const { isNumericContextId } = require('./paths');
74
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
75
+ const { isCanonicalEpicDir, isValidBlueprintOrder } = require('./layout');
76
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
77
+ const { parseContextPath, validateSlug } = require('./context-ids');
78
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
79
+ const { readEpicRegistry } = require('./epic-registry');
80
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
81
+ const { SCALE_ENUM, DEFAULT_SCALE } = require('./schema');
82
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
83
+ const { readVerifyPolicy } = require('./config');
84
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
85
+ const { nowIsoKst } = require('./time');
86
+ // kind는 위치 인자 — parseFlags에 rest 전체를 주면 epic이 플래그 키로 사라진다.
87
+ const [kind, ...flagArgs] = rest;
88
+ const f = parseFlags(flagArgs);
89
+ const repoRoot = (f.repo || process.cwd());
90
+ // 테스트가 시각을 고정하려고 --timestamp를 넘긴다. 없으면 KST now.
91
+ const timestamp = typeof f.timestamp === 'string' ? f.timestamp : nowIsoKst();
92
+ let created;
93
+ try {
94
+ if (kind === 'epic' || kind === 'blueprint') {
95
+ // 새 문서의 형식을 정한다. blueprint는 부모 epic 경로가, 경로가 아직 없는 새 epic은 등록부 버전(v2)이
96
+ // 정한다 — 플래그·환경 변수로 형식을 켜지 않아야 같은 저장소에서 epic마다 형식이 흔들리지 않는다.
97
+ // 등록부 부재·invalid는 번호 형식(기존 동작)으로 둔다. invalid의 보고는 S35 소관이다.
98
+ const epicDirFlag = f['epic-dir'];
99
+ const parentFormat = kind === 'blueprint' && typeof epicDirFlag === 'string' && isCanonicalEpicDir(epicDirFlag)
100
+ ? parseContextPath(epicDirFlag)?.format
101
+ : undefined;
102
+ const registryRead = kind === 'epic' ? readEpicRegistry(repoRoot) : null;
103
+ const slugForm = parentFormat === 'slug'
104
+ || (registryRead !== null && registryRead.status === 'ok' && registryRead.registry.version === 2);
105
+ if (slugForm) {
106
+ // slug 형식은 번호를 받지 않는다. 조용히 무시하면 번호 디렉터리를 만든 줄 오해하므로 거절한다.
107
+ if (f.id !== undefined) {
108
+ io.err(kind === 'epic'
109
+ ? 'scaffold epic: the v2 registry names epics by slug; drop --id and pass --name <slug>\n'
110
+ : 'scaffold blueprint: a slug epic orders blueprints with --order <n>; drop --id\n');
111
+ return 2;
112
+ }
113
+ }
114
+ else if (parentFormat === 'numeric' && f.order !== undefined) {
115
+ io.err('scaffold blueprint: a numbered epic takes --id <ddd>, not --order\n');
116
+ return 2;
117
+ }
118
+ else if (!isNumericContextId(f.id)) {
119
+ // EPIC-001 / 1 / 01 거절 — 정본은 zero-pad 세 자리만. 라이브러리 throw와 메시지를 맞춤.
120
+ io.err(`scaffold: --id must be a zero-padded three-digit id (\\d{3}), got ${JSON.stringify(f.id)}\n`);
121
+ return 2;
122
+ }
123
+ // id 형식 다음에 name. 잘못된 id를 --name 누락으로 안내하면 고칠 곳이 빗나간다.
124
+ if (typeof f.name !== 'string' || f.name === '') {
125
+ io.err(`scaffold ${kind}: --name is required\n`);
126
+ return 2;
127
+ }
128
+ // 위 가드 뒤의 좁힌 값을 지역에 고정한다. 이후 `if (kind === 'epic')`로
129
+ // 갈라지면 TS가 epic|blueprint 가드 좁히기를 유지하지 않는다.
130
+ const name = f.name;
131
+ const description = typeof f.description === 'string' ? f.description : undefined;
132
+ if (kind === 'epic') {
133
+ created = scaffoldEpic({
134
+ repoRoot, epicId: slugForm ? undefined : f.id, name, timestamp, description,
135
+ });
136
+ }
137
+ else {
138
+ // id/name 통과 뒤에만 epic-dir을 묻는다. 형식 오류를 부모 경로 누락과
139
+ // 같은 메시지로 섞지 않기 위함.
140
+ if (typeof f['epic-dir'] !== 'string' || f['epic-dir'] === '') {
141
+ io.err('scaffold blueprint: --epic-dir is required\n');
142
+ return 2;
143
+ }
144
+ // slug blueprint의 순서. `--order` 뒤에 값이 없으면 parseFlags가 true를 주므로 문자열 검사가
145
+ // 그 형태도 거른다. 되돌려 문자열화한 값이 입력과 같아야 해 `1.5`·`03`·`1e2` 같은 모호한 표기를 막는다.
146
+ let order;
147
+ if (parentFormat === 'slug') {
148
+ const parsed = typeof f.order === 'string' ? Number(f.order) : Number.NaN;
149
+ if (!isValidBlueprintOrder(parsed) || String(parsed) !== f.order) {
150
+ io.err(`scaffold blueprint: --order must be a positive integer, got ${JSON.stringify(f.order)}\n`);
151
+ return 2;
152
+ }
153
+ const slugViolation = validateSlug(name);
154
+ if (slugViolation !== null) {
155
+ io.err(`scaffold blueprint: --name must be a valid slug: ${slugViolation}\n`);
156
+ return 2;
157
+ }
158
+ order = parsed;
159
+ }
160
+ // scale은 선택 인자다. 생략은 기존 계약(full)이고, 알 수 없는 값은
161
+ // 라이브러리에 닿기 전에 사용법 오류(2)로 거절해 파일을 하나도 만들지
162
+ // 않는다. `--scale` 뒤에 값이 없으면 parseFlags가 boolean true를 주므로
163
+ // 문자열 검사가 그 형태도 같이 거른다.
164
+ const scale = f.scale === undefined ? DEFAULT_SCALE : f.scale;
165
+ if (typeof scale !== 'string' || !SCALE_ENUM.includes(scale)) {
166
+ io.err(`scaffold blueprint: --scale must be one of ${SCALE_ENUM.join('|')}, `
167
+ + `got ${JSON.stringify(f.scale)}\n`);
168
+ return 2;
169
+ }
170
+ // area·attribution도 선택 인자다. 값 없이 온 플래그(true)와 잘못된 모양은
171
+ // 파일을 쓰기 전에 사용법 오류(2)로 거절한다.
172
+ const area = f.area;
173
+ if (area !== undefined && (typeof area !== 'string' || !AREA_SLUG_RE.test(area))) {
174
+ io.err(`scaffold blueprint: --area must be a lowercase slug, got ${JSON.stringify(area)}\n`);
175
+ return 2;
176
+ }
177
+ const attribution = f.attribution;
178
+ if (attribution !== undefined && (typeof attribution !== 'string'
179
+ || attribution.trim() === '' || /[\r\n]/.test(attribution))) {
180
+ io.err('scaffold blueprint: --attribution must be a non-empty single line\n');
181
+ return 2;
182
+ }
183
+ created = scaffoldBlueprint({
184
+ repoRoot,
185
+ epicDir: f['epic-dir'],
186
+ ...(order === undefined ? { blueprintId: f.id } : { order }),
187
+ name,
188
+ timestamp,
189
+ scale,
190
+ area,
191
+ attribution,
192
+ });
193
+ }
194
+ }
195
+ else if (kind === 'task') {
196
+ // --blueprint / --id 누락은 형식 오류보다 먼저 안내한다.
197
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
198
+ io.err('scaffold task: --blueprint is required\n');
199
+ return 2;
200
+ }
201
+ if (typeof f.id !== 'string' || f.id === '') {
202
+ io.err('scaffold task: --id is required\n');
203
+ return 2;
204
+ }
205
+ if (!isNumericContextId(f.id)) {
206
+ io.err(`scaffold: --id must be a zero-padded three-digit id (\\d{3}), got ${JSON.stringify(f.id)}\n`);
207
+ return 2;
208
+ }
209
+ const executionKind = f['execution-kind'] === undefined ? 'commit' : f['execution-kind'];
210
+ let dependsOn;
211
+ let verifyAllowlist;
212
+ if (executionKind === 'verification') {
213
+ if (typeof f['depends-on'] !== 'string' || f['depends-on'].trim() === '') {
214
+ io.err('scaffold task: --depends-on is required for verification\n');
215
+ return 2;
216
+ }
217
+ // parseFlags는 한 플래그에 한 문자열만 보존한다. fan-in은 쉼표 구분으로
218
+ // 받아 argv 하나를 유지하고, 빈 원소는 library shape 검사가 거절한다.
219
+ dependsOn = f['depends-on'].split(',').map((entry) => entry.trim());
220
+ if (typeof f.verify !== 'string' || f.verify.trim() === '') {
221
+ io.err('scaffold task: --verify is required for verification\n');
222
+ return 2;
223
+ }
224
+ const policy = readVerifyPolicy(repoRoot);
225
+ if (policy.ok === false) {
226
+ io.err('scaffold task: verification config is invalid\n');
227
+ return 2;
228
+ }
229
+ verifyAllowlist = policy.allowlist;
230
+ }
231
+ created = scaffoldTask({
232
+ repoRoot, blueprintDir: f.blueprint, taskId: f.id, timestamp,
233
+ executionKind, dependsOn, verify: f.verify, verifyAllowlist,
234
+ });
235
+ }
236
+ else if (kind === 'explain') {
237
+ // explain은 id/name이 없다. blueprint만 없으면 라이브러리가 경로 throw를 낸다.
238
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
239
+ io.err('scaffold explain: --blueprint is required\n');
240
+ return 2;
241
+ }
242
+ created = scaffoldExplain({ repoRoot, blueprintDir: f.blueprint, timestamp });
243
+ }
244
+ else if (kind === 'context-review') {
245
+ // explain과 같은 필수 플래그. 메시지를 kind별로 두어 호출자가 어느
246
+ // scaffold인지 알게 한다.
247
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
248
+ io.err('scaffold context-review: --blueprint is required\n');
249
+ return 2;
250
+ }
251
+ created = scaffoldContextReview({ repoRoot, blueprintDir: f.blueprint, timestamp });
252
+ }
253
+ else {
254
+ // 알려진 kind가 아니면 라이브러리를 호출하지 않는다. throw 메시지가
255
+ // 구현 상세를 흘리지 않게 CLI가 사용법(2)로 거절한다.
256
+ io.err(`unknown scaffold kind: ${kind}\n`);
257
+ return 2;
258
+ }
259
+ }
260
+ catch (error) {
261
+ // closed blueprint 거절 등 라이브러리 throw. 플래그는 통과했으나
262
+ // 호출 상태로 막을 일이라 실행 실패(1)가 아니라 사용법/상태(2).
263
+ io.err(`scaffold: ${catchMessage(error)}\n`);
264
+ return 2;
265
+ }
266
+ io.out(`${JSON.stringify({ ok: true, created }, null, 2)}\n`);
267
+ return 0;
268
+ }
269
+ /**
270
+ * `plan inspect`(추천 JSON)와 `plan approve`(status 일괄 전이)를 받는다. 결과 JSON은
271
+ * stdout에 내고, 거절은 같은 채널의 `{ok:false}`와 종료 코드 1이다. 알 수 없는
272
+ * 서브커맨드·값 없는 `--blueprint`·중복 `--blueprint`는 사용법(2).
273
+ *
274
+ * @param {string[]} rest - 서브커맨드와 플래그
275
+ * @param {CliIo} io - stdout/stderr 싱크
276
+ * @returns {number} 성공 0, 거절 1, 사용법 2
277
+ */
278
+ function cmdPlan(rest, io) {
279
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
280
+ const { parseFlags } = require('./cli-flags');
281
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
282
+ const { planInspect } = require('./plan-inspect');
283
+ // 명령을 선택한 뒤에만 기능을 읽어 다른 경로의 적재를 피한다.
284
+ const { approvePlan } = require('./plan-approve');
285
+ const command = rest[0];
286
+ const f = parseFlags(rest.slice(1));
287
+ // inspect·approve만 공개한다. 다른 서브커맨드를 받으면 사용법(2) — 런타임 거절(1)과
288
+ // 구분해, 없는 동사를 계획 쓰기로 착각하지 않게 한다.
289
+ if (command !== 'inspect' && command !== 'approve') {
290
+ io.err('plan: command must be inspect or approve\n');
291
+ return 2;
292
+ }
293
+ if (command === 'approve') {
294
+ // parseFlags는 중복 플래그를 마지막 값으로 덮어 조용히 삼킨다. 승인 대상이
295
+ // 모호해지므로 토큰을 직접 세어 사용법(2)으로 거절한다.
296
+ if (rest.filter((tok) => tok === '--blueprint').length > 1) {
297
+ io.err('plan approve: --blueprint given more than once\n');
298
+ return 2;
299
+ }
300
+ if (typeof f.blueprint !== 'string' || f.blueprint === '') {
301
+ io.err('plan approve: --blueprint requires a directory\n');
302
+ return 2;
303
+ }
304
+ // --repo도 같은 이유로 거절한다: 중복이면 마지막 값이 이겨 다른 저장소를 승인할 수 있고,
305
+ // 값이 없으면 parseFlags가 true를 줘 cwd로 조용히 폴백한다. 둘 다 사용법(2).
306
+ if (rest.filter((tok) => tok === '--repo').length > 1) {
307
+ io.err('plan approve: --repo given more than once\n');
308
+ return 2;
309
+ }
310
+ if (Object.hasOwn(f, 'repo') && (typeof f.repo !== 'string' || f.repo === '')) {
311
+ io.err('plan approve: --repo requires a directory\n');
312
+ return 2;
313
+ }
314
+ const approved = approvePlan({
315
+ repoRoot: (f.repo || process.cwd()),
316
+ blueprintDir: f.blueprint,
317
+ });
318
+ io.out(`${JSON.stringify(approved, null, 2)}\n`);
319
+ return approved.ok ? 0 : 1;
320
+ }
321
+ // --blueprint는 선택이다. 플래그만 주고 값이 없으면 parseFlags가 true를
322
+ // 주므로 invalid-blueprint-dir(1)로 위장하지 않고 사용법(2)로 가른다.
323
+ if (Object.hasOwn(f, 'blueprint') && typeof f.blueprint !== 'string') {
324
+ io.err('plan inspect: --blueprint requires a directory\n');
325
+ return 2;
326
+ }
327
+ const result = planInspect({
328
+ repoRoot: (f.repo || process.cwd()),
329
+ epicDir: f['epic-dir'],
330
+ blueprintDir: f.blueprint,
331
+ });
332
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
333
+ return result.ok ? 0 : 1;
334
+ }
335
+ module.exports = {
336
+ validate: {
337
+ run: cmdValidate,
338
+ usage: cliUsage.COMMAND_USAGE['validate'],
339
+ },
340
+ verify: {
341
+ run: cmdVerify,
342
+ usage: cliUsage.COMMAND_USAGE['verify'],
343
+ },
344
+ scaffold: {
345
+ run: cmdScaffold,
346
+ usage: cliUsage.COMMAND_USAGE['scaffold'],
347
+ },
348
+ plan: {
349
+ run: cmdPlan,
350
+ usage: cliUsage.COMMAND_USAGE['plan'],
351
+ },
352
+ };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+ const cliUsage = require("./cli-usage");
3
+ const cliFlags = require("./cli-flags");
4
+ const { parseFlags } = cliFlags;
5
+ const USAGE = `usage: bouncer epic <add-domain|add-area> [--repo <dir>]\n${cliUsage.COMMAND_USAGE.epic}`;
6
+ // 하위 명령별 허용 플래그. 목록 밖 플래그는 오타일 가능성이 커서 조용히 무시하지 않고 거절한다.
7
+ const ALLOWED = {
8
+ 'add-domain': ['slug', 'reason', 'yes', 'timestamp', 'repo'],
9
+ 'add-area': ['name', 'yes', 'repo'],
10
+ };
11
+ // 값을 받는 필수 플래그.
12
+ const REQUIRED = { 'add-domain': ['slug', 'reason'], 'add-area': ['name'] };
13
+ /**
14
+ * 원시 argv에서 `--flag` 토큰 이름을 순서대로 모은다.
15
+ *
16
+ * @param {string[]} tokens - 하위 명령 뒤 argv
17
+ * @returns {string[]} 플래그 이름 목록(중복 포함)
18
+ */
19
+ function flagNames(tokens) {
20
+ return tokens.filter((t) => t.startsWith('--')).map((t) => t.slice(2));
21
+ }
22
+ /**
23
+ * `epic` 하위 명령 argv를 검사한다. 사용법 오류(exit 2)만 여기서 거르고,
24
+ * `--yes` 누락은 승인 경계이므로 통과시켜 등록부를 읽기 전에 confirmation-required로 거절하게 한다.
25
+ *
26
+ * @param {string} sub - 하위 명령 이름
27
+ * @param {string[]} flags - 하위 명령 뒤 argv
28
+ * @returns {string | null} 사용법 오류 메시지(통과하면 null)
29
+ */
30
+ function argvError(sub, flags) {
31
+ const names = flagNames(flags);
32
+ for (const name of names) {
33
+ if (!ALLOWED[sub].includes(name))
34
+ return `unknown flag --${name}`;
35
+ if (names.filter((n) => n === name).length > 1)
36
+ return `--${name} specified more than once`;
37
+ }
38
+ const f = parseFlags(flags);
39
+ for (const name of REQUIRED[sub]) {
40
+ if (typeof f[name] !== 'string')
41
+ return `--${name} is required`;
42
+ }
43
+ // slug·name은 빈 문자열이면 값이 없는 것과 같다. reason은 공백 거절을 reason 코드로
44
+ // 보이려고 빈 문자열도 통과시킨다.
45
+ for (const name of REQUIRED[sub].filter((n) => n !== 'reason')) {
46
+ if (f[name] === '')
47
+ return `--${name} is required`;
48
+ }
49
+ if (f.repo !== undefined && (typeof f.repo !== 'string' || f.repo === ''))
50
+ return '--repo requires a directory';
51
+ if (f.timestamp !== undefined && (typeof f.timestamp !== 'string' || f.timestamp === '')) {
52
+ return '--timestamp requires a value';
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * 공개 `epic` 핸들러. 등록부에 도메인 epic이나 area를 더한다. `--yes`가 사용자 명시 승인이며
58
+ * 없으면 등록부를 읽기 전에 거절한다. 디렉터리는 만들지 않는다(scaffold는 다음 plan이 한다).
59
+ *
60
+ * @param {string[]} rest - 하위 명령과 플래그
61
+ * @param {CliIo} io - stdout/stderr 싱크
62
+ * @returns {number} 성공 0, 등록부 거절 1, 사용법 오류 2
63
+ */
64
+ function run(rest, io) {
65
+ if (rest.includes('--help') || rest.includes('-h')) {
66
+ io.out(USAGE);
67
+ return 0;
68
+ }
69
+ const [sub, ...flags] = rest;
70
+ if (sub === undefined || !Object.hasOwn(ALLOWED, sub)) {
71
+ io.err(`epic: unknown or missing subcommand${sub === undefined ? '' : `: ${sub}`}\n${USAGE}`);
72
+ return 2;
73
+ }
74
+ const bad = argvError(sub, flags);
75
+ if (bad !== null) {
76
+ io.err(`epic ${sub}: ${bad}\n${USAGE}`);
77
+ return 2;
78
+ }
79
+ const f = parseFlags(flags);
80
+ const emit = (value, code) => {
81
+ io.out(`${JSON.stringify(value, null, 2)}\n`);
82
+ return code;
83
+ };
84
+ const refuse = (reason, cause, next) => (emit({ ok: false, reason, cause, next }, 1));
85
+ // 승인 확인은 어떤 파일 접근보다 먼저(review accept-risk 선례).
86
+ if (f.yes !== true) {
87
+ return refuse('epic-registry-confirmation-required', `epic ${sub} needs explicit user approval`, 'Ask the user to approve, then rerun with --yes.');
88
+ }
89
+ // 승인 뒤에만 기능 모듈을 적재한다. 사용법·승인 거절이 등록부 I/O 경로를 require.cache에 남기지 않게 한다.
90
+ const registryLib = require('./epic-registry');
91
+ const fs = require('node:fs');
92
+ const path = require('node:path');
93
+ const repoRoot = (typeof f.repo === 'string' ? f.repo : process.cwd());
94
+ const read = registryLib.readEpicRegistry(repoRoot);
95
+ if (read.status === 'missing') {
96
+ return refuse('epic-registry-missing', `${registryLib.REGISTRY_REL} does not exist`, 'Run `bouncer init` first.');
97
+ }
98
+ if (read.status === 'invalid') {
99
+ return refuse('epic-registry-invalid', read.message, `Fix ${registryLib.REGISTRY_REL} to match the format.`);
100
+ }
101
+ let result;
102
+ if (sub === 'add-domain') {
103
+ const { nowIsoKst } = require('./time');
104
+ // 번호 배정이 디렉터리 번호도 보므로 epics 디렉터리를 읽는다. 디렉터리 없음(ENOENT)만 빈 목록이다.
105
+ let dirs = [];
106
+ try {
107
+ dirs = fs.readdirSync(path.join(repoRoot, '.bouncer/context/epics'));
108
+ }
109
+ catch (error) {
110
+ if (error.code !== 'ENOENT')
111
+ throw error;
112
+ }
113
+ result = registryLib.addDomainEpic(read.registry, {
114
+ slug: f.slug,
115
+ reason: f.reason,
116
+ approvedAt: typeof f.timestamp === 'string' ? f.timestamp : nowIsoKst(),
117
+ }, dirs);
118
+ }
119
+ else {
120
+ result = registryLib.addArea(read.registry, f.name);
121
+ }
122
+ if (!result.ok)
123
+ return refuse(result.reason, result.cause, 'Adjust the input and rerun; nothing was written.');
124
+ // 검증을 모두 통과한 뒤 한 번만 쓴다.
125
+ registryLib.writeEpicRegistry(repoRoot, result.registry);
126
+ return emit('epic' in result
127
+ ? { ok: true, path: registryLib.REGISTRY_REL, epic: result.epic }
128
+ : { ok: true, path: registryLib.REGISTRY_REL, area: result.area }, 0);
129
+ }
130
+ module.exports = { run };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ /**
3
+ * CLI 공통 플래그 파서. 명령 모듈이 cli.ts를 require하지 못하게 여기 둔다
4
+ * (순환: cli.ts → 명령 모듈 → cli.ts).
5
+ */
6
+ function parseFlags(rest) {
7
+ const flags = {};
8
+ // 위치 인자(scaffold kind, migrate kind)는 건너뛴다. 전부 플래그로 삼키면
9
+ // 핸들러가 rest[0]을 kind로 읽지 못한다.
10
+ for (let i = 0; i < rest.length; i += 1) {
11
+ const tok = rest[i];
12
+ if (!tok.startsWith('--'))
13
+ continue;
14
+ const key = tok.slice(2);
15
+ const next = rest[i + 1];
16
+ // 다음이 없거나 또 다른 -- 토큰이면 boolean. --yes --repo <dir> 에서
17
+ // '--repo'를 --yes의 값으로 소비하지 않기 위함.
18
+ if (next === undefined || next.startsWith('--')) {
19
+ flags[key] = true;
20
+ }
21
+ else {
22
+ flags[key] = next;
23
+ // 값을 소비했으니 다음 루프가 그 값을 새 플래그로 파싱하지 않는다.
24
+ i += 1;
25
+ }
26
+ }
27
+ return flags;
28
+ }
29
+ /**
30
+ * 서브커맨드 도움말 요청 여부. parseFlags는 `--help` 뒤 값을 먹고 `-h`를 무시하므로
31
+ * 원시 토큰을 본다. `--reason -h`처럼 직전 토큰이 `--` 플래그면 `-h`는 그 플래그의 값이다.
32
+ * runCli(handler 적재 전 가로채기)와 자체 help를 가진 handler가 같은 규칙을 쓰도록 한 곳에 둔다.
33
+ *
34
+ * @param {string[]} tokens - 명령 이름 뒤 원시 argv
35
+ * @returns {boolean} 도움말을 내면 true
36
+ */
37
+ function argvRequestsHelp(tokens) {
38
+ for (let i = 0; i < tokens.length; i += 1) {
39
+ const tok = tokens[i];
40
+ if (tok === '--help')
41
+ return true;
42
+ if (tok === '-h') {
43
+ const prev = i > 0 ? tokens[i - 1] : undefined;
44
+ if (prev === undefined || !prev.startsWith('--'))
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ module.exports = { parseFlags, argvRequestsHelp };