space-data-module-sdk 0.8.9 → 0.8.11
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/bin/space-data-module.js +92 -0
- package/docs/flatsql-host-contract.md +95 -0
- package/docs/tri-runtime-parity-gate.md +146 -0
- package/package.json +7 -2
- package/parity/gate.json +28 -0
- package/parity/negative-control.json +14 -0
- package/parity/sdk-command.json +22 -0
- package/src/standards/catalogCore.js +28 -1
- package/src/testing/hostContract.js +467 -0
- package/src/testing/index.js +32 -0
- package/src/testing/parityGate.js +1147 -0
- package/src/testing/parityGateBrowserProbe.js +171 -0
- package/src/testing/parityLanes.js +11 -6
- package/src/testing/wasmedgeOutput.js +77 -0
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE tri-runtime parity GATE.
|
|
3
|
+
*
|
|
4
|
+
* `parityHarness.js` proves that ONE module.wasm produces byte-identical
|
|
5
|
+
* OUTPUT across three runtimes for one fixture. That is necessary and not
|
|
6
|
+
* sufficient: it can only speak about artifacts that already instantiate
|
|
7
|
+
* everywhere, and it says nothing about the artifact SET a release actually
|
|
8
|
+
* ships. The gate adds the missing half and is what the gauntlet runs:
|
|
9
|
+
*
|
|
10
|
+
* Tier A — HOST-CONTRACT CONFORMANCE, per artifact, per lane, by REAL
|
|
11
|
+
* instantiation:
|
|
12
|
+
* * wasmedge lane: the pinned WasmEdge runtime (native binary
|
|
13
|
+
* when installed AND the pinned container; both when both are
|
|
14
|
+
* present, and they must agree — that is the host/container
|
|
15
|
+
* pin-pair law made testable);
|
|
16
|
+
* * browser lane: real headless Chrome behind COOP/COEP,
|
|
17
|
+
* instantiating with EXACTLY the declared host surface.
|
|
18
|
+
* Plus the structural import classification, which names the
|
|
19
|
+
* defect CLASS (emscripten glue vs. a declared capability) so a
|
|
20
|
+
* receipt can never read "failed" without saying why.
|
|
21
|
+
*
|
|
22
|
+
* Tier B — BEHAVIORAL PARITY: artifacts that declare a fixture go through
|
|
23
|
+
* runParityHarness() — byte-identical stdout and identical
|
|
24
|
+
* trap classes across every lane × thread count.
|
|
25
|
+
*
|
|
26
|
+
* Verdict rules (all hard failures — an advisory isomorphism gate is not a
|
|
27
|
+
* gate):
|
|
28
|
+
* R1 forbidden-import-class -> FAIL. emcc-shaped artifacts are auto-reject
|
|
29
|
+
* whether or not the lanes agree; lanes
|
|
30
|
+
* agreeing that an artifact is broken
|
|
31
|
+
* everywhere is not parity.
|
|
32
|
+
* R2 imports outside surface -> FAIL. A private import is a NEW HOST
|
|
33
|
+
* CAPABILITY, which is an owner decision.
|
|
34
|
+
* R3 lane disagreement -> FAIL. P1 cross-runtime divergence.
|
|
35
|
+
* R4 shim gap -> FAIL. A lane that cannot supply a DECLARED
|
|
36
|
+
* capability is an SDK host-shim defect.
|
|
37
|
+
* R5 behavioral divergence -> FAIL (delegated to the parity harness).
|
|
38
|
+
* R6 lane unavailable -> FAIL, with kind `lane-unavailable`, kept
|
|
39
|
+
* lexically distinct from divergence: a
|
|
40
|
+
* harness that cannot run and a runtime that
|
|
41
|
+
* disagrees must never look alike in a
|
|
42
|
+
* receipt.
|
|
43
|
+
*
|
|
44
|
+
* Honest labelling of what each lane proves is part of the deliverable; see
|
|
45
|
+
* LANE_EVIDENCE below and the `evidence` field of every lane result.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { spawn, execFile as execFileCallback } from "node:child_process";
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import { constants as fsConstants, existsSync } from "node:fs";
|
|
51
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
52
|
+
import http from "node:http";
|
|
53
|
+
import os from "node:os";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
import process from "node:process";
|
|
56
|
+
import { createRequire } from "node:module";
|
|
57
|
+
import { fileURLToPath } from "node:url";
|
|
58
|
+
import { promisify } from "node:util";
|
|
59
|
+
|
|
60
|
+
import { toLoadableWasmBytes } from "../bundle/artifactBytes.js";
|
|
61
|
+
import {
|
|
62
|
+
classifyArtifactImports,
|
|
63
|
+
describeClassification,
|
|
64
|
+
readWasmExportNames,
|
|
65
|
+
resolveHostSurface,
|
|
66
|
+
} from "./hostContract.js";
|
|
67
|
+
import { normalizeWasmEdgeOutcome } from "./wasmedgeOutput.js";
|
|
68
|
+
import {
|
|
69
|
+
assertWasmEdgeVersionMatchesPin,
|
|
70
|
+
loadWasmEdgePin,
|
|
71
|
+
runParityHarness,
|
|
72
|
+
sha256Hex,
|
|
73
|
+
} from "./parityHarness.js";
|
|
74
|
+
|
|
75
|
+
const execFile = promisify(execFileCallback);
|
|
76
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
const require = createRequire(import.meta.url);
|
|
78
|
+
|
|
79
|
+
export const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
80
|
+
export const DEFAULT_GATE_MANIFEST = path.join(REPO_ROOT, "parity", "gate.json");
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What each lane runner actually demonstrates. Printed in every report so a
|
|
84
|
+
* reader never has to guess how strong the evidence is.
|
|
85
|
+
*/
|
|
86
|
+
export const LANE_EVIDENCE = Object.freeze({
|
|
87
|
+
browser:
|
|
88
|
+
"REAL headless Chrome behind COOP/COEP (cross-origin isolated); instantiates with EXACTLY the declared host surface.",
|
|
89
|
+
"wasmedge-native":
|
|
90
|
+
"REAL native WasmEdge binary at the pin; bare CLI supplies WASI only, so declared capability imports surface as a named link error (see contractVerdict).",
|
|
91
|
+
"wasmedge-docker":
|
|
92
|
+
"REAL pinned WasmEdge container (image tag derived from wasmedgePin.json); bare CLI supplies WASI only, same capability caveat as the native lane.",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const ContractVerdict = Object.freeze({
|
|
96
|
+
/** Instantiated on the declared surface. */
|
|
97
|
+
Satisfied: "satisfied",
|
|
98
|
+
/**
|
|
99
|
+
* Did not instantiate, and the ONLY blocker is a DECLARED capability import
|
|
100
|
+
* this lane runner does not supply (the bare WasmEdge CLI has no mechanism
|
|
101
|
+
* to register host functions). The artifact is contract-clean; the lane
|
|
102
|
+
* runner is the limitation, and it is named as such — never silently
|
|
103
|
+
* counted as a pass, never conflated with a divergence.
|
|
104
|
+
* Upgrade path: module-sdk-parity-lane-embedded-wasmedge.
|
|
105
|
+
*/
|
|
106
|
+
RunnerCannotSupplyCapability: "runner-cannot-supply-declared-capability",
|
|
107
|
+
/** The artifact demands something the contract does not grant. */
|
|
108
|
+
Violated: "violated",
|
|
109
|
+
/** The lane could not supply a DECLARED capability -> SDK host-shim defect. */
|
|
110
|
+
ShimGap: "shim-gap",
|
|
111
|
+
/** The lane could not run at all. */
|
|
112
|
+
Unavailable: "lane-unavailable",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- Manifest ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Locate an installed package's ROOT directory. `require.resolve(pkg +
|
|
119
|
+
* "/package.json")` is the obvious route and fails on packages whose
|
|
120
|
+
* "exports" map does not publish ./package.json (flatsql 0.4.2 is one), so
|
|
121
|
+
* fall back to resolving the package entry and walking up. Wasm artifacts are
|
|
122
|
+
* frequently outside the exports map — the gate must be able to see the bytes
|
|
123
|
+
* a consumer would actually load, not only the ones the package advertises.
|
|
124
|
+
*/
|
|
125
|
+
export function packageRootDir(pkg, fromDir = REPO_ROOT) {
|
|
126
|
+
try {
|
|
127
|
+
return path.dirname(require.resolve(`${pkg}/package.json`, { paths: [fromDir] }));
|
|
128
|
+
} catch {
|
|
129
|
+
/* exports map hides package.json — walk up from the entry point */
|
|
130
|
+
}
|
|
131
|
+
// ESM-only packages with a restrictive exports map resolve through neither
|
|
132
|
+
// route (flatsql 0.4.2 publishes no CJS main). Walk node_modules directly —
|
|
133
|
+
// the installed directory is a fact on disk, not a package-author opinion.
|
|
134
|
+
let dir = fromDir;
|
|
135
|
+
while (true) {
|
|
136
|
+
const candidate = path.join(dir, "node_modules", ...pkg.split("/"));
|
|
137
|
+
if (existsSync(path.join(candidate, "package.json"))) return candidate;
|
|
138
|
+
const parent = path.dirname(dir);
|
|
139
|
+
if (parent === dir) break;
|
|
140
|
+
dir = parent;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(
|
|
143
|
+
`cannot locate the installed root of package "${pkg}" from ${fromDir} (run npm install in this worktree).`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveArtifactPath(spec) {
|
|
148
|
+
if (spec.packagePath) {
|
|
149
|
+
// Resolve through node module resolution so the gate works in ANY fresh
|
|
150
|
+
// worktree after `npm install` — never through a sibling checkout.
|
|
151
|
+
const [pkg, ...rest] = String(spec.packagePath).split("/");
|
|
152
|
+
return path.join(packageRootDir(pkg), ...rest);
|
|
153
|
+
}
|
|
154
|
+
return path.resolve(REPO_ROOT, String(spec.path));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Load the gate manifest: the representative artifact SET this SDK certifies.
|
|
159
|
+
*
|
|
160
|
+
* Every entry resolves INSIDE this repo (a repo path or a node-resolved
|
|
161
|
+
* dependency). External artifacts — e.g. a decrypted closed rf-* module —
|
|
162
|
+
* are injected by the caller via `extraArtifacts`, so the gate never reaches
|
|
163
|
+
* into a sibling checkout on its own (that shared-checkout coupling is a
|
|
164
|
+
* documented trap, and it is what made the harness un-runnable before).
|
|
165
|
+
*/
|
|
166
|
+
export async function loadGateManifest(manifestPath = DEFAULT_GATE_MANIFEST) {
|
|
167
|
+
const resolved = path.resolve(manifestPath);
|
|
168
|
+
const raw = JSON.parse(await readFile(resolved, "utf8"));
|
|
169
|
+
const artifacts = [];
|
|
170
|
+
for (const spec of raw.artifacts ?? []) {
|
|
171
|
+
const id = String(spec.id ?? "").trim();
|
|
172
|
+
if (!id) throw new Error("gate manifest: every artifact needs an id.");
|
|
173
|
+
resolveHostSurface(spec.surface);
|
|
174
|
+
artifacts.push(
|
|
175
|
+
Object.freeze({
|
|
176
|
+
id,
|
|
177
|
+
surface: String(spec.surface),
|
|
178
|
+
artifactPath: resolveArtifactPath(spec),
|
|
179
|
+
profile: String(spec.profile ?? "library"),
|
|
180
|
+
fixture: spec.fixture
|
|
181
|
+
? path.resolve(path.dirname(resolved), String(spec.fixture))
|
|
182
|
+
: null,
|
|
183
|
+
required: spec.required !== false,
|
|
184
|
+
note: spec.note ? String(spec.note) : null,
|
|
185
|
+
negativeControl: spec.negativeControl === true,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (artifacts.length === 0) {
|
|
190
|
+
throw new Error("gate manifest: artifacts[] must be non-empty.");
|
|
191
|
+
}
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
name: String(raw.name ?? path.basename(resolved)),
|
|
194
|
+
manifestPath: resolved,
|
|
195
|
+
artifacts: Object.freeze(artifacts),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function makeExternalArtifact(spec) {
|
|
200
|
+
resolveHostSurface(spec.surface);
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
id: String(spec.id),
|
|
203
|
+
surface: String(spec.surface),
|
|
204
|
+
artifactPath: path.resolve(String(spec.path)),
|
|
205
|
+
profile: String(spec.profile ?? "library"),
|
|
206
|
+
fixture: spec.fixture ? path.resolve(String(spec.fixture)) : null,
|
|
207
|
+
required: spec.required !== false,
|
|
208
|
+
note: spec.note ? String(spec.note) : "injected by caller (external artifact)",
|
|
209
|
+
negativeControl: spec.negativeControl === true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- WasmEdge probe lanes -------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const UNKNOWN_IMPORT_RE =
|
|
216
|
+
/unknown import[\s\S]*?When linking module:\s*"([^"]*)"\s*,\s*function name:\s*"([^"]*)"/;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Classify a bare-WasmEdge probe run.
|
|
220
|
+
*
|
|
221
|
+
* `instantiated` requires POSITIVE evidence — a clean exit, or a diagnostic
|
|
222
|
+
* that can only be produced AFTER linking succeeded (a reactor artifact has no
|
|
223
|
+
* `_start`, so "function not found" is proof it linked). Anything else is a
|
|
224
|
+
* `probe-failure`, which fails the gate as `lane-unavailable`.
|
|
225
|
+
*
|
|
226
|
+
* The earlier version of this function defaulted to `instantiated` whenever it
|
|
227
|
+
* did not recognize the output, and it read only stderr — while WasmEdge logs
|
|
228
|
+
* to STDOUT. Together those produced a silent FALSE PASS on an artifact that
|
|
229
|
+
* demonstrably cannot link. An acceptance instrument may return "I could not
|
|
230
|
+
* tell"; it may never return "fine" by default.
|
|
231
|
+
*/
|
|
232
|
+
export function classifyWasmEdgeProbe({
|
|
233
|
+
code,
|
|
234
|
+
signal,
|
|
235
|
+
stderrText,
|
|
236
|
+
guestOutputLength = 0,
|
|
237
|
+
}) {
|
|
238
|
+
const text = String(stderrText ?? "");
|
|
239
|
+
const unknownImport = UNKNOWN_IMPORT_RE.exec(text);
|
|
240
|
+
if (unknownImport) {
|
|
241
|
+
return {
|
|
242
|
+
outcome: "link-error",
|
|
243
|
+
missingImport: `${unknownImport[1]}.${unknownImport[2]}`,
|
|
244
|
+
detail: `instantiation failed: unknown import ${unknownImport[1]}.${unknownImport[2]}`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const head = (limit = 3) => text.trim().split("\n").slice(0, limit).join(" | ");
|
|
248
|
+
if (/instantiation failed/i.test(text)) {
|
|
249
|
+
return { outcome: "instantiate-error", missingImport: null, detail: head() };
|
|
250
|
+
}
|
|
251
|
+
if (/(loading failed|validation failed|magic header|malformed|invalid section)/i.test(text)) {
|
|
252
|
+
return { outcome: "compile-error", missingImport: null, detail: head() };
|
|
253
|
+
}
|
|
254
|
+
// The CLI rejected the INVOCATION, before loading anything: reactor mode
|
|
255
|
+
// needs an entry name. This says nothing about the artifact, so it must not
|
|
256
|
+
// be reported against the artifact — stageArtifact() now names `_initialize`
|
|
257
|
+
// for reactor artifacts, and this branch exists so a regression there is
|
|
258
|
+
// legible instead of masquerading as a cross-runtime divergence.
|
|
259
|
+
if (/function name is required when reactor mode is enabled/i.test(text)) {
|
|
260
|
+
return {
|
|
261
|
+
outcome: "probe-failure",
|
|
262
|
+
missingImport: null,
|
|
263
|
+
detail:
|
|
264
|
+
"WasmEdge refused the invocation: reactor mode requires an entry " +
|
|
265
|
+
"function name. This is a PROBE defect, not an artifact defect — the " +
|
|
266
|
+
"lane must pass the artifact's reactor entry (see resolveReactorEntry).",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// Linked, then the CLI could not find a start entry: a reactor artifact.
|
|
270
|
+
// Only reachable after successful instantiation.
|
|
271
|
+
if (/(wasm function not found|function not found|_start|_initialize)/i.test(text)) {
|
|
272
|
+
return {
|
|
273
|
+
outcome: "instantiated",
|
|
274
|
+
missingImport: null,
|
|
275
|
+
detail: `linked; no command entry point (reactor artifact): ${head(1)}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!signal && code === 0) {
|
|
279
|
+
return { outcome: "instantiated", missingImport: null, detail: "linked; guest exited 0" };
|
|
280
|
+
}
|
|
281
|
+
if (/\[error\]/i.test(text)) {
|
|
282
|
+
return { outcome: "instantiate-error", missingImport: null, detail: head() };
|
|
283
|
+
}
|
|
284
|
+
if (guestOutputLength > 0) {
|
|
285
|
+
// The guest WROTE something, so it ran, so it linked — even though it then
|
|
286
|
+
// chose to exit nonzero.
|
|
287
|
+
return {
|
|
288
|
+
outcome: "instantiated",
|
|
289
|
+
missingImport: null,
|
|
290
|
+
detail: `linked; guest produced ${guestOutputLength} byte(s) and exited (code=${code ?? "null"}, signal=${signal ?? "null"})`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// Nonzero/signalled exit, no runtime diagnostic, no guest output: nothing
|
|
294
|
+
// observed instantiation. Do not guess. (This is precisely the shape that
|
|
295
|
+
// used to read as a pass — a docker mount that delivered no file, a runtime
|
|
296
|
+
// that logged to a stream nobody read.)
|
|
297
|
+
return {
|
|
298
|
+
outcome: "probe-failure",
|
|
299
|
+
missingImport: null,
|
|
300
|
+
detail: `WasmEdge exited (code=${code ?? "null"}, signal=${signal ?? "null"}) with no diagnostic output and no guest output — the probe could not observe instantiation. Refusing to infer a pass.`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function spawnCapture(command, args, { cwd, env, timeoutMs }) {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const child = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
307
|
+
const stdout = [];
|
|
308
|
+
const stderr = [];
|
|
309
|
+
let settled = false;
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
if (settled) return;
|
|
312
|
+
settled = true;
|
|
313
|
+
child.kill("SIGKILL");
|
|
314
|
+
reject(new Error(`${command} timed out after ${timeoutMs}ms (gate probe).`));
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
317
|
+
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
318
|
+
child.on("error", (error) => {
|
|
319
|
+
if (settled) return;
|
|
320
|
+
settled = true;
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
reject(error);
|
|
323
|
+
});
|
|
324
|
+
child.on("close", (code, signal) => {
|
|
325
|
+
if (settled) return;
|
|
326
|
+
settled = true;
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
resolve({
|
|
329
|
+
code,
|
|
330
|
+
signal,
|
|
331
|
+
stdout: Buffer.concat(stdout),
|
|
332
|
+
stderr: Buffer.concat(stderr),
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
child.stdin.on("error", () => {});
|
|
336
|
+
child.stdin.end();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function binaryExists(candidate) {
|
|
341
|
+
try {
|
|
342
|
+
await access(candidate, fsConstants.X_OK);
|
|
343
|
+
return true;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function detectNativeWasmEdge(context = {}) {
|
|
350
|
+
const explicit =
|
|
351
|
+
context.wasmedgeBinary ??
|
|
352
|
+
process.env.SDM_WASMEDGE_BINARY ??
|
|
353
|
+
process.env.WASMEDGE_BINARY;
|
|
354
|
+
const candidates = explicit
|
|
355
|
+
? [String(explicit)]
|
|
356
|
+
: [path.join(os.homedir(), ".wasmedge", "bin", "wasmedge"), "wasmedge"];
|
|
357
|
+
for (const candidate of candidates) {
|
|
358
|
+
if (candidate !== "wasmedge" && !(await binaryExists(candidate))) continue;
|
|
359
|
+
try {
|
|
360
|
+
const { stdout } = await execFile(candidate, ["--version"]);
|
|
361
|
+
return { binary: candidate, versionOutput: stdout };
|
|
362
|
+
} catch {
|
|
363
|
+
/* try next */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Native WasmEdge probe lane. */
|
|
370
|
+
async function probeWithNativeWasmEdge(context, staged) {
|
|
371
|
+
const detected = context.nativeWasmEdge;
|
|
372
|
+
if (!detected) throw new Error("native WasmEdge binary not available");
|
|
373
|
+
assertWasmEdgeVersionMatchesPin(
|
|
374
|
+
detected.versionOutput,
|
|
375
|
+
context.pin,
|
|
376
|
+
`native binary ${detected.binary}`,
|
|
377
|
+
);
|
|
378
|
+
const outcome = await spawnCapture(detected.binary, wasmEdgeProbeArgs(staged), {
|
|
379
|
+
cwd: staged.dir,
|
|
380
|
+
env: { PATH: process.env.PATH ?? "" },
|
|
381
|
+
timeoutMs: context.timeoutMs,
|
|
382
|
+
});
|
|
383
|
+
const normalized = normalizeWasmEdgeOutcome(outcome);
|
|
384
|
+
return classifyWasmEdgeProbe({
|
|
385
|
+
code: outcome.code,
|
|
386
|
+
signal: outcome.signal,
|
|
387
|
+
stderrText: normalized.diagnosticText,
|
|
388
|
+
guestOutputLength: normalized.stdout.length,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Pinned-container WasmEdge probe lane. */
|
|
393
|
+
async function probeWithDockerWasmEdge(context, staged) {
|
|
394
|
+
const args = [
|
|
395
|
+
"run",
|
|
396
|
+
"--rm",
|
|
397
|
+
"-i",
|
|
398
|
+
"--network",
|
|
399
|
+
"none",
|
|
400
|
+
"-v",
|
|
401
|
+
`${staged.dir}:/parity:ro`,
|
|
402
|
+
"-w",
|
|
403
|
+
"/parity",
|
|
404
|
+
];
|
|
405
|
+
if (context.dockerPlatform) args.push("--platform", String(context.dockerPlatform));
|
|
406
|
+
args.push(context.pin.dockerImage, ...wasmEdgeProbeArgs(staged));
|
|
407
|
+
const outcome = await spawnCapture(context.dockerBinary ?? "docker", args, {
|
|
408
|
+
cwd: staged.dir,
|
|
409
|
+
env: process.env,
|
|
410
|
+
timeoutMs: context.timeoutMs,
|
|
411
|
+
});
|
|
412
|
+
const normalized = normalizeWasmEdgeOutcome(outcome);
|
|
413
|
+
return classifyWasmEdgeProbe({
|
|
414
|
+
code: outcome.code,
|
|
415
|
+
signal: outcome.signal,
|
|
416
|
+
stderrText: normalized.diagnosticText,
|
|
417
|
+
guestOutputLength: normalized.stdout.length,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function ensureDockerLane(context) {
|
|
422
|
+
const dockerBinary = context.dockerBinary ?? "docker";
|
|
423
|
+
await execFile(dockerBinary, ["--version"]);
|
|
424
|
+
const { pin } = context;
|
|
425
|
+
let present = true;
|
|
426
|
+
try {
|
|
427
|
+
await execFile(dockerBinary, ["image", "inspect", pin.dockerImage]);
|
|
428
|
+
} catch {
|
|
429
|
+
present = false;
|
|
430
|
+
}
|
|
431
|
+
if (!present) {
|
|
432
|
+
if (!context.autoBuildDockerImage) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`pinned WasmEdge image ${pin.dockerImage} is missing and autoBuildDockerImage is disabled.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
context.log(`gate: building ${pin.dockerImage} (WasmEdge ${pin.wasmedgeVersion})`);
|
|
438
|
+
await execFile(
|
|
439
|
+
dockerBinary,
|
|
440
|
+
[
|
|
441
|
+
"build",
|
|
442
|
+
"-f",
|
|
443
|
+
pin.dockerfilePath,
|
|
444
|
+
"--build-arg",
|
|
445
|
+
`WASMEDGE_VERSION=${pin.wasmedgeVersion}`,
|
|
446
|
+
"-t",
|
|
447
|
+
pin.dockerImage,
|
|
448
|
+
pin.dockerfileContextDir,
|
|
449
|
+
],
|
|
450
|
+
{ maxBuffer: 64 * 1024 * 1024, timeout: 900_000 },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const { stdout } = await execFile(
|
|
454
|
+
dockerBinary,
|
|
455
|
+
["run", "--rm", "--entrypoint", "wasmedge", pin.dockerImage, "--version"],
|
|
456
|
+
{ timeout: 120_000 },
|
|
457
|
+
);
|
|
458
|
+
return assertWasmEdgeVersionMatchesPin(
|
|
459
|
+
stdout,
|
|
460
|
+
context.pin,
|
|
461
|
+
`docker image ${pin.dockerImage}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- Browser probe lane ---------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
const CHROME_CANDIDATES = [
|
|
468
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
469
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
470
|
+
"/usr/bin/google-chrome-stable",
|
|
471
|
+
"/usr/bin/google-chrome",
|
|
472
|
+
"/usr/bin/chromium",
|
|
473
|
+
"/usr/bin/chromium-browser",
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
export async function resolveChromeBinary(context = {}) {
|
|
477
|
+
const explicit =
|
|
478
|
+
context.chromeBinary ?? process.env.SDM_CHROME_BINARY ?? process.env.CHROME_BINARY;
|
|
479
|
+
if (explicit) return String(explicit);
|
|
480
|
+
for (const candidate of CHROME_CANDIDATES) {
|
|
481
|
+
if (await binaryExists(candidate)) return candidate;
|
|
482
|
+
}
|
|
483
|
+
throw new Error(
|
|
484
|
+
"browser lane: no Chrome/Chromium binary found (set SDM_CHROME_BINARY). " +
|
|
485
|
+
"The browser lane requires a REAL browser context — jsdom masks SAB/threading realities.",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const PROBE_HTML = `<!doctype html>
|
|
490
|
+
<html><head><meta charset="utf-8"><title>sdm parity gate</title></head>
|
|
491
|
+
<body><pre id="status">gate probe booting…</pre>
|
|
492
|
+
<script type="module" src="/probe.js"></script></body></html>`;
|
|
493
|
+
|
|
494
|
+
async function runBrowserProbeLane(context, artifacts) {
|
|
495
|
+
const chromeBinary = await resolveChromeBinary(context);
|
|
496
|
+
const esbuild = await import("esbuild");
|
|
497
|
+
const built = await esbuild.build({
|
|
498
|
+
entryPoints: [path.join(__dirname, "parityGateBrowserProbe.js")],
|
|
499
|
+
bundle: true,
|
|
500
|
+
write: false,
|
|
501
|
+
format: "esm",
|
|
502
|
+
platform: "browser",
|
|
503
|
+
target: ["chrome110"],
|
|
504
|
+
external: ["node:*"],
|
|
505
|
+
logLevel: "silent",
|
|
506
|
+
});
|
|
507
|
+
const bundle = built.outputFiles[0].text;
|
|
508
|
+
|
|
509
|
+
let resolveDone;
|
|
510
|
+
let rejectDone;
|
|
511
|
+
const done = new Promise((resolve, reject) => {
|
|
512
|
+
resolveDone = resolve;
|
|
513
|
+
rejectDone = reject;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const byUrl = new Map(
|
|
517
|
+
artifacts.map((artifact) => [`/artifact/${artifact.id}.wasm`, artifact]),
|
|
518
|
+
);
|
|
519
|
+
const plan = {
|
|
520
|
+
artifacts: artifacts.map((artifact) => ({
|
|
521
|
+
id: artifact.id,
|
|
522
|
+
surface: artifact.surface,
|
|
523
|
+
url: `/artifact/${artifact.id}.wasm`,
|
|
524
|
+
})),
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const headers = {
|
|
528
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
529
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
530
|
+
"Cache-Control": "no-store",
|
|
531
|
+
};
|
|
532
|
+
const server = http.createServer((request, response) => {
|
|
533
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
534
|
+
if (request.method === "POST" && url.pathname === "/done") {
|
|
535
|
+
const chunks = [];
|
|
536
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
537
|
+
request.on("end", () => {
|
|
538
|
+
response.writeHead(200, headers);
|
|
539
|
+
response.end("ok");
|
|
540
|
+
try {
|
|
541
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
542
|
+
if (body.fatal) rejectDone(new Error(`browser gate lane fatal: ${body.fatal}`));
|
|
543
|
+
else resolveDone(body.results ?? []);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
rejectDone(error);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
551
|
+
response.writeHead(200, { ...headers, "Content-Type": "text/html; charset=utf-8" });
|
|
552
|
+
response.end(PROBE_HTML);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (url.pathname === "/probe.js") {
|
|
556
|
+
response.writeHead(200, {
|
|
557
|
+
...headers,
|
|
558
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
559
|
+
});
|
|
560
|
+
response.end(bundle);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (url.pathname === "/gate-plan") {
|
|
564
|
+
response.writeHead(200, { ...headers, "Content-Type": "application/json" });
|
|
565
|
+
response.end(JSON.stringify(plan));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const artifact = byUrl.get(url.pathname);
|
|
569
|
+
if (artifact) {
|
|
570
|
+
response.writeHead(200, { ...headers, "Content-Type": "application/wasm" });
|
|
571
|
+
response.end(Buffer.from(artifact.loadableBytes));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
response.writeHead(404, headers);
|
|
575
|
+
response.end("not found");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
579
|
+
const { port } = server.address();
|
|
580
|
+
const userDataDir = await mkdtemp(path.join(os.tmpdir(), "sdm-gate-chrome-"));
|
|
581
|
+
const chrome = spawn(
|
|
582
|
+
chromeBinary,
|
|
583
|
+
[
|
|
584
|
+
"--headless=new",
|
|
585
|
+
"--disable-gpu",
|
|
586
|
+
"--no-first-run",
|
|
587
|
+
"--no-default-browser-check",
|
|
588
|
+
"--disable-extensions",
|
|
589
|
+
"--disable-background-networking",
|
|
590
|
+
"--disable-sync",
|
|
591
|
+
`--user-data-dir=${userDataDir}`,
|
|
592
|
+
`http://127.0.0.1:${port}/`,
|
|
593
|
+
],
|
|
594
|
+
{ stdio: ["ignore", "ignore", "pipe"] },
|
|
595
|
+
);
|
|
596
|
+
const chromeStderr = [];
|
|
597
|
+
chrome.stderr.on("data", (chunk) => chromeStderr.push(Buffer.from(chunk)));
|
|
598
|
+
chrome.on("error", (error) =>
|
|
599
|
+
rejectDone(new Error(`browser gate lane: failed to launch Chrome (${error.message})`)),
|
|
600
|
+
);
|
|
601
|
+
chrome.on("exit", (code, signal) =>
|
|
602
|
+
rejectDone(
|
|
603
|
+
new Error(
|
|
604
|
+
`browser gate lane: Chrome exited before reporting (code=${code}, signal=${signal}). ` +
|
|
605
|
+
Buffer.concat(chromeStderr).toString("utf8").slice(-400),
|
|
606
|
+
),
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
const timer = setTimeout(
|
|
610
|
+
() => rejectDone(new Error(`browser gate lane timed out after ${context.timeoutMs}ms.`)),
|
|
611
|
+
context.timeoutMs,
|
|
612
|
+
);
|
|
613
|
+
try {
|
|
614
|
+
return await done;
|
|
615
|
+
} finally {
|
|
616
|
+
clearTimeout(timer);
|
|
617
|
+
chrome.removeAllListeners("exit");
|
|
618
|
+
chrome.kill("SIGKILL");
|
|
619
|
+
server.close();
|
|
620
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// --- Verdict derivation ---------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Reduce one lane's raw probe outcome to a contract verdict, using the
|
|
628
|
+
* artifact's structural classification and what the lane runner is CAPABLE of
|
|
629
|
+
* supplying. Pure — this is the rule set, testable without any runtime.
|
|
630
|
+
*/
|
|
631
|
+
export function deriveContractVerdict({
|
|
632
|
+
laneSuppliesCapabilities,
|
|
633
|
+
probe,
|
|
634
|
+
structural,
|
|
635
|
+
}) {
|
|
636
|
+
if (probe.outcome === "lane-unavailable") {
|
|
637
|
+
return {
|
|
638
|
+
verdict: ContractVerdict.Unavailable,
|
|
639
|
+
reason: probe.detail ?? "lane could not run",
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (structural.verdict === "forbidden") {
|
|
643
|
+
return {
|
|
644
|
+
verdict: ContractVerdict.Violated,
|
|
645
|
+
reason: describeClassification(structural),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (structural.verdict === "malformed") {
|
|
649
|
+
return { verdict: ContractVerdict.Violated, reason: describeClassification(structural) };
|
|
650
|
+
}
|
|
651
|
+
if (structural.verdict === "outside-surface") {
|
|
652
|
+
return {
|
|
653
|
+
verdict: ContractVerdict.Violated,
|
|
654
|
+
reason: describeClassification(structural),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (probe.outcome === "instantiated") {
|
|
658
|
+
return { verdict: ContractVerdict.Satisfied, reason: null };
|
|
659
|
+
}
|
|
660
|
+
if (probe.outcome === "link-error") {
|
|
661
|
+
const missing = probe.missingImport;
|
|
662
|
+
const isDeclaredCapability =
|
|
663
|
+
missing !== null && structural.capabilityImports.includes(missing);
|
|
664
|
+
if (isDeclaredCapability) {
|
|
665
|
+
return laneSuppliesCapabilities
|
|
666
|
+
? {
|
|
667
|
+
verdict: ContractVerdict.ShimGap,
|
|
668
|
+
reason: `lane supplies the declared surface but failed to link ${missing} — SDK host-shim defect`,
|
|
669
|
+
}
|
|
670
|
+
: {
|
|
671
|
+
verdict: ContractVerdict.RunnerCannotSupplyCapability,
|
|
672
|
+
reason: `blocked ONLY on the declared capability import ${missing}; this lane runner registers no host functions`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
verdict: ContractVerdict.Violated,
|
|
677
|
+
reason: `link error on ${missing ?? "an unnamed import"} which is NOT in the declared surface: ${probe.detail}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
verdict: ContractVerdict.Violated,
|
|
682
|
+
reason: `${probe.outcome}: ${probe.detail ?? "no detail"}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Cross-lane comparison. Two lanes agree when their verdicts are equivalent
|
|
688
|
+
* modulo the runner's declared capability limitation — an artifact that is
|
|
689
|
+
* `satisfied` in the browser and `runner-cannot-supply-declared-capability`
|
|
690
|
+
* under the bare WasmEdge CLI is NOT a divergence, and the report says so in
|
|
691
|
+
* those words rather than pretending the lanes matched.
|
|
692
|
+
*/
|
|
693
|
+
export function lanesAgree(a, b) {
|
|
694
|
+
const equivalence = (verdict) =>
|
|
695
|
+
verdict === ContractVerdict.RunnerCannotSupplyCapability
|
|
696
|
+
? ContractVerdict.Satisfied
|
|
697
|
+
: verdict;
|
|
698
|
+
return equivalence(a) === equivalence(b);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// --- Orchestrator ---------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* A REACTOR artifact has no `_start`; its initialisation entry is `_initialize`
|
|
705
|
+
* (clang `-mexec-model=reactor`). The WasmEdge CLI refuses to run one without
|
|
706
|
+
* being told which function to call — "A function name is required when reactor
|
|
707
|
+
* mode is enabled." on stderr, exit 1, and NO runtime diagnostic — which the
|
|
708
|
+
* probe classifier could only honestly report as `probe-failure`. The effect
|
|
709
|
+
* was that a CORRECTLY built library module (the shape the module contract
|
|
710
|
+
* mandates for the RF family) was reported as a P1 cross-runtime divergence
|
|
711
|
+
* while the browser lane passed: the gate failed the artifact for the gate's
|
|
712
|
+
* own inability to invoke it.
|
|
713
|
+
*
|
|
714
|
+
* Naming the entry is also STRICTLY STRONGER evidence than the old bare
|
|
715
|
+
* invocation: a clean exit 0 means the runtime linked the imports, instantiated
|
|
716
|
+
* the module, and RAN its initialiser — observed, not inferred from an error
|
|
717
|
+
* string.
|
|
718
|
+
*/
|
|
719
|
+
export function resolveReactorEntry(loadableBytes) {
|
|
720
|
+
let exportNames;
|
|
721
|
+
try {
|
|
722
|
+
exportNames = readWasmExportNames(loadableBytes);
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
if (exportNames.includes("_start")) return null;
|
|
727
|
+
return exportNames.includes("_initialize") ? "_initialize" : null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function stageArtifact(artifact) {
|
|
731
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), `sdm-gate-${artifact.id}-`));
|
|
732
|
+
const basename = "artifact.wasm";
|
|
733
|
+
await writeFile(path.join(dir, basename), artifact.loadableBytes);
|
|
734
|
+
return {
|
|
735
|
+
dir,
|
|
736
|
+
basename,
|
|
737
|
+
reactorEntry: resolveReactorEntry(artifact.loadableBytes),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* The invocation tail shared by both WasmEdge lanes, so the native and Docker
|
|
743
|
+
* lanes can never drift into probing the same artifact two different ways.
|
|
744
|
+
*/
|
|
745
|
+
export function wasmEdgeProbeArgs(staged) {
|
|
746
|
+
return staged.reactorEntry
|
|
747
|
+
? ["--enable-threads", "--reactor", staged.basename, staged.reactorEntry]
|
|
748
|
+
: ["--enable-threads", staged.basename];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Run the gate.
|
|
753
|
+
*
|
|
754
|
+
* @param {Object} options
|
|
755
|
+
* @param {string} [options.manifestPath]
|
|
756
|
+
* @param {Object[]} [options.extraArtifacts] external artifacts (e.g. a
|
|
757
|
+
* decrypted closed rf-* module) injected by the caller.
|
|
758
|
+
* @param {boolean} [options.requireNativeWasmEdge] when true, the absence of a
|
|
759
|
+
* native WasmEdge at the pin FAILS the gate (host+container pin pair cannot
|
|
760
|
+
* be cross-verified without it). Default false: the absence is reported as
|
|
761
|
+
* an explicit `pinVerification` gap, never as a pass.
|
|
762
|
+
* @param {boolean} [options.expectFailure] negative-control mode: the gate
|
|
763
|
+
* must FAIL; a PASS is itself the error.
|
|
764
|
+
*/
|
|
765
|
+
export async function runParityGate(options = {}) {
|
|
766
|
+
const log = options.log ?? (() => {});
|
|
767
|
+
const pin = loadWasmEdgePin();
|
|
768
|
+
const manifest = options.manifest ?? (await loadGateManifest(options.manifestPath));
|
|
769
|
+
const declared = [
|
|
770
|
+
...manifest.artifacts,
|
|
771
|
+
...(options.extraArtifacts ?? []).map(makeExternalArtifact),
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
const context = {
|
|
775
|
+
pin,
|
|
776
|
+
log,
|
|
777
|
+
dockerBinary: options.dockerBinary ?? "docker",
|
|
778
|
+
dockerPlatform: options.dockerPlatform,
|
|
779
|
+
chromeBinary: options.chromeBinary,
|
|
780
|
+
wasmedgeBinary: options.wasmedgeBinary,
|
|
781
|
+
autoBuildDockerImage: options.autoBuildDockerImage !== false,
|
|
782
|
+
timeoutMs: options.timeoutMs ?? 120_000,
|
|
783
|
+
nativeWasmEdge: null,
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// --- resolve + load artifacts
|
|
787
|
+
const artifacts = [];
|
|
788
|
+
const failures = [];
|
|
789
|
+
for (const spec of declared) {
|
|
790
|
+
try {
|
|
791
|
+
const rawBytes = new Uint8Array(await readFile(spec.artifactPath));
|
|
792
|
+
const loadableBytes = toLoadableWasmBytes(rawBytes);
|
|
793
|
+
artifacts.push({
|
|
794
|
+
...spec,
|
|
795
|
+
rawBytes,
|
|
796
|
+
loadableBytes,
|
|
797
|
+
artifactSha256: sha256Hex(rawBytes),
|
|
798
|
+
moduleSha256: sha256Hex(loadableBytes),
|
|
799
|
+
structural: classifyArtifactImports(loadableBytes, spec.surface),
|
|
800
|
+
});
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (spec.required) {
|
|
803
|
+
failures.push({
|
|
804
|
+
artifact: spec.id,
|
|
805
|
+
kind: "artifact-missing",
|
|
806
|
+
message: `${spec.artifactPath}: ${error?.message ?? error}`,
|
|
807
|
+
});
|
|
808
|
+
} else {
|
|
809
|
+
log(`gate: optional artifact ${spec.id} absent (${spec.artifactPath})`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// --- lane availability (unavailability is a FAILURE, distinct from divergence)
|
|
815
|
+
const laneNames = options.lanes ?? ["browser", "wasmedge-docker", "wasmedge-native"];
|
|
816
|
+
const laneState = new Map();
|
|
817
|
+
|
|
818
|
+
context.nativeWasmEdge = await detectNativeWasmEdge(context);
|
|
819
|
+
const nativeRequested = laneNames.includes("wasmedge-native");
|
|
820
|
+
const nativeAvailable = Boolean(context.nativeWasmEdge);
|
|
821
|
+
if (nativeRequested && !nativeAvailable) {
|
|
822
|
+
if (options.requireNativeWasmEdge) {
|
|
823
|
+
failures.push({
|
|
824
|
+
artifact: null,
|
|
825
|
+
kind: "lane-unavailable",
|
|
826
|
+
message:
|
|
827
|
+
"native WasmEdge lane: no binary found. Host and container WasmEdge versions pin and bump TOGETHER, so with no host runtime the pin pair cannot be cross-verified.",
|
|
828
|
+
});
|
|
829
|
+
} else {
|
|
830
|
+
laneState.set("wasmedge-native", {
|
|
831
|
+
available: false,
|
|
832
|
+
reason: "no native WasmEdge binary on this box",
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const activeLanes = [];
|
|
838
|
+
for (const lane of laneNames) {
|
|
839
|
+
if (lane === "wasmedge-native") {
|
|
840
|
+
if (nativeAvailable) activeLanes.push(lane);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (lane === "wasmedge-docker") {
|
|
844
|
+
try {
|
|
845
|
+
const version = await ensureDockerLane(context);
|
|
846
|
+
laneState.set(lane, { available: true, version });
|
|
847
|
+
activeLanes.push(lane);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
failures.push({
|
|
850
|
+
artifact: null,
|
|
851
|
+
kind: "lane-unavailable",
|
|
852
|
+
message: `docker WasmEdge lane: ${error?.message ?? error}`,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
activeLanes.push(lane);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const wasmedgeLanes = activeLanes.filter((lane) => lane.startsWith("wasmedge"));
|
|
861
|
+
if (wasmedgeLanes.length === 0) {
|
|
862
|
+
failures.push({
|
|
863
|
+
artifact: null,
|
|
864
|
+
kind: "lane-unavailable",
|
|
865
|
+
message:
|
|
866
|
+
"no WasmEdge lane could run (neither a native binary at the pin nor the pinned container). Parity is unprovable.",
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (!activeLanes.includes("browser")) {
|
|
870
|
+
failures.push({
|
|
871
|
+
artifact: null,
|
|
872
|
+
kind: "lane-unavailable",
|
|
873
|
+
message: "browser lane not selected; a WasmEdge-only run cannot claim parity.",
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// --- Tier A: real instantiation probes
|
|
878
|
+
const laneProbes = new Map(); // lane -> Map(artifactId -> probe)
|
|
879
|
+
|
|
880
|
+
if (activeLanes.includes("browser") && artifacts.length > 0) {
|
|
881
|
+
const probes = new Map();
|
|
882
|
+
try {
|
|
883
|
+
const results = await runBrowserProbeLane(context, artifacts);
|
|
884
|
+
for (const result of results) {
|
|
885
|
+
probes.set(result.id, {
|
|
886
|
+
outcome: result.outcome,
|
|
887
|
+
missingImport: result.missingImport ?? null,
|
|
888
|
+
detail: result.detail ?? null,
|
|
889
|
+
exportCount: result.exportCount ?? 0,
|
|
890
|
+
crossOriginIsolated: result.crossOriginIsolated === true,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
const missing = artifacts.filter((artifact) => !probes.has(artifact.id));
|
|
894
|
+
for (const artifact of missing) {
|
|
895
|
+
probes.set(artifact.id, {
|
|
896
|
+
outcome: "lane-unavailable",
|
|
897
|
+
detail: "browser lane returned no result for this artifact",
|
|
898
|
+
missingImport: null,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
} catch (error) {
|
|
902
|
+
for (const artifact of artifacts) {
|
|
903
|
+
probes.set(artifact.id, {
|
|
904
|
+
outcome: "lane-unavailable",
|
|
905
|
+
detail: error?.message ?? String(error),
|
|
906
|
+
missingImport: null,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
failures.push({
|
|
910
|
+
artifact: null,
|
|
911
|
+
kind: "lane-unavailable",
|
|
912
|
+
message: `browser lane: ${error?.message ?? error}`,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
laneProbes.set("browser", probes);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
for (const lane of wasmedgeLanes) {
|
|
919
|
+
const probes = new Map();
|
|
920
|
+
for (const artifact of artifacts) {
|
|
921
|
+
const staged = await stageArtifact(artifact);
|
|
922
|
+
try {
|
|
923
|
+
const probe =
|
|
924
|
+
lane === "wasmedge-native"
|
|
925
|
+
? await probeWithNativeWasmEdge(context, staged)
|
|
926
|
+
: await probeWithDockerWasmEdge(context, staged);
|
|
927
|
+
probes.set(artifact.id, probe);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
probes.set(artifact.id, {
|
|
930
|
+
outcome: "lane-unavailable",
|
|
931
|
+
detail: error?.message ?? String(error),
|
|
932
|
+
missingImport: null,
|
|
933
|
+
});
|
|
934
|
+
} finally {
|
|
935
|
+
await rm(staged.dir, { recursive: true, force: true });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
laneProbes.set(lane, probes);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// --- Tier A verdicts + cross-lane diff
|
|
942
|
+
const artifactReports = [];
|
|
943
|
+
for (const artifact of artifacts) {
|
|
944
|
+
const lanes = [];
|
|
945
|
+
for (const lane of laneProbes.keys()) {
|
|
946
|
+
const probe = laneProbes.get(lane).get(artifact.id) ?? {
|
|
947
|
+
outcome: "lane-unavailable",
|
|
948
|
+
detail: "no probe recorded",
|
|
949
|
+
missingImport: null,
|
|
950
|
+
};
|
|
951
|
+
const derived = deriveContractVerdict({
|
|
952
|
+
laneSuppliesCapabilities: lane === "browser",
|
|
953
|
+
probe,
|
|
954
|
+
structural: artifact.structural,
|
|
955
|
+
});
|
|
956
|
+
lanes.push({
|
|
957
|
+
lane,
|
|
958
|
+
evidence: LANE_EVIDENCE[lane] ?? "unlabelled lane",
|
|
959
|
+
outcome: probe.outcome,
|
|
960
|
+
missingImport: probe.missingImport ?? null,
|
|
961
|
+
detail: probe.detail ?? null,
|
|
962
|
+
contractVerdict: derived.verdict,
|
|
963
|
+
reason: derived.reason,
|
|
964
|
+
crossOriginIsolated: probe.crossOriginIsolated,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
for (const laneResult of lanes) {
|
|
969
|
+
if (laneResult.contractVerdict === ContractVerdict.Violated) {
|
|
970
|
+
failures.push({
|
|
971
|
+
artifact: artifact.id,
|
|
972
|
+
kind:
|
|
973
|
+
artifact.structural.verdict === "forbidden"
|
|
974
|
+
? "forbidden-import-class"
|
|
975
|
+
: "contract-violation",
|
|
976
|
+
message: `${laneResult.lane}: ${laneResult.reason}`,
|
|
977
|
+
});
|
|
978
|
+
} else if (laneResult.contractVerdict === ContractVerdict.ShimGap) {
|
|
979
|
+
failures.push({
|
|
980
|
+
artifact: artifact.id,
|
|
981
|
+
kind: "host-shim-gap",
|
|
982
|
+
message: `${laneResult.lane}: ${laneResult.reason}`,
|
|
983
|
+
});
|
|
984
|
+
} else if (laneResult.contractVerdict === ContractVerdict.Unavailable) {
|
|
985
|
+
failures.push({
|
|
986
|
+
artifact: artifact.id,
|
|
987
|
+
kind: "lane-unavailable",
|
|
988
|
+
message: `${laneResult.lane}: ${laneResult.reason}`,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
for (let index = 1; index < lanes.length; index += 1) {
|
|
994
|
+
if (!lanesAgree(lanes[0].contractVerdict, lanes[index].contractVerdict)) {
|
|
995
|
+
failures.push({
|
|
996
|
+
artifact: artifact.id,
|
|
997
|
+
kind: "lane-divergence",
|
|
998
|
+
message: `P1 cross-runtime divergence: ${lanes[0].lane}=${lanes[0].contractVerdict} vs ${lanes[index].lane}=${lanes[index].contractVerdict} (${lanes[index].reason ?? "-"})`,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
artifactReports.push({
|
|
1004
|
+
id: artifact.id,
|
|
1005
|
+
surface: artifact.surface,
|
|
1006
|
+
profile: artifact.profile,
|
|
1007
|
+
artifactPath: artifact.artifactPath,
|
|
1008
|
+
artifactSha256: artifact.artifactSha256,
|
|
1009
|
+
moduleSha256: artifact.moduleSha256,
|
|
1010
|
+
byteLength: artifact.loadableBytes.length,
|
|
1011
|
+
structural: {
|
|
1012
|
+
verdict: artifact.structural.verdict,
|
|
1013
|
+
summary: describeClassification(artifact.structural),
|
|
1014
|
+
importCount: artifact.structural.importCount,
|
|
1015
|
+
capabilityImports: artifact.structural.capabilityImports,
|
|
1016
|
+
forbidden: artifact.structural.forbidden,
|
|
1017
|
+
outsideSurface: artifact.structural.outsideSurface,
|
|
1018
|
+
},
|
|
1019
|
+
lanes,
|
|
1020
|
+
note: artifact.note,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// --- Tier B: behavioral parity for artifacts that declare a fixture
|
|
1025
|
+
const behavioral = [];
|
|
1026
|
+
for (const artifact of artifacts) {
|
|
1027
|
+
if (!artifact.fixture) continue;
|
|
1028
|
+
const harnessLanes = ["browser", ...(wasmedgeLanes.includes("wasmedge-native") ? ["wasmedge"] : []), "docker-wasmedge"];
|
|
1029
|
+
try {
|
|
1030
|
+
const report = await runParityHarness({
|
|
1031
|
+
wasmPath: artifact.artifactPath,
|
|
1032
|
+
fixturePath: artifact.fixture,
|
|
1033
|
+
lanes: harnessLanes,
|
|
1034
|
+
chromeBinary: context.chromeBinary,
|
|
1035
|
+
wasmedgeBinary: context.wasmedgeBinary,
|
|
1036
|
+
dockerPlatform: context.dockerPlatform,
|
|
1037
|
+
timeoutMs: context.timeoutMs,
|
|
1038
|
+
log,
|
|
1039
|
+
});
|
|
1040
|
+
behavioral.push({ artifact: artifact.id, ok: report.ok, report });
|
|
1041
|
+
if (!report.ok) {
|
|
1042
|
+
for (const failure of report.failures) {
|
|
1043
|
+
failures.push({
|
|
1044
|
+
artifact: artifact.id,
|
|
1045
|
+
kind: `behavioral-${failure.kind}`,
|
|
1046
|
+
message: `${failure.caseId ?? "-"}: ${failure.message}`,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
behavioral.push({ artifact: artifact.id, ok: false, error: String(error?.message ?? error) });
|
|
1052
|
+
failures.push({
|
|
1053
|
+
artifact: artifact.id,
|
|
1054
|
+
kind: "behavioral-harness-failure",
|
|
1055
|
+
message: String(error?.message ?? error),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const ok = failures.length === 0;
|
|
1061
|
+
return {
|
|
1062
|
+
ok,
|
|
1063
|
+
gate: manifest.name,
|
|
1064
|
+
manifestPath: manifest.manifestPath,
|
|
1065
|
+
pin: pin.wasmedgeVersion,
|
|
1066
|
+
pinVerification: {
|
|
1067
|
+
native: nativeAvailable
|
|
1068
|
+
? { status: "verified", binary: context.nativeWasmEdge.binary }
|
|
1069
|
+
: {
|
|
1070
|
+
status: "absent",
|
|
1071
|
+
note:
|
|
1072
|
+
"No native WasmEdge on this box: the host/container pin PAIR could not be cross-verified. This is a recorded gap, not a pass — pass requireNativeWasmEdge on hosts that provision it (graph: parity-harness-cannot-run-locally).",
|
|
1073
|
+
},
|
|
1074
|
+
docker: laneState.get("wasmedge-docker") ?? { status: "not-run" },
|
|
1075
|
+
},
|
|
1076
|
+
lanes: activeLanes.map((lane) => ({
|
|
1077
|
+
lane,
|
|
1078
|
+
evidence: LANE_EVIDENCE[lane] ?? "unlabelled lane",
|
|
1079
|
+
})),
|
|
1080
|
+
lanesSkipped: [...laneState.entries()]
|
|
1081
|
+
.filter(([, value]) => value.available === false)
|
|
1082
|
+
.map(([lane, value]) => ({ lane, reason: value.reason })),
|
|
1083
|
+
artifacts: artifactReports,
|
|
1084
|
+
behavioral: behavioral.map((entry) => ({
|
|
1085
|
+
artifact: entry.artifact,
|
|
1086
|
+
ok: entry.ok,
|
|
1087
|
+
comparisons: entry.report?.comparisons ?? 0,
|
|
1088
|
+
lanes: entry.report?.lanes ?? [],
|
|
1089
|
+
error: entry.error ?? null,
|
|
1090
|
+
})),
|
|
1091
|
+
failures,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export function formatGateReport(report) {
|
|
1096
|
+
const lines = [];
|
|
1097
|
+
lines.push(
|
|
1098
|
+
`parity-gate ${report.ok ? "PASS" : "FAIL"} gate=${report.gate} wasmedge-pin=${report.pin} artifacts=${report.artifacts.length} lanes=[${report.lanes.map((lane) => lane.lane).join(", ")}]`,
|
|
1099
|
+
);
|
|
1100
|
+
for (const lane of report.lanes) {
|
|
1101
|
+
lines.push(` lane ${lane.lane}: ${lane.evidence}`);
|
|
1102
|
+
}
|
|
1103
|
+
for (const skipped of report.lanesSkipped ?? []) {
|
|
1104
|
+
lines.push(` lane ${skipped.lane}: NOT RUN — ${skipped.reason}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (report.pinVerification?.native?.status === "absent") {
|
|
1107
|
+
lines.push(` PIN GAP: ${report.pinVerification.native.note}`);
|
|
1108
|
+
}
|
|
1109
|
+
for (const artifact of report.artifacts) {
|
|
1110
|
+
lines.push(
|
|
1111
|
+
` ${artifact.id} [${artifact.surface}/${artifact.profile}] sha256=${artifact.moduleSha256.slice(0, 16)} :: ${artifact.structural.summary}`,
|
|
1112
|
+
);
|
|
1113
|
+
for (const lane of artifact.lanes) {
|
|
1114
|
+
lines.push(
|
|
1115
|
+
` ${lane.lane.padEnd(16)} ${lane.contractVerdict}${lane.reason ? ` — ${lane.reason}` : ""}`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
for (const entry of report.behavioral ?? []) {
|
|
1120
|
+
lines.push(
|
|
1121
|
+
` behavioral ${entry.artifact}: ${entry.ok ? "PASS" : "FAIL"} comparisons=${entry.comparisons}${entry.error ? ` (${entry.error})` : ""}`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
for (const failure of report.failures) {
|
|
1125
|
+
lines.push(
|
|
1126
|
+
` GATE FAIL artifact=${failure.artifact ?? "-"} kind=${failure.kind}: ${failure.message}`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return lines.join("\n");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export function gateReceiptDigest(report) {
|
|
1133
|
+
return createHash("sha256")
|
|
1134
|
+
.update(
|
|
1135
|
+
JSON.stringify({
|
|
1136
|
+
gate: report.gate,
|
|
1137
|
+
pin: report.pin,
|
|
1138
|
+
artifacts: report.artifacts.map((artifact) => [
|
|
1139
|
+
artifact.id,
|
|
1140
|
+
artifact.moduleSha256,
|
|
1141
|
+
artifact.lanes.map((lane) => [lane.lane, lane.contractVerdict]),
|
|
1142
|
+
]),
|
|
1143
|
+
ok: report.ok,
|
|
1144
|
+
}),
|
|
1145
|
+
)
|
|
1146
|
+
.digest("hex");
|
|
1147
|
+
}
|