@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,602 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { createHash, randomBytes } = require('node:crypto');
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
6
|
+
const guards = require("./guards");
|
|
7
|
+
const { isRecord } = guards;
|
|
8
|
+
const frontmatter = require("./frontmatter");
|
|
9
|
+
const { readDoc } = frontmatter;
|
|
10
|
+
const planSnapshot = require("./plan-snapshot");
|
|
11
|
+
const snapshotContract = require("./plan-snapshot-contract");
|
|
12
|
+
const { SNAPSHOT_VERSION } = snapshotContract;
|
|
13
|
+
const { computePlanSnapshot, computePlanParts } = planSnapshot;
|
|
14
|
+
const paths = require("./paths");
|
|
15
|
+
const { toPosix } = paths;
|
|
16
|
+
const layout = require("./layout");
|
|
17
|
+
const { isCanonicalBlueprintDir } = layout;
|
|
18
|
+
const schema = require("./schema");
|
|
19
|
+
const { planReviewContractOf, PLAN_FULL_PERSPECTIVES, PLAN_LIGHT_PERSPECTIVES } = schema;
|
|
20
|
+
// 컨텍스트 리뷰의 디스크 접근만 모은다. cycle 재시작 한도·finding 판정은 여기 두지 않고
|
|
21
|
+
// context-review-state가 맡긴다. 전이는 이 모듈의 함수로만 읽고 쓴다.
|
|
22
|
+
// cycle id는 newCycleId가 만드는 형식 하나뿐이다. 경로 구성요소로 쓰이므로 그 밖은 손상이다.
|
|
23
|
+
const CYCLE_ID_RE = /^cr-[0-9a-f]{16}$/;
|
|
24
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
25
|
+
// 디스크 표식 restart_count의 허용 스칼라(0 또는 1). 전이의 재시작 한도 정책이 아니라
|
|
26
|
+
// YAML에 적힌 값의 모양이다.
|
|
27
|
+
const MARKER_VALUES = new Set([0, 1]);
|
|
28
|
+
function fail(reason, cause, next) {
|
|
29
|
+
return { ok: false, reason, cause, next };
|
|
30
|
+
}
|
|
31
|
+
function sha256(data) {
|
|
32
|
+
return createHash('sha256').update(data).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
/** 쓰기 대상 경로에 symlink나 비정상 구성요소가 있을 때 던진다. 호출부가 Fail로 바꾼다. */
|
|
35
|
+
class UnsafePathError extends Error {
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* root 아래 dir까지 구성요소를 lstat로 훑는다. symlink이거나 디렉터리가 아니면 거부하고,
|
|
39
|
+
* create면 없는 구성요소를 만든다. realpath 검사는 namespace 루트에서 끝나므로 그 아래
|
|
40
|
+
* (cycles/<id>/dispatch 등)의 symlink 바꿔치기는 여기서 막는다.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} root - 이미 repo 안으로 확인된 namespace 절대 경로
|
|
43
|
+
* @param {string} dir - root 아래 디렉터리 절대 경로
|
|
44
|
+
* @param {boolean} create - true면 없는 구성요소를 만든다
|
|
45
|
+
* @returns {boolean} 끝까지 존재하면 true, create가 false이고 중간에 없으면 false
|
|
46
|
+
*/
|
|
47
|
+
function walkSafeDir(root, dir, create) {
|
|
48
|
+
const rel = path.relative(root, dir);
|
|
49
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
50
|
+
throw new UnsafePathError(`path escapes namespace: ${dir}`);
|
|
51
|
+
let cur = root;
|
|
52
|
+
for (const seg of rel === '' ? [] : rel.split(path.sep)) {
|
|
53
|
+
cur = path.join(cur, seg);
|
|
54
|
+
let st;
|
|
55
|
+
try {
|
|
56
|
+
st = fs.lstatSync(cur);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// ENOENT만 흡수: 아직 없는 구성요소다. create면 만들고 아니면 "없음"으로 돌려준다.
|
|
60
|
+
if (!isRecord(error) || error.code !== 'ENOENT')
|
|
61
|
+
throw error;
|
|
62
|
+
if (!create)
|
|
63
|
+
return false;
|
|
64
|
+
fs.mkdirSync(cur);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
68
|
+
throw new UnsafePathError(`not a plain directory (symlink?): ${cur}`);
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** abs가 있으면 symlink가 아닌 일반 파일인지 확인한다. 없으면 false. */
|
|
73
|
+
function isPlainFile(abs) {
|
|
74
|
+
let st;
|
|
75
|
+
try {
|
|
76
|
+
st = fs.lstatSync(abs);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// ENOENT만 흡수: 아직 없는 파일이다.
|
|
80
|
+
if (!isRecord(error) || error.code !== 'ENOENT')
|
|
81
|
+
throw error;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (st.isSymbolicLink() || !st.isFile())
|
|
85
|
+
throw new UnsafePathError(`refusing symlink or non-file target: ${abs}`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 같은 디렉터리의 고유 임시 파일에 쓴 뒤 rename으로 교체한다. tmp는 'wx'로 열어
|
|
90
|
+
* 미리 심어 둔 symlink·파일을 따라가지 않고 실패한다(닫힌 실패). 대상이 symlink여도
|
|
91
|
+
* 거부한다. rename 전에 죽어도 기존 파일은 그대로이고, 우리가 만든 tmp만 지운다.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} abs - 최종 파일 절대 경로
|
|
94
|
+
* @param {string} text - 쓸 본문
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function writeAtomic(abs, text) {
|
|
98
|
+
const existed = isPlainFile(abs);
|
|
99
|
+
const mode = existed ? fs.statSync(abs).mode & 0o777 : 0o644;
|
|
100
|
+
const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
101
|
+
// 열기 실패(EEXIST 등)는 우리가 만든 파일이 없으므로 정리 없이 그대로 올린다.
|
|
102
|
+
const fd = fs.openSync(tmp, 'wx', mode);
|
|
103
|
+
try {
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(fd, text);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
fs.closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
fs.renameSync(tmp, abs);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
try {
|
|
114
|
+
fs.unlinkSync(tmp);
|
|
115
|
+
}
|
|
116
|
+
catch (unlinkError) {
|
|
117
|
+
// ENOENT만 흡수: rename이 이미 옮겼다.
|
|
118
|
+
if (!isRecord(unlinkError) || unlinkError.code !== 'ENOENT')
|
|
119
|
+
throw unlinkError;
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 내용 주소(sha256) 파일명으로 immutable 자료를 쓴다. 상위 구성요소와 대상이 symlink이면
|
|
126
|
+
* 거부하고, 이미 있는 파일은 같은 바이트일 때만 건너뛴다(다르면 변조·손상이다). 먼저 쓰고
|
|
127
|
+
* state가 나중에 참조하므로, 이후 단계가 실패해도 남는 것은 참조되지 않는 파일뿐이다.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} root - namespace 절대 경로(symlink 검사 시작점)
|
|
130
|
+
* @param {string} abs - 저장 절대 경로
|
|
131
|
+
* @param {string} text - 저장할 본문
|
|
132
|
+
* @returns {void}
|
|
133
|
+
*/
|
|
134
|
+
function writeImmutable(root, abs, text) {
|
|
135
|
+
walkSafeDir(root, path.dirname(abs), true);
|
|
136
|
+
try {
|
|
137
|
+
fs.writeFileSync(abs, text, { flag: 'wx' });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
// EEXIST만 흡수: 파일명이 내용 해시라 같은 바이트면 건너뛴다. 다르면 아래에서 거부한다.
|
|
141
|
+
if (!isRecord(error) || error.code !== 'EEXIST')
|
|
142
|
+
throw error;
|
|
143
|
+
isPlainFile(abs);
|
|
144
|
+
if (fs.readFileSync(abs, 'utf8') !== text) {
|
|
145
|
+
throw new UnsafePathError(`existing immutable file differs from new content: ${abs}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** 쓰기 구간의 UnsafePathError만 target-invalid Fail로 바꾼다. 다른 오류는 올린다. */
|
|
150
|
+
function guardWrites(fn) {
|
|
151
|
+
try {
|
|
152
|
+
return fn();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (!(error instanceof UnsafePathError))
|
|
156
|
+
throw error;
|
|
157
|
+
return targetInvalid(error.message, 'remove the symlink or altered file under .bouncer/runtime');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* namespace 디렉터리를 repo 안에서만 해석한다. 구성요소마다 realpath를 보고
|
|
162
|
+
* repo 밖을 가리키는 symlink를 만나면 아무것도 만들기 전에 null을 돌려준다.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} repoRoot - 저장소 루트
|
|
165
|
+
* @param {string} relDir - repo 상대 POSIX 경로
|
|
166
|
+
* @param {boolean} create - true면 없는 구성요소를 만든다
|
|
167
|
+
* @returns {string | null} 절대 경로, repo 밖으로 나가면 null
|
|
168
|
+
*/
|
|
169
|
+
function resolveInsideRepo(repoRoot, relDir, create) {
|
|
170
|
+
const realRoot = fs.realpathSync(path.resolve(repoRoot));
|
|
171
|
+
let cur = path.resolve(repoRoot);
|
|
172
|
+
for (const seg of relDir.split('/')) {
|
|
173
|
+
cur = path.join(cur, seg);
|
|
174
|
+
let real;
|
|
175
|
+
try {
|
|
176
|
+
real = fs.realpathSync(cur);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
// ENOENT만 흡수: 아직 없는 구성요소다. 읽기 호출은 없는 상태로 두고,
|
|
180
|
+
// 쓰기 호출만 만든다. 그 외(EACCES 등)는 위장하지 않고 올린다.
|
|
181
|
+
if (!isRecord(error) || error.code !== 'ENOENT')
|
|
182
|
+
throw error;
|
|
183
|
+
if (!create)
|
|
184
|
+
return path.join(path.resolve(repoRoot), relDir);
|
|
185
|
+
fs.mkdirSync(cur);
|
|
186
|
+
real = fs.realpathSync(cur);
|
|
187
|
+
}
|
|
188
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep))
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return cur;
|
|
192
|
+
}
|
|
193
|
+
function targetInvalid(cause, next) {
|
|
194
|
+
return fail('context-review-target-invalid', cause, next);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* dispatch 파일을 읽어 blueprint·현재 snapshot·문서 집합과 대조한다.
|
|
198
|
+
* skip·light·closed·다른 blueprint·stale digest는 모두 거절한다 — 오래된 payload로
|
|
199
|
+
* 기록하면 이후 review 신선도와 어긋난 보고가 cycle에 쌓인다.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} repoRoot - 저장소 루트
|
|
202
|
+
* @param {string} bp - blueprint 상대 POSIX 경로
|
|
203
|
+
* @param {string} dispatchFile - review-dispatch plan 출력 JSON 파일
|
|
204
|
+
* @returns {LoadedDispatch | Fail} 검증된 payload 정보(+내부 scale) 또는 거절
|
|
205
|
+
*/
|
|
206
|
+
function loadDispatch(repoRoot, bp, dispatchFile) {
|
|
207
|
+
let bytes;
|
|
208
|
+
try {
|
|
209
|
+
bytes = fs.readFileSync(dispatchFile);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return fail('context-review-input-invalid', isRecord(error) ? error.code : String(error), 'pass --dispatch <review-dispatch plan JSON file>');
|
|
213
|
+
}
|
|
214
|
+
let payload;
|
|
215
|
+
try {
|
|
216
|
+
payload = JSON.parse(bytes.toString('utf8'));
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// SyntaxError만 흡수. 그 외 throw는 호출부 버그다.
|
|
220
|
+
if (!(error instanceof SyntaxError))
|
|
221
|
+
throw error;
|
|
222
|
+
return fail('context-review-input-invalid', error.message, 'pass --dispatch <review-dispatch plan JSON file>');
|
|
223
|
+
}
|
|
224
|
+
const next = 'rerun review-dispatch plan --blueprint <dir> and pass its JSON';
|
|
225
|
+
if (!isRecord(payload) || payload.ok !== true || payload.phase !== 'plan'
|
|
226
|
+
|| !isRecord(payload.target) || typeof payload.target.digest !== 'string'
|
|
227
|
+
|| !Array.isArray(payload.target.documents)) {
|
|
228
|
+
return targetInvalid('dispatch is not a successful plan payload', next);
|
|
229
|
+
}
|
|
230
|
+
if (payload.strategy !== 'single' && payload.strategy !== 'clustered' && payload.strategy !== 'perspectives') {
|
|
231
|
+
return targetInvalid(`strategy ${String(payload.strategy)} has no context review`, next);
|
|
232
|
+
}
|
|
233
|
+
if (payload.blueprint !== undefined && payload.blueprint !== bp) {
|
|
234
|
+
return targetInvalid('dispatch belongs to another blueprint', next);
|
|
235
|
+
}
|
|
236
|
+
let indexData;
|
|
237
|
+
try {
|
|
238
|
+
indexData = readDoc(path.join(repoRoot, bp, 'index.md')).data;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
// 문서 부재·파싱 실패는 같은 target-invalid로 묶되 원인 문구를 보존한다.
|
|
242
|
+
return targetInvalid(error instanceof Error ? error.message : String(error), next);
|
|
243
|
+
}
|
|
244
|
+
const meta = isRecord(indexData) && isRecord(indexData.bouncer) ? indexData.bouncer : {};
|
|
245
|
+
if (meta.status === 'closed')
|
|
246
|
+
return targetInvalid('blueprint is closed', 'open a new blueprint');
|
|
247
|
+
// 표식(review_contract: brief) blueprint는 perspectives dispatch만, 표식 없는 blueprint는
|
|
248
|
+
// single/clustered만 받는다. 어긋난 payload를 받으면 record가 관점 집합을 대조할 기준이
|
|
249
|
+
// 사라지고, 옛 계약의 저장 payload가 새 계약 blueprint에 섞인다.
|
|
250
|
+
const marked = planReviewContractOf(meta) === 'brief';
|
|
251
|
+
if (marked && payload.strategy !== 'perspectives') {
|
|
252
|
+
return targetInvalid('review_contract brief requires a perspectives dispatch', next);
|
|
253
|
+
}
|
|
254
|
+
if (!marked && payload.strategy === 'perspectives') {
|
|
255
|
+
// 표식 없는 blueprint에는 perspectives 전략이 존재하지 않던 계약이라 구형 cause를 유지한다.
|
|
256
|
+
return targetInvalid('strategy perspectives has no context review', next);
|
|
257
|
+
}
|
|
258
|
+
// record가 round 관점을 대조하는 기준이 payload의 perspectives라서, 손으로 바꾼 목록이
|
|
259
|
+
// 기준이 되지 못하게 scale이 정하는 값(full 4관점 / light verifiability)과 같아야 한다.
|
|
260
|
+
if (marked) {
|
|
261
|
+
const expected = meta.scale === 'light' ? PLAN_LIGHT_PERSPECTIVES : PLAN_FULL_PERSPECTIVES;
|
|
262
|
+
if (!isDeepStrictEqual(payload.perspectives, expected)) {
|
|
263
|
+
return targetInvalid(`dispatch perspectives differ from ${expected.join(',')}`, next);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// light 거절은 표식 없는 light에만 남는다. 표식 light는 verifiability 리뷰를 받는다.
|
|
267
|
+
if (meta.scale === 'light' && !marked) {
|
|
268
|
+
return targetInvalid('light blueprint skips context review', 'no review is needed');
|
|
269
|
+
}
|
|
270
|
+
const snap = computePlanSnapshot({ repoRoot, blueprintDir: bp });
|
|
271
|
+
if (!snap.ok)
|
|
272
|
+
return targetInvalid(snap.error, next);
|
|
273
|
+
if (snap.digest !== payload.target.digest
|
|
274
|
+
|| JSON.stringify(snap.documents) !== JSON.stringify(payload.target.documents)) {
|
|
275
|
+
return targetInvalid(`dispatch is stale: ${payload.target.digest} != ${snap.digest}`, next);
|
|
276
|
+
}
|
|
277
|
+
// 새로 기록하는 dispatch는 v2여야 한다. snapshot_version이 없는 digest-only payload는
|
|
278
|
+
// 새 digest 알고리즘과 맞지 않아 위 digest 대조에서 이미 stale로 걸리지만, 우연히 맞는
|
|
279
|
+
// 경우에도 내용 없는 payload를 cycle 증거로 저장하지 않도록 형식을 따로 거절한다.
|
|
280
|
+
if (payload.snapshot_version !== SNAPSHOT_VERSION) {
|
|
281
|
+
return targetInvalid(`dispatch snapshot_version ${String(payload.snapshot_version)} is not ${SNAPSHOT_VERSION}`, next);
|
|
282
|
+
}
|
|
283
|
+
// payload가 실어 온 parts·scope_parts·structure_parts·documents_content는 믿지 않는다:
|
|
284
|
+
// 현재 blueprint에서 다시 계산해 같아야 한다. 위조된 문서별 digest나 before/after 내용으로
|
|
285
|
+
// follow_up 판정과 reviewer 입력이 흔들리지 못하게 한다.
|
|
286
|
+
const partSnap = computePlanParts({ repoRoot, blueprintDir: bp });
|
|
287
|
+
if (!partSnap.ok)
|
|
288
|
+
return targetInvalid(partSnap.error, next);
|
|
289
|
+
if (!isDeepStrictEqual(payload.parts, partSnap.parts)
|
|
290
|
+
|| !isDeepStrictEqual(payload.scope_parts, partSnap.scope_parts)
|
|
291
|
+
|| !isDeepStrictEqual(payload.structure_parts, partSnap.structure_parts)
|
|
292
|
+
|| !isDeepStrictEqual(payload.documents_content, partSnap.documents_content)) {
|
|
293
|
+
return targetInvalid('dispatch parts/content differ from the current blueprint', next);
|
|
294
|
+
}
|
|
295
|
+
// scale은 위에서 perspectives 목록 대조에 쓴 값과 같은 출처(index.md)다. light가 아니면 full로 읽는다.
|
|
296
|
+
const scale = meta.scale === 'light' ? 'light' : 'full';
|
|
297
|
+
return { sha256: sha256(bytes), digest: snap.digest, text: bytes.toString('utf8'), scale };
|
|
298
|
+
}
|
|
299
|
+
function isFail(value) {
|
|
300
|
+
return isRecord(value) && value.ok === false;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 같은 namespace의 배타 잠금 아래에서 fn을 실행한다. 잠금은 mkdir(원자적)이며
|
|
304
|
+
* 이미 있으면 대기 없이 거절한다. 프로세스가 잠금 보유 중 죽으면 lock 디렉터리가
|
|
305
|
+
* 남는다 — 자동 회수는 하지 않고(소유 확인 없는 회수는 동시 쓰기를 허용한다)
|
|
306
|
+
* next 안내로 사람이 확인 후 지우게 한다.
|
|
307
|
+
*
|
|
308
|
+
* @param {Ctx} ctx - namespace 경로
|
|
309
|
+
* @param {() => T} fn - 잠금 안에서 실행할 전이
|
|
310
|
+
* @returns {T | Fail} fn 결과 또는 잠금 거절
|
|
311
|
+
*/
|
|
312
|
+
function withLock(ctx, fn) {
|
|
313
|
+
const lock = path.join(ctx.nsAbs, 'lock');
|
|
314
|
+
try {
|
|
315
|
+
fs.mkdirSync(lock);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (isRecord(error) && error.code === 'EEXIST') {
|
|
319
|
+
return fail('context-review-state-conflict', 'another transition holds the lock', `retry later; if no other process runs, remove ${ctx.nsRel}/lock`);
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return fn();
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
fs.rmdirSync(lock);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* namespace 경로를 정한다. blueprint 경로가 canonical이 아니거나 namespace가 repo 밖을
|
|
332
|
+
* 가리키면 어떤 디렉터리도 만들기 전에 거절한다.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} repoRoot - 저장소 루트
|
|
335
|
+
* @param {string} blueprintDir - 입력 blueprint 경로
|
|
336
|
+
* @param {boolean} create - 없는 디렉터리를 만들지 여부
|
|
337
|
+
* @returns {Ctx | Fail} namespace 컨텍스트 또는 거절
|
|
338
|
+
*/
|
|
339
|
+
function openContext(repoRoot, blueprintDir, create) {
|
|
340
|
+
const bp = toPosix(blueprintDir);
|
|
341
|
+
if (bp.split('/').includes('..') || !isCanonicalBlueprintDir(bp)) {
|
|
342
|
+
return targetInvalid(`not a canonical blueprint dir: ${blueprintDir}`, 'pass --blueprint .bouncer/context/epics/<id>/blueprints/<id>');
|
|
343
|
+
}
|
|
344
|
+
const nsRel = `.bouncer/runtime/context-review/${bp.split('/').join('__')}`;
|
|
345
|
+
const nsAbs = resolveInsideRepo(repoRoot, nsRel, create);
|
|
346
|
+
if (nsAbs === null) {
|
|
347
|
+
return targetInvalid('runtime namespace escapes the repository', 'remove the symlink under .bouncer/runtime');
|
|
348
|
+
}
|
|
349
|
+
return { nsAbs, nsRel, bp };
|
|
350
|
+
}
|
|
351
|
+
function corrupt(cause) {
|
|
352
|
+
return fail('context-review-state-conflict', `corrupt state: ${cause}`, 'restore state.json from backup; counters must not be reset by hand');
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* state.json을 읽고 모양·한도·namespace 결속을 검사한다. restart_count와 history
|
|
356
|
+
* 길이가 어긋나거나 참조 경로가 이 cycle 밖이면 손상으로 보고 모든 전이를 거절한다.
|
|
357
|
+
*
|
|
358
|
+
* @param {Ctx} ctx - namespace 경로
|
|
359
|
+
* @returns {State | null | Fail} 정상 state, 파일 없음이면 null, 손상이면 거절
|
|
360
|
+
*/
|
|
361
|
+
function loadState(ctx) {
|
|
362
|
+
const statePath = path.join(ctx.nsAbs, 'state.json');
|
|
363
|
+
let raw;
|
|
364
|
+
try {
|
|
365
|
+
if (!isPlainFile(statePath))
|
|
366
|
+
return null;
|
|
367
|
+
raw = fs.readFileSync(statePath, 'utf8');
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof UnsafePathError)
|
|
371
|
+
return corrupt(error.message);
|
|
372
|
+
// 권한 오류 등을 "없음"으로 위장하지 않고 코드를 보존한 손상으로 알린다.
|
|
373
|
+
if (isRecord(error) && typeof error.code === 'string')
|
|
374
|
+
return corrupt(`state unreadable: ${error.code}`);
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(raw);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (!(error instanceof SyntaxError))
|
|
383
|
+
throw error;
|
|
384
|
+
return corrupt(error.message);
|
|
385
|
+
}
|
|
386
|
+
if (!isRecord(parsed) || parsed.version !== 1 || parsed.blueprint !== ctx.bp
|
|
387
|
+
|| !isRecord(parsed.cycle) || !Array.isArray(parsed.transitions)
|
|
388
|
+
|| typeof parsed.closed !== 'boolean') {
|
|
389
|
+
return corrupt('shape');
|
|
390
|
+
}
|
|
391
|
+
// closure는 선택 필드다. 존재하면 형상과 dispositions 참조를 검증한다.
|
|
392
|
+
if (parsed.closure !== undefined) {
|
|
393
|
+
if (!isRecord(parsed.closure)
|
|
394
|
+
|| (parsed.closure.dispositions !== undefined && !isRecord(parsed.closure.dispositions))
|
|
395
|
+
|| !Array.isArray(parsed.closure.accept_risk)
|
|
396
|
+
|| !parsed.closure.accept_risk.every((x) => typeof x === 'string')) {
|
|
397
|
+
return corrupt('closure shape');
|
|
398
|
+
}
|
|
399
|
+
// 손상된 digest를 현재 내용으로 대체 인증하지 않는다: 형식이 틀리면 state 손상이다.
|
|
400
|
+
const digest = parsed.closure.closure_target_digest;
|
|
401
|
+
if (digest !== undefined && (typeof digest !== 'string' || !SHA256_RE.test(digest))) {
|
|
402
|
+
return corrupt('closure digest shape');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const c = parsed.cycle;
|
|
406
|
+
if (typeof c.id !== 'string' || !CYCLE_ID_RE.test(c.id)
|
|
407
|
+
|| (c.restart_count !== 0 && c.restart_count !== 1)
|
|
408
|
+
|| !Array.isArray(c.history) || c.history.length !== c.restart_count
|
|
409
|
+
|| !Array.isArray(c.active_reports) || !isRecord(c.latest_dispatch)) {
|
|
410
|
+
return corrupt('cycle');
|
|
411
|
+
}
|
|
412
|
+
const prefix = `${ctx.nsRel}/cycles/${c.id}/`;
|
|
413
|
+
const reportDispatches = c.active_reports.map((r) => (isRecord(r) ? r.dispatch : null));
|
|
414
|
+
const refs = [c.latest_dispatch, ...c.active_reports, ...reportDispatches];
|
|
415
|
+
if (parsed.closure && isRecord(parsed.closure) && isRecord(parsed.closure.dispositions)) {
|
|
416
|
+
refs.push(parsed.closure.dispositions);
|
|
417
|
+
}
|
|
418
|
+
for (const ref of refs) {
|
|
419
|
+
// 정규화된 POSIX 상대 경로만 인정한다: `..`·`.`·빈 구간·절대 경로는 prefix 검사를 우회한다.
|
|
420
|
+
const ok = isRecord(ref) && typeof ref.path === 'string' && ref.path.startsWith(prefix)
|
|
421
|
+
&& ref.path.length > prefix.length && path.posix.normalize(ref.path) === ref.path
|
|
422
|
+
&& !ref.path.split('/').some((seg) => seg === '' || seg === '.' || seg === '..')
|
|
423
|
+
&& typeof ref.sha256 === 'string' && SHA256_RE.test(ref.sha256);
|
|
424
|
+
if (!ok)
|
|
425
|
+
return corrupt('reference outside cycle namespace');
|
|
426
|
+
}
|
|
427
|
+
return parsed;
|
|
428
|
+
}
|
|
429
|
+
function saveState(ctx, state) {
|
|
430
|
+
writeAtomic(path.join(ctx.nsAbs, 'state.json'), `${JSON.stringify(state, null, 2)}\n`);
|
|
431
|
+
}
|
|
432
|
+
function cyclePrefix(ctx, id) {
|
|
433
|
+
return `${ctx.nsRel}/cycles/${id}`;
|
|
434
|
+
}
|
|
435
|
+
/** dispatch를 cycle namespace 아래에 immutable로 저장하고 참조를 돌려준다. */
|
|
436
|
+
function storeDispatch(repoRoot, ctx, id, d) {
|
|
437
|
+
const rel = `${cyclePrefix(ctx, id)}/dispatch/${d.sha256}.json`;
|
|
438
|
+
writeImmutable(ctx.nsAbs, path.join(repoRoot, rel), d.text);
|
|
439
|
+
return { path: rel, sha256: d.sha256 };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* 저장된 자료를 읽고 sha256이 참조와 같은지 확인한다. 같은 cycle의 보고를 다시
|
|
443
|
+
* 합칠 때 변조·유실을 조용히 넘기지 않기 위함.
|
|
444
|
+
*
|
|
445
|
+
* @param {Ctx} ctx - namespace 경로
|
|
446
|
+
* @param {string} repoRoot - 저장소 루트
|
|
447
|
+
* @param {Ref} ref - 저장된 파일 참조
|
|
448
|
+
* @returns {string | Fail} 파일 본문 또는 손상 거절
|
|
449
|
+
*/
|
|
450
|
+
function readRef(ctx, repoRoot, ref) {
|
|
451
|
+
let text;
|
|
452
|
+
const abs = path.join(repoRoot, ref.path);
|
|
453
|
+
try {
|
|
454
|
+
// 읽기도 symlink를 따라가지 않는다: 구성요소가 바뀌었으면 내용이 같아도 손상으로 본다.
|
|
455
|
+
if (!walkSafeDir(ctx.nsAbs, path.dirname(abs), false) || !isPlainFile(abs))
|
|
456
|
+
return corrupt(`missing ${ref.path}`);
|
|
457
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error instanceof UnsafePathError)
|
|
461
|
+
return corrupt(error.message);
|
|
462
|
+
if (isRecord(error) && error.code === 'ENOENT')
|
|
463
|
+
return corrupt(`missing ${ref.path}`);
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
if (sha256(text) !== ref.sha256)
|
|
467
|
+
return corrupt(`digest mismatch ${ref.path}`);
|
|
468
|
+
return text;
|
|
469
|
+
}
|
|
470
|
+
function transitionKey(parts) {
|
|
471
|
+
return sha256(parts.join('\u0000'));
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* context-review.md에 남은 기존(cycle 도입 전) 증거를 읽는다.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} repoRoot - 저장소 루트
|
|
477
|
+
* @param {string} bp - blueprint 상대 경로
|
|
478
|
+
* @returns {Legacy | Fail} rounds·findings가 비어 있으면 present false.
|
|
479
|
+
* digest는 마지막 round의 target.digest
|
|
480
|
+
*/
|
|
481
|
+
function readLegacy(repoRoot, bp) {
|
|
482
|
+
const abs = path.join(repoRoot, bp, 'context-review.md');
|
|
483
|
+
if (!fs.existsSync(abs))
|
|
484
|
+
return { present: false, rounds: [], findings: [], digest: null, marker: 0 };
|
|
485
|
+
let data;
|
|
486
|
+
try {
|
|
487
|
+
({ data } = readDoc(abs));
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return fail('context-review-legacy-evidence', error instanceof Error ? error.message : String(error), 'fix context-review.md frontmatter, then retry');
|
|
491
|
+
}
|
|
492
|
+
const bouncer = isRecord(data) && isRecord(data.bouncer) ? data.bouncer : {};
|
|
493
|
+
const cr = isRecord(bouncer.context_review) ? bouncer.context_review : {};
|
|
494
|
+
const rounds = Array.isArray(cr.rounds) ? cr.rounds : [];
|
|
495
|
+
const findings = (Array.isArray(cr.findings) ? cr.findings : []);
|
|
496
|
+
let digest = null;
|
|
497
|
+
for (const r of rounds) {
|
|
498
|
+
if (isRecord(r) && isRecord(r.target) && typeof r.target.digest === 'string')
|
|
499
|
+
digest = r.target.digest;
|
|
500
|
+
}
|
|
501
|
+
const rawMarker = cr.restart_count;
|
|
502
|
+
if (rawMarker !== undefined && !MARKER_VALUES.has(rawMarker)) {
|
|
503
|
+
return fail('context-review-legacy-evidence', `restart_count marker ${JSON.stringify(rawMarker)} is invalid`, 'restart_count must be 0 or 1; restore context-review.md from version control');
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
present: rounds.length > 0 || findings.length > 0, rounds, findings, digest,
|
|
507
|
+
marker: typeof rawMarker === 'number' ? rawMarker : 0,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* 목록 한 줄에 들어갈 값의 줄바꿈을 공백으로 접는다. 줄바꿈이 남으면 한 finding이 여러 줄로 갈라진다.
|
|
512
|
+
* LF뿐 아니라 CR·CRLF도 줄 경계로 읽히므로 함께 접는다(`\r`만 있는 값도 마찬가지).
|
|
513
|
+
*
|
|
514
|
+
* @param {unknown} value - 접을 값
|
|
515
|
+
* @returns {string} 한 줄 문자열
|
|
516
|
+
*/
|
|
517
|
+
function oneLine(value) {
|
|
518
|
+
return String(value).replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* namespace의 state.json 존재만 본다. loadState의 모양 검증·symlink 거부와 달리
|
|
522
|
+
* existsSync를 그대로 써서, 깨졌거나 symlink여도 "있다"로 보고 호출부가 loadState·legacy
|
|
523
|
+
* 분기를 타게 한다 — 전이 거절 순서를 바꾸지 않으려면 존재만 봐야 한다.
|
|
524
|
+
*
|
|
525
|
+
* @param {Ctx} ctx - namespace 경로
|
|
526
|
+
* @returns {boolean} state.json 경로가 존재하면 true
|
|
527
|
+
*/
|
|
528
|
+
function cycleStateExists(ctx) {
|
|
529
|
+
return fs.existsSync(path.join(ctx.nsAbs, 'state.json'));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 전이 입력 파일(dispatch·round·dispositions)을 바이트로 읽는다. 해석과 거절 reason은
|
|
533
|
+
* 호출부가 맡는다 — 같은 읽기를 여러 전이가 공유하고 next 문구만 다르기 때문이다.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} abs - 입력 파일 절대 경로
|
|
536
|
+
* @returns {Buffer} 파일 바이트
|
|
537
|
+
*/
|
|
538
|
+
function readInputFile(abs) {
|
|
539
|
+
return fs.readFileSync(abs);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* 투영 문서(context-review.md) 존재만 본다. 생성·패치 여부는 호출부가 정한다.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} abs - 문서 절대 경로
|
|
545
|
+
* @returns {boolean} 파일이 있으면 true
|
|
546
|
+
*/
|
|
547
|
+
function projectionDocExists(abs) {
|
|
548
|
+
return fs.existsSync(abs);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* 투영 문서 원문을 읽는다. YAML 패치·렌더는 전이 쪽이 맡는다.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} abs - 문서 절대 경로
|
|
554
|
+
* @returns {string} UTF-8 본문
|
|
555
|
+
*/
|
|
556
|
+
function readProjectionText(abs) {
|
|
557
|
+
return fs.readFileSync(abs, 'utf8');
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* review-dispatch가 쓰는 읽기 전용 조회. state.json과 latest_dispatch 저장본을 loadState와 같은
|
|
561
|
+
* 검증(id 형식·정규화 경로·sha256·symlink 거부)으로 읽는다. 아무것도 만들거나 쓰지 않는다.
|
|
562
|
+
*
|
|
563
|
+
* @param {string} repoRoot - 저장소 루트
|
|
564
|
+
* @param {string} blueprintDir - blueprint 상대 경로
|
|
565
|
+
* @returns {object} state가 없으면 `{ok:true,state:null}`, 있으면 `{ok:true,state,payload}`
|
|
566
|
+
* (payload는 저장된 latest_dispatch JSON), 검증 실패는 `{ok:false,cause}`
|
|
567
|
+
*/
|
|
568
|
+
function readStoredLatest(repoRoot, blueprintDir) {
|
|
569
|
+
const ctx = openContext(repoRoot, blueprintDir, false);
|
|
570
|
+
if (isFail(ctx))
|
|
571
|
+
return { ok: false, cause: String(ctx.cause) };
|
|
572
|
+
const state = loadState(ctx);
|
|
573
|
+
if (isFail(state))
|
|
574
|
+
return { ok: false, cause: String(state.cause) };
|
|
575
|
+
if (state === null)
|
|
576
|
+
return { ok: true, state: null };
|
|
577
|
+
const text = readRef(ctx, repoRoot, state.cycle.latest_dispatch);
|
|
578
|
+
if (isFail(text))
|
|
579
|
+
return { ok: false, cause: String(text.cause) };
|
|
580
|
+
try {
|
|
581
|
+
return { ok: true, state, payload: JSON.parse(text) };
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (!(error instanceof SyntaxError))
|
|
585
|
+
throw error;
|
|
586
|
+
return { ok: false, cause: 'stored latest_dispatch is not JSON' };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** context-review.md 증거(rounds·findings·restart_count 표식)를 읽는 읽기 전용 조회. */
|
|
590
|
+
function readLegacyEvidence(repoRoot, bp) {
|
|
591
|
+
const legacy = readLegacy(repoRoot, bp);
|
|
592
|
+
if (isFail(legacy))
|
|
593
|
+
return { ok: false, cause: String(legacy.cause) };
|
|
594
|
+
return { ok: true, present: legacy.present, digest: legacy.digest, marker: legacy.marker };
|
|
595
|
+
}
|
|
596
|
+
module.exports = {
|
|
597
|
+
fail, sha256, targetInvalid, isFail, corrupt, transitionKey, oneLine,
|
|
598
|
+
writeAtomic, writeImmutable, guardWrites,
|
|
599
|
+
loadDispatch, withLock, openContext, loadState, saveState, cyclePrefix, storeDispatch,
|
|
600
|
+
readRef, readLegacy, readStoredLatest, readLegacyEvidence,
|
|
601
|
+
cycleStateExists, readInputFile, projectionDocExists, readProjectionText,
|
|
602
|
+
};
|