@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const contextIds = require("./context-ids");
|
|
4
|
+
const { isRecord, isEnoentError } = guards;
|
|
5
|
+
const { validateSlug, parseNumericPrefix, parseNumericEpicDir } = contextIds;
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const REGISTRY_REL = '.bouncer/epics.json';
|
|
9
|
+
const ID_RE = /^\d{3}$/;
|
|
10
|
+
const KINDS = ['common', 'domain', 'legacy'];
|
|
11
|
+
// 순서가 곧 ID 배정 순서다. 바꾸면 신규 저장소의 공통 epic 번호가 달라진다.
|
|
12
|
+
// description은 scaffoldEpic이 새 epic에 요구하는 printable ASCII 영어 한 문장이다.
|
|
13
|
+
const COMMON_EPICS = [
|
|
14
|
+
{
|
|
15
|
+
slug: 'foundation',
|
|
16
|
+
description: 'Project setup, build tooling, and shared conventions every other epic relies on.',
|
|
17
|
+
include: '저장소 구조, 빌드·의존성·런타임 버전, 개발 환경, lint·format, CI/CD, 배포·릴리스, 인프라 코드(IaC), 환경별 설정',
|
|
18
|
+
exclude: '특정 기능의 설정값 변경 → 그 기능의 epic',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
slug: 'core',
|
|
22
|
+
description: 'Core domain model and shared logic that feature epics build on.',
|
|
23
|
+
include: '공통 모듈과 아키텍처 경계, 에러 처리, 설정 로딩, 공통 유틸',
|
|
24
|
+
exclude: '한 기능에서만 쓰는 헬퍼 → 그 기능의 epic',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
slug: 'data',
|
|
28
|
+
description: 'Persistence, schemas, migrations, and data access.',
|
|
29
|
+
include: '데이터 모델, 저장소, 마이그레이션 체계, 캐시, 데이터 보존 정책',
|
|
30
|
+
exclude: '한 도메인 기능에만 쓰는 테이블·필드 → `features`',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
slug: 'integration',
|
|
34
|
+
description: 'Connections to external systems, services, and third-party APIs.',
|
|
35
|
+
include: '외부 API·SDK 클라이언트, 메시징, 공개 API 계약과 버전 정책',
|
|
36
|
+
exclude: '외부 연동을 사용하는 기능 동작 변경 → `features`',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
slug: 'identity-access',
|
|
40
|
+
description: 'Authentication, authorization, and account management.',
|
|
41
|
+
include: '인증, 인가, 계정, 세션, 접근 감사',
|
|
42
|
+
exclude: '권한 검사를 쓰는 도메인 기능 동작 → `features`',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
slug: 'interface',
|
|
46
|
+
description: 'User-facing interfaces such as screens, commands, and public APIs.',
|
|
47
|
+
include: '사용자 접점: 화면 골격·내비게이션, 디자인 시스템, CLI 표면, 알림, 다국어, 접근성',
|
|
48
|
+
exclude: '특정 도메인 화면·명령의 동작 → `features`',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
slug: 'features',
|
|
52
|
+
description: 'End-to-end product features that combine the other epics.',
|
|
53
|
+
include: '도메인 업무 규칙, 워크플로, 해당 기능 전용 화면·API·스키마·테스트',
|
|
54
|
+
exclude: '공통 계층 자체의 변경 → 해당 공통 epic',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
slug: 'quality-ops',
|
|
58
|
+
description: 'Testing, observability, release, and operational tooling.',
|
|
59
|
+
include: '테스트 하네스·픽스처·E2E 인프라, 로그·메트릭·모니터링, 장애 대응, 성능 측정, 운영 스크립트',
|
|
60
|
+
exclude: '특정 기능의 테스트 추가·성능 개선 → 그 기능의 epic',
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
const PURPOSE_NAMES = [
|
|
64
|
+
'performance', 'security', 'refactor', 'refactoring', 'followup', 'follow-up', 'cleanup', 'fix', 'bugfix',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* `blueprint_aliases` 형식을 검사한다. 키는 옛 세 자리 번호나 옛 slug, 값은 현재 slug여야 한다.
|
|
68
|
+
* 두 키가 같은 값을 가리키는 것은 옛 이름 여러 개를 한 blueprint로 모으는 정상 사용이라 검사하지 않는다.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} aliases - epic 항목의 `blueprint_aliases` 값
|
|
71
|
+
* @returns {string | null} 위반 사유(없거나 통과하면 null)
|
|
72
|
+
*/
|
|
73
|
+
function aliasViolationOf(aliases) {
|
|
74
|
+
if (aliases === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
if (!isRecord(aliases))
|
|
77
|
+
return 'must be an object';
|
|
78
|
+
for (const [key, target] of Object.entries(aliases)) {
|
|
79
|
+
if (!ID_RE.test(key) && validateSlug(key) !== null)
|
|
80
|
+
return `key is neither three digits nor a slug: ${key}`;
|
|
81
|
+
const targetViolation = validateSlug(target);
|
|
82
|
+
if (targetViolation !== null)
|
|
83
|
+
return `value for ${key}: ${targetViolation}`;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `moved_to`의 키·값이 slug 형식인지 본다. 값이 같은 등록부의 도메인 epic인지는 모든 항목을
|
|
89
|
+
* 읽어야 알 수 있어 이 함수가 아니라 parseEpicRegistry의 마지막 단계에서 검사한다.
|
|
90
|
+
*
|
|
91
|
+
* @param {unknown} movedTo - epic 항목의 `moved_to` 값
|
|
92
|
+
* @returns {string | null} 위반 사유(통과하면 null)
|
|
93
|
+
*/
|
|
94
|
+
function movedToShapeViolation(movedTo) {
|
|
95
|
+
if (!isRecord(movedTo))
|
|
96
|
+
return 'must be an object';
|
|
97
|
+
for (const [key, target] of Object.entries(movedTo)) {
|
|
98
|
+
const keyViolation = validateSlug(key);
|
|
99
|
+
if (keyViolation !== null)
|
|
100
|
+
return `key is not a slug: ${key}: ${keyViolation}`;
|
|
101
|
+
const targetViolation = validateSlug(target);
|
|
102
|
+
if (targetViolation !== null)
|
|
103
|
+
return `value for ${key}: ${targetViolation}`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 등록부 JSON 값을 검사해 EpicRegistry로 돌려준다. 형식 위반은 throw하지 않고
|
|
109
|
+
* 첫 위반의 message를 돌려줘 호출자가 S35로 접게 한다.
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} value - JSON.parse한 값
|
|
112
|
+
* @returns {{ok: true, registry: EpicRegistry} | {ok: false, message: string}} 검사 결과
|
|
113
|
+
*/
|
|
114
|
+
function parseEpicRegistry(value) {
|
|
115
|
+
const fail = (message) => ({ ok: false, message });
|
|
116
|
+
if (!isRecord(value))
|
|
117
|
+
return fail('registry must be a JSON object');
|
|
118
|
+
if (value.version !== 1 && value.version !== 2)
|
|
119
|
+
return fail('version must be 1 or 2');
|
|
120
|
+
if (typeof value.profile !== 'string' || value.profile === '')
|
|
121
|
+
return fail('profile must be a non-empty string');
|
|
122
|
+
const limit = value.domainEpicLimit;
|
|
123
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0) {
|
|
124
|
+
return fail('domainEpicLimit must be a non-negative integer');
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(value.areas))
|
|
127
|
+
return fail('areas must be an array');
|
|
128
|
+
const seenAreas = new Set();
|
|
129
|
+
for (const area of value.areas) {
|
|
130
|
+
if (typeof area !== 'string' || validateSlug(area) !== null) {
|
|
131
|
+
return fail(`area is not a valid slug: ${String(area)}`);
|
|
132
|
+
}
|
|
133
|
+
if (seenAreas.has(area))
|
|
134
|
+
return fail(`area is duplicated: ${area}`);
|
|
135
|
+
seenAreas.add(area);
|
|
136
|
+
}
|
|
137
|
+
const threshold = value.areaPromotionThreshold;
|
|
138
|
+
if (threshold !== undefined && (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold < 1)) {
|
|
139
|
+
return fail('areaPromotionThreshold must be an integer >= 1');
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(value.epics))
|
|
142
|
+
return fail('epics must be an array');
|
|
143
|
+
const idBySlug = new Map();
|
|
144
|
+
const slugById = new Map();
|
|
145
|
+
for (const epic of value.epics) {
|
|
146
|
+
if (!isRecord(epic))
|
|
147
|
+
return fail('epics entries must be objects');
|
|
148
|
+
const { kind, active, approval } = epic;
|
|
149
|
+
const slugViolation = validateSlug(epic.slug);
|
|
150
|
+
if (slugViolation !== null)
|
|
151
|
+
return fail(`epic slug is invalid: ${slugViolation}`);
|
|
152
|
+
const slug = epic.slug;
|
|
153
|
+
// v1은 id가 필수이고, v2는 옛 번호가 있는 epic만 legacy_id를 가진다. 둘은 같은 번호 공간이라
|
|
154
|
+
// 아래 중복 검사를 한 변수로 한다. v2에서 id 없는 epic은 번호 중복 검사에서 빠진다.
|
|
155
|
+
const rawId = value.version === 1 ? epic.id : epic.legacy_id;
|
|
156
|
+
if ((value.version === 1 || rawId !== undefined) && (typeof rawId !== 'string' || !ID_RE.test(rawId))) {
|
|
157
|
+
return fail(`epic id must be 3 digits: ${slug}`);
|
|
158
|
+
}
|
|
159
|
+
const id = rawId ?? '';
|
|
160
|
+
const aliasViolation = aliasViolationOf(epic.blueprint_aliases);
|
|
161
|
+
if (aliasViolation !== null)
|
|
162
|
+
return fail(`blueprint_aliases is invalid: ${slug}: ${aliasViolation}`);
|
|
163
|
+
if (typeof kind !== 'string' || !KINDS.includes(kind))
|
|
164
|
+
return fail(`epic kind is invalid: ${slug}`);
|
|
165
|
+
if (typeof active !== 'boolean')
|
|
166
|
+
return fail(`epic active must be a boolean: ${slug}`);
|
|
167
|
+
if (epic.moved_to !== undefined) {
|
|
168
|
+
// 이동 표는 legacy epic이 도메인 epic으로 넘긴 blueprint를 기록하는 v2 전용 필드다.
|
|
169
|
+
if (kind !== 'legacy')
|
|
170
|
+
return fail(`moved_to is only allowed on legacy epics: ${slug}`);
|
|
171
|
+
const movedViolation = value.version === 1
|
|
172
|
+
? 'only supported in version 2'
|
|
173
|
+
: movedToShapeViolation(epic.moved_to);
|
|
174
|
+
if (movedViolation !== null)
|
|
175
|
+
return fail(`moved_to is invalid: ${slug}: ${movedViolation}`);
|
|
176
|
+
}
|
|
177
|
+
if (approval !== undefined
|
|
178
|
+
&& !(isRecord(approval) && typeof approval.reason === 'string' && typeof approval.approvedAt === 'string')) {
|
|
179
|
+
return fail(`epic approval needs string reason and approvedAt: ${slug}`);
|
|
180
|
+
}
|
|
181
|
+
if (idBySlug.has(slug) && idBySlug.get(slug) !== id)
|
|
182
|
+
return fail(`slug has more than one id: ${slug}`);
|
|
183
|
+
if (rawId !== undefined && slugById.has(id) && slugById.get(id) !== slug) {
|
|
184
|
+
return fail(`id has more than one slug: ${id}`);
|
|
185
|
+
}
|
|
186
|
+
// 같은 (slug,id) 행이 두 번 있으면 위 두 검사를 모두 통과하므로 따로 거절한다.
|
|
187
|
+
if (idBySlug.has(slug))
|
|
188
|
+
return fail(`epic row is duplicated: ${slug} ${id}`);
|
|
189
|
+
idBySlug.set(slug, id);
|
|
190
|
+
if (rawId !== undefined)
|
|
191
|
+
slugById.set(id, slug);
|
|
192
|
+
}
|
|
193
|
+
// 이동 대상은 항목 순서와 무관하게 확인하려고 모든 항목을 읽은 뒤 검사한다.
|
|
194
|
+
const domainSlugs = new Set(value.epics.filter((e) => e.kind === 'domain').map((e) => e.slug));
|
|
195
|
+
for (const epic of value.epics) {
|
|
196
|
+
if (epic.moved_to === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
for (const [bp, target] of Object.entries(epic.moved_to)) {
|
|
199
|
+
if (!domainSlugs.has(target)) {
|
|
200
|
+
return fail(`moved_to is invalid: ${String(epic.slug)}: value for ${bp} is not a domain epic: ${target}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, registry: value };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 옛 번호(v1 `id`, v2 `legacy_id`)로 epic 항목을 찾는다.
|
|
208
|
+
*
|
|
209
|
+
* @param {EpicRegistry} registry - 등록부
|
|
210
|
+
* @param {string} id - 옛 epic 번호(세 자리)
|
|
211
|
+
* @returns {RegistryEpic | null} 항목. 없으면 null
|
|
212
|
+
*/
|
|
213
|
+
function epicByLegacyId(registry, id) {
|
|
214
|
+
return registry.epics.find((e) => (e.legacy_id ?? e.id) === id) ?? null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* epic의 `blueprint_aliases`로 옛 blueprint 번호·이름을 현재 slug로 바꾼다.
|
|
218
|
+
*
|
|
219
|
+
* @param {EpicRegistry} registry - 등록부
|
|
220
|
+
* @param {string} epicSlug - epic slug
|
|
221
|
+
* @param {string} key - 옛 blueprint 번호(세 자리) 또는 옛 slug
|
|
222
|
+
* @returns {string | null} 현재 blueprint slug. epic이나 별칭이 없으면 null
|
|
223
|
+
*/
|
|
224
|
+
function resolveBlueprintAlias(registry, epicSlug, key) {
|
|
225
|
+
const epic = registry.epics.find((e) => e.slug === epicSlug);
|
|
226
|
+
const aliases = epic?.blueprint_aliases;
|
|
227
|
+
// 상속 키(`constructor` 등)가 별칭으로 새지 않도록 own property만 본다.
|
|
228
|
+
return aliases && Object.prototype.hasOwnProperty.call(aliases, key) ? aliases[key] : null;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* repoRoot 아래 `.bouncer/epics.json` 하나만 읽는다. 부재(ENOENT)만 missing이고
|
|
232
|
+
* 권한 오류·깨진 JSON·형식 위반은 invalid로 구분한다.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
235
|
+
* @returns {RegistryRead} ok / missing / invalid
|
|
236
|
+
*/
|
|
237
|
+
function readEpicRegistry(repoRoot) {
|
|
238
|
+
let raw;
|
|
239
|
+
try {
|
|
240
|
+
raw = fs.readFileSync(path.join(repoRoot, REGISTRY_REL), 'utf8');
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
// ENOENT만 "등록부 없음"이다. 권한 오류 등을 missing으로 합치면 형식 위반이
|
|
244
|
+
// 조용히 통과하므로 invalid로 보고한다.
|
|
245
|
+
if (isEnoentError(error))
|
|
246
|
+
return { status: 'missing' };
|
|
247
|
+
return { status: 'invalid', message: `cannot read registry: ${error.message}` };
|
|
248
|
+
}
|
|
249
|
+
let value;
|
|
250
|
+
try {
|
|
251
|
+
value = JSON.parse(raw);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
// SyntaxError만 "깨진 JSON"으로 접는다. JSON.parse는 그 밖의 오류를 내지 않는다.
|
|
255
|
+
return { status: 'invalid', message: `registry is not valid JSON: ${error.message}` };
|
|
256
|
+
}
|
|
257
|
+
const parsed = parseEpicRegistry(value);
|
|
258
|
+
return parsed.ok ? { status: 'ok', registry: parsed.registry } : { status: 'invalid', message: parsed.message };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 프로필을 등록부로 펼친다. standard는 공통 epic 8개(ID 001~008, 활성), domain-only는 0개다.
|
|
262
|
+
* epics에는 공통 epic만 담는다.
|
|
263
|
+
*
|
|
264
|
+
* @param {'standard'|'domain-only'} profile - 프로필 이름
|
|
265
|
+
* @param {{domainEpicLimit: number}} opts - 도메인 epic 상한(0 이상 정수)
|
|
266
|
+
* @returns {EpicRegistry} 펼친 등록부
|
|
267
|
+
* @throws {Error} 알 수 없는 프로필이거나 domainEpicLimit이 음수·비정수일 때
|
|
268
|
+
*/
|
|
269
|
+
function expandProfile(profile, opts) {
|
|
270
|
+
if (profile !== 'standard' && profile !== 'domain-only') {
|
|
271
|
+
throw new Error(`unknown profile: ${String(profile)}`);
|
|
272
|
+
}
|
|
273
|
+
const limit = opts.domainEpicLimit;
|
|
274
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
275
|
+
throw new Error(`domainEpicLimit must be a non-negative integer: ${String(limit)}`);
|
|
276
|
+
}
|
|
277
|
+
const epics = profile === 'standard'
|
|
278
|
+
? COMMON_EPICS.map((common, index) => ({
|
|
279
|
+
slug: common.slug, id: String(index + 1).padStart(3, '0'), kind: 'common', active: true,
|
|
280
|
+
}))
|
|
281
|
+
: [];
|
|
282
|
+
return { version: 1, profile, domainEpicLimit: limit, areas: [], epics };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 기존 epic 디렉터리에 맞춰 등록부를 만든다. 공통 slug와 같은 디렉터리는 그 ID의
|
|
286
|
+
* common, 나머지는 legacy로 두고, 없는 공통 epic에는 기존 최대 번호 다음부터 ID를 배정한다.
|
|
287
|
+
*
|
|
288
|
+
* @param {EpicRegistry} base - expandProfile 결과(공통 epic만 담김)
|
|
289
|
+
* @param {string[]} existingEpicDirs - `NNN-slug` 형식 디렉터리 이름 목록
|
|
290
|
+
* @returns {{registry: EpicRegistry, scaffold: {slug: string, id: string}[]}} 등록부와 새로 만들 epic
|
|
291
|
+
* @throws {Error} 한 공통 slug에 디렉터리가 둘 이상이거나 ID가 999를 넘을 때
|
|
292
|
+
*/
|
|
293
|
+
function planRegistry(base, existingEpicDirs) {
|
|
294
|
+
const parsedDirs = [];
|
|
295
|
+
for (const name of existingEpicDirs) {
|
|
296
|
+
const parsed = parseNumericEpicDir(name);
|
|
297
|
+
if (parsed)
|
|
298
|
+
parsedDirs.push(parsed);
|
|
299
|
+
}
|
|
300
|
+
const commonSlugs = new Set(base.epics.filter((e) => e.kind === 'common').map((e) => e.slug));
|
|
301
|
+
for (const slug of commonSlugs) {
|
|
302
|
+
const dirs = parsedDirs.filter((d) => d.slug === slug);
|
|
303
|
+
if (dirs.length > 1) {
|
|
304
|
+
const names = dirs.map((d) => `${d.id}-${d.slug}`).join(', ');
|
|
305
|
+
throw new Error(`more than one existing directory for common epic ${slug}: ${names}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let next = parsedDirs.reduce((max, d) => Math.max(max, Number(d.id)), 0) + 1;
|
|
309
|
+
const epics = [];
|
|
310
|
+
const scaffold = [];
|
|
311
|
+
for (const common of base.epics.filter((e) => e.kind === 'common')) {
|
|
312
|
+
const existing = parsedDirs.find((d) => d.slug === common.slug);
|
|
313
|
+
if (existing) {
|
|
314
|
+
epics.push({ slug: common.slug, id: existing.id, kind: 'common', active: common.active });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (next > 999)
|
|
318
|
+
throw new Error('no free epic id left (max 999)');
|
|
319
|
+
const id = String(next).padStart(3, '0');
|
|
320
|
+
next += 1;
|
|
321
|
+
epics.push({ slug: common.slug, id, kind: 'common', active: common.active });
|
|
322
|
+
scaffold.push({ slug: common.slug, id });
|
|
323
|
+
}
|
|
324
|
+
for (const dir of parsedDirs) {
|
|
325
|
+
if (commonSlugs.has(dir.slug))
|
|
326
|
+
continue;
|
|
327
|
+
epics.push({ slug: dir.slug, id: dir.id, kind: 'legacy', active: true });
|
|
328
|
+
}
|
|
329
|
+
return { registry: { ...base, epics }, scaffold };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 등록부 형식 위반을 S35 실패 목록으로 돌려준다. 파일이 없으면 빈 배열이다.
|
|
333
|
+
*
|
|
334
|
+
* @param {{repoRoot: string}} opts - 저장소 루트
|
|
335
|
+
* @returns {RegistryFailure[]} 형식 위반 S35 항목(없으면 빈 배열)
|
|
336
|
+
*/
|
|
337
|
+
function checkEpicRegistry({ repoRoot }) {
|
|
338
|
+
const read = readEpicRegistry(repoRoot);
|
|
339
|
+
if (read.status !== 'invalid')
|
|
340
|
+
return [];
|
|
341
|
+
return [{ code: 'S35', message: `epic registry is invalid: ${read.message}`, file: REGISTRY_REL }];
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* 등록부를 JSON 2칸 들여쓰기 + 끝 개행으로 직렬화한다. 등록부 쓰기는 이 함수 하나로 한다.
|
|
345
|
+
*
|
|
346
|
+
* @param {EpicRegistry} registry - 직렬화할 등록부
|
|
347
|
+
* @returns {string} 파일에 쓸 문자열
|
|
348
|
+
*/
|
|
349
|
+
function serializeEpicRegistry(registry) {
|
|
350
|
+
return `${JSON.stringify(registry, null, 2)}\n`;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* epic slug·area 이름이 새 이름으로 쓸 수 있는 형식인지 본다. 공통 epic slug와
|
|
354
|
+
* 목적형 이름은 등록부가 막으려는 대상이라 도메인 epic과 area 모두에서 거절한다.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} name - 검사할 이름
|
|
357
|
+
* @returns {string | null} 위반 사유(통과하면 null)
|
|
358
|
+
*/
|
|
359
|
+
function nameViolation(name) {
|
|
360
|
+
const slugViolation = validateSlug(name);
|
|
361
|
+
if (slugViolation !== null)
|
|
362
|
+
return `"${name}" is not a valid slug: ${slugViolation}`;
|
|
363
|
+
if (COMMON_EPICS.some((c) => c.slug === name))
|
|
364
|
+
return `"${name}" is a common epic slug`;
|
|
365
|
+
if (PURPOSE_NAMES.includes(name))
|
|
366
|
+
return `"${name}" is a purpose-style name, not a domain name`;
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 등록부에 도메인 epic 하나를 더한 새 등록부를 돌려준다. 입력 등록부는 바꾸지 않으며
|
|
371
|
+
* 검증을 모두 통과해야만 ok를 돌려줘 호출자가 쓰기를 한 번만 하게 한다.
|
|
372
|
+
* id는 등록부 id와 디렉터리 번호 중 최대값+1이다(디렉터리만 있는 번호를 재사용하지 않기 위함).
|
|
373
|
+
*
|
|
374
|
+
* @param {EpicRegistry} registry - 현재 등록부
|
|
375
|
+
* @param {{slug: string, reason: string, approvedAt: string}} input - 새 epic과 승인 근거
|
|
376
|
+
* @param {string[]} existingEpicDirs - epics 디렉터리 직계 이름(`NNN-slug`)
|
|
377
|
+
* @returns {{ok: true, registry: EpicRegistry, epic: {slug: string, id: string}} | AddFailure} 추가 결과
|
|
378
|
+
*/
|
|
379
|
+
function addDomainEpic(registry, input, existingEpicDirs) {
|
|
380
|
+
const fail = (reason, cause) => ({ ok: false, reason, cause });
|
|
381
|
+
if (input.reason.trim() === '')
|
|
382
|
+
return fail('epic-registry-reason-missing', '--reason must not be blank');
|
|
383
|
+
const violation = nameViolation(input.slug);
|
|
384
|
+
if (violation !== null)
|
|
385
|
+
return fail('epic-registry-name-invalid', violation);
|
|
386
|
+
if (registry.epics.some((e) => e.slug === input.slug)) {
|
|
387
|
+
return fail('epic-registry-name-taken', `epic slug already registered: ${input.slug}`);
|
|
388
|
+
}
|
|
389
|
+
const domainCount = registry.epics.filter((e) => e.kind === 'domain').length;
|
|
390
|
+
if (domainCount >= registry.domainEpicLimit) {
|
|
391
|
+
const limit = registry.domainEpicLimit;
|
|
392
|
+
return fail('epic-registry-domain-limit', `domain epics ${domainCount} reached domainEpicLimit ${limit}`);
|
|
393
|
+
}
|
|
394
|
+
// v2 항목은 id가 없을 수 있다(NaN 방지). 번호 공간은 v1 id와 v2 legacy_id가 같다.
|
|
395
|
+
let max = registry.epics.reduce((m, e) => Math.max(m, Number(e.legacy_id ?? e.id ?? 0)), 0);
|
|
396
|
+
for (const name of existingEpicDirs) {
|
|
397
|
+
const prefix = parseNumericPrefix(name);
|
|
398
|
+
if (prefix !== null)
|
|
399
|
+
max = Math.max(max, Number(prefix));
|
|
400
|
+
}
|
|
401
|
+
if (max + 1 > 999)
|
|
402
|
+
return fail('epic-registry-invalid', 'no free epic id left (max 999)');
|
|
403
|
+
const id = String(max + 1).padStart(3, '0');
|
|
404
|
+
// v2는 번호를 경로·키로 쓰지 않으므로 새 항목에 id를 쓰지 않는다(호출자 응답용 id만 돌려준다).
|
|
405
|
+
const epic = {
|
|
406
|
+
slug: input.slug, ...(registry.version === 1 ? { id } : {}), kind: 'domain', active: true,
|
|
407
|
+
approval: { reason: input.reason, approvedAt: input.approvedAt },
|
|
408
|
+
};
|
|
409
|
+
return { ok: true, registry: { ...registry, epics: [...registry.epics, epic] }, epic: { slug: input.slug, id } };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 등록부 `areas`에 이름 하나를 더한 새 등록부를 돌려준다. 입력 등록부는 바꾸지 않는다.
|
|
413
|
+
*
|
|
414
|
+
* @param {EpicRegistry} registry - 현재 등록부
|
|
415
|
+
* @param {string} name - 추가할 area 이름
|
|
416
|
+
* @returns {{ok: true, registry: EpicRegistry, area: string} | AddFailure} 추가 결과
|
|
417
|
+
*/
|
|
418
|
+
function addArea(registry, name) {
|
|
419
|
+
const violation = nameViolation(name);
|
|
420
|
+
if (violation !== null)
|
|
421
|
+
return { ok: false, reason: 'epic-registry-name-invalid', cause: violation };
|
|
422
|
+
if (registry.areas.includes(name)) {
|
|
423
|
+
return { ok: false, reason: 'epic-registry-name-taken', cause: `area already registered: ${name}` };
|
|
424
|
+
}
|
|
425
|
+
return { ok: true, registry: { ...registry, areas: [...registry.areas, name] }, area: name };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* 등록부를 `.bouncer/epics.json`에 쓴다. 호출자가 검증을 끝낸 뒤 한 번만 부른다.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
431
|
+
* @param {EpicRegistry} registry - 쓸 등록부
|
|
432
|
+
* @returns {void}
|
|
433
|
+
* @throws {Error} 파일 쓰기에 실패했을 때(호출자가 그대로 올린다)
|
|
434
|
+
*/
|
|
435
|
+
function writeEpicRegistry(repoRoot, registry) {
|
|
436
|
+
fs.writeFileSync(path.join(repoRoot, REGISTRY_REL), serializeEpicRegistry(registry));
|
|
437
|
+
}
|
|
438
|
+
module.exports = {
|
|
439
|
+
REGISTRY_REL,
|
|
440
|
+
addDomainEpic,
|
|
441
|
+
addArea,
|
|
442
|
+
writeEpicRegistry,
|
|
443
|
+
COMMON_EPICS,
|
|
444
|
+
PURPOSE_NAMES,
|
|
445
|
+
parseEpicRegistry,
|
|
446
|
+
readEpicRegistry,
|
|
447
|
+
epicByLegacyId,
|
|
448
|
+
resolveBlueprintAlias,
|
|
449
|
+
expandProfile,
|
|
450
|
+
planRegistry,
|
|
451
|
+
checkEpicRegistry,
|
|
452
|
+
serializeEpicRegistry,
|
|
453
|
+
};
|