@milpa/comal 0.2.6 → 0.2.8

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.
@@ -0,0 +1,309 @@
1
+ import Ajv from 'ajv';
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ import Ajv2019 from 'ajv/dist/2019.js';
4
+ import { digest, immutable } from './operations.mjs';
5
+
6
+ // A plugin's contract: what the house can state about it without reading its code (its surface)
7
+ // and the promises it makes in use cases (its cases). Cases are data the house evaluates on an
8
+ // operation's real result; they are never code, and a plugin never grades itself. Pure module.
9
+ //
10
+ // A repair keeps the contract when the surface is identical and every existing case is still
11
+ // present, unchanged; adding cases is allowed (compareContracts → kept).
12
+
13
+ const MAX_CASES = 200, CASE_BYTES = 64 * 1024, NAME_MAX = 120, CODE_MAX = 128, JSON_DEPTH = 64;
14
+ const OPERATION_NAME = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
15
+ const EXPECT_FORMS = ['returns', 'output_schema', 'throws'];
16
+ const validatorOptions = { strict: false, allErrors: true, validateFormats: false };
17
+ const compiled = new WeakMap();
18
+
19
+ const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
20
+ const shown = value => { let text; try { text = JSON.stringify(value); } catch { text = String(value); } text ??= String(value); return text.length > 120 ? `${text.slice(0, 120)}…` : text; };
21
+
22
+ function invalidCase(label, reason) {
23
+ return Object.assign(new Error(`Invalid plugin case ${label}: ${reason}`), { code: 'PLUGIN_CASES_INVALID', case: label });
24
+ }
25
+
26
+ // Throws (a plain message) unless value is JSON: null, strings, booleans, finite numbers, arrays
27
+ // and plain objects, at most JSON_DEPTH levels deep (a cycle runs into the depth limit).
28
+ function requireJson(value, what, depth = 0) {
29
+ if (depth > JSON_DEPTH) throw new Error(`${what} nests deeper than ${JSON_DEPTH} levels or is cyclic.`);
30
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
31
+ if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error(`${what} holds a number that is not finite.`); return; }
32
+ if (Array.isArray(value)) { for (const item of value) requireJson(item, what, depth + 1); return; }
33
+ if (plain(value)) { for (const key of Object.keys(value)) requireJson(value[key], what, depth + 1); return; }
34
+ throw new Error(`${what} is not JSON.`);
35
+ }
36
+
37
+ // An expectation that every output meets promises nothing.
38
+ const vacuous = expect => (Object.hasOwn(expect, 'returns') && plain(expect.returns) && !Object.keys(expect.returns).length) || (Object.hasOwn(expect, 'output_schema') && (expect.output_schema === true || (plain(expect.output_schema) && !Object.keys(expect.output_schema).length)));
39
+ // An $async schema validates through a promise: never a synchronous verdict.
40
+ function hasAsync(value, depth = 0) {
41
+ if (depth > JSON_DEPTH || value === null || typeof value !== 'object') return false;
42
+ if (!Array.isArray(value) && Object.hasOwn(value, '$async')) return true;
43
+ return Object.values(value).some(item => hasAsync(item, depth + 1));
44
+ }
45
+
46
+ function schemaValidator(schema) {
47
+ let validate = compiled.get(schema);
48
+ if (!validate) {
49
+ const Validator = typeof schema.$schema === 'string' && schema.$schema.includes('2020-12') ? Ajv2020 : typeof schema.$schema === 'string' && schema.$schema.includes('2019-09') ? Ajv2019 : Ajv;
50
+ validate = new Validator(validatorOptions).compile(structuredClone(schema));
51
+ compiled.set(schema, validate);
52
+ }
53
+ return validate;
54
+ }
55
+
56
+ /**
57
+ * Validate a plugin's `cases`. Each case is { name, operation, input, expect }: name 1–120 chars,
58
+ * unique; operation one of `operationNames` (the short names passed to ctx.operation; when
59
+ * operationNames is omitted only the shape is checked); input a JSON object; expect exactly one
60
+ * of { returns: <JSON> }, { output_schema: <JSON Schema object> }, { throws: { code } }. At most
61
+ * 200 cases, each at most 64 KB as JSON. Returns a frozen copy; throws PLUGIN_CASES_INVALID
62
+ * naming the case. `undefined` is no cases.
63
+ */
64
+ export function validateCases(cases, operationNames) {
65
+ if (cases === undefined) return immutable([]);
66
+ if (!Array.isArray(cases)) throw invalidCase('list', 'cases must be an array.');
67
+ if (cases.length > MAX_CASES) throw invalidCase('list', `a plugin declares at most ${MAX_CASES} cases (${cases.length} given).`);
68
+ const known = operationNames === undefined || operationNames === null ? null : new Set(operationNames);
69
+ const names = new Set(), out = [];
70
+ cases.forEach((item, index) => {
71
+ const label = plain(item) && typeof item.name === 'string' && item.name ? JSON.stringify(item.name.slice(0, NAME_MAX)) : `#${index + 1}`;
72
+ const refuse = reason => { throw invalidCase(label, reason); };
73
+ if (!plain(item)) refuse('a case must be an object.');
74
+ const extra = Object.keys(item).filter(key => !['name', 'operation', 'input', 'expect'].includes(key));
75
+ if (extra.length) refuse(`unknown fields ${extra.join(', ')}; a case is exactly { name, operation, input, expect }.`);
76
+ const { name, operation, input, expect } = item;
77
+ if (typeof name !== 'string' || name.length < 1 || name.length > NAME_MAX) refuse(`name must be a string of 1–${NAME_MAX} characters.`);
78
+ if (names.has(name)) refuse('duplicate case name.');
79
+ if (typeof operation !== 'string' || !OPERATION_NAME.test(operation)) refuse('operation must be an operation name as passed to ctx.operation.');
80
+ if (known && !known.has(operation)) refuse(`the plugin has no operation ${operation}.`);
81
+ if (!plain(input)) refuse('input must be a JSON object.');
82
+ if (!plain(expect)) refuse('expect must be an object.');
83
+ const forms = Object.keys(expect);
84
+ if (forms.length !== 1 || !EXPECT_FORMS.includes(forms[0])) refuse('expect must hold exactly one of returns, output_schema or throws.');
85
+ try { requireJson(item, 'The case'); } catch (error) { refuse(error.message); }
86
+ if (Object.hasOwn(expect, 'returns') && expect.returns === undefined) refuse('expect.returns must be a JSON value.');
87
+ if (vacuous(expect)) refuse('this expectation cannot fail (an empty returns object or an empty output_schema).');
88
+ if (Object.hasOwn(expect, 'output_schema')) {
89
+ if (!plain(expect.output_schema)) refuse('expect.output_schema must be a JSON Schema object.');
90
+ if (hasAsync(expect.output_schema)) refuse('expect.output_schema must not use $async.');
91
+ try { schemaValidator(expect.output_schema); } catch (error) { refuse(`expect.output_schema is not a valid schema: ${String(error.message).slice(0, 300)}`); }
92
+ }
93
+ if (Object.hasOwn(expect, 'throws')) {
94
+ const thrown = expect.throws;
95
+ if (!plain(thrown) || Object.keys(thrown).length !== 1 || typeof thrown.code !== 'string' || !thrown.code || thrown.code.length > CODE_MAX) refuse(`expect.throws must be exactly { code } with a code of 1–${CODE_MAX} characters.`);
96
+ }
97
+ if (Buffer.byteLength(JSON.stringify(item)) > CASE_BYTES) refuse('a case is at most 64 KB as JSON.');
98
+ names.add(name);
99
+ out.push({ name, operation, input: structuredClone(input), expect: structuredClone(expect) });
100
+ });
101
+ return immutable(out);
102
+ }
103
+
104
+ /** SHA-256 (64 hex) of the canonical JSON of a case's four fields. */
105
+ export function caseDigest(c) {
106
+ return digest({ name: c.name, operation: c.operation, input: c.input, expect: c.expect });
107
+ }
108
+
109
+ // A contract is plain JSON or it is no contract: a value JSON cannot hold (NaN, ±Infinity, a RegExp, a
110
+ // Date, a function, a class instance, a hole or undefined in an array, a cycle) could make two
111
+ // different surfaces digest alike. Returns a copy; undefined object properties are dropped (absent).
112
+ function contractInvalid(reason) { return Object.assign(new Error(`Invalid plugin contract: ${reason}`), { code: 'PLUGIN_CONTRACT_INVALID' }); }
113
+ function strictJson(value, at, ancestors = new Set()) {
114
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
115
+ if (typeof value === 'number') { if (!Number.isFinite(value)) throw contractInvalid(`${at} is a number that is not finite.`); return value; }
116
+ if (typeof value !== 'object') throw contractInvalid(`${at} is a ${typeof value}, not JSON.`);
117
+ if (ancestors.has(value)) throw contractInvalid(`${at} is cyclic.`);
118
+ if (!Array.isArray(value) && !plain(value)) throw contractInvalid(`${at} is a ${value.constructor?.name || 'non-plain object'}, not JSON.`);
119
+ ancestors.add(value);
120
+ try {
121
+ if (Array.isArray(value)) {
122
+ const out = [];
123
+ for (let index = 0; index < value.length; index++) {
124
+ if (!Object.hasOwn(value, index) || value[index] === undefined) throw contractInvalid(`${at}[${index}] is undefined or a hole.`);
125
+ out.push(strictJson(value[index], `${at}[${index}]`, ancestors));
126
+ }
127
+ return out;
128
+ }
129
+ const out = {};
130
+ for (const key of Object.keys(value)) if (value[key] !== undefined) Object.defineProperty(out, key, { value: strictJson(value[key], `${at}.${key}`, ancestors), enumerable: true, writable: true, configurable: true });
131
+ return out;
132
+ } finally { ancestors.delete(value); }
133
+ }
134
+
135
+ // What an Operation carries that is derived for each generation or build, and so is not promised:
136
+ // revision — a fresh UUID for every generation;
137
+ // origin.generation — the same per-generation UUID;
138
+ // origin.module_sha256 — the code's fingerprint, which every repair changes;
139
+ // origin.version — the plugin's version, which a repair may bump (version is not surface).
140
+ // Everything else on the Operation's contract is surface, including fields added to Operation
141
+ // later: the default is covered, the exclusion is explicit.
142
+ const DERIVED_FIELDS = Object.freeze(['revision']);
143
+ const DERIVED_ORIGIN_FIELDS = Object.freeze(['generation', 'module_sha256', 'version']);
144
+
145
+ /** The surface entry of a registered operation: its full contract minus the derived fields, under its short name. */
146
+ export function operationSurface(operationContract, name = operationContract.name) {
147
+ const entry = {};
148
+ for (const key of Object.keys(operationContract)) if (!DERIVED_FIELDS.includes(key)) Object.defineProperty(entry, key, { value: operationContract[key], enumerable: true, writable: true, configurable: true });
149
+ if (plain(entry.origin)) {
150
+ const origin = {};
151
+ for (const key of Object.keys(entry.origin)) if (!DERIVED_ORIGIN_FIELDS.includes(key)) Object.defineProperty(origin, key, { value: entry.origin[key], enumerable: true, writable: true, configurable: true });
152
+ entry.origin = origin;
153
+ }
154
+ entry.name = name;
155
+ return entry;
156
+ }
157
+
158
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
159
+ const sortedNames = (list, what) => { if (!Array.isArray(list) || list.some(item => typeof item !== 'string')) throw contractInvalid(`${what} must be a list of names.`); return [...list].sort(); };
160
+
161
+ /**
162
+ * The contract of a plugin. `operations` are surface entries (operationSurface, or any objects with a
163
+ * string `name`: the short name). `guards` is { count, covers: [short names] }, `observers`
164
+ * { <event>: count }, `services` [{ name, type, keys?: [{ key, type }] }]. Returns, frozen,
165
+ * { surface_digest, cases: [{ name, digest, operation, input, expect }], surface } where surface is
166
+ * { id, requires, provides, config_schema, tool_loading, discovery, hook_timeout_ms, operations
167
+ * (sorted by name), guards, observers, services } and surface_digest its SHA-256. `version` and the
168
+ * cases are not part of the surface. Throws PLUGIN_CONTRACT_INVALID for anything that is not plain
169
+ * JSON, and for duplicate operation names.
170
+ */
171
+ export function contractOf({ id, version, requires = [], provides = [], config_schema = null, tool_loading = 'eager', discovery = null, hook_timeout_ms = 1000, operations = [], cases = [], guards = { count: 0, covers: [] }, observers = {}, services = [] }) {
172
+ void version;
173
+ if (!Array.isArray(operations) || operations.some(op => !plain(op) || typeof op.name !== 'string')) throw contractInvalid('operations must be objects with a name.');
174
+ if (new Set(operations.map(op => op.name)).size !== operations.length) throw contractInvalid('two operations share a name.');
175
+ if (!Array.isArray(services) || services.some(service => !plain(service) || typeof service.name !== 'string')) throw contractInvalid('services must be objects with a name.');
176
+ if (!plain(guards)) throw contractInvalid('guards must be an object.');
177
+ if (!Array.isArray(cases) || cases.some(c => !plain(c))) throw contractInvalid('cases must be a list of objects.');
178
+ const surface = strictJson({
179
+ id,
180
+ requires: sortedNames(requires, 'requires'),
181
+ provides: sortedNames(provides, 'provides'),
182
+ config_schema: config_schema ?? null,
183
+ tool_loading: tool_loading ?? 'eager',
184
+ discovery: discovery ?? null,
185
+ hook_timeout_ms: hook_timeout_ms ?? 1000,
186
+ operations: [...operations].sort(byName),
187
+ guards: { ...guards, ...(Array.isArray(guards.covers) ? { covers: sortedNames(guards.covers, 'guards.covers') } : {}) },
188
+ observers,
189
+ services: [...services].sort(byName),
190
+ }, 'surface');
191
+ const entries = strictJson(cases.map(c => ({ name: c.name, operation: c.operation, input: c.input, expect: c.expect })), 'cases');
192
+ return immutable({ surface_digest: digest(surface), cases: entries.map(c => ({ name: c.name, digest: caseDigest(c), operation: c.operation, input: c.input, expect: c.expect })), surface });
193
+ }
194
+
195
+ const SURFACE_ORDER = ['id', 'requires', 'provides', 'config_schema', 'tool_loading', 'discovery', 'hook_timeout_ms', 'guards', 'observers', 'services'];
196
+ const sameValue = (a, b) => digest(a ?? null) === digest(b ?? null);
197
+ const union = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
198
+
199
+ // A contract compareContracts can trust: plain JSON, unique operation names, cases with a name and a digest.
200
+ function readable(contract) {
201
+ if (!plain(contract) || Object.hasOwn(contract, 'invalid') || !plain(contract.surface) || !Array.isArray(contract.cases)) return null;
202
+ try {
203
+ const surface = strictJson(contract.surface, 'surface'), cases = strictJson(contract.cases, 'cases');
204
+ const operations = surface.operations ?? [];
205
+ if (!Array.isArray(operations) || operations.some(op => !plain(op) || typeof op.name !== 'string') || new Set(operations.map(op => op.name)).size !== operations.length) return null;
206
+ if (cases.some(c => !plain(c) || typeof c.name !== 'string' || typeof c.digest !== 'string')) return null;
207
+ return { surface, operations, cases };
208
+ } catch { return null; }
209
+ }
210
+
211
+ /**
212
+ * What a candidate contract changes against the live one. surface_changed lists the plugin-level
213
+ * fields that differ (id, requires, provides, config_schema, tool_loading, discovery,
214
+ * hook_timeout_ms, guards, observers, services, then any other), then per operation by name
215
+ * 'operation:<name>:removed', 'operation:<name>:<field>' (origin by 'origin.<field>') and
216
+ * 'operation:<name>:added'. A case name that appears twice on either side is in cases_changed.
217
+ * kept is true only when nothing in the surface changed and no live case was removed or changed;
218
+ * added cases keep the contract. A contract that is invalid or unreadable on either side is
219
+ * never kept: surface_changed ['contract:invalid']. Never throws.
220
+ */
221
+ export function compareContracts(live, candidate) {
222
+ const before = readable(live), after = readable(candidate);
223
+ if (!before || !after) return { kept: false, surface_changed: ['contract:invalid'], cases_added: [], cases_removed: [], cases_changed: [] };
224
+ const surface_changed = [];
225
+ const keys = union(before.surface, after.surface).filter(key => key !== 'operations');
226
+ for (const key of [...SURFACE_ORDER, ...keys.filter(key => !SURFACE_ORDER.includes(key))]) if (!sameValue(before.surface[key], after.surface[key])) surface_changed.push(key);
227
+ const index = list => new Map(list.map(op => [op.name, op]));
228
+ const was = index(before.operations), now = index(after.operations);
229
+ for (const name of [...was.keys()].sort()) {
230
+ if (!now.has(name)) { surface_changed.push(`operation:${name}:removed`); continue; }
231
+ const a = was.get(name), b = now.get(name);
232
+ for (const field of union(a, b).filter(field => field !== 'name')) {
233
+ if (field === 'origin' && plain(a.origin) && plain(b.origin)) { for (const sub of union(a.origin, b.origin)) if (!sameValue(a.origin[sub], b.origin[sub])) surface_changed.push(`operation:${name}:origin.${sub}`); }
234
+ else if (!sameValue(a[field], b[field])) surface_changed.push(`operation:${name}:${field}`);
235
+ }
236
+ }
237
+ for (const name of [...now.keys()].sort()) if (!was.has(name)) surface_changed.push(`operation:${name}:added`);
238
+ if (!surface_changed.length && digest(before.surface) !== digest(after.surface)) surface_changed.push('surface');
239
+ const tally = list => { const out = new Map(); for (const c of list) out.set(c.name, [...(out.get(c.name) ?? []), c.digest]); return out; };
240
+ const liveCases = tally(before.cases), nextCases = tally(after.cases);
241
+ const duplicated = new Set([...liveCases, ...nextCases].filter(([, digests]) => digests.length > 1).map(([name]) => name));
242
+ const cases_added = [...nextCases.keys()].filter(name => !liveCases.has(name) && !duplicated.has(name));
243
+ const cases_removed = [...liveCases.keys()].filter(name => !nextCases.has(name) && !duplicated.has(name));
244
+ const cases_changed = [...new Set([...liveCases.keys(), ...nextCases.keys()])].filter(name => duplicated.has(name) || (liveCases.has(name) && nextCases.has(name) && liveCases.get(name)[0] !== nextCases.get(name)[0]));
245
+ return { kept: !surface_changed.length && !cases_removed.length && !cases_changed.length, surface_changed, cases_added, cases_removed, cases_changed };
246
+ }
247
+
248
+ // null when `actual` contains `expected`: objects by recursive subset, arrays element by element
249
+ // with equal length, scalars by strict equality. Otherwise the first difference, by path.
250
+ function difference(expected, actual, at) {
251
+ if (Array.isArray(expected)) {
252
+ if (!Array.isArray(actual)) return `${at}: expected an array, got ${shown(actual)}`;
253
+ if (actual.length !== expected.length) return `${at}: expected ${expected.length} items, got ${actual.length}`;
254
+ for (let index = 0; index < expected.length; index++) { const found = difference(expected[index], actual[index], `${at}[${index}]`); if (found) return found; }
255
+ return null;
256
+ }
257
+ if (expected !== null && typeof expected === 'object') {
258
+ if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return `${at}: expected an object, got ${shown(actual)}`;
259
+ for (const key of Object.keys(expected)) {
260
+ if (!Object.hasOwn(actual, key)) return `${at}.${key}: missing`;
261
+ const found = difference(expected[key], actual[key], `${at}.${key}`); if (found) return found;
262
+ }
263
+ return null;
264
+ }
265
+ return Object.is(expected, actual) || expected === actual ? null : `${at}: expected ${shown(expected)}, got ${shown(actual)}`;
266
+ }
267
+
268
+ // An operation's output usually reaches the house as text: a string that is JSON is read as JSON.
269
+ function readOutput(output) {
270
+ if (typeof output !== 'string') return output;
271
+ try { return JSON.parse(output); } catch { return output; }
272
+ }
273
+
274
+ /**
275
+ * Evaluate a case on an operation's real result: { status: 'returned', output } |
276
+ * { status: 'threw', code, message } | { status: 'not_run', reason }. Returns { pass, reason? }.
277
+ * `not_run` never passes. Never throws.
278
+ */
279
+ export function evaluateCase(c, result) {
280
+ try {
281
+ const fail = reason => ({ pass: false, reason: String(reason).slice(0, 500) });
282
+ const expect = c?.expect;
283
+ if (!plain(result) || !['returned', 'threw', 'not_run'].includes(result.status)) return fail('No result to evaluate.');
284
+ if (result.status === 'not_run') return fail(`Not run: ${typeof result.reason === 'string' && result.reason ? result.reason : 'no reason given'}`);
285
+ if (!plain(expect)) return fail('The case has no expectation.');
286
+ if (vacuous(expect)) return fail('The expectation cannot fail, so it proves nothing.');
287
+ if (Object.hasOwn(expect, 'throws')) {
288
+ if (result.status !== 'threw') return fail(`Expected a throw with code ${expect.throws.code}; it returned.`);
289
+ return result.code === expect.throws.code ? { pass: true } : fail(`Expected code ${expect.throws.code}; it threw ${shown(result.code ?? null)}: ${shown(result.message ?? '')}`);
290
+ }
291
+ if (result.status !== 'returned') return fail(`Expected a return; it threw ${shown(result.code ?? null)}: ${shown(result.message ?? '')}`);
292
+ const output = readOutput(result.output);
293
+ if (Object.hasOwn(expect, 'returns')) {
294
+ const found = difference(expect.returns, output, 'output');
295
+ // A string promise is also kept by the same text, read as it is.
296
+ if (found && typeof expect.returns === 'string' && result.output === expect.returns) return { pass: true };
297
+ return found ? fail(found) : { pass: true };
298
+ }
299
+ if (Object.hasOwn(expect, 'output_schema')) {
300
+ if (!plain(expect.output_schema) || hasAsync(expect.output_schema)) return fail('output_schema must be a synchronous JSON Schema object.');
301
+ const validate = schemaValidator(expect.output_schema), verdict = validate(output);
302
+ if (verdict && typeof verdict.then === 'function') { verdict.then(() => {}, () => {}); return fail('output_schema did not give a synchronous verdict.'); }
303
+ return verdict === true ? { pass: true } : fail(`The output does not match output_schema: ${validate.errors.slice(0, 6).map(error => `${error.instancePath || 'output'} ${error.message}`).join('; ')}`);
304
+ }
305
+ return fail('The case has no known expectation.');
306
+ } catch (error) {
307
+ return { pass: false, reason: `The case could not be evaluated: ${String(error?.message ?? error).slice(0, 300)}` };
308
+ }
309
+ }