@rotifer/playground 0.27.0 → 0.29.0
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/CHANGELOG.md +48 -0
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/dist/cloud/capability-candidates.d.ts +89 -0
- package/dist/cloud/capability-candidates.d.ts.map +1 -0
- package/dist/cloud/capability-candidates.js +296 -0
- package/dist/cloud/capability-candidates.js.map +1 -0
- package/dist/commands/agent-create.d.ts +8 -0
- package/dist/commands/agent-create.d.ts.map +1 -1
- package/dist/commands/agent-create.js +5 -2
- package/dist/commands/agent-create.js.map +1 -1
- package/dist/commands/agent-run.d.ts.map +1 -1
- package/dist/commands/agent-run.js +30 -13
- package/dist/commands/agent-run.js.map +1 -1
- package/dist/commands/genome-format.d.ts +180 -0
- package/dist/commands/genome-format.d.ts.map +1 -0
- package/dist/commands/genome-format.js +774 -0
- package/dist/commands/genome-format.js.map +1 -0
- package/dist/commands/genome-import.d.ts +100 -0
- package/dist/commands/genome-import.d.ts.map +1 -0
- package/dist/commands/genome-import.js +425 -0
- package/dist/commands/genome-import.js.map +1 -0
- package/dist/commands/genome.d.ts +82 -0
- package/dist/commands/genome.d.ts.map +1 -0
- package/dist/commands/genome.js +392 -0
- package/dist/commands/genome.js.map +1 -0
- package/dist/commands/install.d.ts +54 -0
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +148 -75
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/publish.js +2 -2
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/vg.js +2 -2
- package/dist/commands/vg.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/publish/pre-publish-check.d.ts +15 -0
- package/dist/publish/pre-publish-check.d.ts.map +1 -1
- package/dist/publish/pre-publish-check.js +17 -2
- package/dist/publish/pre-publish-check.js.map +1 -1
- package/dist/scanner/index.d.ts +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +3 -1
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/literals.d.ts +2 -0
- package/dist/scanner/literals.d.ts.map +1 -0
- package/dist/scanner/literals.js +293 -0
- package/dist/scanner/literals.js.map +1 -0
- package/dist/scanner/rules.d.ts +14 -0
- package/dist/scanner/rules.d.ts.map +1 -1
- package/dist/scanner/rules.js +30 -1
- package/dist/scanner/rules.js.map +1 -1
- package/dist/scanner/scanner.d.ts.map +1 -1
- package/dist/scanner/scanner.js +8 -1
- package/dist/scanner/scanner.js.map +1 -1
- package/dist/scanner/types.d.ts +7 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/utils/binding.d.ts +1 -1
- package/dist/utils/binding.d.ts.map +1 -1
- package/dist/utils/binding.js.map +1 -1
- package/dist/utils/rust-phenotype.d.ts +24 -0
- package/dist/utils/rust-phenotype.d.ts.map +1 -0
- package/dist/utils/rust-phenotype.js +59 -0
- package/dist/utils/rust-phenotype.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GenomeImportError = exports.BARE_HEX_RULE = exports.CREDENTIAL_KEY_NAMES = exports.COMPARE_OPS = exports.GENE_ID_V2 = exports.BINDING_NAME = exports.MIN_VG_FLOOR = exports.DEFAULT_MIN_FITNESS = exports.BLUEPRINT_PROTOCOL_VERSION = exports.BLUEPRINT_FORMAT = void 0;
|
|
4
|
+
exports.shannonEntropy = shannonEntropy;
|
|
5
|
+
exports.looksLikeCredential = looksLikeCredential;
|
|
6
|
+
exports.scanForCredentials = scanForCredentials;
|
|
7
|
+
exports.compositionLeafIdPointers = compositionLeafIdPointers;
|
|
8
|
+
exports.scanBlueprint = scanBlueprint;
|
|
9
|
+
exports.compositionPlan = compositionPlan;
|
|
10
|
+
exports.jcs = jcs;
|
|
11
|
+
exports.computeGenomeId = computeGenomeId;
|
|
12
|
+
exports.checkMinVg = checkMinVg;
|
|
13
|
+
exports.parseBlueprint = parseBlueprint;
|
|
14
|
+
/**
|
|
15
|
+
* The Genome Blueprint format (.rgenome) as both ends see it: constants and
|
|
16
|
+
* types, the credential scan, and the reader's validation. The writer
|
|
17
|
+
* (genome.ts) runs the same validation on every file before writing it, so an
|
|
18
|
+
* export always imports with the same CLI; the reader (genome-import.ts) runs
|
|
19
|
+
* it before resolving anything.
|
|
20
|
+
*/
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
22
|
+
const pre_publish_check_js_1 = require("../publish/pre-publish-check.js");
|
|
23
|
+
// ── Genome Blueprint format (.rgenome) ───────────────────────
|
|
24
|
+
//
|
|
25
|
+
// A Blueprint describes an agent's genome so someone else can rebuild it: the
|
|
26
|
+
// spec's GenomeSnapshot (§25.1) plus how to find each gene again. It carries
|
|
27
|
+
// references and public fields only — never gene code or WASM (§10.4.1).
|
|
28
|
+
/** Blueprint format number; an importer refuses a larger one. */
|
|
29
|
+
exports.BLUEPRINT_FORMAT = 1;
|
|
30
|
+
/** Protocol version whose Blueprint layout this command writes. */
|
|
31
|
+
exports.BLUEPRINT_PROTOCOL_VERSION = { major: 0, minor: 9, patch: 2 };
|
|
32
|
+
/** Default `minFitness` for a CapabilityQuery: the recommended τ of §5.1. */
|
|
33
|
+
exports.DEFAULT_MIN_FITNESS = 0.3;
|
|
34
|
+
/** §5.1: V_min MUST lie in [0.5, 1.0). */
|
|
35
|
+
exports.MIN_VG_FLOOR = 0.5;
|
|
36
|
+
/** Key under `bindingExtensions` for semantics only this binding defines. */
|
|
37
|
+
exports.BINDING_NAME = "rotifer-playground";
|
|
38
|
+
exports.GENE_ID_V2 = /^v2:[0-9a-f]{64}$/;
|
|
39
|
+
/** Rust executor CompareOp (what `agent run` accepts) → §15.2 CompareOp. */
|
|
40
|
+
exports.COMPARE_OPS = {
|
|
41
|
+
Eq: "EQ",
|
|
42
|
+
Neq: "NEQ",
|
|
43
|
+
Gt: "GT",
|
|
44
|
+
Gte: "GTE",
|
|
45
|
+
Lt: "LT",
|
|
46
|
+
Lte: "LTE",
|
|
47
|
+
Contains: "CONTAINS",
|
|
48
|
+
Matches: "MATCHES",
|
|
49
|
+
};
|
|
50
|
+
/** Field names that hold a credential. Matched after lower-casing and dropping `_`/`-`, as the whole name or its suffix. */
|
|
51
|
+
exports.CREDENTIAL_KEY_NAMES = [
|
|
52
|
+
"apiKey",
|
|
53
|
+
"password",
|
|
54
|
+
"privateKey",
|
|
55
|
+
"secret",
|
|
56
|
+
"token",
|
|
57
|
+
"bearer",
|
|
58
|
+
"accessToken",
|
|
59
|
+
"clientSecret",
|
|
60
|
+
"refreshToken",
|
|
61
|
+
"oauth_token",
|
|
62
|
+
"aws_secret_access_key",
|
|
63
|
+
"stripe_secret_key",
|
|
64
|
+
];
|
|
65
|
+
const NORMALIZED_KEY_NAMES = exports.CREDENTIAL_KEY_NAMES.map(normalizeKey);
|
|
66
|
+
function normalizeKey(key) {
|
|
67
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
68
|
+
}
|
|
69
|
+
function isCredentialKeyName(key) {
|
|
70
|
+
const k = normalizeKey(key);
|
|
71
|
+
return NORMALIZED_KEY_NAMES.some((name) => k === name || k.endsWith(name));
|
|
72
|
+
}
|
|
73
|
+
/** A bare 32-byte key in hex: exactly 64 hex characters, optionally `0x`-prefixed. */
|
|
74
|
+
const BARE_HEX_KEY = /(?<![0-9A-Fa-f])(?:0x)?[0-9A-Fa-f]{64}(?![0-9A-Fa-f])/;
|
|
75
|
+
/** A `/`-separated run of plain words ("docs/protocol/genome") — prose, not key material. */
|
|
76
|
+
const PATH_WORD = /^[A-Za-z][a-z]*[0-9]*$/;
|
|
77
|
+
function isPathLike(run) {
|
|
78
|
+
const segments = run.split("/");
|
|
79
|
+
return segments.length >= 2 && segments.every((s) => s.length >= 2 && PATH_WORD.test(s));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A bare 32- or 64-byte key in Base64 outside a PEM block: a Base64 run of
|
|
83
|
+
* 43–44 or 86–88 characters that also looks random. Letters and `/` are in the
|
|
84
|
+
* Base64 alphabet, so length and charset alone also catch path text; random
|
|
85
|
+
* key material is told apart by its entropy (over 200,000 random keys the
|
|
86
|
+
* lowest seen was 4.24 bits/char at 32 bytes and 4.88 at 64; one million of
|
|
87
|
+
* each all still matched this rule) and by not splitting into plain words on `/`.
|
|
88
|
+
*/
|
|
89
|
+
function hasBareBase64Key(value) {
|
|
90
|
+
for (const run of value.match(/[A-Za-z0-9+/=]+/g) ?? []) {
|
|
91
|
+
const body = run.replace(/={1,2}$/, "");
|
|
92
|
+
if (body.includes("="))
|
|
93
|
+
continue;
|
|
94
|
+
const len = run.length;
|
|
95
|
+
const isKey32 = len >= 43 && len <= 44;
|
|
96
|
+
const isKey64 = len >= 86 && len <= 88;
|
|
97
|
+
if (!isKey32 && !isKey64)
|
|
98
|
+
continue;
|
|
99
|
+
if (shannonEntropy(body) < (isKey32 ? 4.0 : 4.5))
|
|
100
|
+
continue;
|
|
101
|
+
if (isPathLike(body))
|
|
102
|
+
continue;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
exports.BARE_HEX_RULE = "Bare 32-byte hex key";
|
|
108
|
+
const BARE_BASE64_RULE = "Bare Base64 key (32/64 bytes)";
|
|
109
|
+
/** Shannon entropy in bits per character. */
|
|
110
|
+
function shannonEntropy(s) {
|
|
111
|
+
if (s.length === 0)
|
|
112
|
+
return 0;
|
|
113
|
+
const counts = new Map();
|
|
114
|
+
for (const ch of s)
|
|
115
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
116
|
+
let bits = 0;
|
|
117
|
+
for (const n of counts.values()) {
|
|
118
|
+
const p = n / s.length;
|
|
119
|
+
bits -= p * Math.log2(p);
|
|
120
|
+
}
|
|
121
|
+
return bits;
|
|
122
|
+
}
|
|
123
|
+
const ENTROPY_THRESHOLD = 4.5;
|
|
124
|
+
const ENTROPY_MIN_LENGTH = 20;
|
|
125
|
+
/** RFC 6901 escaping of one reference token. */
|
|
126
|
+
function pointerToken(key) {
|
|
127
|
+
return key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Scan one string. `isHexExempt` lifts only the bare-hex rule — for fields that
|
|
131
|
+
* legitimately hold a hash; every other rule still applies.
|
|
132
|
+
*/
|
|
133
|
+
function scanString(value, pointer, isHexExempt, out) {
|
|
134
|
+
for (const { label, pattern } of pre_publish_check_js_1.SENSITIVE_PATTERNS) {
|
|
135
|
+
if (pattern.test(value))
|
|
136
|
+
out.push({ pointer, layer: "value-pattern", rule: label });
|
|
137
|
+
}
|
|
138
|
+
if (!isHexExempt && BARE_HEX_KEY.test(value)) {
|
|
139
|
+
out.push({ pointer, layer: "value-pattern", rule: exports.BARE_HEX_RULE });
|
|
140
|
+
}
|
|
141
|
+
if (hasBareBase64Key(value))
|
|
142
|
+
out.push({ pointer, layer: "value-pattern", rule: BARE_BASE64_RULE });
|
|
143
|
+
// Per run of printable ASCII: credentials are printable ASCII, so whitespace
|
|
144
|
+
// and any other character end a token. Prose does not add up to a hit, and
|
|
145
|
+
// text without spaces — Chinese, or ASCII words joined by symbols like "≥" —
|
|
146
|
+
// is not one long high-entropy token; a key glued to such text is still
|
|
147
|
+
// judged on its own. Hex tops out at 4.0 bits/char and never trips this —
|
|
148
|
+
// the hex rule above covers it.
|
|
149
|
+
for (const token of value.match(/[\x21-\x7e]+/g) ?? []) {
|
|
150
|
+
if (token.length > ENTROPY_MIN_LENGTH && shannonEntropy(token) > ENTROPY_THRESHOLD) {
|
|
151
|
+
out.push({ pointer, layer: "entropy", rule: `High-entropy string (> ${ENTROPY_THRESHOLD} bits/char)` });
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A number array of key length (32 or 64 entries, each a byte) — key material stored as bytes. */
|
|
157
|
+
function isKeyLengthByteArray(node) {
|
|
158
|
+
return (node.length === 32 || node.length === 64) && node.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
|
|
159
|
+
}
|
|
160
|
+
const BYTE_ARRAY_RULE = "Byte array of key length (32/64)";
|
|
161
|
+
/** A value a credential-named field could hold: a non-empty string, a number, or a list of those. */
|
|
162
|
+
function holdsCredentialValue(value) {
|
|
163
|
+
if (typeof value === "string")
|
|
164
|
+
return value.trim() !== "";
|
|
165
|
+
if (typeof value === "number")
|
|
166
|
+
return Number.isFinite(value);
|
|
167
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === "number" || typeof v === "string");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Code a Blueprint must not carry (it holds references and public fields only).
|
|
171
|
+
* Checked only where free-form binding data lives, so prose like "calls the
|
|
172
|
+
* function" in a description is not mistaken for code.
|
|
173
|
+
*/
|
|
174
|
+
const CODE_SHAPE = /\breturn\b[^;\n]*;|=>|\bfunction\b\s*[\w$]*\s*\(|\b(?:eval|require|Function|setTimeout|setInterval|fetch)\s*\(|<script\b|^\s*import\s|\bmodule\.exports\b/m;
|
|
175
|
+
const CODE_RULE = "Code in extension field";
|
|
176
|
+
/** Whether a string, on its own, trips any value rule — for masking it wherever it is about to be printed. */
|
|
177
|
+
function looksLikeCredential(text) {
|
|
178
|
+
const out = [];
|
|
179
|
+
scanString(text, "", false, out);
|
|
180
|
+
return out.length > 0;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Scan any JSON value.
|
|
184
|
+
*
|
|
185
|
+
* Pointers are built for humans to find the problem, so they must not become
|
|
186
|
+
* the leak: an object key that itself looks like a credential is replaced by
|
|
187
|
+
* `<redacted key #i>` (i = its position among that object's keys) in its own
|
|
188
|
+
* pointer and in every pointer below it.
|
|
189
|
+
*/
|
|
190
|
+
function scanForCredentials(doc, options = {}) {
|
|
191
|
+
const isHexExempt = options.isHexExempt ?? (() => false);
|
|
192
|
+
const isCodeForbidden = options.isCodeForbidden ?? (() => false);
|
|
193
|
+
const out = [];
|
|
194
|
+
const walk = (node, pointer) => {
|
|
195
|
+
if (typeof node === "string") {
|
|
196
|
+
scanString(node, pointer, isHexExempt(pointer, node), out);
|
|
197
|
+
if (isCodeForbidden(pointer) && CODE_SHAPE.test(node))
|
|
198
|
+
out.push({ pointer, layer: "value-pattern", rule: CODE_RULE });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(node)) {
|
|
202
|
+
if (isKeyLengthByteArray(node))
|
|
203
|
+
out.push({ pointer, layer: "value-pattern", rule: BYTE_ARRAY_RULE });
|
|
204
|
+
node.forEach((item, i) => walk(item, `${pointer}/${i}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (node === null || typeof node !== "object")
|
|
208
|
+
return;
|
|
209
|
+
Object.entries(node).forEach(([key, value], index) => {
|
|
210
|
+
// Map keys (roles, schema properties, extension names) are content too.
|
|
211
|
+
const keyFindings = [];
|
|
212
|
+
scanString(key, "", false, keyFindings);
|
|
213
|
+
const isSecretKey = keyFindings.length > 0;
|
|
214
|
+
const token = isSecretKey ? `<redacted key #${index}>` : pointerToken(key);
|
|
215
|
+
const child = `${pointer}/${token}`;
|
|
216
|
+
for (const f of keyFindings)
|
|
217
|
+
out.push({ ...f, pointer: child });
|
|
218
|
+
// A key that names a credential only counts when it holds one: a schema
|
|
219
|
+
// declaring an `apiKey` parameter (an object) is not a leak.
|
|
220
|
+
if (isCredentialKeyName(key) && holdsCredentialValue(value)) {
|
|
221
|
+
out.push({ pointer: child, layer: "key-name", rule: `Credential field name ${isSecretKey ? token : `'${key}'`}` });
|
|
222
|
+
}
|
|
223
|
+
walk(value, child);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
walk(doc, "");
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
return out.filter((f) => {
|
|
229
|
+
const id = `${f.pointer}\u0000${f.layer}\u0000${f.rule}`;
|
|
230
|
+
if (seen.has(id))
|
|
231
|
+
return false;
|
|
232
|
+
seen.add(id);
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const GENES_GENE_ID = /^\/snapshot\/genes\/\d+\/geneId$/;
|
|
237
|
+
const EXCLUDED_GENE_ID = /^\/capabilities\/[^/]+\/excludeGeneIds\/\d+$/;
|
|
238
|
+
const COMPOSITION_GENE_ID = /^\/snapshot\/composition(?:\/[^/]+)*\/geneRef\/geneId$/;
|
|
239
|
+
/** Pointers of the `geneRef/geneId` of every real leaf in a §15.2 subset composition. */
|
|
240
|
+
function compositionLeafIdPointers(expr, base = "/snapshot/composition") {
|
|
241
|
+
const out = new Set();
|
|
242
|
+
const body = (node, tag) => {
|
|
243
|
+
if (typeof node !== "object" || node === null || Array.isArray(node))
|
|
244
|
+
return null;
|
|
245
|
+
const keys = Object.keys(node);
|
|
246
|
+
if (keys.length !== 1 || keys[0] !== tag)
|
|
247
|
+
return null;
|
|
248
|
+
const b = node[tag];
|
|
249
|
+
return typeof b === "object" && b !== null && !Array.isArray(b) ? b : null;
|
|
250
|
+
};
|
|
251
|
+
const visit = (node, at) => {
|
|
252
|
+
if (typeof node !== "object" || node === null || Array.isArray(node))
|
|
253
|
+
return;
|
|
254
|
+
const [tag] = Object.keys(node);
|
|
255
|
+
const b = body(node, tag);
|
|
256
|
+
if (!b)
|
|
257
|
+
return;
|
|
258
|
+
switch (tag) {
|
|
259
|
+
case "Gene":
|
|
260
|
+
out.add(`${at}/Gene/geneRef/geneId`);
|
|
261
|
+
break;
|
|
262
|
+
case "Seq":
|
|
263
|
+
if (Array.isArray(b.steps))
|
|
264
|
+
b.steps.forEach((step, i) => visit(step, `${at}/Seq/steps/${i}`));
|
|
265
|
+
break;
|
|
266
|
+
case "Par":
|
|
267
|
+
visit(b.left, `${at}/Par/left`);
|
|
268
|
+
visit(b.right, `${at}/Par/right`);
|
|
269
|
+
break;
|
|
270
|
+
case "Cond":
|
|
271
|
+
visit(b.ifTrue, `${at}/Cond/ifTrue`);
|
|
272
|
+
visit(b.ifFalse, `${at}/Cond/ifFalse`);
|
|
273
|
+
break;
|
|
274
|
+
case "TryCatch":
|
|
275
|
+
visit(b.body, `${at}/TryCatch/body`);
|
|
276
|
+
visit(b.handler, `${at}/TryCatch/handler`);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
visit(expr, base);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function scanBlueprint(blueprint, options = {}) {
|
|
284
|
+
// Also called on unvalidated import input, so nothing about its shape is assumed.
|
|
285
|
+
const snapshot = blueprint?.snapshot;
|
|
286
|
+
const genes = snapshot?.genes;
|
|
287
|
+
const listed = new Set((Array.isArray(genes) ? genes : [])
|
|
288
|
+
.map((g) => (typeof g === "object" && g !== null ? g.geneId : undefined))
|
|
289
|
+
.filter((id) => typeof id === "string" && exports.GENE_ID_V2.test(id)));
|
|
290
|
+
const leafIds = options.isLeafPositionExact ? compositionLeafIdPointers(snapshot?.composition) : null;
|
|
291
|
+
return scanForCredentials(blueprint, {
|
|
292
|
+
isHexExempt: (pointer, value) => {
|
|
293
|
+
if (!exports.GENE_ID_V2.test(value))
|
|
294
|
+
return false;
|
|
295
|
+
if (GENES_GENE_ID.test(pointer) || EXCLUDED_GENE_ID.test(pointer))
|
|
296
|
+
return true;
|
|
297
|
+
const isLeafPosition = leafIds ? leafIds.has(pointer) : COMPOSITION_GENE_ID.test(pointer);
|
|
298
|
+
return isLeafPosition && listed.has(value);
|
|
299
|
+
},
|
|
300
|
+
isCodeForbidden: (pointer) => pointer.startsWith("/bindingExtensions/"),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const PAR_MERGES = new Set(["first", "concat", "merge"]);
|
|
304
|
+
class GenomeImportError extends Error {
|
|
305
|
+
hint;
|
|
306
|
+
constructor(message, hint) {
|
|
307
|
+
super(message);
|
|
308
|
+
this.hint = hint;
|
|
309
|
+
this.name = "GenomeImportError";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
exports.GenomeImportError = GenomeImportError;
|
|
313
|
+
// A Map, not an object: "toString" or "__proto__" must not find an inherited member.
|
|
314
|
+
const OPS_FROM_SPEC = new Map(Object.entries(exports.COMPARE_OPS).map(([local, spec]) => [spec, local]));
|
|
315
|
+
function outsideSubset(why) {
|
|
316
|
+
return new GenomeImportError(`Composition is outside the v0.9.2 executable subset: ${why}`, "v0.9.2 imports Gene, Seq of genes, Par of genes (no deadline), and Cond/TryCatch over exactly two genes.");
|
|
317
|
+
}
|
|
318
|
+
function isObject(v) {
|
|
319
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
320
|
+
}
|
|
321
|
+
/** The single key of an externally tagged AlgebraExpr, and its body. */
|
|
322
|
+
function tagOf(expr) {
|
|
323
|
+
if (!isObject(expr))
|
|
324
|
+
throw outsideSubset("an expression is not an object");
|
|
325
|
+
const keys = Object.keys(expr);
|
|
326
|
+
if (keys.length !== 1)
|
|
327
|
+
throw outsideSubset(`an expression has ${keys.length} tags`);
|
|
328
|
+
const body = expr[keys[0]];
|
|
329
|
+
if (!isObject(body))
|
|
330
|
+
throw outsideSubset(`'${keys[0]}' has no body`);
|
|
331
|
+
return [keys[0], body];
|
|
332
|
+
}
|
|
333
|
+
/** A `Gene` leaf's role, checked against the snapshot's gene list. */
|
|
334
|
+
function leafRole(expr, genes) {
|
|
335
|
+
const [tag, body] = tagOf(expr);
|
|
336
|
+
if (tag === "RemoteGene")
|
|
337
|
+
throw outsideSubset("RemoteGene is not supported");
|
|
338
|
+
if (tag !== "Gene")
|
|
339
|
+
throw outsideSubset(`'${tag}' appears where only a single gene may`);
|
|
340
|
+
const ref = body.geneRef;
|
|
341
|
+
if (!isObject(ref) || typeof ref.role !== "string" || typeof ref.geneId !== "string") {
|
|
342
|
+
throw outsideSubset("a Gene leaf has no geneRef { geneId, role }");
|
|
343
|
+
}
|
|
344
|
+
const listed = genes.find((g) => g.role === ref.role);
|
|
345
|
+
if (!listed || listed.geneId !== ref.geneId) {
|
|
346
|
+
throw new GenomeImportError(`Composition leaf '${ref.role}' does not match snapshot.genes`);
|
|
347
|
+
}
|
|
348
|
+
return ref.role;
|
|
349
|
+
}
|
|
350
|
+
function compositionPlan(blueprint) {
|
|
351
|
+
const genes = blueprint.snapshot.genes;
|
|
352
|
+
const order = genes.map((g) => g.role);
|
|
353
|
+
const ext = blueprint.bindingExtensions?.[exports.BINDING_NAME] ?? {};
|
|
354
|
+
const expr = blueprint.snapshot.composition;
|
|
355
|
+
// Format 1 requires the expression (RFC B3): §4.3's dataFlow fallback is
|
|
356
|
+
// neither written nor read, so list order alone does not say what runs.
|
|
357
|
+
if (expr === undefined) {
|
|
358
|
+
throw new GenomeImportError("Blueprint has no snapshot.composition", "Blueprint format 1 requires it; export the agent again with rotifer genome export.");
|
|
359
|
+
}
|
|
360
|
+
const [tag, body] = tagOf(expr);
|
|
361
|
+
let plan;
|
|
362
|
+
switch (tag) {
|
|
363
|
+
case "Gene":
|
|
364
|
+
plan = { type: "Single", roles: [leafRole(expr, genes)] };
|
|
365
|
+
break;
|
|
366
|
+
case "Seq": {
|
|
367
|
+
if (!Array.isArray(body.steps) || body.steps.length === 0)
|
|
368
|
+
throw outsideSubset("Seq has no steps");
|
|
369
|
+
plan = { type: "Seq", roles: body.steps.map((s) => leafRole(s, genes)) };
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case "Par": {
|
|
373
|
+
// `agent run` executes Par flat and n-ary; export writes it right-nested.
|
|
374
|
+
// Flatten Par(a, Par(b, c)) back to [a, b, c]; anything else nested is refused.
|
|
375
|
+
const roles = [];
|
|
376
|
+
let node = body;
|
|
377
|
+
for (;;) {
|
|
378
|
+
if (node.deadline !== undefined && node.deadline !== null)
|
|
379
|
+
throw outsideSubset("Par with a deadline");
|
|
380
|
+
roles.push(leafRole(node.left, genes));
|
|
381
|
+
const [rightTag, rightBody] = tagOf(node.right);
|
|
382
|
+
if (rightTag === "Par") {
|
|
383
|
+
node = rightBody;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
roles.push(leafRole(node.right, genes));
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
plan = { type: "Par", roles, parMerge: checkParMerge(ext.parMerge) };
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "Cond": {
|
|
393
|
+
const p = body.predicate;
|
|
394
|
+
if (!isObject(p) || typeof p.field !== "string" || typeof p.operator !== "string" || !("value" in p)) {
|
|
395
|
+
throw outsideSubset("Cond predicate must be { field, operator, value }");
|
|
396
|
+
}
|
|
397
|
+
// agent run substitutes "type" for an empty field and "primary" for a null
|
|
398
|
+
// value; accepting them would run a different branch than the one declared.
|
|
399
|
+
if (p.field === "")
|
|
400
|
+
throw outsideSubset("Cond predicate needs a non-empty field");
|
|
401
|
+
if (p.value === null)
|
|
402
|
+
throw outsideSubset("Cond predicate value must not be null");
|
|
403
|
+
const op = OPS_FROM_SPEC.get(p.operator);
|
|
404
|
+
if (!op)
|
|
405
|
+
throw outsideSubset(`unknown CompareOp '${p.operator}'`);
|
|
406
|
+
// What the executor actually does (R3): MATCHES always evaluates to false,
|
|
407
|
+
// CONTAINS only tests substrings, and field is looked up as one top-level key.
|
|
408
|
+
if (p.operator === "MATCHES")
|
|
409
|
+
throw outsideSubset("Cond operator MATCHES is not executed by agent run (it always evaluates to false)");
|
|
410
|
+
if (p.operator === "CONTAINS" && typeof p.value !== "string") {
|
|
411
|
+
throw outsideSubset("Cond operator CONTAINS needs a string value; agent run only tests substrings");
|
|
412
|
+
}
|
|
413
|
+
if (/[./[\]]/.test(p.field)) {
|
|
414
|
+
throw outsideSubset("Cond field must be a top-level field name (no '.', '/', '[' or ']'); agent run does not follow paths");
|
|
415
|
+
}
|
|
416
|
+
plan = {
|
|
417
|
+
type: "Cond",
|
|
418
|
+
roles: [leafRole(body.ifTrue, genes), leafRole(body.ifFalse, genes)],
|
|
419
|
+
predicate: { field: p.field, op, value: p.value },
|
|
420
|
+
};
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case "TryCatch":
|
|
424
|
+
plan = { type: "Try", roles: [leafRole(body.body, genes), leafRole(body.handler, genes)] };
|
|
425
|
+
break;
|
|
426
|
+
default:
|
|
427
|
+
throw outsideSubset(`'${tag}' is not a §15.2 operator v0.9.2 supports`);
|
|
428
|
+
}
|
|
429
|
+
if (plan.roles.length !== order.length || plan.roles.some((role, i) => role !== order[i])) {
|
|
430
|
+
throw outsideSubset("the composition must use every gene of snapshot.genes once, in order");
|
|
431
|
+
}
|
|
432
|
+
return plan;
|
|
433
|
+
}
|
|
434
|
+
/** The Par merge strategy (RFC §2.6, on the §7 list): applied as defined, or the Blueprint is refused. */
|
|
435
|
+
function checkParMerge(value) {
|
|
436
|
+
// Only an absent field means "first"; an explicit null is not one of the three.
|
|
437
|
+
const merge = value === undefined ? "first" : value;
|
|
438
|
+
if (typeof merge !== "string" || !PAR_MERGES.has(merge)) {
|
|
439
|
+
throw new GenomeImportError(`bindingExtensions["${exports.BINDING_NAME}"].parMerge '${String(merge)}' is not first, concat or merge`);
|
|
440
|
+
}
|
|
441
|
+
return merge;
|
|
442
|
+
}
|
|
443
|
+
// ── genomeId (RFC §4.4, decision B4) ─────────────────────────
|
|
444
|
+
/**
|
|
445
|
+
* RFC 8785 canonical JSON. For finite numbers and well-formed strings,
|
|
446
|
+
* JSON.stringify gives the RFC 8785 form of every scalar, and keys sort by
|
|
447
|
+
* UTF-16 code unit as RFC 8785 says; anything else throws. parseBlueprint has
|
|
448
|
+
* already refused both in a Blueprint; schemas read from a Registry row have not
|
|
449
|
+
* been through it.
|
|
450
|
+
*/
|
|
451
|
+
function jcs(value) {
|
|
452
|
+
if (Array.isArray(value))
|
|
453
|
+
return `[${value.map(jcs).join(",")}]`;
|
|
454
|
+
if (isObject(value)) {
|
|
455
|
+
return `{${Object.keys(value)
|
|
456
|
+
.sort()
|
|
457
|
+
.map((k) => `${jcsString(k)}:${jcs(value[k])}`)
|
|
458
|
+
.join(",")}}`;
|
|
459
|
+
}
|
|
460
|
+
if (typeof value === "string")
|
|
461
|
+
return jcsString(value);
|
|
462
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
463
|
+
throw new GenomeImportError("Canonical JSON has no form for a non-finite number");
|
|
464
|
+
const text = JSON.stringify(value);
|
|
465
|
+
if (text === undefined)
|
|
466
|
+
throw new GenomeImportError("Canonical JSON has no form for this value");
|
|
467
|
+
return text;
|
|
468
|
+
}
|
|
469
|
+
function jcsString(s) {
|
|
470
|
+
if (UNPAIRED_SURROGATE.test(s))
|
|
471
|
+
throw new GenomeImportError("Canonical JSON has no form for a string with an unpaired surrogate");
|
|
472
|
+
return JSON.stringify(s);
|
|
473
|
+
}
|
|
474
|
+
/** The execution projection of RFC §4.4: leaf gene IDs, operators, predicate and Par merge; nothing else. */
|
|
475
|
+
function project(expr, merge) {
|
|
476
|
+
const [tag, body] = tagOf(expr);
|
|
477
|
+
switch (tag) {
|
|
478
|
+
case "Gene":
|
|
479
|
+
return { Gene: body.geneRef.geneId };
|
|
480
|
+
case "Seq":
|
|
481
|
+
return { Seq: body.steps.map((step) => project(step, merge)) };
|
|
482
|
+
case "Par":
|
|
483
|
+
return { Par: { left: project(body.left, merge), right: project(body.right, merge), merge } };
|
|
484
|
+
case "Cond": {
|
|
485
|
+
const p = body.predicate;
|
|
486
|
+
return {
|
|
487
|
+
Cond: {
|
|
488
|
+
field: p.field,
|
|
489
|
+
operator: p.operator,
|
|
490
|
+
value: p.value,
|
|
491
|
+
ifTrue: project(body.ifTrue, merge),
|
|
492
|
+
ifFalse: project(body.ifFalse, merge),
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
case "TryCatch":
|
|
497
|
+
return { TryCatch: { body: project(body.body, merge), handler: project(body.handler, merge) } };
|
|
498
|
+
default:
|
|
499
|
+
throw outsideSubset(`'${tag}' is not a §15.2 operator v0.9.2 supports`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** "bp1:" + SHA-256 of the canonical execution projection. Call only on a Blueprint parseBlueprint accepted. */
|
|
503
|
+
function computeGenomeId(blueprint) {
|
|
504
|
+
const merge = checkParMerge(blueprint.bindingExtensions?.[exports.BINDING_NAME]?.parMerge);
|
|
505
|
+
const canonical = jcs(project(blueprint.snapshot.composition, merge));
|
|
506
|
+
return `bp1:${(0, node_crypto_1.createHash)("sha256").update(canonical, "utf8").digest("hex")}`;
|
|
507
|
+
}
|
|
508
|
+
function checkMinVg(value, where) {
|
|
509
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < exports.MIN_VG_FLOOR || value >= 1) {
|
|
510
|
+
throw new GenomeImportError(`${where} must be in [${exports.MIN_VG_FLOOR}, 1.0)`, "§5.1 does not allow a V(g) threshold below 0.5.");
|
|
511
|
+
}
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The first number in the file that cannot be imported as written, with its
|
|
516
|
+
* pointer. Keys are already scanned. Judged on the parsed value, whatever the
|
|
517
|
+
* spelling: JSON.parse rounds an integer beyond 2^53 to a neighbour
|
|
518
|
+
* (9007199254740993, ...993e0 and ...993.0 all become ...992), and rounding is
|
|
519
|
+
* monotonic with 2^53 itself representable, so every such literal lands on an
|
|
520
|
+
* integer outside the safe range — which is what is refused. Integer-valued
|
|
521
|
+
* floats such as 1e20 are refused with them.
|
|
522
|
+
*/
|
|
523
|
+
function firstUnrepresentableNumber(node, pointer) {
|
|
524
|
+
if (typeof node === "number") {
|
|
525
|
+
if (!Number.isFinite(node))
|
|
526
|
+
return { pointer, problem: "a value that is not a finite number" };
|
|
527
|
+
if (Number.isInteger(node) && !Number.isSafeInteger(node)) {
|
|
528
|
+
return { pointer, problem: "an integer outside the exactly representable range" };
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
if (Array.isArray(node)) {
|
|
533
|
+
for (let i = 0; i < node.length; i++) {
|
|
534
|
+
const hit = firstUnrepresentableNumber(node[i], `${pointer}/${i}`);
|
|
535
|
+
if (hit !== null)
|
|
536
|
+
return hit;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
if (isObject(node)) {
|
|
541
|
+
for (const [key, value] of Object.entries(node)) {
|
|
542
|
+
const hit = firstUnrepresentableNumber(value, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
|
|
543
|
+
if (hit !== null)
|
|
544
|
+
return hit;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const UNPAIRED_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
550
|
+
const BAD_KEY_TOKEN = "<key with an unpaired surrogate>";
|
|
551
|
+
function pointerTokenFor(key) {
|
|
552
|
+
if (UNPAIRED_SURROGATE.test(key))
|
|
553
|
+
return BAD_KEY_TOKEN;
|
|
554
|
+
if (looksLikeCredential(key))
|
|
555
|
+
return "<redacted key>";
|
|
556
|
+
return key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
557
|
+
}
|
|
558
|
+
/** Pointer of the first string (value or key) holding an unpaired UTF-16 surrogate, or null. */
|
|
559
|
+
function firstUnpairedSurrogate(node, pointer) {
|
|
560
|
+
if (typeof node === "string")
|
|
561
|
+
return UNPAIRED_SURROGATE.test(node) ? pointer : null;
|
|
562
|
+
if (Array.isArray(node)) {
|
|
563
|
+
for (let i = 0; i < node.length; i++) {
|
|
564
|
+
const hit = firstUnpairedSurrogate(node[i], `${pointer}/${i}`);
|
|
565
|
+
if (hit !== null)
|
|
566
|
+
return hit;
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
if (isObject(node)) {
|
|
571
|
+
for (const [key, value] of Object.entries(node)) {
|
|
572
|
+
if (UNPAIRED_SURROGATE.test(key))
|
|
573
|
+
return `${pointer}/${BAD_KEY_TOKEN}`;
|
|
574
|
+
const hit = firstUnpairedSurrogate(value, `${pointer}/${pointerTokenFor(key)}`);
|
|
575
|
+
if (hit !== null)
|
|
576
|
+
return hit;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Pointer of the first key that repeats within its object, or null. JSON.parse
|
|
583
|
+
* keeps the last occurrence and leaves no trace, so this reads the source text.
|
|
584
|
+
* It runs only after JSON.parse accepted the text, so the input is valid JSON;
|
|
585
|
+
* keys are decoded with JSON.parse, so "a" and "\u0061" are the same key.
|
|
586
|
+
*/
|
|
587
|
+
function firstDuplicateKey(text) {
|
|
588
|
+
const stack = [];
|
|
589
|
+
const childToken = () => {
|
|
590
|
+
const top = stack[stack.length - 1];
|
|
591
|
+
if (!top)
|
|
592
|
+
return "";
|
|
593
|
+
return top.kind === "object" ? pointerTokenFor(top.currentKey) : String(top.index);
|
|
594
|
+
};
|
|
595
|
+
const pathTo = (key) => [...stack.slice(1).map((f) => f.token), pointerTokenFor(key)].map((t) => `/${t}`).join("");
|
|
596
|
+
for (let i = 0; i < text.length; i++) {
|
|
597
|
+
const ch = text[i];
|
|
598
|
+
if (ch === '"') {
|
|
599
|
+
let j = i + 1;
|
|
600
|
+
while (text[j] !== '"')
|
|
601
|
+
j += text[j] === "\\" ? 2 : 1;
|
|
602
|
+
const top = stack[stack.length - 1];
|
|
603
|
+
if (top?.kind === "object" && top.isExpectingKey) {
|
|
604
|
+
const key = JSON.parse(text.slice(i, j + 1));
|
|
605
|
+
if (top.keys.has(key))
|
|
606
|
+
return pathTo(key);
|
|
607
|
+
top.keys.add(key);
|
|
608
|
+
top.currentKey = key;
|
|
609
|
+
top.isExpectingKey = false;
|
|
610
|
+
}
|
|
611
|
+
i = j;
|
|
612
|
+
}
|
|
613
|
+
else if (ch === "{") {
|
|
614
|
+
stack.push({ kind: "object", token: childToken(), keys: new Set(), isExpectingKey: true, currentKey: "" });
|
|
615
|
+
}
|
|
616
|
+
else if (ch === "[") {
|
|
617
|
+
stack.push({ kind: "array", token: childToken(), index: 0 });
|
|
618
|
+
}
|
|
619
|
+
else if (ch === "}" || ch === "]") {
|
|
620
|
+
stack.pop();
|
|
621
|
+
}
|
|
622
|
+
else if (ch === ",") {
|
|
623
|
+
const top = stack[stack.length - 1];
|
|
624
|
+
if (top?.kind === "object")
|
|
625
|
+
top.isExpectingKey = true;
|
|
626
|
+
else if (top?.kind === "array")
|
|
627
|
+
top.index++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
/** Parse a Blueprint, keep only what v0.9.2 can rebuild faithfully, and run the credential scan. */
|
|
633
|
+
function parseBlueprint(text) {
|
|
634
|
+
let raw;
|
|
635
|
+
try {
|
|
636
|
+
raw = JSON.parse(text);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
// The parser's own message quotes the input around the error, which may be
|
|
640
|
+
// a key; report only where it broke.
|
|
641
|
+
const position = /position (\d+)/.exec(err.message)?.[1];
|
|
642
|
+
throw new GenomeImportError(`Not a JSON Blueprint: the file is not valid JSON${position ? ` (at position ${position})` : ""}`);
|
|
643
|
+
}
|
|
644
|
+
if (!isObject(raw))
|
|
645
|
+
throw new GenomeImportError("Not a Blueprint: the top level is not an object");
|
|
646
|
+
// The credential scan runs before any structural check: structural errors
|
|
647
|
+
// name roles, keys and values from the file, and none of those may be a
|
|
648
|
+
// secret by the time they are printed. Findings carry masked pointers only.
|
|
649
|
+
const findings = scanBlueprint(raw, { isLeafPositionExact: true });
|
|
650
|
+
if (findings.length > 0) {
|
|
651
|
+
const where = findings.map((f) => `${f.pointer || "/"} [${f.layer}] ${f.rule}`).join("; ");
|
|
652
|
+
throw new GenomeImportError(`The Blueprint carries what looks like a credential, so it is not imported: ${where}`);
|
|
653
|
+
}
|
|
654
|
+
// A number must import as written. JSON.parse reads 1e400 as Infinity (saved
|
|
655
|
+
// again it becomes null, and agent run substitutes a default) and rounds an
|
|
656
|
+
// integer beyond 2^53 to a neighbour — either can switch a Cond branch.
|
|
657
|
+
const unrepresentable = firstUnrepresentableNumber(raw, "");
|
|
658
|
+
if (unrepresentable !== null) {
|
|
659
|
+
throw new GenomeImportError(`The Blueprint has ${unrepresentable.problem} at ${unrepresentable.pointer || "/"}`, "Numbers must be finite, and integers within ±(2^53 − 1); write larger values as strings.");
|
|
660
|
+
}
|
|
661
|
+
// Canonical JSON (RFC 8785) cannot represent either of these, and the genome ID
|
|
662
|
+
// is computed over canonical JSON: refuse them before anything else is decided.
|
|
663
|
+
const surrogate = firstUnpairedSurrogate(raw, "");
|
|
664
|
+
if (surrogate !== null) {
|
|
665
|
+
throw new GenomeImportError(`The Blueprint has a string with an unpaired surrogate at ${surrogate || "/"}`);
|
|
666
|
+
}
|
|
667
|
+
const duplicate = firstDuplicateKey(text);
|
|
668
|
+
if (duplicate !== null) {
|
|
669
|
+
throw new GenomeImportError(`The Blueprint has a duplicate key at ${duplicate}`, "JSON.parse would silently keep the last one; write each key once.");
|
|
670
|
+
}
|
|
671
|
+
const format = raw.blueprintFormat;
|
|
672
|
+
if (typeof format !== "number" || !Number.isInteger(format) || format < 1) {
|
|
673
|
+
throw new GenomeImportError("Not a Blueprint: blueprintFormat is missing or not a positive integer");
|
|
674
|
+
}
|
|
675
|
+
if (format > exports.BLUEPRINT_FORMAT) {
|
|
676
|
+
throw new GenomeImportError(`Blueprint format ${format} is newer than this CLI understands (${exports.BLUEPRINT_FORMAT})`, "Update the CLI: rotifer self-update");
|
|
677
|
+
}
|
|
678
|
+
// RFC §2.1 (B5): every MUST field, with the shape §2.7 gives it. protocolVersion
|
|
679
|
+
// is informational (B6): its shape is checked, its value never gates anything.
|
|
680
|
+
const pv = raw.protocolVersion;
|
|
681
|
+
const isVersionPart = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
682
|
+
if (!isObject(pv) ||
|
|
683
|
+
!["major", "minor", "patch"].every((k) => isVersionPart(pv[k])) ||
|
|
684
|
+
!Array.isArray(pv.supportedFeatures) ||
|
|
685
|
+
!pv.supportedFeatures.every((f) => typeof f === "string")) {
|
|
686
|
+
throw new GenomeImportError("Not a Blueprint: protocolVersion must be { major, minor, patch, supportedFeatures } with non-negative integers and a list of strings");
|
|
687
|
+
}
|
|
688
|
+
const metadata = raw.metadata;
|
|
689
|
+
if (!isObject(metadata) || typeof metadata.createdAt !== "string") {
|
|
690
|
+
throw new GenomeImportError("Not a Blueprint: metadata.createdAt is missing or not a string");
|
|
691
|
+
}
|
|
692
|
+
for (const key of ["name", "description", "author"]) {
|
|
693
|
+
if (metadata[key] !== undefined && typeof metadata[key] !== "string") {
|
|
694
|
+
throw new GenomeImportError(`metadata.${key} must be a string`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const snapshot = raw.snapshot;
|
|
698
|
+
if (!isObject(snapshot) || !Array.isArray(snapshot.genes) || snapshot.genes.length === 0) {
|
|
699
|
+
throw new GenomeImportError("Blueprint has no snapshot.genes");
|
|
700
|
+
}
|
|
701
|
+
if (typeof snapshot.snapshotAt !== "string") {
|
|
702
|
+
throw new GenomeImportError("Not a Blueprint: snapshot.snapshotAt is missing or not a string");
|
|
703
|
+
}
|
|
704
|
+
const roles = new Set();
|
|
705
|
+
for (const g of snapshot.genes) {
|
|
706
|
+
if (!isObject(g) || typeof g.geneId !== "string" || typeof g.role !== "string" || g.role === "") {
|
|
707
|
+
throw new GenomeImportError("Every snapshot.genes entry needs a geneId and a role");
|
|
708
|
+
}
|
|
709
|
+
if (!exports.GENE_ID_V2.test(g.geneId))
|
|
710
|
+
throw new GenomeImportError(`Gene '${g.role}' has no v2 gene ID`);
|
|
711
|
+
if (typeof g.optional !== "boolean")
|
|
712
|
+
throw new GenomeImportError(`Gene '${g.role}' needs optional: true or false`);
|
|
713
|
+
if (g.optional === true)
|
|
714
|
+
throw outsideSubset(`gene '${g.role}' is optional, which agent run has no notion of`);
|
|
715
|
+
if (roles.has(g.role))
|
|
716
|
+
throw new GenomeImportError(`Role '${g.role}' appears twice in snapshot.genes`);
|
|
717
|
+
roles.add(g.role);
|
|
718
|
+
}
|
|
719
|
+
if (raw.capabilities !== undefined) {
|
|
720
|
+
if (!isObject(raw.capabilities))
|
|
721
|
+
throw new GenomeImportError("capabilities must be an object");
|
|
722
|
+
for (const [role, q] of Object.entries(raw.capabilities)) {
|
|
723
|
+
if (!roles.has(role))
|
|
724
|
+
throw new GenomeImportError(`capabilities has a role '${role}' that snapshot.genes lacks`);
|
|
725
|
+
if (!isObject(q) || typeof q.domain !== "string" || q.domain === "") {
|
|
726
|
+
throw new GenomeImportError(`capabilities['${role}'] has no domain`);
|
|
727
|
+
}
|
|
728
|
+
for (const key of ["inputSchemaCompat", "outputSchemaCompat"]) {
|
|
729
|
+
if (q[key] === undefined)
|
|
730
|
+
throw new GenomeImportError(`capabilities['${role}'] has no ${key}`);
|
|
731
|
+
}
|
|
732
|
+
if (typeof q.minFitness !== "number" || q.minFitness < 0 || q.minFitness > 1) {
|
|
733
|
+
throw new GenomeImportError(`capabilities['${role}'].minFitness must be a number between 0 and 1`);
|
|
734
|
+
}
|
|
735
|
+
if (q.excludeGeneIds !== undefined) {
|
|
736
|
+
if (!Array.isArray(q.excludeGeneIds) || !q.excludeGeneIds.every((id) => typeof id === "string" && exports.GENE_ID_V2.test(id))) {
|
|
737
|
+
throw new GenomeImportError(`capabilities['${role}'].excludeGeneIds must be v2 gene IDs`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (raw.resolutionHints !== undefined) {
|
|
743
|
+
if (!isObject(raw.resolutionHints))
|
|
744
|
+
throw new GenomeImportError("resolutionHints must be an object");
|
|
745
|
+
for (const [role, h] of Object.entries(raw.resolutionHints)) {
|
|
746
|
+
if (!roles.has(role))
|
|
747
|
+
throw new GenomeImportError(`resolutionHints has a role '${role}' that snapshot.genes lacks`);
|
|
748
|
+
const isValid = isObject(h) &&
|
|
749
|
+
["owner", "name", "version", "fidelity"].every((k) => typeof h[k] === "string" && h[k] !== "") &&
|
|
750
|
+
(h.cloudId === undefined || typeof h.cloudId === "string");
|
|
751
|
+
if (!isValid)
|
|
752
|
+
throw new GenomeImportError(`resolutionHints['${role}'] needs owner, name, version and fidelity`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (raw.bindingExtensions !== undefined) {
|
|
756
|
+
if (!isObject(raw.bindingExtensions))
|
|
757
|
+
throw new GenomeImportError("bindingExtensions must be an object");
|
|
758
|
+
for (const [binding, value] of Object.entries(raw.bindingExtensions)) {
|
|
759
|
+
if (!isObject(value))
|
|
760
|
+
throw new GenomeImportError(`bindingExtensions['${binding}'] must be an object`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const blueprint = raw;
|
|
764
|
+
// The §7 list (parMerge, minVg): apply as defined or refuse; checked whenever written.
|
|
765
|
+
const ext = blueprint.bindingExtensions?.[exports.BINDING_NAME];
|
|
766
|
+
if (ext?.parMerge !== undefined)
|
|
767
|
+
checkParMerge(ext.parMerge);
|
|
768
|
+
const suggestedMinVg = ext?.minVg === undefined ? undefined : checkMinVg(ext.minVg, `bindingExtensions["${exports.BINDING_NAME}"].minVg`);
|
|
769
|
+
const plan = compositionPlan(blueprint);
|
|
770
|
+
// Step 7 of RFC §4.1: last, on a composition already known to be in the subset.
|
|
771
|
+
const genomeId = computeGenomeId(blueprint);
|
|
772
|
+
return { blueprint, plan, genomeId, suggestedMinVg };
|
|
773
|
+
}
|
|
774
|
+
//# sourceMappingURL=genome-format.js.map
|