yay-layer 1.0.0-rc.1
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/CONSTITUTION.md +55 -0
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/yay.js +3550 -0
- package/package.json +55 -0
- package/src/adopt.js +181 -0
- package/src/adversary.js +119 -0
- package/src/analyze.js +270 -0
- package/src/assurance.js +122 -0
- package/src/attest.js +216 -0
- package/src/capability.js +59 -0
- package/src/constitution.js +162 -0
- package/src/coverage.js +77 -0
- package/src/crypto.js +78 -0
- package/src/dashboard.js +463 -0
- package/src/durable.js +152 -0
- package/src/e2e.js +67 -0
- package/src/extract.js +179 -0
- package/src/foundation.js +143 -0
- package/src/gate.js +252 -0
- package/src/grants.js +249 -0
- package/src/history.js +77 -0
- package/src/ids.js +40 -0
- package/src/manifest.js +303 -0
- package/src/map.js +1742 -0
- package/src/mutate.js +192 -0
- package/src/objects.js +31 -0
- package/src/phone.js +188 -0
- package/src/plan.js +141 -0
- package/src/policy.js +0 -0
- package/src/predicate.js +143 -0
- package/src/prove.js +876 -0
- package/src/ratify.js +28 -0
- package/src/record.js +30 -0
- package/src/reverify.js +212 -0
- package/src/roster.js +117 -0
- package/src/signer-page.js +603 -0
- package/src/specdiff.js +69 -0
- package/src/tags.js +67 -0
- package/src/testrun.js +41 -0
- package/src/util.js +234 -0
- package/src/vendor/recovery.js +217 -0
- package/src/vendor/tweetnacl.min.js +1 -0
- package/src/verify.js +560 -0
- package/standard/STANDARD.md +135 -0
package/src/prove.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Behavioural verification: does the code actually satisfy its `ensures`?
|
|
3
|
+
//
|
|
4
|
+
// For every leaf Cell that declares `pure: yes` and an `ensures:`, we:
|
|
5
|
+
// 1. load the Cell's file in an isolated VM sandbox (TS types stripped, module
|
|
6
|
+
// syntax neutralised, unknown globals black-holed so pure code still loads),
|
|
7
|
+
// 2. capture the unit function by name (works for function decls, const arrows…),
|
|
8
|
+
// 3. generate inputs from the `in:` types (edge + ordinary values, deterministic),
|
|
9
|
+
// 4. call the real function and evaluate the `ensures` expression against `out`.
|
|
10
|
+
//
|
|
11
|
+
// A single counterexample ⇒ FAIL (Red): the code contradicts its promise. If we
|
|
12
|
+
// genuinely cannot check it (prose ensures, exotic types, code won't load) we say
|
|
13
|
+
// so and leave it UNPROVEN (Yellow / info) — never a fake pass. Test inputs come
|
|
14
|
+
// from the SPEC, never the implementation, so passing means something.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const vm = require('node:vm');
|
|
19
|
+
const cp = require('child_process');
|
|
20
|
+
const { mutants, deletions, harvestLiterals } = require('./mutate');
|
|
21
|
+
const { makeRecorder } = require('./record');
|
|
22
|
+
const { instrument: instrumentBranches } = require('./coverage');
|
|
23
|
+
const { analyzePredicates } = require('./predicate');
|
|
24
|
+
const { isJsLang, looseTopLevelNonJs, normLangName } = require('./util');
|
|
25
|
+
|
|
26
|
+
function stripTS(code) {
|
|
27
|
+
let m; try { m = require('node:module'); } catch (_) { return code; }
|
|
28
|
+
if (typeof m.stripTypeScriptTypes !== 'function') return code;
|
|
29
|
+
const prev = process.emitWarning; process.emitWarning = () => {};
|
|
30
|
+
try { return m.stripTypeScriptTypes(code, { mode: 'strip' }); }
|
|
31
|
+
catch (_) { return null; }
|
|
32
|
+
finally { process.emitWarning = prev; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Drop imports and unwrap `export` so the file runs as a plain script in the VM.
|
|
36
|
+
function neutralizeModules(code) {
|
|
37
|
+
return code.split('\n').map((l) => {
|
|
38
|
+
if (/^\s*import\b/.test(l)) return '';
|
|
39
|
+
if (/^\s*export\s+(\{|\*)/.test(l)) return '';
|
|
40
|
+
return l.replace(/^(\s*)export\s+default\s+/, '$1').replace(/^(\s*)export\s+/, '$1');
|
|
41
|
+
}).join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A permissive stand-in for globals a pure function shouldn't need but a file might
|
|
45
|
+
// touch at load time (document/window/…): every access yields another black hole.
|
|
46
|
+
function blackHole() {
|
|
47
|
+
const bh = new Proxy(function () {}, {
|
|
48
|
+
get: (_t, k) => (k === Symbol.toPrimitive ? () => '' : bh),
|
|
49
|
+
apply: () => bh, construct: () => bh, has: () => true,
|
|
50
|
+
});
|
|
51
|
+
return bh;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A recording "hyperscript": JSX compiles to __h(type, props, ...children) (pragma
|
|
55
|
+
// __h), and this builds a plain { type, props, children } vnode tree — no real React,
|
|
56
|
+
// no DOM — that a component Cell's `ensures` can be checked against. Child components
|
|
57
|
+
// appear as { type:'<Name>' } nodes (shallow render); primitives/strings are leaves.
|
|
58
|
+
function hyperscript(type, props) {
|
|
59
|
+
const kids = [];
|
|
60
|
+
for (let i = 2; i < arguments.length; i++) kids.push(arguments[i]);
|
|
61
|
+
const flat = [];
|
|
62
|
+
(function fl(a) { for (let i = 0; i < a.length; i++) { const c = a[i]; if (Array.isArray(c)) fl(c); else if (c != null && c !== false && c !== true) flat.push(c); } })(kids);
|
|
63
|
+
const t = typeof type === 'function' ? (type.displayName || type.name || 'Component') : (type == null ? '#fragment' : type);
|
|
64
|
+
return { type: t, props: props || {}, children: flat };
|
|
65
|
+
}
|
|
66
|
+
// Deterministic hook shims so a component renders ONCE for given props without a real
|
|
67
|
+
// React runtime. This exercises the initial render (what render-proving checks); it does
|
|
68
|
+
// not drive state transitions, effects, or events (an honest v1 boundary).
|
|
69
|
+
function reactShim() {
|
|
70
|
+
const noop = () => {};
|
|
71
|
+
const hooks = {
|
|
72
|
+
useState: (i) => [typeof i === 'function' ? i() : i, noop],
|
|
73
|
+
useReducer: (_r, i) => [i, noop],
|
|
74
|
+
useRef: (i) => ({ current: i === undefined ? null : i }),
|
|
75
|
+
useMemo: (f) => (typeof f === 'function' ? f() : undefined),
|
|
76
|
+
useCallback: (f) => f,
|
|
77
|
+
useEffect: noop, useLayoutEffect: noop, useContext: () => undefined,
|
|
78
|
+
};
|
|
79
|
+
return { hooks, React: Object.assign({ createElement: hyperscript, Fragment: '#fragment' }, hooks) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeSandbox(names, registry, extras) {
|
|
83
|
+
const rs = reactShim();
|
|
84
|
+
const real = {
|
|
85
|
+
Math, JSON, String, Number, Boolean, Array, Object, RegExp, Date, Symbol,
|
|
86
|
+
Error, TypeError, RangeError, // so probes/units can throw with a readable message
|
|
87
|
+
isNaN, isFinite, parseInt, parseFloat, encodeURIComponent, decodeURIComponent,
|
|
88
|
+
NaN, Infinity, undefined,
|
|
89
|
+
console: { log() {}, warn() {}, error() {}, info() {}, debug() {} },
|
|
90
|
+
__ylreg: (obj) => { Object.assign(registry, obj); },
|
|
91
|
+
__mkrec: makeRecorder, // effect recorder factory (for `records:` Cells)
|
|
92
|
+
__h: hyperscript, __Fragment: '#fragment', // JSX pragma targets (for `renders:` Cells)
|
|
93
|
+
React: rs.React, ...rs.hooks, // React.createElement/hooks AND bare hooks (imports are stripped)
|
|
94
|
+
...(extras || {}), // e.g. __ylcov (a real branch-coverage array the instrumented probes increment)
|
|
95
|
+
};
|
|
96
|
+
const sandbox = new Proxy(real, {
|
|
97
|
+
has: () => true, // tell the VM every identifier is "global" → no ReferenceError
|
|
98
|
+
get: (t, k) => (k in t ? t[k] : blackHole()),
|
|
99
|
+
});
|
|
100
|
+
return vm.createContext(sandbox);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Compile JSX (and, for .tsx, TS types) to plain JS via Babel, with the JSX pragma
|
|
104
|
+
// pointed at our __h/__Fragment. Babel is an OPTIONAL dependency, required lazily and
|
|
105
|
+
// only on the JSX path — if it's absent we return a distinct error so the Cell skips
|
|
106
|
+
// with an honest "install …" note rather than a fake pass. @babel/parser is already a
|
|
107
|
+
// dependency, so this stays in-family and battle-tested on JSX's edge cases.
|
|
108
|
+
function transformJSX(source, file) {
|
|
109
|
+
// Require the plugins as MODULES (resolved relative to this file → yay-layer's own
|
|
110
|
+
// node_modules), never as string names — Babel resolves plugin strings from the cwd,
|
|
111
|
+
// which is the USER's project when `yay verify` runs, where these deps don't live.
|
|
112
|
+
let babel, jsxPlugin, tsPlugin;
|
|
113
|
+
try {
|
|
114
|
+
babel = require('@babel/core');
|
|
115
|
+
jsxPlugin = require('@babel/plugin-transform-react-jsx'); jsxPlugin = jsxPlugin.default || jsxPlugin;
|
|
116
|
+
} catch (_) { return { error: 'jsx-needs-babel' }; }
|
|
117
|
+
const isTS = /\.tsx?$/.test(file || '');
|
|
118
|
+
const isTSX = /\.tsx$/.test(file || '');
|
|
119
|
+
const plugins = [];
|
|
120
|
+
if (isTS) {
|
|
121
|
+
try { tsPlugin = require('@babel/plugin-transform-typescript'); tsPlugin = tsPlugin.default || tsPlugin; }
|
|
122
|
+
catch (_) { return { error: 'jsx-needs-babel' }; }
|
|
123
|
+
plugins.push([tsPlugin, { isTSX, allowDeclareFields: true }]);
|
|
124
|
+
}
|
|
125
|
+
plugins.push([jsxPlugin, { runtime: 'classic', pragma: '__h', pragmaFrag: '__Fragment' }]);
|
|
126
|
+
try {
|
|
127
|
+
const out = babel.transformSync(source, { filename: file || 'component.jsx', babelrc: false, configFile: false, compact: false, plugins });
|
|
128
|
+
return { code: out && out.code != null ? out.code : source };
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { error: 'jsx-transform: ' + ((e && e.message) || 'failed').split('\n')[0] };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Run a source string in a fresh sandbox; return { fns, ctx } capturing `names`.
|
|
135
|
+
// opts.jsx compiles JSX/TSX first (Babel); otherwise TS types are stripped as before.
|
|
136
|
+
function runSource(source, names, opts) {
|
|
137
|
+
opts = opts || {};
|
|
138
|
+
let stripped;
|
|
139
|
+
if (opts.jsx) {
|
|
140
|
+
const t = transformJSX(source, opts.file);
|
|
141
|
+
if (t.error) return { error: t.error };
|
|
142
|
+
stripped = t.code;
|
|
143
|
+
} else {
|
|
144
|
+
stripped = stripTS(source);
|
|
145
|
+
if (stripped == null) return { error: 'typescript-strip-failed' };
|
|
146
|
+
}
|
|
147
|
+
const code = neutralizeModules(stripped);
|
|
148
|
+
const registry = {};
|
|
149
|
+
const ctx = makeSandbox(names, registry);
|
|
150
|
+
const cap = '\n;try{__ylreg({' + names.map((n) => JSON.stringify(n) + ':(typeof ' + safeIdent(n) + "!=='undefined'?" + safeIdent(n) + ':undefined)').join(',') + '});}catch(e){}';
|
|
151
|
+
try { vm.runInContext(code + cap, ctx, { timeout: 2000 }); }
|
|
152
|
+
catch (e) { return { error: 'load: ' + (e && e.message ? e.message.split('\n')[0] : 'threw') }; }
|
|
153
|
+
return { fns: registry, ctx };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Only plain identifiers can be captured as bare names (skip Class.method units).
|
|
157
|
+
function safeIdent(n) { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n) ? n : '__nope_' + Math.abs(hash(n)); }
|
|
158
|
+
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; }
|
|
159
|
+
|
|
160
|
+
// ── inputs ────────────────────────────────────────────────────────────────
|
|
161
|
+
// Split on TOP-LEVEL commas only, so object/array/generic param types keep their
|
|
162
|
+
// internal commas (`paddle: {x,y,w,h}, step: number` → two params, not six).
|
|
163
|
+
function splitTopLevel(s) {
|
|
164
|
+
const out = []; let depth = 0, cur = '';
|
|
165
|
+
for (const ch of s) {
|
|
166
|
+
if (ch === '{' || ch === '[' || ch === '(' || ch === '<') depth++;
|
|
167
|
+
else if (ch === '}' || ch === ']' || ch === ')' || ch === '>') depth = Math.max(0, depth - 1);
|
|
168
|
+
if (ch === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += ch;
|
|
169
|
+
}
|
|
170
|
+
if (cur.trim()) out.push(cur);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function parseIn(spec) {
|
|
174
|
+
let raw = (spec && spec.in ? String(spec.in) : '').trim();
|
|
175
|
+
raw = raw.replace(/^\(([\s\S]*)\)$/, '$1').trim(); // tolerate `(a:number, b:number)` wrapping
|
|
176
|
+
if (!raw || /^todo$/i.test(raw)) return [];
|
|
177
|
+
return splitTopLevel(raw).map((part) => {
|
|
178
|
+
const i = part.indexOf(':'); // split on the FIRST colon; object types keep theirs
|
|
179
|
+
const name = (i < 0 ? part : part.slice(0, i)).trim();
|
|
180
|
+
const type = (i < 0 ? 'any' : part.slice(i + 1)).trim().toLowerCase().replace(/\s+/g, '');
|
|
181
|
+
return { name, type };
|
|
182
|
+
}).filter((p) => p.name);
|
|
183
|
+
}
|
|
184
|
+
// Same as parseIn but PRESERVES case in the type — needed for component props, whose
|
|
185
|
+
// names are case-sensitive (onClick, className). valuesFor lowercases for primitive
|
|
186
|
+
// lookups internally, so keeping case here only affects object field names.
|
|
187
|
+
function parseInCased(spec) {
|
|
188
|
+
let raw = (spec && spec.in ? String(spec.in) : '').trim();
|
|
189
|
+
raw = raw.replace(/^\(([\s\S]*)\)$/, '$1').trim();
|
|
190
|
+
if (!raw || /^todo$/i.test(raw)) return [];
|
|
191
|
+
return splitTopLevel(raw).map((part) => {
|
|
192
|
+
const i = part.indexOf(':');
|
|
193
|
+
const name = (i < 0 ? part : part.slice(0, i)).trim();
|
|
194
|
+
const type = (i < 0 ? 'any' : part.slice(i + 1)).trim().replace(/\s+/g, '');
|
|
195
|
+
return { name, type };
|
|
196
|
+
}).filter((p) => p.name);
|
|
197
|
+
}
|
|
198
|
+
const POOLS = {
|
|
199
|
+
number: [0, 1, 2, -1, 3, 7, 10, -5, 0.5, 100],
|
|
200
|
+
string: ['', 'a', 'ab', 'abc', 'Hello', '/x', '123', 'a b'],
|
|
201
|
+
boolean: [true, false],
|
|
202
|
+
};
|
|
203
|
+
// Parse an object-shape type `{title:string, featured:boolean}` into fields, PRESERVING
|
|
204
|
+
// field-name case (prop names are case-sensitive). Returns null if it isn't a shape.
|
|
205
|
+
function objectShape(type) {
|
|
206
|
+
const m = String(type).match(/^\{([\s\S]*)\}$/);
|
|
207
|
+
if (!m) return null;
|
|
208
|
+
const fields = splitTopLevel(m[1]).map((f) => { const i = f.indexOf(':'); if (i < 0) return null; return { name: f.slice(0, i).trim(), type: f.slice(i + 1).trim() }; }).filter(Boolean);
|
|
209
|
+
return fields.length ? fields : null;
|
|
210
|
+
}
|
|
211
|
+
function valuesFor(type) {
|
|
212
|
+
const lt = String(type).toLowerCase();
|
|
213
|
+
if (POOLS[lt]) return POOLS[lt];
|
|
214
|
+
const arr = lt.match(/^(number|string|boolean)\[\]$/);
|
|
215
|
+
if (arr) { const b = POOLS[arr[1]]; return [[], [b[1]], [b[1], b[2]], b.slice(0, 3)]; }
|
|
216
|
+
const shape = objectShape(type); // object props → generate objects (cased field names)
|
|
217
|
+
if (shape) {
|
|
218
|
+
const fv = shape.map((f) => valuesFor(f.type));
|
|
219
|
+
if (fv.some((v) => !v)) return null;
|
|
220
|
+
const combos = cartesian(fv, 12);
|
|
221
|
+
return combos.map((tuple) => { const o = {}; shape.forEach((f, i) => { o[f.name] = tuple[i]; }); return o; });
|
|
222
|
+
}
|
|
223
|
+
if (/\[\]$/.test(String(type))) { // any-element array (e.g. object[]) → a few sample arrays
|
|
224
|
+
const base = valuesFor(String(type).replace(/\[\]$/, ''));
|
|
225
|
+
if (!base || !base.length) return null;
|
|
226
|
+
return [[], [base[Math.min(1, base.length - 1)]], base.slice(0, 3)];
|
|
227
|
+
}
|
|
228
|
+
return null; // uncheckable type
|
|
229
|
+
}
|
|
230
|
+
function cartesian(lists, cap) {
|
|
231
|
+
let out = [[]];
|
|
232
|
+
for (const list of lists) {
|
|
233
|
+
const next = [];
|
|
234
|
+
for (const tuple of out) for (const v of list) { next.push(tuple.concat([v])); if (next.length > cap * 4) break; }
|
|
235
|
+
out = next;
|
|
236
|
+
}
|
|
237
|
+
if (out.length > cap) { // deterministic thinning
|
|
238
|
+
const step = out.length / cap; const picked = [];
|
|
239
|
+
for (let i = 0; i < out.length; i += step) picked.push(out[Math.floor(i)]);
|
|
240
|
+
out = picked;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── the ensures expression ──────────────────────────────────────────────────
|
|
246
|
+
// SAFE, unambiguous normalizations only (never anything that could change meaning
|
|
247
|
+
// and mint a false pass): `;` clause-separator → conjunction, and |simple| → Math.abs.
|
|
248
|
+
function normalizeEnsures(ensuresExpr) {
|
|
249
|
+
return String(ensuresExpr || '')
|
|
250
|
+
.replace(/;/g, ' && ')
|
|
251
|
+
.replace(/\|\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*\|/g, 'Math.abs($1)') // |dx| → Math.abs(dx)
|
|
252
|
+
.replace(/(?:&&\s*)+$/, '')
|
|
253
|
+
.trim() || 'true';
|
|
254
|
+
}
|
|
255
|
+
// A helpful, honest reason when an `ensures` can't be machine-checked — tells the
|
|
256
|
+
// author exactly how to make it checkable, instead of a bare "prose?".
|
|
257
|
+
function ensuresHint(ensuresExpr) {
|
|
258
|
+
const e = String(ensuresExpr || '');
|
|
259
|
+
if (/\b(iff|⇔|<=>)\b/i.test(e)) return 'uses "iff" — write it as JS: `A === B` (boolean equality)';
|
|
260
|
+
if (/(=>|⇒|\bimplies\b)/i.test(e)) return 'uses implication — write it as JS: `(!A || B)`';
|
|
261
|
+
if (/\b(for ?all|every|each|∀)\b/i.test(e)) return 'uses "for all/every" — write it as JS: `arr.every(x => …)`';
|
|
262
|
+
if (/\b(unchanged|not ?mutated|immutab)\b/i.test(e)) return 'talks about mutation — compare a copy: `JSON.stringify(out) === JSON.stringify(fn(...))`';
|
|
263
|
+
if (/\bclamp\b/i.test(e)) return 'uses clamp() — inline it: `Math.max(lo, Math.min(x, hi))`';
|
|
264
|
+
if (!/[<>=!]=?|===|!==|&&|\|\||\.every|\.some|\.includes/.test(e)) return 'reads as prose — write a boolean JS expression over `out` and the inputs';
|
|
265
|
+
return 'not valid JavaScript — write `ensures:` as a boolean expression over `out` and the inputs';
|
|
266
|
+
}
|
|
267
|
+
function buildChecker(ctx, params, ensuresExpr) {
|
|
268
|
+
const expr = normalizeEnsures(ensuresExpr);
|
|
269
|
+
const decl = params.map((p, i) => `var ${p}=A[${i}];`).join(' ');
|
|
270
|
+
const call = `fn(${params.map((_, i) => `A[${i}]`).join(',')})`;
|
|
271
|
+
const src = `(function(fn,A){ ${decl} var out=${call}; return {out:out, ok:!!(${expr})}; })`;
|
|
272
|
+
return vm.runInContext(src, ctx, { timeout: 2000 });
|
|
273
|
+
}
|
|
274
|
+
// Effect-aware checker: instrument the `records:` param with a recorder, run the
|
|
275
|
+
// (side-effecting) function, and evaluate `ensures` against the recorded trace.
|
|
276
|
+
// The ensures may use: `trace` (raw), `calls(name)` → arg-arrays, `sets(name)` →
|
|
277
|
+
// assigned values, `didCall(name)`, `didSet(name, value)` — plus `out` and the args.
|
|
278
|
+
function buildEffectChecker(ctx, params, recordsName, ensuresExpr) {
|
|
279
|
+
const idx = params.indexOf(recordsName);
|
|
280
|
+
if (idx < 0) throw new Error('records: names "' + recordsName + '", which is not a parameter in in:');
|
|
281
|
+
const expr = normalizeEnsures(ensuresExpr);
|
|
282
|
+
const decl = params.map((p, i) => `var ${p}=A[${i}];`).join(' ');
|
|
283
|
+
const call = `fn(${params.map((_, i) => `A[${i}]`).join(',')})`;
|
|
284
|
+
const helpers = 'function calls(n){return trace.filter(function(e){return e.type==="call"&&e.name===n;}).map(function(e){return e.args;});}'
|
|
285
|
+
+ 'function sets(n){return trace.filter(function(e){return e.type==="set"&&e.name===n;}).map(function(e){return e.value;});}'
|
|
286
|
+
+ 'function didCall(n){return calls(n).length>0;}function didSet(n,v){return sets(n).indexOf(v)>=0;}';
|
|
287
|
+
const src = `(function(fn,A){ var __r=__mkrec(); A[${idx}]=__r.proxy; ${decl} var trace=__r.trace; ${helpers} var out=${call}; return {out:out, trace:trace, ok:!!(${expr})}; })`;
|
|
288
|
+
return vm.runInContext(src, ctx, { timeout: 2000 });
|
|
289
|
+
}
|
|
290
|
+
// Render checker (for `renders:` Cells): run the component with generated props, take
|
|
291
|
+
// the returned vnode tree as `out`, and evaluate `ensures` with tree helpers in scope:
|
|
292
|
+
// text(n) · find(n,type) · findAll(n,type) · has(n,type) · count(n,type)
|
|
293
|
+
// attr(n,name) · hasClass(n,class) · kids(n)
|
|
294
|
+
// (Mirrors buildEffectChecker: a checkable surface + helpers instead of a bare return.)
|
|
295
|
+
function buildRenderChecker(ctx, params, ensuresExpr) {
|
|
296
|
+
const expr = normalizeEnsures(ensuresExpr);
|
|
297
|
+
const decl = params.map((p, i) => `var ${p}=A[${i}];`).join(' ');
|
|
298
|
+
const call = `fn(${params.map((_, i) => `A[${i}]`).join(',')})`;
|
|
299
|
+
const helpers = ''
|
|
300
|
+
+ 'function __nodes(n){var acc=[];(function w(x){if(x&&typeof x==="object"&&x.type!==undefined){acc.push(x);(x.children||[]).forEach(w);}})(n);return acc;}'
|
|
301
|
+
+ 'function find(n,t){var a=__nodes(n);for(var i=0;i<a.length;i++)if(a[i].type===t)return a[i];return null;}'
|
|
302
|
+
+ 'function findAll(n,t){return __nodes(n).filter(function(x){return x.type===t;});}'
|
|
303
|
+
+ 'function has(n,t){return findAll(n,t).length>0;}'
|
|
304
|
+
+ 'function count(n,t){return findAll(n,t).length;}'
|
|
305
|
+
+ 'function attr(n,name){return n&&n.props?n.props[name]:undefined;}'
|
|
306
|
+
+ 'function kids(n){return n&&n.children?n.children:[];}'
|
|
307
|
+
+ 'function hasClass(n,c){var cn=n&&n.props?String(n.props.className||""):"";return cn.split(/\\s+/).indexOf(c)>=0;}'
|
|
308
|
+
+ 'function text(n){var s="";(function w(x){if(x==null||x===false||x===true)return;if(typeof x==="object"&&x.type!==undefined){(x.children||[]).forEach(w);}else{s+=String(x);}})(n);return s;}';
|
|
309
|
+
const src = `(function(fn,A){ ${decl} var out=${call}; ${helpers} return {out:out, ok:!!(${expr})}; })`;
|
|
310
|
+
return vm.runInContext(src, ctx, { timeout: 2000 });
|
|
311
|
+
}
|
|
312
|
+
function show(v) { try { return typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v) ?? String(v); } catch (_) { return String(v); } }
|
|
313
|
+
|
|
314
|
+
// ── proving adapters ────────────────────────────────────────────────────────
|
|
315
|
+
// Each prover mode is an adapter: it says which Cells it handles (canHandle),
|
|
316
|
+
// how to parse the spec's `in:` (inputs), how to build a checker that exposes a
|
|
317
|
+
// checkable surface + evaluates `ensures` (checker), and how to phrase a
|
|
318
|
+
// counterexample (describe). The core driver owns dispatch, the case-loop, and
|
|
319
|
+
// mutation — so a new framework/language is a new adapter, not a core edit.
|
|
320
|
+
// (`load` is uniform here because all current adapters run in the same JS VM; an
|
|
321
|
+
// out-of-VM adapter, e.g. Python, will carry its own load — see docs/design.)
|
|
322
|
+
function jsLoad(source, names, opts) { return runSource(source, names, { jsx: !!(opts && opts.jsx), file: opts && opts.file }); }
|
|
323
|
+
|
|
324
|
+
const pureCallAdapter = {
|
|
325
|
+
name: 'pure-call', inVM: true, wantsJSX: false,
|
|
326
|
+
canHandle: (cell) => /^yes\b/i.test((cell.spec && cell.spec.pure) || ''),
|
|
327
|
+
load: jsLoad,
|
|
328
|
+
inputs: (cell) => parseIn(cell.spec),
|
|
329
|
+
inputNoun: 'inputs',
|
|
330
|
+
threwVerb: 'on generated inputs',
|
|
331
|
+
checker: (ctx, params, ensures) => buildChecker(ctx, params, ensures),
|
|
332
|
+
describe: (cell, argstr, r, ensures) => `${cell.unitName}(${argstr || ''}) → ${show(r.out)} — violates ensures: ${ensures}`,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const renderAdapter = {
|
|
336
|
+
name: 'render', inVM: true, wantsJSX: true,
|
|
337
|
+
canHandle: (cell) => /^yes\b/i.test((cell.spec && cell.spec.renders) || ''),
|
|
338
|
+
load: jsLoad,
|
|
339
|
+
inputs: (cell) => parseInCased(cell.spec), // case-preserved props (onClick/className)
|
|
340
|
+
inputNoun: 'props',
|
|
341
|
+
threwVerb: 'while rendering',
|
|
342
|
+
checker: (ctx, params, ensures) => buildRenderChecker(ctx, params, ensures),
|
|
343
|
+
describe: (cell, argstr, r, ensures) => {
|
|
344
|
+
const rootT = r.out && r.out.type ? '<' + r.out.type + '>' : show(r.out);
|
|
345
|
+
return `${cell.unitName}(${argstr || ''}) → renders ${rootT} — violates ensures: ${ensures}`;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// ── Python adapter (out-of-VM) — the first non-JS prover, proving the interface
|
|
350
|
+
// generalises. It runs a Cell's Python function in a subprocess over spec-generated
|
|
351
|
+
// inputs and evaluates the (Python) `ensures` against each. Purely additive: it only
|
|
352
|
+
// claims `lang: python` Cells, which the JS-VM adapters already skip — so no JS/TS/JSX
|
|
353
|
+
// behaviour can change. Python is a lazy/optional runtime dep: absent → honest skip.
|
|
354
|
+
function isPythonLang(l) { return /^(py|python)$/i.test(String(l || '')); }
|
|
355
|
+
let PY_BIN;
|
|
356
|
+
function detectPython() {
|
|
357
|
+
if (PY_BIN !== undefined) return PY_BIN;
|
|
358
|
+
for (const bin of ['python3', 'python']) {
|
|
359
|
+
try { cp.execFileSync(bin, ['-c', 'import sys,json'], { stdio: 'ignore', timeout: 4000 }); PY_BIN = bin; return bin; } catch (_) {}
|
|
360
|
+
}
|
|
361
|
+
PY_BIN = null; return PY_BIN;
|
|
362
|
+
}
|
|
363
|
+
// Harness: read {source, unit, params, inputs, ensures} as JSON on stdin; exec the
|
|
364
|
+
// file, call the unit for each input tuple, and eval the ensures with a SAFE builtin
|
|
365
|
+
// subset (never a fake pass — a raised exception skips that tuple; all-throw ⇒ skip).
|
|
366
|
+
const PY_HARNESS = [
|
|
367
|
+
'import sys, json',
|
|
368
|
+
'd = json.loads(sys.stdin.read())',
|
|
369
|
+
'ns = {}',
|
|
370
|
+
'try:',
|
|
371
|
+
" exec(d['source'], ns)",
|
|
372
|
+
'except Exception as e:',
|
|
373
|
+
" print(json.dumps({'error': 'load: ' + str(e)})); sys.exit(0)",
|
|
374
|
+
"fn = ns.get(d['unit'])",
|
|
375
|
+
'if not callable(fn):',
|
|
376
|
+
" print(json.dumps({'error': 'unit not callable in isolation'})); sys.exit(0)",
|
|
377
|
+
"SAFE = {'len':len,'abs':abs,'all':all,'any':any,'min':min,'max':max,'sum':sum,'sorted':sorted,'range':range,'str':str,'int':int,'float':float,'bool':bool,'round':round,'list':list,'dict':dict,'set':set,'tuple':tuple,'enumerate':enumerate,'zip':zip}",
|
|
378
|
+
'def ser(v):',
|
|
379
|
+
' try:',
|
|
380
|
+
' json.dumps(v); return v',
|
|
381
|
+
' except Exception:',
|
|
382
|
+
' return str(v)',
|
|
383
|
+
'threw = 0; total = 0',
|
|
384
|
+
"for args in d['inputs']:",
|
|
385
|
+
' total += 1',
|
|
386
|
+
' try:',
|
|
387
|
+
' out = fn(*args)',
|
|
388
|
+
" scope = dict(zip(d['params'], args)); scope['out'] = out",
|
|
389
|
+
" ok = bool(eval(d['ensures'], {'__builtins__': {}}, dict(SAFE, **scope)))",
|
|
390
|
+
' except Exception as e:',
|
|
391
|
+
' threw += 1; continue',
|
|
392
|
+
' if not ok:',
|
|
393
|
+
" argstr = ', '.join('%s=%r' % (p, a) for p, a in zip(d['params'], args))",
|
|
394
|
+
" print(json.dumps({'fail': {'argstr': argstr, 'out': ser(out)}})); sys.exit(0)",
|
|
395
|
+
'if total and threw >= total:',
|
|
396
|
+
" print(json.dumps({'error': 'threw on all generated inputs'})); sys.exit(0)",
|
|
397
|
+
"print(json.dumps({'pass': True, 'cases': total - threw}))",
|
|
398
|
+
].join('\n');
|
|
399
|
+
|
|
400
|
+
function runPythonBatch(pyBin, source, unit, paramNames, inputs, ensures) {
|
|
401
|
+
const payload = JSON.stringify({ source, unit, params: paramNames, inputs, ensures });
|
|
402
|
+
let out;
|
|
403
|
+
try { out = cp.execFileSync(pyBin, ['-c', PY_HARNESS], { input: payload, timeout: 8000, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); }
|
|
404
|
+
catch (e) { return { error: (e && e.message ? String(e.message).split('\n')[0] : 'python failed') }; }
|
|
405
|
+
const line = String(out).trim().split('\n').filter(Boolean).pop() || '';
|
|
406
|
+
try { return JSON.parse(line); } catch (_) { return { error: 'unreadable harness output' }; }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const pythonAdapter = {
|
|
410
|
+
name: 'python', inVM: false, wantsJSX: false,
|
|
411
|
+
canHandle: (cell) => isPythonLang(cell.lang || (cell.spec && cell.spec.lang)) && /^yes\b/i.test((cell.spec && cell.spec.pure) || ''),
|
|
412
|
+
load: (source, names) => {
|
|
413
|
+
const pyBin = detectPython();
|
|
414
|
+
if (!pyBin) return { error: 'python-missing' };
|
|
415
|
+
// The harness exec()s the whole module to reach the function — with REAL builtins,
|
|
416
|
+
// no VM sandbox — so top-level imperative code (os.system(…), a bare call) would
|
|
417
|
+
// actually RUN during verify. Refuse to prove such a file: it's flagged Pink by the
|
|
418
|
+
// manifest and gate-blocked anyway; we must not execute it to get there.
|
|
419
|
+
if (looseTopLevelNonJs(String(source).split(/\r?\n/), 'python').length) {
|
|
420
|
+
return { error: 'python-toplevel' };
|
|
421
|
+
}
|
|
422
|
+
const fns = {}; for (const n of names) fns[n] = function () {}; // placeholder — real run is out-of-process in prove()
|
|
423
|
+
return { fns, ctx: { pyBin, source } };
|
|
424
|
+
},
|
|
425
|
+
// Out-of-VM proving in ONE subprocess per Cell; same verdict shape as runCases.
|
|
426
|
+
prove: (cell, base) => {
|
|
427
|
+
const params = parseIn(cell.spec);
|
|
428
|
+
if (params.some((p) => !valuesFor(p.type))) return { status: 'skip', level: 'yellow', reason: 'inputs include a type the prover can\'t generate (' + params.map((p) => p.type).join(', ') + ')' };
|
|
429
|
+
const ensures = String(cell.spec.ensures || '').trim();
|
|
430
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
431
|
+
const inputs = tuples.length ? tuples : [[]];
|
|
432
|
+
const r = runPythonBatch(base.ctx.pyBin, base.ctx.source, cell.unitName, params.map((p) => p.name), inputs, ensures);
|
|
433
|
+
if (r.error) return { status: 'skip', level: 'yellow', reason: 'python: ' + r.error };
|
|
434
|
+
if (r.fail) return { status: 'fail', level: 'red', cases: inputs.length, counterexample: `${cell.unitName}(${r.fail.argstr}) → ${show(r.fail.out)} — violates ensures: ${ensures}` };
|
|
435
|
+
return { status: 'pass', level: 'info', cases: (typeof r.cases === 'number' ? r.cases : inputs.length) };
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// ── Ruby adapter (out-of-VM) — same shape as Python: proves a pure Ruby method against a Ruby
|
|
440
|
+
// `ensures` in a `ruby` subprocess over spec-generated inputs. Optional runtime: absent → skip.
|
|
441
|
+
function isRubyLang(l) { return normLangName(l) === 'ruby'; }
|
|
442
|
+
let RB_BIN;
|
|
443
|
+
function detectRuby() {
|
|
444
|
+
if (RB_BIN !== undefined) return RB_BIN;
|
|
445
|
+
try { cp.execFileSync('ruby', ['-e', 'require "json"'], { stdio: 'ignore', timeout: 4000 }); RB_BIN = 'ruby'; return RB_BIN; } catch (_) {}
|
|
446
|
+
RB_BIN = null; return RB_BIN;
|
|
447
|
+
}
|
|
448
|
+
const RB_HARNESS = [
|
|
449
|
+
'require "json"',
|
|
450
|
+
'd = JSON.parse(STDIN.read)',
|
|
451
|
+
'b = binding',
|
|
452
|
+
'begin',
|
|
453
|
+
' eval(d["source"], b)',
|
|
454
|
+
'rescue Exception => e',
|
|
455
|
+
' puts JSON.generate({"error" => "load: " + e.message}); exit 0',
|
|
456
|
+
'end',
|
|
457
|
+
'fn = (b.eval("method(:" + d["unit"] + ")") rescue nil)',
|
|
458
|
+
'if fn.nil?',
|
|
459
|
+
' puts JSON.generate({"error" => "unit not callable in isolation"}); exit 0',
|
|
460
|
+
'end',
|
|
461
|
+
'threw = 0; total = 0',
|
|
462
|
+
'd["inputs"].each do |args|',
|
|
463
|
+
' total += 1',
|
|
464
|
+
' begin',
|
|
465
|
+
' out = fn.call(*args)',
|
|
466
|
+
' eb = binding',
|
|
467
|
+
' d["params"].each_with_index { |p, i| eb.local_variable_set(p.to_sym, args[i]) }',
|
|
468
|
+
' eb.local_variable_set(:out, out)',
|
|
469
|
+
' ok = eb.eval(d["ensures"]) ? true : false',
|
|
470
|
+
' rescue Exception => e',
|
|
471
|
+
' threw += 1; next',
|
|
472
|
+
' end',
|
|
473
|
+
' unless ok',
|
|
474
|
+
' argstr = d["params"].each_with_index.map { |p, i| "#{p}=#{args[i].inspect}" }.join(", ")',
|
|
475
|
+
' o = ((JSON.generate(out) rescue nil) ? out : out.inspect)',
|
|
476
|
+
' puts JSON.generate({"fail" => {"argstr" => argstr, "out" => o}}); exit 0',
|
|
477
|
+
' end',
|
|
478
|
+
'end',
|
|
479
|
+
'if total > 0 && threw >= total',
|
|
480
|
+
' puts JSON.generate({"error" => "threw on all generated inputs"}); exit 0',
|
|
481
|
+
'end',
|
|
482
|
+
'puts JSON.generate({"pass" => true, "cases" => total - threw})',
|
|
483
|
+
].join('\n');
|
|
484
|
+
function runRubyBatch(bin, source, unit, paramNames, inputs, ensures) {
|
|
485
|
+
const payload = JSON.stringify({ source, unit, params: paramNames, inputs, ensures });
|
|
486
|
+
let out;
|
|
487
|
+
try { out = cp.execFileSync(bin, ['-e', RB_HARNESS], { input: payload, timeout: 8000, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); }
|
|
488
|
+
catch (e) { return { error: (e && e.message ? String(e.message).split('\n')[0] : 'ruby failed') }; }
|
|
489
|
+
const line = String(out).trim().split('\n').filter(Boolean).pop() || '';
|
|
490
|
+
try { return JSON.parse(line); } catch (_) { return { error: 'unreadable harness output' }; }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── PHP adapter (out-of-VM) — same shape. Proves a pure PHP function against a PHP `ensures`.
|
|
494
|
+
function isPhpLang(l) { return normLangName(l) === 'php'; }
|
|
495
|
+
let PHP_BIN;
|
|
496
|
+
function detectPhp() {
|
|
497
|
+
if (PHP_BIN !== undefined) return PHP_BIN;
|
|
498
|
+
try { cp.execFileSync('php', ['-r', 'echo json_encode(1);'], { stdio: 'ignore', timeout: 4000 }); PHP_BIN = 'php'; return PHP_BIN; } catch (_) {}
|
|
499
|
+
PHP_BIN = null; return PHP_BIN;
|
|
500
|
+
}
|
|
501
|
+
const PHP_HARNESS = [
|
|
502
|
+
'$d = json_decode(stream_get_contents(STDIN), true);',
|
|
503
|
+
'$unit = $d["unit"]; $params = $d["params"]; $ens = $d["ensures"];',
|
|
504
|
+
'try { eval("?>" . $d["source"]); } catch (\\Throwable $e) { echo json_encode(["error" => "load: " . $e->getMessage()]); exit(0); }',
|
|
505
|
+
'if (!function_exists($unit)) { echo json_encode(["error" => "unit not defined in isolation"]); exit(0); }',
|
|
506
|
+
'$threw = 0; $total = 0;',
|
|
507
|
+
'foreach ($d["inputs"] as $args) {',
|
|
508
|
+
' $total++;',
|
|
509
|
+
' try {',
|
|
510
|
+
' $out = call_user_func_array($unit, $args);',
|
|
511
|
+
' $scope = ["out" => $out]; foreach ($params as $i => $p) { $scope[$p] = $args[$i]; }',
|
|
512
|
+
' extract($scope);',
|
|
513
|
+
' $ok = (bool) eval("return (" . $ens . ");");',
|
|
514
|
+
' } catch (\\Throwable $e) { $threw++; continue; }',
|
|
515
|
+
' if (!$ok) {',
|
|
516
|
+
' $parts = []; foreach ($params as $i => $p) { $parts[] = "$p=" . var_export($args[$i], true); }',
|
|
517
|
+
' echo json_encode(["fail" => ["argstr" => implode(", ", $parts), "out" => $out]]); exit(0);',
|
|
518
|
+
' }',
|
|
519
|
+
'}',
|
|
520
|
+
'if ($total > 0 && $threw >= $total) { echo json_encode(["error" => "threw on all generated inputs"]); exit(0); }',
|
|
521
|
+
'echo json_encode(["pass" => true, "cases" => $total - $threw]);',
|
|
522
|
+
].join('\n');
|
|
523
|
+
function runPhpBatch(bin, source, unit, paramNames, inputs, ensures) {
|
|
524
|
+
const payload = JSON.stringify({ source, unit, params: paramNames, inputs, ensures });
|
|
525
|
+
let out;
|
|
526
|
+
try { out = cp.execFileSync(bin, ['-r', PHP_HARNESS], { input: payload, timeout: 8000, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); }
|
|
527
|
+
catch (e) { return { error: (e && e.message ? String(e.message).split('\n')[0] : 'php failed') }; }
|
|
528
|
+
const line = String(out).trim().split('\n').filter(Boolean).pop() || '';
|
|
529
|
+
try { return JSON.parse(line); } catch (_) { return { error: 'unreadable harness output' }; }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Shared prove() body for the subprocess (Ruby/PHP) adapters — mirrors the Python one.
|
|
533
|
+
function proveSubprocess(runBatch, langLabel, cell, base) {
|
|
534
|
+
const params = parseIn(cell.spec);
|
|
535
|
+
if (params.some((p) => !valuesFor(p.type))) return { status: 'skip', level: 'yellow', reason: 'inputs include a type the prover can\'t generate (' + params.map((p) => p.type).join(', ') + ')' };
|
|
536
|
+
const ensures = String(cell.spec.ensures || '').trim();
|
|
537
|
+
if (!ensures) return { status: 'skip', level: 'yellow', reason: 'no `ensures:` to machine-check' };
|
|
538
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
539
|
+
const inputs = tuples.length ? tuples : [[]];
|
|
540
|
+
const r = runBatch(base.ctx.bin, base.ctx.source, cell.unitName, params.map((p) => p.name), inputs, ensures);
|
|
541
|
+
if (r.error) return { status: 'skip', level: 'yellow', reason: langLabel + ': ' + r.error };
|
|
542
|
+
if (r.fail) return { status: 'fail', level: 'red', cases: inputs.length, counterexample: `${cell.unitName}(${r.fail.argstr}) → ${show(r.fail.out)} — violates ensures: ${ensures}` };
|
|
543
|
+
return { status: 'pass', level: 'info', cases: (typeof r.cases === 'number' ? r.cases : inputs.length) };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const rubyAdapter = {
|
|
547
|
+
name: 'ruby', inVM: false, wantsJSX: false,
|
|
548
|
+
canHandle: (cell) => isRubyLang(cell.langName || cell.lang || (cell.spec && cell.spec.lang)) && /^yes\b/i.test((cell.spec && cell.spec.pure) || ''),
|
|
549
|
+
load: (source, names) => {
|
|
550
|
+
const bin = detectRuby();
|
|
551
|
+
if (!bin) return { error: 'ruby-missing' };
|
|
552
|
+
if (looseTopLevelNonJs(String(source).split(/\r?\n/), 'ruby').length) return { error: 'ruby-toplevel' };
|
|
553
|
+
const fns = {}; for (const n of names) fns[n] = function () {};
|
|
554
|
+
return { fns, ctx: { bin, source } };
|
|
555
|
+
},
|
|
556
|
+
prove: (cell, base) => proveSubprocess(runRubyBatch, 'ruby', cell, base),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const phpAdapter = {
|
|
560
|
+
name: 'php', inVM: false, wantsJSX: false,
|
|
561
|
+
canHandle: (cell) => isPhpLang(cell.langName || cell.lang || (cell.spec && cell.spec.lang)) && /^yes\b/i.test((cell.spec && cell.spec.pure) || ''),
|
|
562
|
+
load: (source, names) => {
|
|
563
|
+
const bin = detectPhp();
|
|
564
|
+
if (!bin) return { error: 'php-missing' };
|
|
565
|
+
if (looseTopLevelNonJs(String(source).split(/\r?\n/), 'php').length) return { error: 'php-toplevel' };
|
|
566
|
+
const fns = {}; for (const n of names) fns[n] = function () {};
|
|
567
|
+
return { fns, ctx: { bin, source } };
|
|
568
|
+
},
|
|
569
|
+
prove: (cell, base) => proveSubprocess(runPhpBatch, 'php', cell, base),
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Registry (order = precedence). The out-of-VM language provers (Python/Ruby/PHP) are claimed
|
|
573
|
+
// before pure-call so a `pure: yes` Cell in those languages routes to its subprocess adapter,
|
|
574
|
+
// not the JS-VM one. The effect/`records:` surface stays an ADVERSARY concern, not a prover mode.
|
|
575
|
+
const ADAPTERS = [renderAdapter, pythonAdapter, rubyAdapter, phpAdapter, pureCallAdapter];
|
|
576
|
+
|
|
577
|
+
// Stage 5 — run one Cell's generated inputs through the adapter's checker. A single
|
|
578
|
+
// counterexample ⇒ Red; can't-check ⇒ honest skip; all cases hold ⇒ proven. Shared
|
|
579
|
+
// across adapters (this is the loop the old proveCell/proveRenderCell each duplicated).
|
|
580
|
+
function runCases(adapter, ctx, cell, fn) {
|
|
581
|
+
const params = adapter.inputs(cell);
|
|
582
|
+
if (params.some((p) => !valuesFor(p.type))) return { status: 'skip', level: 'yellow', reason: adapter.inputNoun + ' include a type the prover can\'t generate (' + params.map((p) => p.type).join(', ') + ')' };
|
|
583
|
+
const ensures = String(cell.spec.ensures || '').trim();
|
|
584
|
+
let checker;
|
|
585
|
+
try { checker = adapter.checker(ctx, params.map((p) => p.name), ensures); }
|
|
586
|
+
catch (_) { return { status: 'skip', level: 'yellow', reason: 'ensures not machine-checkable — ' + ensuresHint(ensures) }; }
|
|
587
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
588
|
+
const cases = tuples.length || 1;
|
|
589
|
+
let threw = 0, lastErr = '';
|
|
590
|
+
for (const A of (tuples.length ? tuples : [[]])) {
|
|
591
|
+
let r;
|
|
592
|
+
try { r = checker(fn, A); }
|
|
593
|
+
catch (e) { threw++; lastErr = e && e.message ? e.message.split('\n')[0] : 'threw'; continue; }
|
|
594
|
+
if (!r.ok) {
|
|
595
|
+
const argstr = params.map((p, i) => `${p.name}=${show(A[i])}`).join(', ');
|
|
596
|
+
return { status: 'fail', level: 'red', cases, counterexample: adapter.describe(cell, argstr, r, ensures) };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (threw >= cases) return { status: 'skip', level: 'yellow', reason: `threw ${adapter.threwVerb} (${lastErr}) — can't prove` };
|
|
600
|
+
return { status: 'pass', level: 'info', cases: cases - threw };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Mutation testing: corrupt the code one edit at a time, re-run the SAME ensures
|
|
604
|
+
// tests (via the Cell's own adapter), and see how many mutants the ensures kills. A
|
|
605
|
+
// low score means the ensures is too weak to be trusted — it passes even when broken.
|
|
606
|
+
function runMutation(adapter, source, cell, opts) {
|
|
607
|
+
opts = opts || {};
|
|
608
|
+
const params = adapter.inputs(cell);
|
|
609
|
+
const ensures = String(cell.spec.ensures || '');
|
|
610
|
+
const muts = mutants(cell.unitBody || '', 30);
|
|
611
|
+
if (!muts.length) return { total: 0, killed: 0, survived: 0, score: null };
|
|
612
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
613
|
+
const inputs = tuples.length ? tuples : [[]];
|
|
614
|
+
const srcLines = source.split(/\r?\n/);
|
|
615
|
+
const start = cell.unitBodyStart || 0;
|
|
616
|
+
const len = (cell.unitBody || '').split('\n').length;
|
|
617
|
+
let killed = 0, survived = 0, survivor = null;
|
|
618
|
+
for (const m of muts) {
|
|
619
|
+
const lines = srcLines.slice();
|
|
620
|
+
lines.splice(start, len, ...m.code.split('\n'));
|
|
621
|
+
const r = adapter.load(lines.join('\n'), [cell.unitName], { jsx: opts.jsx, file: opts.file });
|
|
622
|
+
if (r.error || typeof r.fns[cell.unitName] !== 'function') { killed++; continue; } // mutant broke → detected
|
|
623
|
+
let checker;
|
|
624
|
+
try { checker = adapter.checker(r.ctx, params.map((p) => p.name), ensures); }
|
|
625
|
+
catch (_) { killed++; continue; }
|
|
626
|
+
let dead = false;
|
|
627
|
+
for (const A of inputs) {
|
|
628
|
+
let rr; try { rr = checker(r.fns[cell.unitName], A); } catch (_) { dead = true; break; }
|
|
629
|
+
if (!rr.ok) { dead = true; break; }
|
|
630
|
+
}
|
|
631
|
+
if (dead) killed++; else { survived++; if (!survivor) survivor = m.op; }
|
|
632
|
+
}
|
|
633
|
+
const total = killed + survived;
|
|
634
|
+
return { total, killed, survived, score: total ? killed / total : null, survivor };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// INERTNESS check — the dual of mutation testing. Mutation corrupts code and asks
|
|
638
|
+
// "does the ensures notice?"; inertness DELETES a branch and asks "does anything
|
|
639
|
+
// notice?". A branch removable with every spec-derived test still passing is
|
|
640
|
+
// semantically inert under the promise: dead weight, ahead-of-spec scaffolding, or a
|
|
641
|
+
// dormant payload riding under the signature. Route: prune it, spec it, or declare it.
|
|
642
|
+
// Exemptions (both SIGNED — they live inside the spec, so using one to hide a payload
|
|
643
|
+
// means getting a human to sign the declaration):
|
|
644
|
+
// throws: — a deleted guard containing `throw` is exempt when the spec declares throws
|
|
645
|
+
// perf: — declares intentional semantically-invisible code (cache, early-exit);
|
|
646
|
+
// exempts the Cell, with the reason shown.
|
|
647
|
+
function runInertness(adapter, source, cell, opts) {
|
|
648
|
+
opts = opts || {};
|
|
649
|
+
const declaredThrows = String((cell.spec && cell.spec.throws) || '').trim();
|
|
650
|
+
const perf = String((cell.spec && cell.spec.perf) || '').trim();
|
|
651
|
+
if (perf && !/^todo$/i.test(perf)) return { checked: 0, flagged: [], exempt: 'perf: ' + perf };
|
|
652
|
+
const params = adapter.inputs(cell);
|
|
653
|
+
const ensures = String((cell.spec && cell.spec.ensures) || '');
|
|
654
|
+
const dels = deletions(cell.unitBody || '', 20);
|
|
655
|
+
if (!dels.length) return { checked: 0, flagged: [] };
|
|
656
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
657
|
+
const inputs = tuples.length ? tuples : [[]];
|
|
658
|
+
const srcLines = source.split(/\r?\n/);
|
|
659
|
+
const start = cell.unitBodyStart || 0;
|
|
660
|
+
const len = (cell.unitBody || '').split('\n').length;
|
|
661
|
+
const flagged = [];
|
|
662
|
+
let checked = 0;
|
|
663
|
+
for (const d of dels) {
|
|
664
|
+
if (/\bthrow\b/.test(d.removed) && declaredThrows && !/^todo$/i.test(declaredThrows)) continue; // declared guard
|
|
665
|
+
checked++;
|
|
666
|
+
const lines = srcLines.slice();
|
|
667
|
+
lines.splice(start, len, ...d.code.split('\n'));
|
|
668
|
+
const r = adapter.load(lines.join('\n'), [cell.unitName], { jsx: opts.jsx, file: opts.file });
|
|
669
|
+
if (r.error || typeof r.fns[cell.unitName] !== 'function') continue; // deletion broke the load → not inert
|
|
670
|
+
let checker;
|
|
671
|
+
try { checker = adapter.checker(r.ctx, params.map((p) => p.name), ensures); }
|
|
672
|
+
catch (_) { continue; }
|
|
673
|
+
let noticed = false;
|
|
674
|
+
for (const A of inputs) {
|
|
675
|
+
let rr; try { rr = checker(r.fns[cell.unitName], A); } catch (_) { noticed = true; break; }
|
|
676
|
+
if (!rr.ok) { noticed = true; break; }
|
|
677
|
+
}
|
|
678
|
+
if (!noticed) {
|
|
679
|
+
const snippet = d.removed.trim().split('\n')[0].slice(0, 80);
|
|
680
|
+
flagged.push({ desc: d.desc, line: (cell.unitBodyStart || 0) + d.line, snippet });
|
|
681
|
+
if (flagged.length >= 5) break; // enough to act on; don't drown the report
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return { checked, flagged };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// LITERAL-SEEDED TRIGGER HUNTING — the complement to inertness. Harvest the magic
|
|
688
|
+
// constants a dormant gate compares inputs against, synthesise IN-DOMAIN inputs that
|
|
689
|
+
// fire the gate, and check `ensures`. A hit is a PROVEN spec↔code contradiction → RED
|
|
690
|
+
// (same as any counterexample). Honesty: code-derived inputs live in a RED-ONLY lane —
|
|
691
|
+
// they can convict but never acquit, and never touch the proven-case count. Only builds
|
|
692
|
+
// inputs valid per `in:` (a string literal vs a `number` param is out-of-domain → skip),
|
|
693
|
+
// so every Red it produces is real.
|
|
694
|
+
function cloneVal(v) { try { return v == null ? v : JSON.parse(JSON.stringify(v)); } catch (_) { return v; } }
|
|
695
|
+
// Candidate IN-DOMAIN inputs for a param that satisfy the harvested trigger constraints.
|
|
696
|
+
// Returns a FEW variants (varying the free fields) so a triggered payload whose fake
|
|
697
|
+
// output coincidentally matches one fill is still caught by another — like a fuzzer
|
|
698
|
+
// varying the non-dictionary bytes after hitting a comparison. [] ⇒ can't build (skip).
|
|
699
|
+
function synthSeeds(type, cons) {
|
|
700
|
+
const lt = String(type).toLowerCase();
|
|
701
|
+
const eq = cons.find((c) => c.kind === 'eq');
|
|
702
|
+
if (eq) {
|
|
703
|
+
if (lt === 'string' && typeof eq.value === 'string') return [eq.value];
|
|
704
|
+
if (lt === 'number' && typeof eq.value === 'number') return [eq.value];
|
|
705
|
+
if (lt === 'boolean' && typeof eq.value === 'boolean') return [eq.value];
|
|
706
|
+
return []; // out-of-domain scalar
|
|
707
|
+
}
|
|
708
|
+
const lenC = cons.find((c) => c.kind === 'length');
|
|
709
|
+
const idxC = cons.filter((c) => c.kind === 'index-prop');
|
|
710
|
+
const props = cons.filter((c) => c.kind === 'prop');
|
|
711
|
+
if (lt === 'string' && lenC && typeof lenC.value === 'number' && !idxC.length && !props.length) {
|
|
712
|
+
return (lenC.value >= 0 && lenC.value <= 256) ? ['x'.repeat(lenC.value)] : [];
|
|
713
|
+
}
|
|
714
|
+
if (/\[\]$/.test(String(type)) && (lenC || idxC.length)) {
|
|
715
|
+
const elemType = String(type).replace(/\[\]$/, '');
|
|
716
|
+
let len = lenC ? lenC.value : 1;
|
|
717
|
+
for (const c of idxC) len = Math.max(len, (c.key.index || 0) + 1);
|
|
718
|
+
if (!(len >= 0 && len <= 64)) return [];
|
|
719
|
+
const ev = valuesFor(elemType) || [];
|
|
720
|
+
const fills = (ev.length ? ev.slice(-3) : [null]); // a few distinct element fills (non-degenerate)
|
|
721
|
+
const out = [];
|
|
722
|
+
for (const e of fills) {
|
|
723
|
+
const arr = []; for (let i = 0; i < len; i++) arr.push(cloneVal(e));
|
|
724
|
+
let ok = true;
|
|
725
|
+
for (const c of idxC) { const el = arr[c.key.index]; if (el && typeof el === 'object') el[c.key.prop] = c.value; else { ok = false; break; } }
|
|
726
|
+
if (ok) out.push(arr);
|
|
727
|
+
}
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
const shape = objectShape(type);
|
|
731
|
+
if (shape && props.length) {
|
|
732
|
+
const sv = valuesFor(type) || [];
|
|
733
|
+
const bases = sv.length ? sv.slice(0, 3) : [{}];
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const b of bases) {
|
|
736
|
+
const o = (b && typeof b === 'object') ? cloneVal(b) : {};
|
|
737
|
+
let ok = true;
|
|
738
|
+
for (const c of props) { if (!shape.some((f) => f.name === c.key)) { ok = false; break; } o[c.key] = c.value; }
|
|
739
|
+
if (ok) out.push(o);
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
function runSeeded(adapter, ctx, cell, fn) {
|
|
746
|
+
const params = adapter.inputs(cell);
|
|
747
|
+
if (!params.length) return null;
|
|
748
|
+
const lits = harvestLiterals(cell.unitBody || '', 30);
|
|
749
|
+
if (!lits.length) return null;
|
|
750
|
+
const byRoot = {};
|
|
751
|
+
for (const c of lits) { if (params.some((p) => p.name === c.root)) (byRoot[c.root] = byRoot[c.root] || []).push(c); }
|
|
752
|
+
const roots = Object.keys(byRoot);
|
|
753
|
+
if (!roots.length) return null;
|
|
754
|
+
const ensures = String(cell.spec.ensures || '').trim();
|
|
755
|
+
let checker;
|
|
756
|
+
try { checker = adapter.checker(ctx, params.map((p) => p.name), ensures); } catch (_) { return null; }
|
|
757
|
+
const baseTuple = (cartesian(params.map((p) => valuesFor(p.type) || [undefined]), 1)[0] || params.map(() => undefined));
|
|
758
|
+
let budget = 24; // cap total seeded runs per Cell (cost)
|
|
759
|
+
for (const root of roots) {
|
|
760
|
+
const pi = params.findIndex((p) => p.name === root);
|
|
761
|
+
for (const seed of synthSeeds(params[pi].type, byRoot[root])) {
|
|
762
|
+
if (budget-- <= 0) return null;
|
|
763
|
+
const A = baseTuple.slice(); A[pi] = seed;
|
|
764
|
+
let r; try { r = checker(fn, A); } catch (_) { continue; } // threw on this input → not a promise violation
|
|
765
|
+
if (r && r.ok === false) {
|
|
766
|
+
const argstr = params.map((p, i) => `${p.name}=${show(A[i])}`).join(', ');
|
|
767
|
+
return { status: 'fail', level: 'red', viaSeed: true, cases: 0, counterexample: `${cell.unitName}(${argstr}) → ${show(r.out)} — violates ensures: ${ensures} (triggered by a constant found in the code)` };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// BRANCH-EXERCISE HONESTY (in-VM JS only). Instrument the unit's branches, run the SAME
|
|
775
|
+
// spec-derived inputs, and report which branches were exercised. Returns { total, exercised,
|
|
776
|
+
// missed:[{line,kind}] } or null (no branches, or Babel absent → no badge, never an error).
|
|
777
|
+
// Coverage is a HONESTY BADGE, not a verdict: it never turns a pass into a fail here — policy
|
|
778
|
+
// decides whether unexercised branches block the gate (see verify.js `coverage: full`).
|
|
779
|
+
function runCoverageForCell(cell, source, inputs, opts) {
|
|
780
|
+
opts = opts || {};
|
|
781
|
+
const file = opts.file || '';
|
|
782
|
+
const startLine = (cell.unitBodyStart || 0) + 1; // 1-based
|
|
783
|
+
const endLine = startLine + (cell.unitBody || '').split('\n').length; // inclusive-ish
|
|
784
|
+
const inst = instrumentBranches(source, startLine, endLine, {
|
|
785
|
+
ts: /\.tsx?$/.test(file), tsx: /\.tsx$/.test(file), jsx: !!opts.jsx, file,
|
|
786
|
+
});
|
|
787
|
+
if (!inst || !inst.probes.length) return null; // straight-line code or Babel missing → no badge
|
|
788
|
+
const cov = new Array(inst.probes.length).fill(0);
|
|
789
|
+
const registry = {};
|
|
790
|
+
const ctx = makeSandbox([cell.unitName], registry, { __ylcov: cov });
|
|
791
|
+
const code = neutralizeModules(inst.code); // inst.code is already TS/JSX-lowered by the coverage pass
|
|
792
|
+
const cap = '\n;try{__ylreg({' + JSON.stringify(cell.unitName) + ':(typeof ' + safeIdent(cell.unitName) + "!=='undefined'?" + safeIdent(cell.unitName) + ":undefined)});}catch(e){}";
|
|
793
|
+
try { vm.runInContext(code + cap, ctx, { timeout: 2000 }); } catch (_) { return null; }
|
|
794
|
+
const fn = registry[cell.unitName];
|
|
795
|
+
if (typeof fn !== 'function') return null;
|
|
796
|
+
for (const A of inputs) { try { fn.apply(null, A); } catch (_) { /* branch may throw; the hit still counted */ } }
|
|
797
|
+
const exercised = cov.reduce((a, c) => a + (c > 0 ? 1 : 0), 0);
|
|
798
|
+
const missed = inst.probes.map((p, i) => ({ line: p.line, kind: p.kind, hit: cov[i] > 0 })).filter((x) => !x.hit).map((x) => ({ line: x.line, kind: x.kind }));
|
|
799
|
+
return { total: inst.probes.length, exercised, missed };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// The driver — dispatch each Cell to an adapter, load once per file, run cases, grade.
|
|
803
|
+
// opts.mutate (default true) also grades each passing Cell's ensures by mutation.
|
|
804
|
+
// opts.adapters overrides the registry (used by tests). Returns { [cellId]: result }.
|
|
805
|
+
function proveManifest(manifest, opts) {
|
|
806
|
+
const mutate = !opts || opts.mutate !== false;
|
|
807
|
+
const adapters = (opts && opts.adapters) || ADAPTERS;
|
|
808
|
+
const out = {};
|
|
809
|
+
const byFile = {};
|
|
810
|
+
for (const id of Object.keys(manifest.cells)) {
|
|
811
|
+
const c = manifest.cells[id];
|
|
812
|
+
const isLeaf = !(c.contains && c.contains.length);
|
|
813
|
+
if (!isLeaf || !c.unitFound || !(c.spec && c.spec.ensures)) continue;
|
|
814
|
+
const adapter = adapters.find((a) => a.canHandle(c));
|
|
815
|
+
if (!adapter) continue; // no adapter handles it → stays Yellow (signed, unproven)
|
|
816
|
+
if (adapter.inVM && c.lang && !isJsLang(c.lang)) continue; // JS-VM adapters skip explicit non-JS langs (missing lang ⇒ JS default)
|
|
817
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(c.unitName || '')) { out[id] = { status: 'skip', level: 'info', reason: 'method/qualified units not yet supported' }; continue; }
|
|
818
|
+
(byFile[c.file] = byFile[c.file] || []).push({ cell: c, adapter });
|
|
819
|
+
}
|
|
820
|
+
for (const file of Object.keys(byFile)) {
|
|
821
|
+
const items = byFile[file];
|
|
822
|
+
// Transform JSX when the file is .jsx/.tsx or any of its adapters wants it.
|
|
823
|
+
const wantsJSX = /\.(jsx|tsx)$/i.test(file) || items.some((it) => it.adapter.wantsJSX);
|
|
824
|
+
let source; try { source = fs.readFileSync(path.join(manifest.root, file), 'utf8'); }
|
|
825
|
+
catch (_) { for (const it of items) out[it.cell.id] = { status: 'skip', level: 'info', reason: 'file unreadable' }; continue; }
|
|
826
|
+
const base = items[0].adapter.load(source, items.map((it) => it.cell.unitName), { jsx: wantsJSX, file });
|
|
827
|
+
if (base.error) {
|
|
828
|
+
const reason = base.error === 'jsx-needs-babel'
|
|
829
|
+
? 'add the optional deps @babel/core + @babel/plugin-transform-react-jsx to machine-prove JSX Cells'
|
|
830
|
+
: base.error === 'python-missing'
|
|
831
|
+
? 'install Python 3 (python3/python on PATH) to machine-prove Python Cells'
|
|
832
|
+
: base.error === 'python-toplevel'
|
|
833
|
+
? 'not run: this file has top-level code that would execute on import — wrap it in a Cell (it is also flagged Pink)'
|
|
834
|
+
: 'could not run file (' + base.error + ')';
|
|
835
|
+
for (const it of items) out[it.cell.id] = { status: 'skip', level: 'info', reason }; continue;
|
|
836
|
+
}
|
|
837
|
+
for (const it of items) {
|
|
838
|
+
const fn = base.fns[it.cell.unitName];
|
|
839
|
+
if (typeof fn !== 'function') { out[it.cell.id] = { status: 'skip', level: 'info', reason: 'unit not callable in isolation' }; continue; }
|
|
840
|
+
try {
|
|
841
|
+
let res;
|
|
842
|
+
if (it.adapter.prove) {
|
|
843
|
+
res = it.adapter.prove(it.cell, base, fn); // adapter owns its execution (e.g. out-of-VM) + verdict
|
|
844
|
+
} else {
|
|
845
|
+
res = runCases(it.adapter, base.ctx, it.cell, fn);
|
|
846
|
+
if (res.status === 'pass') {
|
|
847
|
+
// Code-derived trigger hunt: a hit is a proven contradiction → Red (downgrades
|
|
848
|
+
// the pass). Runs even without mutate — it's a security check, not a grade.
|
|
849
|
+
const seeded = runSeeded(it.adapter, base.ctx, it.cell, fn);
|
|
850
|
+
if (seeded) res = seeded;
|
|
851
|
+
else if (mutate) {
|
|
852
|
+
res.mutation = runMutation(it.adapter, source, it.cell, { jsx: wantsJSX, file });
|
|
853
|
+
res.inertness = runInertness(it.adapter, source, it.cell, { jsx: wantsJSX, file });
|
|
854
|
+
// Branch-exercise honesty (pure-call/JS): does the green cover every branch, or only some?
|
|
855
|
+
if (it.adapter.name === 'pure-call') {
|
|
856
|
+
const params = it.adapter.inputs(it.cell);
|
|
857
|
+
const tuples = cartesian(params.map((p) => valuesFor(p.type)), 40);
|
|
858
|
+
res.coverage = runCoverageForCell(it.cell, source, tuples.length ? tuples : [[]], { jsx: wantsJSX, file });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// Undeclared-input predicate provenance — a STATIC AST check, so it runs on ANY JS unit
|
|
864
|
+
// regardless of proof status (a prose-`ensures` Cell that only reaches Yellow is exactly where a
|
|
865
|
+
// hidden control input can hide). Cheap, and never fails the proof (under-flag on any error).
|
|
866
|
+
if (res && !res.predicate && it.adapter && it.adapter.inVM && (!it.cell.lang || isJsLang(it.cell.lang))) {
|
|
867
|
+
try { const declared = parseIn(it.cell.spec).map((p) => p.name); const pred = analyzePredicates(source, it.cell, declared, { jsx: wantsJSX, file }); if (pred) res.predicate = pred; } catch (_) {}
|
|
868
|
+
}
|
|
869
|
+
out[it.cell.id] = res;
|
|
870
|
+
} catch (e) { out[it.cell.id] = { status: 'skip', level: 'info', reason: 'prover error: ' + (e && e.message) }; }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = { proveManifest, runSource, parseIn, parseInCased, valuesFor, buildChecker, buildEffectChecker, buildRenderChecker, transformJSX, show, ensuresHint, normalizeEnsures, ADAPTERS, pureCallAdapter, renderAdapter, pythonAdapter };
|