@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,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const layout = require("./layout");
|
|
6
|
+
const { isCanonicalBlueprintDir, normalizeRepoPath } = layout;
|
|
7
|
+
const frontmatter = require("./frontmatter");
|
|
8
|
+
const { readDoc } = frontmatter;
|
|
9
|
+
const schema = require("./schema");
|
|
10
|
+
const { PREREQUISITE_MERGED_COMMIT_RE: MERGED_COMMIT_RE } = schema;
|
|
11
|
+
/**
|
|
12
|
+
* `bouncer.prerequisites` 값을 항목별로 판정해 위반 설명과 "유효한 항목"을 함께 돌려준다.
|
|
13
|
+
* 형식 검사(S39)·Git 조회 대상·순환 간선이 각자 선별 조건을 갖고 있으면 서로 다른 답을 내
|
|
14
|
+
* HEAD·짧은 SHA·중복이 승인 때 새어 나갔다. 그래서 선별을 이 한 곳에 모은다.
|
|
15
|
+
* 유효한 항목 = 정확한 두 키, 정본 경로, 40자 소문자 SHA, 자기참조 아님, 앞선 유효 항목과 중복 아님.
|
|
16
|
+
* 부재(undefined)와 `[]`는 선행 없음이라 통과한다. 존재·순환·병합 여부는 보지 않는다.
|
|
17
|
+
*
|
|
18
|
+
* @param {unknown} value - 프론트매터의 prerequisites 값. 키가 없으면 undefined
|
|
19
|
+
* @param {string} selfDir - 이 선언을 가진 blueprint의 저장소 상대 디렉터리(자기참조 판정용)
|
|
20
|
+
* @returns {{issues: string[], valid: ValidEntry[]}} 위반 설명 목록과 Git 조회·간선에 쓸 유효한 항목
|
|
21
|
+
*/
|
|
22
|
+
function classifyPrerequisites(value, selfDir) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
return { issues: [], valid: [] };
|
|
25
|
+
if (!Array.isArray(value)) {
|
|
26
|
+
return { issues: ['prerequisites must be an array of {blueprint, merged_commit}'], valid: [] };
|
|
27
|
+
}
|
|
28
|
+
const issues = [];
|
|
29
|
+
const valid = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const self = normalizeRepoPath(selfDir);
|
|
32
|
+
value.forEach((entry, i) => {
|
|
33
|
+
const label = `prerequisites[${i}]`;
|
|
34
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
35
|
+
issues.push(`${label} must be an object with blueprint and merged_commit`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const rec = entry;
|
|
39
|
+
const keys = Object.keys(rec).sort().join(',');
|
|
40
|
+
if (keys !== 'blueprint,merged_commit') {
|
|
41
|
+
issues.push(`${label} must have exactly blueprint and merged_commit`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { blueprint, merged_commit: commit } = rec;
|
|
45
|
+
// 백슬래시·끝 슬래시·파일 경로는 같은 blueprint를 다른 문자열로 적게 하므로 정본 문자열 그대로만 받는다.
|
|
46
|
+
const canonical = typeof blueprint === 'string'
|
|
47
|
+
&& normalizeRepoPath(blueprint) === blueprint && isCanonicalBlueprintDir(blueprint);
|
|
48
|
+
if (!canonical)
|
|
49
|
+
issues.push(`${label}.blueprint must be a canonical blueprint directory`);
|
|
50
|
+
const shaOk = typeof commit === 'string' && MERGED_COMMIT_RE.test(commit);
|
|
51
|
+
if (!shaOk)
|
|
52
|
+
issues.push(`${label}.merged_commit must be a 40-char lowercase sha`);
|
|
53
|
+
if (!canonical)
|
|
54
|
+
return;
|
|
55
|
+
const bp = blueprint;
|
|
56
|
+
let dup = false;
|
|
57
|
+
if (bp === self)
|
|
58
|
+
issues.push(`${label}.blueprint must not reference itself`);
|
|
59
|
+
else if (seen.has(bp)) {
|
|
60
|
+
issues.push(`${label}.blueprint duplicates an earlier entry`);
|
|
61
|
+
dup = true;
|
|
62
|
+
}
|
|
63
|
+
seen.add(bp);
|
|
64
|
+
if (shaOk && bp !== self && !dup)
|
|
65
|
+
valid.push({ blueprint: bp, merged_commit: commit });
|
|
66
|
+
});
|
|
67
|
+
return { issues, valid };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `bouncer.prerequisites` 한 값의 형식만 판정한다. validate-structural의 S39가 이 목록을 그대로
|
|
71
|
+
* 실패로 옮겨, 구조 검사·승인·helper가 같은 형식 판정을 쓰게 한다.
|
|
72
|
+
*
|
|
73
|
+
* @param {unknown} value - 프론트매터의 prerequisites 값. 키가 없으면 undefined
|
|
74
|
+
* @param {string} selfDir - 이 선언을 가진 blueprint의 저장소 상대 디렉터리(자기참조 판정용)
|
|
75
|
+
* @returns {string[]} 위반 설명 목록. 비어 있으면 형식이 맞다
|
|
76
|
+
*/
|
|
77
|
+
function prerequisiteFormatIssues(value, selfDir) {
|
|
78
|
+
return classifyPrerequisites(value, selfDir).issues;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 기본 Git 실행기. 셸을 거치지 않고 argv 배열로만 호출해 SHA·경로가 명령으로 해석될 여지를 없앤다.
|
|
82
|
+
* spawn 자체가 실패하면(status null) -1로 돌려 "미병합"(exit 1)과 섞이지 않게 한다.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} repoRoot - git을 실행할 저장소 루트
|
|
85
|
+
* @returns {GitExec} argv → {status, stdout}
|
|
86
|
+
*/
|
|
87
|
+
function defaultExec(repoRoot) {
|
|
88
|
+
return (args) => {
|
|
89
|
+
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
90
|
+
return { status: typeof r.status === 'number' ? r.status : -1, stdout: r.stdout || '' };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* blueprint index.md의 prerequisites 원값을 읽는다. 문서가 없거나 frontmatter를 못 읽으면
|
|
95
|
+
* found/raw를 비워 돌려준다 — 참조 누락은 report의 blueprintFound로, 형식 오류는 그 문서의 S39로
|
|
96
|
+
* 따로 드러나므로 여기서 던지면 한 문서의 오류가 전체 판정을 가린다. 그 밖의 오류(권한 등)는 다시 던진다.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} repoRoot - 저장소 루트
|
|
99
|
+
* @param {string} dir - blueprint 디렉터리(저장소 상대)
|
|
100
|
+
* @returns {{found: boolean, raw: unknown}} 문서 존재 여부와 prerequisites 원값
|
|
101
|
+
*/
|
|
102
|
+
function readPrerequisites(repoRoot, dir) {
|
|
103
|
+
let data;
|
|
104
|
+
try {
|
|
105
|
+
data = readDoc(path.join(repoRoot, dir, 'index.md')).data;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const e = err;
|
|
109
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR')
|
|
110
|
+
return { found: false, raw: undefined };
|
|
111
|
+
// 흡수: frontmatter 부재·YAML 오류(해당 문서의 구조 검사가 별도로 거절). 나머지는 그대로 올린다.
|
|
112
|
+
if (e.name === 'YAMLException' || e.message === 'missing frontmatter block') {
|
|
113
|
+
return { found: true, raw: undefined };
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
const bouncer = data?.bouncer;
|
|
118
|
+
const raw = bouncer && typeof bouncer === 'object'
|
|
119
|
+
? bouncer.prerequisites : undefined;
|
|
120
|
+
return { found: true, raw };
|
|
121
|
+
}
|
|
122
|
+
/** 유효한 항목의 blueprint 경로만 뽑는다(순환 탐색용). 위반 항목은 간선으로 쓰지 않는다. */
|
|
123
|
+
function wellFormedTargets(raw, selfDir) {
|
|
124
|
+
return classifyPrerequisites(raw, selfDir).valid.map((e) => e.blueprint);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 선행 선언 그래프에서 순환을 찾는다. 선언된 blueprint 문서만 따라가며 파일은 쓰지 않는다.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} repoRoot - 저장소 루트
|
|
130
|
+
* @param {string} start - 탐색을 시작할 blueprint 디렉터리
|
|
131
|
+
* @returns {string[][]} 순환 경로 목록(첫 원소와 마지막 원소가 같다). 없으면 빈 배열
|
|
132
|
+
*/
|
|
133
|
+
function findCycles(repoRoot, start) {
|
|
134
|
+
const cycles = [];
|
|
135
|
+
const done = new Set();
|
|
136
|
+
const stack = [];
|
|
137
|
+
const visit = (dir) => {
|
|
138
|
+
const at = stack.indexOf(dir);
|
|
139
|
+
if (at >= 0) {
|
|
140
|
+
cycles.push([...stack.slice(at), dir]);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (done.has(dir))
|
|
144
|
+
return;
|
|
145
|
+
stack.push(dir);
|
|
146
|
+
for (const next of wellFormedTargets(readPrerequisites(repoRoot, dir).raw, dir))
|
|
147
|
+
visit(next);
|
|
148
|
+
stack.pop();
|
|
149
|
+
done.add(dir);
|
|
150
|
+
};
|
|
151
|
+
visit(start);
|
|
152
|
+
return cycles;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 한 병합 commit의 상태를 Git으로 판정한다. `rev-parse --verify --quiet`는 object 부재를 exit 1로,
|
|
156
|
+
* `merge-base --is-ancestor`는 비조상을 exit 1로 알려 주고 그 밖의 실패는 둘 다 조회 오류다.
|
|
157
|
+
* 어느 쪽도 통과로 접지 않는다. 기준은 현재 checkout HEAD이며 원격·저장된 base는 보지 않는다.
|
|
158
|
+
*
|
|
159
|
+
* @param {GitExec} exec - Git 실행 seam
|
|
160
|
+
* @param {string} sha - 선언된 병합 결과 commit
|
|
161
|
+
* @returns {CommitState} merged | unmerged | object-missing | lookup-error
|
|
162
|
+
*/
|
|
163
|
+
function commitStateOf(exec, sha) {
|
|
164
|
+
const found = exec(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
|
|
165
|
+
if (found.status === 1)
|
|
166
|
+
return 'object-missing';
|
|
167
|
+
if (found.status !== 0)
|
|
168
|
+
return 'lookup-error';
|
|
169
|
+
const anc = exec(['merge-base', '--is-ancestor', sha, 'HEAD']);
|
|
170
|
+
if (anc.status === 0)
|
|
171
|
+
return 'merged';
|
|
172
|
+
return anc.status === 1 ? 'unmerged' : 'lookup-error';
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* blueprint가 선언한 선행 병합 근거를 읽기 전용으로 판정한다. 파일을 쓰지 않고 승인·gate에 연결하지 않는다.
|
|
176
|
+
* 선언이 없으면 형식·항목·순환이 모두 비어 있는 보고를 돌려준다.
|
|
177
|
+
*
|
|
178
|
+
* @param {{repoRoot: string, blueprintDir: string, exec?: GitExec}} input - 저장소 루트, 대상 blueprint
|
|
179
|
+
* 디렉터리, 테스트용 Git seam(기본은 셸 없는 git spawnSync)
|
|
180
|
+
* @returns {PrerequisiteReport} formatIssues는 S39 후보, entries는 형식이 맞는 항목별 존재·병합 판정,
|
|
181
|
+
* cycles는 선행 선언 순환 경로
|
|
182
|
+
*/
|
|
183
|
+
function checkPrerequisites({ repoRoot, blueprintDir, exec }) {
|
|
184
|
+
const run = exec || defaultExec(repoRoot);
|
|
185
|
+
const dir = normalizeRepoPath(blueprintDir);
|
|
186
|
+
const { raw } = readPrerequisites(repoRoot, dir);
|
|
187
|
+
const { issues: formatIssues, valid } = classifyPrerequisites(raw, dir);
|
|
188
|
+
// 형식이 틀린 항목은 S39가 이미 보고하므로 Git에 보내지 않는다(잘못된 문자열을 git 인자로 넘기지 않기 위함).
|
|
189
|
+
const entries = valid.map(({ blueprint, merged_commit: commit }) => ({
|
|
190
|
+
blueprint,
|
|
191
|
+
merged_commit: commit,
|
|
192
|
+
blueprintFound: fs.existsSync(path.join(repoRoot, blueprint, 'index.md')),
|
|
193
|
+
commitState: commitStateOf(run, commit),
|
|
194
|
+
}));
|
|
195
|
+
return { formatIssues, entries, cycles: raw === undefined ? [] : findCycles(repoRoot, dir) };
|
|
196
|
+
}
|
|
197
|
+
module.exports = { prerequisiteFormatIssues, checkPrerequisites };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { createHash } = require('node:crypto');
|
|
3
|
+
const paths = require("./paths");
|
|
4
|
+
const { epicDirOf } = paths;
|
|
5
|
+
const schema = require("./schema");
|
|
6
|
+
const { executionKindOf, DEFAULT_DEPENDS_ON, DEFAULT_PARALLEL_SAFE, DEFAULT_DEPENDENCY_GATE, DEFAULT_EXCLUSIVE_RESOURCES, } = schema;
|
|
7
|
+
// 본문 단독 hash에서 경로·본문·판단 구조 hash로 바뀐 계약 버전이다.
|
|
8
|
+
const SNAPSHOT_VERSION = 2;
|
|
9
|
+
/**
|
|
10
|
+
* 문서 경로가 판단 구조의 어느 종류인지 정한다.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} rel - 저장소 상대 문서 경로
|
|
13
|
+
* @param {string} bp - blueprint 상대 경로(POSIX)
|
|
14
|
+
* @returns {StructureKind} 구조 종류
|
|
15
|
+
*/
|
|
16
|
+
function structureKindOf(rel, bp) {
|
|
17
|
+
if (rel === `${epicDirOf(bp)}/index.md`)
|
|
18
|
+
return 'epic';
|
|
19
|
+
if (rel === `${bp}/index.md`)
|
|
20
|
+
return 'blueprint';
|
|
21
|
+
return 'task';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 구조 필드 배열에서 문자열만 남겨 정렬한다. 중복은 draft gate가 판정한다.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} value - 문서의 구조 배열 값
|
|
27
|
+
* @returns {string[]} 정렬된 문자열 배열
|
|
28
|
+
*/
|
|
29
|
+
function sortedStrings(value) {
|
|
30
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string').sort() : [];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 실행 판단 구조를 schema 기본값으로 정규화한다. 상태·제목·시각은 digest 대상이 아니다.
|
|
34
|
+
*
|
|
35
|
+
* @param {StructureKind} kind - epic | blueprint | task
|
|
36
|
+
* @param {unknown} bouncer - 문서 frontmatter의 bouncer 객체
|
|
37
|
+
* @returns {PlanStructure} 키 순서가 고정된 정규화 구조
|
|
38
|
+
*/
|
|
39
|
+
function normalizeStructure(kind, bouncer) {
|
|
40
|
+
const b = (bouncer !== null && typeof bouncer === 'object' && !Array.isArray(bouncer)
|
|
41
|
+
? bouncer : {});
|
|
42
|
+
if (kind === 'epic')
|
|
43
|
+
return {};
|
|
44
|
+
if (kind === 'blueprint') {
|
|
45
|
+
return {
|
|
46
|
+
scale: b.scale === 'light' ? 'light' : 'full',
|
|
47
|
+
review_scope: b.review_scope === 'blueprint' ? 'blueprint' : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
affected_paths: sortedStrings(b.affected_paths),
|
|
52
|
+
depends_on: b.depends_on === undefined ? [...DEFAULT_DEPENDS_ON] : sortedStrings(b.depends_on),
|
|
53
|
+
parallel_safe: b.parallel_safe === undefined ? DEFAULT_PARALLEL_SAFE : b.parallel_safe === true,
|
|
54
|
+
dependency_gate: typeof b.dependency_gate === 'string' ? b.dependency_gate : DEFAULT_DEPENDENCY_GATE,
|
|
55
|
+
exclusive_resources: b.exclusive_resources === undefined
|
|
56
|
+
? [...DEFAULT_EXCLUSIVE_RESOURCES] : sortedStrings(b.exclusive_resources),
|
|
57
|
+
execution_kind: executionKindOf(b) ?? 'commit',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 문서 본문의 SHA-256을 계산한다.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} body - 문서 본문
|
|
64
|
+
* @returns {string} hex digest
|
|
65
|
+
*/
|
|
66
|
+
function hashBody(body) {
|
|
67
|
+
return createHash('sha256').update(body).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 정규화 구조의 SHA-256을 계산한다.
|
|
71
|
+
*
|
|
72
|
+
* @param {PlanStructure} structure - normalizeStructure 결과
|
|
73
|
+
* @returns {string} hex digest
|
|
74
|
+
*/
|
|
75
|
+
function hashStructure(structure) {
|
|
76
|
+
return createHash('sha256').update(JSON.stringify(structure)).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* ordered 문서 입력을 경계가 보존되는 JSON으로 직렬화해 digest를 만든다.
|
|
80
|
+
*
|
|
81
|
+
* @param {string[]} documents - 문서 순서
|
|
82
|
+
* @param {Record<string, PlanDocContent>} content - 경로별 본문과 구조
|
|
83
|
+
* @returns {string} hex digest
|
|
84
|
+
*/
|
|
85
|
+
function digestPlanContent(documents, content) {
|
|
86
|
+
const ordered = documents.map((file) => ({
|
|
87
|
+
file, body: content[file].body, structure: content[file].structure,
|
|
88
|
+
}));
|
|
89
|
+
return createHash('sha256').update(JSON.stringify(ordered)).digest('hex');
|
|
90
|
+
}
|
|
91
|
+
module.exports = {
|
|
92
|
+
SNAPSHOT_VERSION,
|
|
93
|
+
digestPlanContent,
|
|
94
|
+
normalizeStructure,
|
|
95
|
+
structureKindOf,
|
|
96
|
+
hashBody,
|
|
97
|
+
hashStructure,
|
|
98
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { createHash } = require('node:crypto');
|
|
5
|
+
const tasksDocs = require("./tasks-docs");
|
|
6
|
+
const { listTasksDocs } = tasksDocs;
|
|
7
|
+
const frontmatter = require("./frontmatter");
|
|
8
|
+
const { readDoc } = frontmatter;
|
|
9
|
+
const paths = require("./paths");
|
|
10
|
+
const { toPosix, epicDirOf } = paths;
|
|
11
|
+
const validateSections = require("./validate-sections");
|
|
12
|
+
const { parseTasksSections } = validateSections;
|
|
13
|
+
const snapshotContract = require("./plan-snapshot-contract");
|
|
14
|
+
const { SNAPSHOT_VERSION, digestPlanContent, normalizeStructure, structureKindOf, hashBody, hashStructure, } = snapshotContract;
|
|
15
|
+
/**
|
|
16
|
+
* epic·blueprint·tasks.md 상대 경로를 집계 digest와 같은 순서로 모은다.
|
|
17
|
+
* 문서 집합이 갈라지면 context review 기록과 follow_up 판정이 서로 다른 대상을 본다.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
20
|
+
* @param {string} blueprintDir - blueprint 상대 경로(POSIX)
|
|
21
|
+
* @returns {string[]} hash·parts에 넣을 저장소 상대 경로 목록
|
|
22
|
+
*/
|
|
23
|
+
function planDocumentRels(repoRoot, blueprintDir) {
|
|
24
|
+
const bp = toPosix(blueprintDir);
|
|
25
|
+
const documents = [
|
|
26
|
+
`${epicDirOf(bp)}/index.md`,
|
|
27
|
+
`${bp}/index.md`,
|
|
28
|
+
];
|
|
29
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir: bp });
|
|
30
|
+
for (const entry of listing.entries) {
|
|
31
|
+
documents.push(entry.tasks.rel);
|
|
32
|
+
}
|
|
33
|
+
return documents;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* plan 문서를 한 번 읽어 경로별 본문·정규화 구조와 관찰용 scope_parts를 만든다.
|
|
37
|
+
* digest·parts·structure_parts가 모두 이 한 번의 읽기에서 나와야 읽기 사이에
|
|
38
|
+
* 파일이 바뀌어도 서로 다른 내용을 가리키지 않는다.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
41
|
+
* @param {string} blueprintDir - blueprint 상대 경로
|
|
42
|
+
* @returns {PlanContent} 성공 시 문서 순서·내용·scope_parts, 부재·파싱 실패 시 ok:false와 사유
|
|
43
|
+
*/
|
|
44
|
+
function readPlanContent(repoRoot, blueprintDir) {
|
|
45
|
+
const bp = toPosix(blueprintDir);
|
|
46
|
+
const documents = planDocumentRels(repoRoot, bp);
|
|
47
|
+
const content = {};
|
|
48
|
+
const scope_parts = {};
|
|
49
|
+
for (const rel of documents) {
|
|
50
|
+
const abs = path.join(repoRoot, rel);
|
|
51
|
+
if (!fs.existsSync(abs)) {
|
|
52
|
+
return { ok: false, error: `snapshot document missing: ${rel}` };
|
|
53
|
+
}
|
|
54
|
+
let body;
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
const doc = readDoc(abs);
|
|
58
|
+
body = doc.body;
|
|
59
|
+
data = doc.data;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
return { ok: false, error: `snapshot document unreadable: ${rel}: ${message}` };
|
|
64
|
+
}
|
|
65
|
+
const bouncer = data?.bouncer;
|
|
66
|
+
const kind = structureKindOf(rel, bp);
|
|
67
|
+
content[rel] = { body, structure: normalizeStructure(kind, bouncer) };
|
|
68
|
+
// epic·blueprint index는 Interface/Touch가 없다. tasks.md만 관찰용 scope_parts에 넣는다.
|
|
69
|
+
if (kind !== 'task')
|
|
70
|
+
continue;
|
|
71
|
+
const sections = parseTasksSections(body);
|
|
72
|
+
const interfaceBody = typeof sections.interface === 'string' ? sections.interface : '';
|
|
73
|
+
const touchBody = typeof sections.touch === 'string' ? sections.touch : '';
|
|
74
|
+
const structure = content[rel].structure;
|
|
75
|
+
// 계약 문자열: 정렬 JSON + Interface 본문 + Touch 본문. v1 payload와 같은 공개 관찰
|
|
76
|
+
// 값이라 순서·구분자를 유지한다. v2에서는 full 판정에 쓰지 않고 보관만 한다.
|
|
77
|
+
const scopeMaterial = [
|
|
78
|
+
JSON.stringify(structure.affected_paths),
|
|
79
|
+
JSON.stringify(structure.depends_on),
|
|
80
|
+
interfaceBody,
|
|
81
|
+
touchBody,
|
|
82
|
+
].join('\n');
|
|
83
|
+
scope_parts[rel] = createHash('sha256').update(scopeMaterial).digest('hex');
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, documents, content, scope_parts };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* epic·blueprint·tasks.md의 경로·본문·판단 구조로 v2 집계 digest를 만든다.
|
|
89
|
+
* context-review frozen snapshot과 같은 문서 집합·순서를 유지한다.
|
|
90
|
+
* frontmatter 중 status/title/timestamp와 commit evidence는 hash에 넣지 않는다 —
|
|
91
|
+
* status flip만으로 digest가 바뀌면 승인 직후 snapshot이 곧바로 stale 판정을 받는다.
|
|
92
|
+
* 반대로 affected_paths 등 실행 구조는 digest에 들어가므로 범위 변경은 stale이 된다.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ repoRoot: string, blueprintDir: string }} opts - 저장소 루트와 blueprint 상대 경로
|
|
95
|
+
* @returns {{ ok: true, digest: string, documents: string[] } | { ok: false, error: string }}
|
|
96
|
+
* 성공 시 hex digest와 hash에 넣은 문서 상대 경로 목록, 문서 부재·파싱 실패 시 ok:false와 사유
|
|
97
|
+
*/
|
|
98
|
+
function computePlanSnapshot({ repoRoot, blueprintDir }) {
|
|
99
|
+
const read = readPlanContent(repoRoot, blueprintDir);
|
|
100
|
+
if (!read.ok)
|
|
101
|
+
return read;
|
|
102
|
+
return { ok: true, digest: digestPlanContent(read.documents, read.content), documents: read.documents };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 문서별 본문 digest(`parts`)·구조 digest(`structure_parts`)·관찰용 `scope_parts`와
|
|
106
|
+
* 이전/이후 비교에 쓰는 `documents_content`를 만든다. 문서마다 별도 sha256을 내어
|
|
107
|
+
* follow_up이 어느 문서가 어떻게 바뀌었는지 가리킬 수 있게 한다.
|
|
108
|
+
*
|
|
109
|
+
* @param {{ repoRoot: string, blueprintDir: string }} opts - 저장소 루트와 blueprint 상대 경로
|
|
110
|
+
* @returns {{ ok: true, parts: Record<string, string>, scope_parts: Record<string, string>,
|
|
111
|
+
* structure_parts: Record<string, string>, documents_content: Record<string, PlanDocContent> }
|
|
112
|
+
* | { ok: false, error: string }}
|
|
113
|
+
* 성공 시 경로→값 맵들, 문서 부재·파싱 실패 시 ok:false와 사유
|
|
114
|
+
*/
|
|
115
|
+
function computePlanParts({ repoRoot, blueprintDir }) {
|
|
116
|
+
const read = readPlanContent(repoRoot, blueprintDir);
|
|
117
|
+
if (!read.ok)
|
|
118
|
+
return read;
|
|
119
|
+
const parts = {};
|
|
120
|
+
const structure_parts = {};
|
|
121
|
+
for (const rel of read.documents) {
|
|
122
|
+
parts[rel] = hashBody(read.content[rel].body);
|
|
123
|
+
structure_parts[rel] = hashStructure(read.content[rel].structure);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
ok: true, parts, scope_parts: read.scope_parts, structure_parts, documents_content: read.content,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
module.exports = {
|
|
130
|
+
SNAPSHOT_VERSION,
|
|
131
|
+
computePlanSnapshot,
|
|
132
|
+
computePlanParts,
|
|
133
|
+
digestPlanContent,
|
|
134
|
+
normalizeStructure,
|
|
135
|
+
structureKindOf,
|
|
136
|
+
hashBody,
|
|
137
|
+
hashStructure,
|
|
138
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.compareSemverDesc = compareSemverDesc;
|
|
7
|
+
exports.parseHost = parseHost;
|
|
8
|
+
exports.findPluginCandidates = findPluginCandidates;
|
|
9
|
+
exports.resolvePluginRoot = resolvePluginRoot;
|
|
10
|
+
exports.assertPluginVersion = assertPluginVersion;
|
|
11
|
+
exports.resolvePluginAssetRoot = resolvePluginAssetRoot;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const HOSTS = ['claude', 'codex', 'antigravity'];
|
|
16
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
17
|
+
function readDir(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return node_fs_1.default.readdirSync(dir).sort();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function candidatePaths(host, homeDir) {
|
|
26
|
+
if (host === 'antigravity')
|
|
27
|
+
return [node_path_1.default.join(homeDir, '.gemini', 'antigravity-ide', 'plugins', 'bouncer')];
|
|
28
|
+
const cache = node_path_1.default.join(homeDir, host === 'claude' ? '.claude' : '.codex', 'plugins', 'cache');
|
|
29
|
+
return readDir(cache).flatMap((marketplace) => readDir(node_path_1.default.join(cache, marketplace, 'bouncer')).map((version) => node_path_1.default.join(cache, marketplace, 'bouncer', version)));
|
|
30
|
+
}
|
|
31
|
+
function parseVersion(value) {
|
|
32
|
+
return typeof value === 'string' && SEMVER.test(value) ? value : null;
|
|
33
|
+
}
|
|
34
|
+
// 플러그인 자산 루트로 인정하려면 루트 바로 아래에 있어야 하는 디렉터리와 파일.
|
|
35
|
+
// 메타데이터만 맞는 빈 디렉터리가 자산 루트로 뽑혀 역할 문서·카드를 못 찾는 일을 막는다.
|
|
36
|
+
const REQUIRED_ASSET_DIRS = ['agents', 'skills', 'rules', 'references', 'hooks'];
|
|
37
|
+
const REQUIRED_ASSET_FILES = ['AGENTS.md'];
|
|
38
|
+
function readCandidate(host, root) {
|
|
39
|
+
const absolute = node_path_1.default.resolve(root);
|
|
40
|
+
try {
|
|
41
|
+
// 자산 배포판은 실행 파일을 포함하지 않을 수 있어, 자산과 manifest만 검증한다.
|
|
42
|
+
for (const dir of REQUIRED_ASSET_DIRS) {
|
|
43
|
+
if (!node_fs_1.default.statSync(node_path_1.default.join(absolute, dir)).isDirectory())
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
for (const file of REQUIRED_ASSET_FILES) {
|
|
47
|
+
if (!node_fs_1.default.statSync(node_path_1.default.join(absolute, file)).isFile())
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// npm package.json은 runtime 설치본의 신원만 설명한다. host가 설치한 자산 루트의
|
|
51
|
+
// plugin manifest 또는 루트 manifest만 자산 버전의 출처로 인정한다.
|
|
52
|
+
for (const metadataFile of [
|
|
53
|
+
'plugin.json',
|
|
54
|
+
'.claude-plugin/plugin.json',
|
|
55
|
+
'.codex-plugin/plugin.json',
|
|
56
|
+
'.cursor-plugin/plugin.json',
|
|
57
|
+
]) {
|
|
58
|
+
try {
|
|
59
|
+
const metadata = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(absolute, metadataFile), 'utf8'));
|
|
60
|
+
const version = parseVersion(metadata.version);
|
|
61
|
+
if ((metadata.name === 'bouncer' || metadata.name === '@cheongfish/bouncer') && version) {
|
|
62
|
+
return {
|
|
63
|
+
host, path: absolute, version,
|
|
64
|
+
...(typeof metadata.runtimeCompatibility === 'string'
|
|
65
|
+
? { runtimeCompatibility: metadata.runtimeCompatibility } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// 다음 명시적 metadata 형식을 확인한다.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function semverParts(version) {
|
|
80
|
+
const match = SEMVER.exec(version);
|
|
81
|
+
if (!match)
|
|
82
|
+
throw new Error(`invalid semver: ${version}`);
|
|
83
|
+
return [match[1], match[2], match[3], match[4]];
|
|
84
|
+
}
|
|
85
|
+
function compareNumericIdentifierDesc(left, right) {
|
|
86
|
+
if (left.length !== right.length)
|
|
87
|
+
return right.length - left.length;
|
|
88
|
+
if (left === right)
|
|
89
|
+
return 0;
|
|
90
|
+
return left < right ? 1 : -1;
|
|
91
|
+
}
|
|
92
|
+
// 외부 semver 의존성 없이 strict semver 순서를 보장한다. 버전이 같으면 호출자가
|
|
93
|
+
// 절대 경로로 정렬해 설치 위치가 달라도 결과가 흔들리지 않게 한다.
|
|
94
|
+
function compareSemverDesc(left, right) {
|
|
95
|
+
const a = semverParts(left);
|
|
96
|
+
const b = semverParts(right);
|
|
97
|
+
for (let index = 0; index < 3; index += 1) {
|
|
98
|
+
const difference = compareNumericIdentifierDesc(a[index], b[index]);
|
|
99
|
+
if (difference)
|
|
100
|
+
return difference;
|
|
101
|
+
}
|
|
102
|
+
if (a[3] === b[3])
|
|
103
|
+
return 0;
|
|
104
|
+
if (a[3] === undefined)
|
|
105
|
+
return -1;
|
|
106
|
+
if (b[3] === undefined)
|
|
107
|
+
return 1;
|
|
108
|
+
const aParts = a[3].split('.');
|
|
109
|
+
const bParts = b[3].split('.');
|
|
110
|
+
for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) {
|
|
111
|
+
const av = aParts[index];
|
|
112
|
+
const bv = bParts[index];
|
|
113
|
+
if (av === undefined)
|
|
114
|
+
return -1;
|
|
115
|
+
if (bv === undefined)
|
|
116
|
+
return 1;
|
|
117
|
+
if (av === bv)
|
|
118
|
+
continue;
|
|
119
|
+
const an = /^\d+$/.test(av);
|
|
120
|
+
const bn = /^\d+$/.test(bv);
|
|
121
|
+
if (an && bn)
|
|
122
|
+
return compareNumericIdentifierDesc(av, bv);
|
|
123
|
+
if (an)
|
|
124
|
+
return -1;
|
|
125
|
+
if (bn)
|
|
126
|
+
return 1;
|
|
127
|
+
return av < bv ? 1 : -1;
|
|
128
|
+
}
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
function parseHost(value) {
|
|
132
|
+
return value !== undefined && HOSTS.includes(value) ? value : null;
|
|
133
|
+
}
|
|
134
|
+
function findPluginCandidates(options = {}, host) {
|
|
135
|
+
const homeDir = options.homeDir ?? node_os_1.default.homedir();
|
|
136
|
+
const hosts = host ? [host] : HOSTS;
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const candidates = [];
|
|
139
|
+
for (const candidateHost of hosts) {
|
|
140
|
+
for (const root of candidatePaths(candidateHost, homeDir)) {
|
|
141
|
+
const candidate = readCandidate(candidateHost, root);
|
|
142
|
+
if (candidate && !seen.has(candidate.path)) {
|
|
143
|
+
seen.add(candidate.path);
|
|
144
|
+
candidates.push(candidate);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return candidates.sort((a, b) => compareSemverDesc(a.version, b.version) || a.path.localeCompare(b.path));
|
|
149
|
+
}
|
|
150
|
+
function resolvePluginRoot(options = {}, host) {
|
|
151
|
+
const env = options.env ?? process.env;
|
|
152
|
+
const runtimeVersion = options.runtimeVersion ?? readRuntimeVersion();
|
|
153
|
+
let candidate;
|
|
154
|
+
if (env.BOUNCER_HOME !== undefined) {
|
|
155
|
+
candidate = readCandidate(host ?? 'claude', env.BOUNCER_HOME) ?? undefined;
|
|
156
|
+
if (!candidate)
|
|
157
|
+
throw new Error('BOUNCER_HOME is not a valid Bouncer plugin root');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
candidate = findPluginCandidates(options, host)[0];
|
|
161
|
+
if (!candidate)
|
|
162
|
+
throw new Error('no valid Bouncer plugin roots found');
|
|
163
|
+
}
|
|
164
|
+
assertPluginVersion(candidate, runtimeVersion);
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 선택된 자산 후보가 이 runtime과 같은 release인지 확인한다.
|
|
169
|
+
* 후보 정렬과 검증을 분리해 가장 최신 설치가 불일치하면 과거 후보로 우회하지 않는다.
|
|
170
|
+
*
|
|
171
|
+
* @param {PluginCandidate} candidate - 선택된 host plugin 후보
|
|
172
|
+
* @param {string} [runtimeVersion] - 테스트용 runtime version. 생략하면 package.json을 읽는다
|
|
173
|
+
* @returns {void} 일치하면 반환하고, 다르면 식별 가능한 Error를 던진다
|
|
174
|
+
*/
|
|
175
|
+
function assertPluginVersion(candidate, runtimeVersion = readRuntimeVersion()) {
|
|
176
|
+
const range = candidate.runtimeCompatibility;
|
|
177
|
+
const supported = range === undefined
|
|
178
|
+
? candidate.version === runtimeVersion
|
|
179
|
+
: isRuntimeCompatible(runtimeVersion, range);
|
|
180
|
+
if (supported)
|
|
181
|
+
return;
|
|
182
|
+
const error = new Error(`Bouncer plugin version ${candidate.version} is incompatible with runtime version ${runtimeVersion}`
|
|
183
|
+
+ `${range === undefined ? '' : ` (supported: ${range})`}; `
|
|
184
|
+
+ 'install a compatible npm runtime version or repair BOUNCER_HOME to a compatible plugin install');
|
|
185
|
+
error.code = 'BOUNCER_PLUGIN_VERSION_MISMATCH';
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
function isRuntimeCompatible(version, range) {
|
|
189
|
+
// Deliberately accept only explicit comparator sets to keep manifest parsing predictable.
|
|
190
|
+
const clauses = range.trim().split(/\s+/);
|
|
191
|
+
if (clauses.length === 0 || clauses.some((clause) => !/^(>=|>|<=|<|=)\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(clause))) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return clauses.every((clause) => {
|
|
195
|
+
const match = /^(>=|>|<=|<|=)(.+)$/.exec(clause);
|
|
196
|
+
if (!match || !parseVersion(match[2]))
|
|
197
|
+
return false;
|
|
198
|
+
const order = compareSemverDesc(version, match[2]);
|
|
199
|
+
// compareSemverDesc returns positive when version sorts before the bound.
|
|
200
|
+
if (match[1] === '>=')
|
|
201
|
+
return order <= 0;
|
|
202
|
+
if (match[1] === '>')
|
|
203
|
+
return order < 0;
|
|
204
|
+
if (match[1] === '<=')
|
|
205
|
+
return order >= 0;
|
|
206
|
+
if (match[1] === '<')
|
|
207
|
+
return order > 0;
|
|
208
|
+
return order === 0;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 설치된 CLI package.json의 버전을 읽는다. 자산 manifest와 runtime의 비교 기준은
|
|
213
|
+
* 실제 실행 중인 npm package에 고정해 배포본 간 불일치를 선택 직후 드러낸다.
|
|
214
|
+
*
|
|
215
|
+
* @returns {string} 유효한 strict semver runtime version
|
|
216
|
+
*/
|
|
217
|
+
function readRuntimeVersion() {
|
|
218
|
+
const metadata = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
219
|
+
const version = parseVersion(metadata.version);
|
|
220
|
+
if (!version)
|
|
221
|
+
throw new Error('Bouncer runtime package.json has an invalid version');
|
|
222
|
+
return version;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 플러그인 자산(`agents/`, `references/coordinator-cards/`)을 읽을 검증된 루트를 돌려준다.
|
|
226
|
+
* 순서는 `BOUNCER_HOME` → strict-semver가 가장 높은 host 후보이며, 선택된 설치의
|
|
227
|
+
* `runtimeCompatibility`가 있으면 runtime version이 범위에 포함되어야 한다. 오래된
|
|
228
|
+
* manifest는 plugin version과 runtime version이 일치해야 한다. runtime tree는 자산 설치 여부를
|
|
229
|
+
* 증명하지 않으므로 후보로 추론하지 않는다.
|
|
230
|
+
*
|
|
231
|
+
* @param {AssetRootOptions} [options] - `env`, `homeDir`, resolver 테스트용 `runtimeVersion`
|
|
232
|
+
* @returns {string} 자산 루트 절대 경로
|
|
233
|
+
* @throws 명시 root가 무효이거나 후보가 없거나 선택된 plugin/runtime version이 다르면 Error
|
|
234
|
+
*/
|
|
235
|
+
function resolvePluginAssetRoot(options = {}) {
|
|
236
|
+
return resolvePluginRoot(options).path;
|
|
237
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// toPosix의 단일 정의. 어떤 ./ 모듈도 import하지 않는다 — paths ↔ tasks-docs
|
|
3
|
+
// 순환을 끊으려면 가장 아래 계층이어야 하고, cli lazy-load 단언도 지켜야 한다.
|
|
4
|
+
/**
|
|
5
|
+
* 경로 문자열의 모든 백슬래시를 슬래시로 바꾼다.
|
|
6
|
+
* unknown을 받는 이유: 기존 호출부 일부가 비문자열 입력을 `String()`으로 넘긴다.
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} p - 변환할 경로 값
|
|
9
|
+
* @returns {string} 슬래시 구분 경로
|
|
10
|
+
*/
|
|
11
|
+
function toPosix(p) {
|
|
12
|
+
return String(p).split('\\').join('/');
|
|
13
|
+
}
|
|
14
|
+
module.exports = { toPosix };
|