@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,575 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const layout = require("./layout");
|
|
5
|
+
const { isCanonicalEpicDir, isCanonicalBlueprintDir, isValidBlueprintOrder, listBlueprintOrders, } = layout;
|
|
6
|
+
const contextIds = require("./context-ids");
|
|
7
|
+
const { parseContextPath } = contextIds;
|
|
8
|
+
const paths = require("./paths");
|
|
9
|
+
const { toPosix } = paths;
|
|
10
|
+
const current = require("./current");
|
|
11
|
+
const { resolveCurrent } = current;
|
|
12
|
+
const frontmatter = require("./frontmatter");
|
|
13
|
+
const { readDoc } = frontmatter;
|
|
14
|
+
const tasksDocs = require("./tasks-docs");
|
|
15
|
+
const { listTasksDocs } = tasksDocs;
|
|
16
|
+
const epicRegistry = require("./epic-registry");
|
|
17
|
+
const { readEpicRegistry, COMMON_EPICS } = epicRegistry;
|
|
18
|
+
const BOUNCER_DIR = '.bouncer';
|
|
19
|
+
const EPICS_REL = '.bouncer/context/epics';
|
|
20
|
+
const VERIFY_FILES = [
|
|
21
|
+
'docker-compose.yml',
|
|
22
|
+
'docker-compose.yaml',
|
|
23
|
+
'compose.yml',
|
|
24
|
+
'compose.yaml',
|
|
25
|
+
'Makefile',
|
|
26
|
+
'Taskfile.yml',
|
|
27
|
+
];
|
|
28
|
+
// light/full 추천 신호용 경로 규칙. 설정 파일로 열지 않는 코드 상수 —
|
|
29
|
+
// review_risk(frontmatter)와 별개 계약이라 여기만 소유한다.
|
|
30
|
+
const SECURITY_SEGMENTS = new Set([
|
|
31
|
+
'auth',
|
|
32
|
+
'credential',
|
|
33
|
+
'credentials',
|
|
34
|
+
'secret',
|
|
35
|
+
'secrets',
|
|
36
|
+
'token',
|
|
37
|
+
'tokens',
|
|
38
|
+
'permission',
|
|
39
|
+
'permissions',
|
|
40
|
+
]);
|
|
41
|
+
const MANIFEST_EXACT = new Set([
|
|
42
|
+
'package.json',
|
|
43
|
+
'package-lock.json',
|
|
44
|
+
'bun.lock',
|
|
45
|
+
'yarn.lock',
|
|
46
|
+
'pnpm-lock.yaml',
|
|
47
|
+
'go.mod',
|
|
48
|
+
]);
|
|
49
|
+
const MODULE_EXCLUDED = new Set(['test', 'tests', 'docs']);
|
|
50
|
+
/**
|
|
51
|
+
* `\d{3}-<slug>` 디렉터리 이름만 모아 최댓값 + 1을 세 자리로 채운다.
|
|
52
|
+
* 구형 `EPIC-009` / `BP-001` 이름은 정규식에서 빠지므로 번호가 그 쪽으로
|
|
53
|
+
* 점프하지 않는다. 후보가 없으면 `001` — 빈 트리의 첫 id. blueprint id 제안에 쓴다.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} names - 한 부모 아래의 디렉터리 이름
|
|
56
|
+
* @returns {string} zero-pad 세 자리 다음 id
|
|
57
|
+
*/
|
|
58
|
+
function nextNumericId(names) {
|
|
59
|
+
let max = 0;
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
// 정본 번호 이름(`ddd-slug`)만 센다. EPIC-/BP- 접두와 slug 이름은 최댓값에 넣지 않아 구형 디렉터리가
|
|
62
|
+
// 다음 번호를 건너뛰게 하지 않는다. 세그먼트 해석은 context-ids 한 곳에 맡기고, blueprint 이름도
|
|
63
|
+
// epic 자리에 끼워 같은 규칙으로 읽는다.
|
|
64
|
+
const parsed = parseContextPath(`epics/${name}`);
|
|
65
|
+
if (!parsed || parsed.format !== 'numeric')
|
|
66
|
+
continue;
|
|
67
|
+
const n = Number(parsed.epicKey);
|
|
68
|
+
if (Number.isFinite(n) && n > max)
|
|
69
|
+
max = n;
|
|
70
|
+
}
|
|
71
|
+
return String(max + 1).padStart(3, '0');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 활성 `features` epic의 blueprint를 `bouncer.area`별로 세어 승격 후보를 낸다.
|
|
75
|
+
* 읽기 전용이며 승격은 실행하지 않는다 — 사용자 승인 뒤 스킬이 명령을 돌린다.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
78
|
+
* @param {string | null} featuresDir - 활성 features epic의 repo-relative 디렉터리(없으면 null)
|
|
79
|
+
* @param {number | undefined} threshold - 등록부 `areaPromotionThreshold`
|
|
80
|
+
* @returns {{ area: string, count: number }[]} count가 threshold 이상인 area(이름순)
|
|
81
|
+
*/
|
|
82
|
+
function countAreaPromotions(repoRoot, featuresDir, threshold) {
|
|
83
|
+
if (threshold === undefined || featuresDir === null)
|
|
84
|
+
return [];
|
|
85
|
+
const bpRoot = path.join(repoRoot, featuresDir, 'blueprints');
|
|
86
|
+
const counts = new Map();
|
|
87
|
+
for (const name of listDirNames(bpRoot)) {
|
|
88
|
+
try {
|
|
89
|
+
const doc = readDoc(path.join(bpRoot, name, 'index.md'));
|
|
90
|
+
const data = doc.data && typeof doc.data === 'object' ? doc.data : {};
|
|
91
|
+
const bouncer = data.bouncer && typeof data.bouncer === 'object'
|
|
92
|
+
? data.bouncer : null;
|
|
93
|
+
const area = bouncer ? bouncer.area : undefined;
|
|
94
|
+
if (typeof area === 'string' && area !== '')
|
|
95
|
+
counts.set(area, (counts.get(area) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
catch (_e) {
|
|
98
|
+
// index.md가 없거나 파싱 불가한 blueprint는 집계에서 뺀다. inspect는 읽기 전용이라
|
|
99
|
+
// 문서를 고치지 않고, 한 문서의 오류가 나머지 집계를 막지 않게 한다.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...counts.entries()]
|
|
103
|
+
.filter(([, count]) => count >= threshold)
|
|
104
|
+
.map(([area, count]) => ({ area, count }))
|
|
105
|
+
.sort((a, b) => (a.area < b.area ? -1 : a.area > b.area ? 1 : 0));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 등록부(`.bouncer/epics.json`)를 한 번 읽어 inspect용 후보 목록으로 바꾼다.
|
|
109
|
+
* 귀속은 판단하지 않고 사실(활성 epic, 디렉터리 유무, legacy, area)만 낸다.
|
|
110
|
+
* 형식 위반도 실패로 올리지 않고 `invalid`로 접어 plan이 안내를 이어가게 한다.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
113
|
+
* @param {string[]} dirNames - epics 디렉터리 직계 이름(v1 `<id>-<slug>`, v2 `<slug>`)
|
|
114
|
+
* @returns {{ registry: InspectRegistry, nextEpicId: string | null }} 등록부 요약과
|
|
115
|
+
* 승인됐으나 디렉터리가 없는 도메인 epic 중 가장 작은 id(없거나 등록부 불량이면 null, v2는 번호가 없어 항상 null)
|
|
116
|
+
*/
|
|
117
|
+
function summarizeRegistry(repoRoot, dirNames) {
|
|
118
|
+
const read = readEpicRegistry(repoRoot);
|
|
119
|
+
if (read.status === 'missing') {
|
|
120
|
+
return {
|
|
121
|
+
registry: { status: 'missing', next: 'Run `bouncer init` to create .bouncer/epics.json.' },
|
|
122
|
+
nextEpicId: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (read.status === 'invalid') {
|
|
126
|
+
return {
|
|
127
|
+
registry: {
|
|
128
|
+
status: 'invalid',
|
|
129
|
+
code: 'S35',
|
|
130
|
+
message: `epic registry is invalid: ${read.message}`,
|
|
131
|
+
next: 'Fix .bouncer/epics.json so `bouncer validate` no longer reports S35.',
|
|
132
|
+
},
|
|
133
|
+
nextEpicId: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const { registry } = read;
|
|
137
|
+
// v1은 `<id>-<slug>` 디렉터리와 `id`를, v2는 `<slug>` 디렉터리와 선택 `legacy_id`를 쓴다.
|
|
138
|
+
// 버전은 등록부 `version`으로만 가른다(디렉터리 이름 모양으로 추측하지 않는다).
|
|
139
|
+
const isV2 = registry.version === 2;
|
|
140
|
+
const idOf = (entry) => (isV2 ? entry.legacy_id ?? null : entry.id);
|
|
141
|
+
const dirOf = (entry) => {
|
|
142
|
+
const name = isV2 ? entry.slug : `${entry.id}-${entry.slug}`;
|
|
143
|
+
return dirNames.includes(name) ? `${EPICS_REL}/${name}` : null;
|
|
144
|
+
};
|
|
145
|
+
const epics = [];
|
|
146
|
+
const legacy = [];
|
|
147
|
+
const pendingDomainIds = [];
|
|
148
|
+
for (const entry of registry.epics) {
|
|
149
|
+
if (!entry.active)
|
|
150
|
+
continue;
|
|
151
|
+
const dir = dirOf(entry);
|
|
152
|
+
const id = idOf(entry);
|
|
153
|
+
if (entry.kind === 'legacy') {
|
|
154
|
+
// legacy는 디렉터리가 있어야 의미가 있다. 없는 항목은 후보로 내지 않는다.
|
|
155
|
+
if (dir !== null)
|
|
156
|
+
legacy.push({ slug: entry.slug, id, dir });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (entry.kind === 'domain') {
|
|
160
|
+
// v2 slug epic은 번호가 없어 다음 번호 후보에 넣지 않는다.
|
|
161
|
+
if (dir === null && !isV2)
|
|
162
|
+
pendingDomainIds.push(entry.id);
|
|
163
|
+
epics.push({
|
|
164
|
+
slug: entry.slug, id, dir, kind: 'domain',
|
|
165
|
+
description: null, include: entry.approval ? entry.approval.reason : '', exclude: '',
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const common = COMMON_EPICS.find((c) => c.slug === entry.slug);
|
|
170
|
+
epics.push({
|
|
171
|
+
slug: entry.slug, id, dir, kind: 'common',
|
|
172
|
+
description: common ? common.description : null,
|
|
173
|
+
include: common ? common.include : '',
|
|
174
|
+
exclude: common ? common.exclude : '',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
pendingDomainIds.sort();
|
|
178
|
+
// 상한이 남아 있을 때만 후보를 낸다: 소진되면 add-domain이 어차피 거절된다.
|
|
179
|
+
const domainCount = registry.epics.filter((e) => e.kind === 'domain').length;
|
|
180
|
+
const features = epics.find((e) => e.slug === 'features');
|
|
181
|
+
const promotions = domainCount < registry.domainEpicLimit
|
|
182
|
+
? countAreaPromotions(repoRoot, features ? features.dir : null, registry.areaPromotionThreshold)
|
|
183
|
+
: [];
|
|
184
|
+
return {
|
|
185
|
+
registry: {
|
|
186
|
+
status: 'ok', profile: registry.profile, epics, legacy, areas: registry.areas, promotions,
|
|
187
|
+
},
|
|
188
|
+
nextEpicId: pendingDomainIds.length > 0 ? pendingDomainIds[0] : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 한 부모의 직계 디렉터리 이름만 나열한다. 읽기 실패는 빈 목록 — inspect는
|
|
193
|
+
* 추천값만 계산하므로 권한 오류를 종료로 승격하지 않는다.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} absDir - 절대 경로
|
|
196
|
+
* @returns {string[]} 디렉터리 이름
|
|
197
|
+
*/
|
|
198
|
+
function listDirNames(absDir) {
|
|
199
|
+
try {
|
|
200
|
+
if (!fs.existsSync(absDir))
|
|
201
|
+
return [];
|
|
202
|
+
const entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
203
|
+
const names = [];
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.isDirectory())
|
|
206
|
+
names.push(entry.name);
|
|
207
|
+
}
|
|
208
|
+
return names;
|
|
209
|
+
}
|
|
210
|
+
catch (_e) {
|
|
211
|
+
// EACCES 등만 접는다. 추천 id를 못 구하면 빈 목록으로 `001`이 나온다.
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* epic/blueprint `index.md`의 `bouncer.status`만 읽는다. 파싱 실패는 null —
|
|
217
|
+
* inspect가 문서를 고치지 않고, 없는 상태를 지어내지 않기 위함.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} absIndex - index.md 절대 경로
|
|
220
|
+
* @returns {string | null} 문자열 status, 없거나 읽을 수 없으면 null
|
|
221
|
+
*/
|
|
222
|
+
function readStatus(absIndex) {
|
|
223
|
+
try {
|
|
224
|
+
const doc = readDoc(absIndex);
|
|
225
|
+
const data = doc.data && typeof doc.data === 'object'
|
|
226
|
+
? doc.data : {};
|
|
227
|
+
const bouncer = data.bouncer && typeof data.bouncer === 'object'
|
|
228
|
+
? data.bouncer : null;
|
|
229
|
+
return bouncer && typeof bouncer.status === 'string' ? bouncer.status : null;
|
|
230
|
+
}
|
|
231
|
+
catch (_e) {
|
|
232
|
+
// 부재·YAML 오류는 상태 없음으로만 접는다. inspect는 문서를 복구하지 않는다.
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* slug epic의 다음 blueprint 순서를 구한다. 쓸 수 있는 `order`(양의 정수)의 최댓값 + 1이다.
|
|
238
|
+
* 간격은 메우지 않는다(1·2·5 → 6) — 순서는 디렉터리 이름이 아니라 frontmatter가 가지므로
|
|
239
|
+
* 중간 값을 비워 둔 채 계속 추가해도 기존 순서가 흔들리지 않는다. 읽을 수 없거나 모양이 틀린 값은
|
|
240
|
+
* 최댓값에서 뺀다(그 문서의 오류는 S37이 따로 보고한다). 후보가 없으면 1이다.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
243
|
+
* @param {string} dirRel - 저장소 상대 epic 디렉터리
|
|
244
|
+
* @returns {number} 다음 순서(양의 정수)
|
|
245
|
+
*/
|
|
246
|
+
function nextBlueprintOrder(repoRoot, dirRel) {
|
|
247
|
+
let max = 0;
|
|
248
|
+
for (const entry of listBlueprintOrders(repoRoot, dirRel)) {
|
|
249
|
+
if (isValidBlueprintOrder(entry.order) && entry.order > max)
|
|
250
|
+
max = entry.order;
|
|
251
|
+
}
|
|
252
|
+
return max + 1;
|
|
253
|
+
}
|
|
254
|
+
function epicInfo(repoRoot, dirRel) {
|
|
255
|
+
const status = readStatus(path.join(repoRoot, dirRel, 'index.md'));
|
|
256
|
+
// 형식은 경로로 판정한다(호출자가 정본 검사를 통과시켰다).
|
|
257
|
+
if (parseContextPath(dirRel)?.format === 'slug') {
|
|
258
|
+
return { dir: dirRel, status, nextBlueprintId: null, nextBlueprintOrder: nextBlueprintOrder(repoRoot, dirRel) };
|
|
259
|
+
}
|
|
260
|
+
const nextBlueprintId = nextNumericId(listDirNames(path.join(repoRoot, dirRel, 'blueprints')));
|
|
261
|
+
return { dir: dirRel, status, nextBlueprintId, nextBlueprintOrder: Number(nextBlueprintId) };
|
|
262
|
+
}
|
|
263
|
+
function presentCurrent(repoRoot) {
|
|
264
|
+
const resolved = resolveCurrent({ repoRoot });
|
|
265
|
+
if (resolved.status === 'selected') {
|
|
266
|
+
return {
|
|
267
|
+
status: 'selected',
|
|
268
|
+
blueprint: resolved.current.blueprint,
|
|
269
|
+
task: resolved.current.task,
|
|
270
|
+
base: resolved.current.base,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// empty·ambiguous·invalid 모두 포인터 필드를 비운다. 워크플로 중단은
|
|
274
|
+
// 스킬이 status를 보고 결정하며, inspect는 후보를 고르지 않는다.
|
|
275
|
+
return {
|
|
276
|
+
status: resolved.status,
|
|
277
|
+
blueprint: null,
|
|
278
|
+
task: null,
|
|
279
|
+
base: null,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 루트 파일 존재와 `package.json`의 `scripts` 키 존재만 본다.
|
|
284
|
+
* compose/Makefile 내용은 읽지 않는다. JSON 파싱은 키 존재 확인에만 쓴다.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
287
|
+
* @returns {string[]} Interface 나열 순의 신호 토큰
|
|
288
|
+
*/
|
|
289
|
+
function collectVerifySignals(repoRoot) {
|
|
290
|
+
const signals = [];
|
|
291
|
+
for (const name of VERIFY_FILES) {
|
|
292
|
+
if (fs.existsSync(path.join(repoRoot, name)))
|
|
293
|
+
signals.push(name);
|
|
294
|
+
}
|
|
295
|
+
const pkgAbs = path.join(repoRoot, 'package.json');
|
|
296
|
+
if (!fs.existsSync(pkgAbs))
|
|
297
|
+
return signals;
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(fs.readFileSync(pkgAbs, 'utf8'));
|
|
300
|
+
if (parsed
|
|
301
|
+
&& typeof parsed === 'object'
|
|
302
|
+
&& !Array.isArray(parsed)
|
|
303
|
+
&& Object.hasOwn(parsed, 'scripts')) {
|
|
304
|
+
signals.push('package.json#scripts');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (_e) {
|
|
308
|
+
// 깨진 JSON은 scripts 키를 확인할 수 없으므로 신호에 넣지 않는다.
|
|
309
|
+
}
|
|
310
|
+
return signals;
|
|
311
|
+
}
|
|
312
|
+
function normalizeRelDir(value) {
|
|
313
|
+
if (typeof value !== 'string' || value === '')
|
|
314
|
+
return null;
|
|
315
|
+
return toPosix(value).replace(/\/+$/, '');
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* basename이 `prefix*suffix` 형태(단일 `*`)와 맞는지 본다.
|
|
319
|
+
* 설정/외부 glob 엔진 없이 상수 규칙만 쓰기 위함.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} name - 파일 basename
|
|
322
|
+
* @param {string} prefix - `*` 앞
|
|
323
|
+
* @param {string} suffix - `*` 뒤
|
|
324
|
+
* @returns {boolean} 매칭 여부
|
|
325
|
+
*/
|
|
326
|
+
function matchStar(name, prefix, suffix) {
|
|
327
|
+
return name.startsWith(prefix) && name.endsWith(suffix)
|
|
328
|
+
&& name.length >= prefix.length + suffix.length;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 경로를 `/` 조각으로 나누고 마지막 조각의 확장자를 벗긴다.
|
|
332
|
+
* `tokenizer`≠`token`처럼 부분 문자열 오탐을 막으려면 확장자 없는
|
|
333
|
+
* 조각과 정확히 비교해야 한다.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} relPath - repo-relative posix 경로
|
|
336
|
+
* @returns {string[]} 확장자 없는 경로 조각
|
|
337
|
+
*/
|
|
338
|
+
function pathSegments(relPath) {
|
|
339
|
+
const parts = toPosix(relPath).split('/').filter(Boolean);
|
|
340
|
+
return parts.map((part, index) => {
|
|
341
|
+
if (index !== parts.length - 1)
|
|
342
|
+
return part;
|
|
343
|
+
// `.github` 같은 leading-dot 디렉터리는 확장자가 아니다.
|
|
344
|
+
if (part.startsWith('.'))
|
|
345
|
+
return part;
|
|
346
|
+
const dot = part.lastIndexOf('.');
|
|
347
|
+
return dot > 0 ? part.slice(0, dot) : part;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* 한 경로가 걸리는 첫 risk kind를 고른다. 우선순위는 security → manifest →
|
|
352
|
+
* build → migration — 한 path에 여러 kind를 쌓지 않아 recommendation 근거가
|
|
353
|
+
* 중복되지 않게 한다.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} relPath - repo-relative 경로
|
|
356
|
+
* @returns {RiskKind | null} 매칭 kind, 없으면 null
|
|
357
|
+
*/
|
|
358
|
+
function classifyOnePath(relPath) {
|
|
359
|
+
const posix = toPosix(relPath);
|
|
360
|
+
const base = path.posix.basename(posix);
|
|
361
|
+
const segments = pathSegments(posix);
|
|
362
|
+
if (segments.some((seg) => SECURITY_SEGMENTS.has(seg)))
|
|
363
|
+
return 'security';
|
|
364
|
+
if (MANIFEST_EXACT.has(base)
|
|
365
|
+
|| matchStar(base, 'requirements', '.txt')) {
|
|
366
|
+
return 'manifest';
|
|
367
|
+
}
|
|
368
|
+
if (matchStar(base, 'Dockerfile', '')
|
|
369
|
+
|| base === 'Makefile'
|
|
370
|
+
|| matchStar(base, 'tsconfig', '.json')
|
|
371
|
+
|| posix === '.github/workflows'
|
|
372
|
+
|| posix.startsWith('.github/workflows/')) {
|
|
373
|
+
return 'build';
|
|
374
|
+
}
|
|
375
|
+
// migrations/ 접두·조각, schema 조각. `schemas` 복수형은 규칙에 없어 제외.
|
|
376
|
+
if (segments.includes('migrations')
|
|
377
|
+
|| segments.includes('schema')) {
|
|
378
|
+
return 'migration';
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 경로 목록을 risk kind로 분류한다. I/O 없음 — 단위 테스트가 규칙만
|
|
384
|
+
* 고정하고 planInspect는 집계 전에 같은 헬퍼를 재사용한다.
|
|
385
|
+
*
|
|
386
|
+
* @param {string[]} pathList - repo-relative 경로 목록
|
|
387
|
+
* @returns {Array<{ path: string, kind: RiskKind }>} 매칭된 {path, kind}만, 입력 순
|
|
388
|
+
*/
|
|
389
|
+
function classifyRoutingPaths(pathList) {
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const relPath of pathList) {
|
|
392
|
+
if (typeof relPath !== 'string' || relPath === '')
|
|
393
|
+
continue;
|
|
394
|
+
const kind = classifyOnePath(relPath);
|
|
395
|
+
if (kind !== null)
|
|
396
|
+
out.push({ path: toPosix(relPath), kind });
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 모듈 토큰: 첫 경로 조각. `test`/`tests`/`docs`와 루트 파일(슬래시 없음)은
|
|
402
|
+
* 접촉 모듈이 아니므로 세지 않는다 — light 후보를 path count로 키우지 않기 위함.
|
|
403
|
+
*
|
|
404
|
+
* @param {string[]} pathList - affected_paths 합집합
|
|
405
|
+
* @returns {string[]} 정렬·중복 제거된 모듈 이름
|
|
406
|
+
*/
|
|
407
|
+
function collectModules(pathList) {
|
|
408
|
+
const modules = new Set();
|
|
409
|
+
for (const relPath of pathList) {
|
|
410
|
+
const posix = toPosix(relPath);
|
|
411
|
+
if (!posix.includes('/'))
|
|
412
|
+
continue;
|
|
413
|
+
const head = posix.split('/')[0];
|
|
414
|
+
if (!head || MODULE_EXCLUDED.has(head))
|
|
415
|
+
continue;
|
|
416
|
+
modules.add(head);
|
|
417
|
+
}
|
|
418
|
+
return [...modules].sort();
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* commit task 요약으로 advisory routing을 만든다. 선택·승인은 하지 않고
|
|
422
|
+
* recommendation/reasons만 낸다 — light 선언은 사용자 소유다.
|
|
423
|
+
*
|
|
424
|
+
* @param {Array<{ paths: string[], dependsOn: string[] }>} taskList - 이미
|
|
425
|
+
* commit-only로 걸러진 task 입력. paths는 affected_paths, dependsOn은 depends_on
|
|
426
|
+
* @returns {Routing} advisory routing 객체
|
|
427
|
+
*/
|
|
428
|
+
function summarizeRouting(taskList) {
|
|
429
|
+
const tasks = taskList.length;
|
|
430
|
+
let dependencies = 0;
|
|
431
|
+
const allPaths = [];
|
|
432
|
+
for (const task of taskList) {
|
|
433
|
+
dependencies += Array.isArray(task.dependsOn) ? task.dependsOn.length : 0;
|
|
434
|
+
if (Array.isArray(task.paths)) {
|
|
435
|
+
for (const p of task.paths) {
|
|
436
|
+
if (typeof p === 'string' && p !== '')
|
|
437
|
+
allPaths.push(p);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const modules = collectModules(allPaths);
|
|
442
|
+
const riskPaths = classifyRoutingPaths(allPaths);
|
|
443
|
+
const reasons = [];
|
|
444
|
+
// 조건 이름 = 필드명. 스킬이 reasons를 그대로 인용할 수 있게 짧게 둔다.
|
|
445
|
+
if (tasks >= 2)
|
|
446
|
+
reasons.push('tasks');
|
|
447
|
+
if (dependencies > 0)
|
|
448
|
+
reasons.push('dependencies');
|
|
449
|
+
if (modules.length >= 3)
|
|
450
|
+
reasons.push('modules');
|
|
451
|
+
if (riskPaths.length > 0)
|
|
452
|
+
reasons.push('riskPaths');
|
|
453
|
+
// empty는 full 트리거가 아니다. 근거만 남기고 light-candidate를 유지한다.
|
|
454
|
+
if (allPaths.length === 0)
|
|
455
|
+
reasons.push('affected-paths-empty');
|
|
456
|
+
const recommendation = (tasks >= 2
|
|
457
|
+
|| dependencies > 0
|
|
458
|
+
|| modules.length >= 3
|
|
459
|
+
|| riskPaths.length > 0) ? 'full-candidate' : 'light-candidate';
|
|
460
|
+
return {
|
|
461
|
+
advisory: true,
|
|
462
|
+
tasks,
|
|
463
|
+
dependencies,
|
|
464
|
+
modules,
|
|
465
|
+
riskPaths,
|
|
466
|
+
recommendation,
|
|
467
|
+
reasons,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* blueprint의 commit task에서 paths/dependsOn만 모은다. verification task는
|
|
472
|
+
* tasks 카운트·모듈·위험 경로에 넣지 않는다 — light 신호는 구현 커밋 범위다.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} repoRoot - 저장소 루트
|
|
475
|
+
* @param {string} blueprintDir - 정본 blueprint 상대 경로
|
|
476
|
+
* @returns {RoutingTask[]} summarizeRouting 입력
|
|
477
|
+
*/
|
|
478
|
+
function loadRoutingTasks(repoRoot, blueprintDir) {
|
|
479
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir });
|
|
480
|
+
const out = [];
|
|
481
|
+
for (const entry of listing.entries) {
|
|
482
|
+
if (entry.executionKind !== 'commit')
|
|
483
|
+
continue;
|
|
484
|
+
let paths = [];
|
|
485
|
+
let dependsOn = [];
|
|
486
|
+
try {
|
|
487
|
+
const doc = readDoc(path.join(repoRoot, entry.tasks.rel));
|
|
488
|
+
const data = doc.data && typeof doc.data === 'object'
|
|
489
|
+
? doc.data : {};
|
|
490
|
+
const bouncer = data.bouncer && typeof data.bouncer === 'object'
|
|
491
|
+
? data.bouncer : null;
|
|
492
|
+
if (bouncer) {
|
|
493
|
+
if (Array.isArray(bouncer.affected_paths)) {
|
|
494
|
+
paths = bouncer.affected_paths.filter((p) => typeof p === 'string' && p !== '');
|
|
495
|
+
}
|
|
496
|
+
if (Array.isArray(bouncer.depends_on)) {
|
|
497
|
+
dependsOn = bouncer.depends_on.filter((p) => typeof p === 'string' && p !== '');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (_e) {
|
|
502
|
+
// 파싱 실패 task는 빈 paths로만 센다. inspect가 draft를 고치지 않는다.
|
|
503
|
+
}
|
|
504
|
+
out.push({ paths, dependsOn });
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* 계획 스킬이 본문에서 반복하던 등록부 후보·다음 도메인 id·verify 신호·포인터 상태와
|
|
510
|
+
* (선택) light 추천 근거를 한 번 계산한다. 추천값만 반환하며 어떤 경로에도
|
|
511
|
+
* 파일을 쓰지 않는다. `--blueprint`가 없으면 `routing: null`.
|
|
512
|
+
*
|
|
513
|
+
* @param {{ repoRoot: string, epicDir?: unknown, blueprintDir?: unknown }} opts -
|
|
514
|
+
* repoRoot는 대상 저장소, epicDir/blueprintDir은 선택적 정본 상대 경로
|
|
515
|
+
* @returns {InspectResult} 성공 JSON 또는 not-initialized / invalid-*-dir
|
|
516
|
+
*/
|
|
517
|
+
function planInspect({ repoRoot, epicDir, blueprintDir }) {
|
|
518
|
+
const bouncerAbs = path.join(repoRoot, BOUNCER_DIR);
|
|
519
|
+
// 1. 초기화 여부. epic-dir 형식보다 앞선다 — 없는 트리의 경로 오류로
|
|
520
|
+
// 위장하면 `/bouncer-init` 안내가 빗나간다.
|
|
521
|
+
if (!fs.existsSync(bouncerAbs) || !fs.statSync(bouncerAbs).isDirectory()) {
|
|
522
|
+
return { ok: false, reason: 'not-initialized' };
|
|
523
|
+
}
|
|
524
|
+
let epic = null;
|
|
525
|
+
if (epicDir !== undefined) {
|
|
526
|
+
// 2. --epic-dir는 정본 상대 경로만. 짧은 이름·EPIC- 접두·부재는 같은
|
|
527
|
+
// 거절 코드라서 호출자가 형식을 추측해 재시도하지 않게 한다.
|
|
528
|
+
const rel = normalizeRelDir(epicDir);
|
|
529
|
+
if (rel === null || !isCanonicalEpicDir(rel)) {
|
|
530
|
+
return { ok: false, reason: 'invalid-epic-dir' };
|
|
531
|
+
}
|
|
532
|
+
const abs = path.join(repoRoot, rel);
|
|
533
|
+
try {
|
|
534
|
+
// ENOENT·ENOTDIR·EACCES는 모두 같은 거절 코드다. 형식이 맞아도
|
|
535
|
+
// 없는 디렉터리를 다음 id 계산에 쓰면 없는 epic을 추천하게 된다.
|
|
536
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
537
|
+
return { ok: false, reason: 'invalid-epic-dir' };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch (_e) {
|
|
541
|
+
return { ok: false, reason: 'invalid-epic-dir' };
|
|
542
|
+
}
|
|
543
|
+
epic = epicInfo(repoRoot, rel);
|
|
544
|
+
}
|
|
545
|
+
let routing = null;
|
|
546
|
+
if (blueprintDir !== undefined) {
|
|
547
|
+
// 3. --blueprint는 정본 blueprint 경로만. routing은 읽기 전용 신호다.
|
|
548
|
+
const rel = normalizeRelDir(blueprintDir);
|
|
549
|
+
if (rel === null || !isCanonicalBlueprintDir(rel)) {
|
|
550
|
+
return { ok: false, reason: 'invalid-blueprint-dir' };
|
|
551
|
+
}
|
|
552
|
+
const abs = path.join(repoRoot, rel);
|
|
553
|
+
try {
|
|
554
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
555
|
+
return { ok: false, reason: 'invalid-blueprint-dir' };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch (_e) {
|
|
559
|
+
return { ok: false, reason: 'invalid-blueprint-dir' };
|
|
560
|
+
}
|
|
561
|
+
routing = summarizeRouting(loadRoutingTasks(repoRoot, rel));
|
|
562
|
+
}
|
|
563
|
+
const epicNames = listDirNames(path.join(repoRoot, EPICS_REL));
|
|
564
|
+
const { registry, nextEpicId } = summarizeRegistry(repoRoot, epicNames);
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
nextEpicId,
|
|
568
|
+
registry,
|
|
569
|
+
epic,
|
|
570
|
+
verifySignals: collectVerifySignals(repoRoot),
|
|
571
|
+
current: presentCurrent(repoRoot),
|
|
572
|
+
routing,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
module.exports = { planInspect, classifyRoutingPaths, summarizeRouting };
|