space-data-module-sdk 0.8.10 → 0.8.12

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.
@@ -58,9 +58,12 @@ import { fileURLToPath } from "node:url";
58
58
  import { promisify } from "node:util";
59
59
 
60
60
  import { toLoadableWasmBytes } from "../bundle/artifactBytes.js";
61
+ import { locateEmbeddedPlgManifest } from "../compliance/index.js";
62
+ import { runtimeTargetSatisfies } from "../host/runtimeTargetGate.js";
61
63
  import {
62
64
  classifyArtifactImports,
63
65
  describeClassification,
66
+ readWasmExportNames,
64
67
  resolveHostSurface,
65
68
  } from "./hostContract.js";
66
69
  import { normalizeWasmEdgeOutcome } from "./wasmedgeOutput.js";
@@ -109,8 +112,56 @@ export const ContractVerdict = Object.freeze({
109
112
  ShimGap: "shim-gap",
110
113
  /** The lane could not run at all. */
111
114
  Unavailable: "lane-unavailable",
115
+ /**
116
+ * The artifact DECLARES that it does not run on this lane's runtime target.
117
+ * Not a pass, not a divergence, not a trap: a scope statement the artifact
118
+ * itself makes, recorded as evidence. Composed flows derive their
119
+ * runtimeTargets from their parts, so a WasmEdge-only flow is legitimate —
120
+ * and without this class its (correct, loud) refusal by the browser harness
121
+ * scored as a P1 cross-runtime divergence, which would have made the gate
122
+ * fail exactly the artifacts this SDK just taught the compiler to emit.
123
+ */
124
+ OutOfDeclaredScope: "out-of-declared-scope",
125
+ });
126
+
127
+ /** Which runtime target each parity lane stands for. */
128
+ export const LANE_RUNTIME_TARGET = Object.freeze({
129
+ browser: "browser",
130
+ "wasmedge-native": "wasmedge",
131
+ "wasmedge-docker": "wasmedge",
112
132
  });
113
133
 
134
+ /**
135
+ * The artifact's own declared runtimeTargets, read from its embedded `$PLG`.
136
+ * An artifact that declares none is unconstrained — every lane is in scope,
137
+ * which is the historical behaviour and stays the default.
138
+ */
139
+ export function declaredRuntimeTargets(loadableBytes) {
140
+ try {
141
+ const located = locateEmbeddedPlgManifest(loadableBytes);
142
+ const declared = located?.decoded?.runtimeTargets;
143
+ return Array.isArray(declared)
144
+ ? declared.map((t) => String(t ?? "").trim().toLowerCase()).filter(Boolean)
145
+ : [];
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
150
+
151
+ export function declaredCapabilities(loadableBytes) {
152
+ try {
153
+ const located = locateEmbeddedPlgManifest(loadableBytes);
154
+ return located?.decoded?.capabilities ?? [];
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ export function laneIsInDeclaredScope(laneName, targets, capabilities) {
161
+ const leg = LANE_RUNTIME_TARGET[laneName];
162
+ return leg ? runtimeTargetSatisfies(targets, leg, capabilities) : true;
163
+ }
164
+
114
165
  // --- Manifest ------------------------------------------------------------------
115
166
 
116
167
  /**
@@ -180,6 +231,16 @@ export async function loadGateManifest(manifestPath = DEFAULT_GATE_MANIFEST) {
180
231
  ? path.resolve(path.dirname(resolved), String(spec.fixture))
181
232
  : null,
182
233
  required: spec.required !== false,
234
+ // The GATE'S claim about which lanes this artifact owes evidence on.
235
+ // Authority for that belongs to the manifest, not to the artifact's
236
+ // own `$PLG`: an artifact must not be able to reduce what it is
237
+ // examined on by editing its own declaration. Omitted means "every
238
+ // active lane its declaration admits".
239
+ expectedLanes: Object.freeze(
240
+ Array.isArray(spec.expectedLanes)
241
+ ? spec.expectedLanes.map((lane) => String(lane))
242
+ : [],
243
+ ),
183
244
  note: spec.note ? String(spec.note) : null,
184
245
  negativeControl: spec.negativeControl === true,
185
246
  }),
@@ -204,6 +265,11 @@ export function makeExternalArtifact(spec) {
204
265
  profile: String(spec.profile ?? "library"),
205
266
  fixture: spec.fixture ? path.resolve(String(spec.fixture)) : null,
206
267
  required: spec.required !== false,
268
+ expectedLanes: Object.freeze(
269
+ Array.isArray(spec.expectedLanes)
270
+ ? spec.expectedLanes.map((lane) => String(lane))
271
+ : [],
272
+ ),
207
273
  note: spec.note ? String(spec.note) : "injected by caller (external artifact)",
208
274
  negativeControl: spec.negativeControl === true,
209
275
  });
@@ -250,6 +316,21 @@ export function classifyWasmEdgeProbe({
250
316
  if (/(loading failed|validation failed|magic header|malformed|invalid section)/i.test(text)) {
251
317
  return { outcome: "compile-error", missingImport: null, detail: head() };
252
318
  }
319
+ // The CLI rejected the INVOCATION, before loading anything: reactor mode
320
+ // needs an entry name. This says nothing about the artifact, so it must not
321
+ // be reported against the artifact — stageArtifact() now names `_initialize`
322
+ // for reactor artifacts, and this branch exists so a regression there is
323
+ // legible instead of masquerading as a cross-runtime divergence.
324
+ if (/function name is required when reactor mode is enabled/i.test(text)) {
325
+ return {
326
+ outcome: "probe-failure",
327
+ missingImport: null,
328
+ detail:
329
+ "WasmEdge refused the invocation: reactor mode requires an entry " +
330
+ "function name. This is a PROBE defect, not an artifact defect — the " +
331
+ "lane must pass the artifact's reactor entry (see resolveReactorEntry).",
332
+ };
333
+ }
253
334
  // Linked, then the CLI could not find a start entry: a reactor artifact.
254
335
  // Only reachable after successful instantiation.
255
336
  if (/(wasm function not found|function not found|_start|_initialize)/i.test(text)) {
@@ -359,15 +440,11 @@ async function probeWithNativeWasmEdge(context, staged) {
359
440
  context.pin,
360
441
  `native binary ${detected.binary}`,
361
442
  );
362
- const outcome = await spawnCapture(
363
- detected.binary,
364
- ["--enable-threads", staged.basename],
365
- {
366
- cwd: staged.dir,
367
- env: { PATH: process.env.PATH ?? "" },
368
- timeoutMs: context.timeoutMs,
369
- },
370
- );
443
+ const outcome = await spawnCapture(detected.binary, wasmEdgeProbeArgs(staged), {
444
+ cwd: staged.dir,
445
+ env: { PATH: process.env.PATH ?? "" },
446
+ timeoutMs: context.timeoutMs,
447
+ });
371
448
  const normalized = normalizeWasmEdgeOutcome(outcome);
372
449
  return classifyWasmEdgeProbe({
373
450
  code: outcome.code,
@@ -391,7 +468,7 @@ async function probeWithDockerWasmEdge(context, staged) {
391
468
  "/parity",
392
469
  ];
393
470
  if (context.dockerPlatform) args.push("--platform", String(context.dockerPlatform));
394
- args.push(context.pin.dockerImage, "--enable-threads", staged.basename);
471
+ args.push(context.pin.dockerImage, ...wasmEdgeProbeArgs(staged));
395
472
  const outcome = await spawnCapture(context.dockerBinary ?? "docker", args, {
396
473
  cwd: staged.dir,
397
474
  env: process.env,
@@ -678,7 +755,49 @@ export function deriveContractVerdict({
678
755
  * under the bare WasmEdge CLI is NOT a divergence, and the report says so in
679
756
  * those words rather than pretending the lanes matched.
680
757
  */
758
+ /**
759
+ * The P1 cross-runtime divergences among the lanes that ACTUALLY RAN.
760
+ *
761
+ * Extracted and exported so a test can drive the real comparison instead of
762
+ * re-implementing it: a test that hand-rolls this loop passes whether or not
763
+ * the shipped one is correct, which is precisely how the defect below survived
764
+ * its own regression test.
765
+ *
766
+ * Pass the COMPARED lanes — the ones not `out-of-declared-scope`. This used to
767
+ * pivot on `lanes[0]`, which is the BROWSER lane, and for a WasmEdge-only
768
+ * artifact that entry is `out-of-declared-scope`, which agrees with everything.
769
+ * Pivoting on it silently stopped comparing wasmedge-native against
770
+ * wasmedge-docker: the gate reported such an artifact adequately gated on two
771
+ * lanes while comparing nothing at all, defeating the very rule
772
+ * `certified-on-a-single-lane` states. Scoping a lane out must remove that LANE
773
+ * from the comparison, never the comparison itself.
774
+ *
775
+ * @param {Array<{lane: string, contractVerdict: string, reason?: string|null}>} comparedLanes
776
+ * @returns {string[]} one message per divergence
777
+ */
778
+ export function laneDivergences(comparedLanes) {
779
+ const messages = [];
780
+ for (let index = 1; index < (comparedLanes?.length ?? 0); index += 1) {
781
+ const pivot = comparedLanes[0];
782
+ const other = comparedLanes[index];
783
+ if (lanesAgree(pivot.contractVerdict, other.contractVerdict)) continue;
784
+ messages.push(
785
+ `P1 cross-runtime divergence: ${pivot.lane}=${pivot.contractVerdict} vs ` +
786
+ `${other.lane}=${other.contractVerdict} (${other.reason ?? "-"})`,
787
+ );
788
+ }
789
+ return messages;
790
+ }
791
+
681
792
  export function lanesAgree(a, b) {
793
+ // A lane the artifact declared itself out of cannot disagree with anything:
794
+ // there is no behaviour to compare, only a scope statement.
795
+ if (
796
+ a === ContractVerdict.OutOfDeclaredScope ||
797
+ b === ContractVerdict.OutOfDeclaredScope
798
+ ) {
799
+ return true;
800
+ }
682
801
  const equivalence = (verdict) =>
683
802
  verdict === ContractVerdict.RunnerCannotSupplyCapability
684
803
  ? ContractVerdict.Satisfied
@@ -688,11 +807,52 @@ export function lanesAgree(a, b) {
688
807
 
689
808
  // --- Orchestrator ---------------------------------------------------------------
690
809
 
810
+ /**
811
+ * A REACTOR artifact has no `_start`; its initialisation entry is `_initialize`
812
+ * (clang `-mexec-model=reactor`). The WasmEdge CLI refuses to run one without
813
+ * being told which function to call — "A function name is required when reactor
814
+ * mode is enabled." on stderr, exit 1, and NO runtime diagnostic — which the
815
+ * probe classifier could only honestly report as `probe-failure`. The effect
816
+ * was that a CORRECTLY built library module (the shape the module contract
817
+ * mandates for the RF family) was reported as a P1 cross-runtime divergence
818
+ * while the browser lane passed: the gate failed the artifact for the gate's
819
+ * own inability to invoke it.
820
+ *
821
+ * Naming the entry is also STRICTLY STRONGER evidence than the old bare
822
+ * invocation: a clean exit 0 means the runtime linked the imports, instantiated
823
+ * the module, and RAN its initialiser — observed, not inferred from an error
824
+ * string.
825
+ */
826
+ export function resolveReactorEntry(loadableBytes) {
827
+ let exportNames;
828
+ try {
829
+ exportNames = readWasmExportNames(loadableBytes);
830
+ } catch {
831
+ return null;
832
+ }
833
+ if (exportNames.includes("_start")) return null;
834
+ return exportNames.includes("_initialize") ? "_initialize" : null;
835
+ }
836
+
691
837
  async function stageArtifact(artifact) {
692
838
  const dir = await mkdtemp(path.join(os.tmpdir(), `sdm-gate-${artifact.id}-`));
693
839
  const basename = "artifact.wasm";
694
840
  await writeFile(path.join(dir, basename), artifact.loadableBytes);
695
- return { dir, basename };
841
+ return {
842
+ dir,
843
+ basename,
844
+ reactorEntry: resolveReactorEntry(artifact.loadableBytes),
845
+ };
846
+ }
847
+
848
+ /**
849
+ * The invocation tail shared by both WasmEdge lanes, so the native and Docker
850
+ * lanes can never drift into probing the same artifact two different ways.
851
+ */
852
+ export function wasmEdgeProbeArgs(staged) {
853
+ return staged.reactorEntry
854
+ ? ["--enable-threads", "--reactor", staged.basename, staged.reactorEntry]
855
+ : ["--enable-threads", staged.basename];
696
856
  }
697
857
 
698
858
  /**
@@ -733,6 +893,10 @@ export async function runParityGate(options = {}) {
733
893
  // --- resolve + load artifacts
734
894
  const artifacts = [];
735
895
  const failures = [];
896
+ // A caller may state the lanes an artifact owes from OUTSIDE the manifest
897
+ // file (`--artifact-lanes`), which is the only way to bind a cross-repo
898
+ // injected artifact whose declaration this repo does not own.
899
+ const expectedLanesById = options.expectedLanesById ?? {};
736
900
  for (const spec of declared) {
737
901
  try {
738
902
  const rawBytes = new Uint8Array(await readFile(spec.artifactPath));
@@ -744,6 +908,11 @@ export async function runParityGate(options = {}) {
744
908
  artifactSha256: sha256Hex(rawBytes),
745
909
  moduleSha256: sha256Hex(loadableBytes),
746
910
  structural: classifyArtifactImports(loadableBytes, spec.surface),
911
+ declaredRuntimeTargets: declaredRuntimeTargets(loadableBytes),
912
+ declaredCapabilities: declaredCapabilities(loadableBytes),
913
+ ...(expectedLanesById[spec.id]
914
+ ? { expectedLanes: expectedLanesById[spec.id] }
915
+ : {}),
747
916
  });
748
917
  } catch (error) {
749
918
  if (spec.required) {
@@ -824,10 +993,25 @@ export async function runParityGate(options = {}) {
824
993
  // --- Tier A: real instantiation probes
825
994
  const laneProbes = new Map(); // lane -> Map(artifactId -> probe)
826
995
 
827
- if (activeLanes.includes("browser") && artifacts.length > 0) {
996
+ // SCOPE BEFORE PROBING. An artifact that declares it does not run on a lane
997
+ // has nothing to learn from being launched there; probing anyway costs a
998
+ // Chrome start or a WasmEdge spawn per artifact for evidence the verdict
999
+ // then discards, and leaves the (expected) trap in the log as noise the
1000
+ // report says was out of scope.
1001
+ const inScopeArtifacts = (lane) =>
1002
+ artifacts.filter((artifact) =>
1003
+ laneIsInDeclaredScope(
1004
+ lane,
1005
+ artifact.declaredRuntimeTargets,
1006
+ artifact.declaredCapabilities,
1007
+ ),
1008
+ );
1009
+
1010
+ const browserArtifacts = inScopeArtifacts("browser");
1011
+ if (activeLanes.includes("browser") && browserArtifacts.length > 0) {
828
1012
  const probes = new Map();
829
1013
  try {
830
- const results = await runBrowserProbeLane(context, artifacts);
1014
+ const results = await runBrowserProbeLane(context, browserArtifacts);
831
1015
  for (const result of results) {
832
1016
  probes.set(result.id, {
833
1017
  outcome: result.outcome,
@@ -837,7 +1021,9 @@ export async function runParityGate(options = {}) {
837
1021
  crossOriginIsolated: result.crossOriginIsolated === true,
838
1022
  });
839
1023
  }
840
- const missing = artifacts.filter((artifact) => !probes.has(artifact.id));
1024
+ const missing = browserArtifacts.filter(
1025
+ (artifact) => !probes.has(artifact.id),
1026
+ );
841
1027
  for (const artifact of missing) {
842
1028
  probes.set(artifact.id, {
843
1029
  outcome: "lane-unavailable",
@@ -846,7 +1032,7 @@ export async function runParityGate(options = {}) {
846
1032
  });
847
1033
  }
848
1034
  } catch (error) {
849
- for (const artifact of artifacts) {
1035
+ for (const artifact of browserArtifacts) {
850
1036
  probes.set(artifact.id, {
851
1037
  outcome: "lane-unavailable",
852
1038
  detail: error?.message ?? String(error),
@@ -864,7 +1050,7 @@ export async function runParityGate(options = {}) {
864
1050
 
865
1051
  for (const lane of wasmedgeLanes) {
866
1052
  const probes = new Map();
867
- for (const artifact of artifacts) {
1053
+ for (const artifact of inScopeArtifacts(lane)) {
868
1054
  const staged = await stageArtifact(artifact);
869
1055
  try {
870
1056
  const probe =
@@ -889,26 +1075,105 @@ export async function runParityGate(options = {}) {
889
1075
  const artifactReports = [];
890
1076
  for (const artifact of artifacts) {
891
1077
  const lanes = [];
892
- for (const lane of laneProbes.keys()) {
893
- const probe = laneProbes.get(lane).get(artifact.id) ?? {
1078
+ // Iterate the ACTIVE lanes, not the probed ones: a lane with no in-scope
1079
+ // artifact is never launched, and its scoping still has to appear in the
1080
+ // report. A skipped lane is evidence; a missing row is silence.
1081
+ for (const lane of activeLanes) {
1082
+ const probe = laneProbes.get(lane)?.get(artifact.id) ?? {
894
1083
  outcome: "lane-unavailable",
895
1084
  detail: "no probe recorded",
896
1085
  missingImport: null,
897
1086
  };
898
- const derived = deriveContractVerdict({
899
- laneSuppliesCapabilities: lane === "browser",
900
- probe,
901
- structural: artifact.structural,
902
- });
1087
+ const inScope = laneIsInDeclaredScope(
1088
+ lane,
1089
+ artifact.declaredRuntimeTargets,
1090
+ artifact.declaredCapabilities,
1091
+ );
1092
+ const derived = inScope
1093
+ ? deriveContractVerdict({
1094
+ laneSuppliesCapabilities: lane === "browser",
1095
+ probe,
1096
+ structural: artifact.structural,
1097
+ })
1098
+ : {
1099
+ verdict: ContractVerdict.OutOfDeclaredScope,
1100
+ reason:
1101
+ `the artifact declares runtimeTargets ` +
1102
+ `[${artifact.declaredRuntimeTargets.join(", ")}], which does not include ` +
1103
+ `"${LANE_RUNTIME_TARGET[lane]}" — this lane is out of its declared scope, ` +
1104
+ "so its refusal here is the contract working, not a divergence",
1105
+ };
903
1106
  lanes.push({
904
1107
  lane,
905
- evidence: LANE_EVIDENCE[lane] ?? "unlabelled lane",
906
- outcome: probe.outcome,
907
- missingImport: probe.missingImport ?? null,
908
- detail: probe.detail ?? null,
1108
+ evidence: inScope
1109
+ ? (LANE_EVIDENCE[lane] ?? "unlabelled lane")
1110
+ : "artifact's own embedded runtimeTargets declaration",
1111
+ outcome: inScope ? probe.outcome : "out-of-declared-scope",
1112
+ missingImport: inScope ? (probe.missingImport ?? null) : null,
1113
+ detail: inScope ? (probe.detail ?? null) : derived.reason,
909
1114
  contractVerdict: derived.verdict,
910
1115
  reason: derived.reason,
911
- crossOriginIsolated: probe.crossOriginIsolated,
1116
+ crossOriginIsolated: inScope ? (probe.crossOriginIsolated ?? null) : null,
1117
+ declaredRuntimeTargets: artifact.declaredRuntimeTargets,
1118
+ });
1119
+ }
1120
+
1121
+ // THE ARTIFACT DOES NOT PICK ITS EXAMINERS.
1122
+ //
1123
+ // Scoping a lane out is a legitimate statement about where an artifact
1124
+ // runs; it is not a way to be certified without being run. An artifact in
1125
+ // the gate's own certified set that scopes ITSELF out of every lane — or
1126
+ // out of all but one, leaving nothing to compare — has not been gated at
1127
+ // all, and `lanesAgree` returning true pairwise must never stand in for
1128
+ // "two lanes were compared". One manifest string would otherwise disarm
1129
+ // the negative control.
1130
+ const comparedLanes = lanes.filter(
1131
+ (entry) => entry.contractVerdict !== ContractVerdict.OutOfDeclaredScope,
1132
+ );
1133
+ const comparedLaneNames = comparedLanes.map((entry) => entry.lane);
1134
+ if (comparedLanes.length === 0) {
1135
+ failures.push({
1136
+ artifact: artifact.id,
1137
+ kind: "artifact-out-of-every-lane",
1138
+ message:
1139
+ `declares runtimeTargets [${artifact.declaredRuntimeTargets.join(", ")}], ` +
1140
+ `which admits none of the active lanes [${activeLanes.join(", ")}] — ` +
1141
+ "it cannot be certified by a gate that never ran it",
1142
+ });
1143
+ } else if (activeLanes.length > 1 && comparedLanes.length < 2) {
1144
+ // NO ARTIFACT IS CERTIFIED ON THE EVIDENCE OF A SINGLE LANE. The rule is
1145
+ // about evidence, not about the artifact's shape: one lane is one
1146
+ // execution, and one execution is not parity. A WasmEdge-only artifact
1147
+ // still clears this — native host vs pinned container at one pin is a
1148
+ // real comparison, and those pins bump together by law. A browser-only
1149
+ // artifact does not, until the second browser lane (sequential vs
1150
+ // worker-pool, owed by browser-worker-topology) exists to compare it to.
1151
+ failures.push({
1152
+ artifact: artifact.id,
1153
+ kind: "certified-on-a-single-lane",
1154
+ message:
1155
+ `only lane "${comparedLanes[0].lane}" of [${activeLanes.join(", ")}] compared it ` +
1156
+ `(it declares runtimeTargets [${artifact.declaredRuntimeTargets.join(", ")}]) — ` +
1157
+ "one execution is not parity, so no artifact is certified on the evidence of a " +
1158
+ "single lane",
1159
+ });
1160
+ }
1161
+ // The manifest's claim outranks the artifact's declaration: an artifact
1162
+ // may not shrink the set of lanes it is examined on by editing its own
1163
+ // `$PLG`.
1164
+ const owed = (artifact.expectedLanes ?? []).filter((lane) =>
1165
+ activeLanes.includes(lane),
1166
+ );
1167
+ const unmet = owed.filter((lane) => !comparedLaneNames.includes(lane));
1168
+ if (unmet.length > 0) {
1169
+ failures.push({
1170
+ artifact: artifact.id,
1171
+ kind: "expected-lane-not-compared",
1172
+ message:
1173
+ `the gate manifest requires lanes [${owed.join(", ")}] for this artifact, but ` +
1174
+ `[${unmet.join(", ")}] produced no comparison — its own declaration ` +
1175
+ `[${artifact.declaredRuntimeTargets.join(", ")}] cannot narrow what the gate ` +
1176
+ "claims to certify",
912
1177
  });
913
1178
  }
914
1179
 
@@ -937,14 +1202,8 @@ export async function runParityGate(options = {}) {
937
1202
  }
938
1203
  }
939
1204
 
940
- for (let index = 1; index < lanes.length; index += 1) {
941
- if (!lanesAgree(lanes[0].contractVerdict, lanes[index].contractVerdict)) {
942
- failures.push({
943
- artifact: artifact.id,
944
- kind: "lane-divergence",
945
- message: `P1 cross-runtime divergence: ${lanes[0].lane}=${lanes[0].contractVerdict} vs ${lanes[index].lane}=${lanes[index].contractVerdict} (${lanes[index].reason ?? "-"})`,
946
- });
947
- }
1205
+ for (const message of laneDivergences(comparedLanes)) {
1206
+ failures.push({ artifact: artifact.id, kind: "lane-divergence", message });
948
1207
  }
949
1208
 
950
1209
  artifactReports.push({
@@ -955,6 +1214,7 @@ export async function runParityGate(options = {}) {
955
1214
  artifactSha256: artifact.artifactSha256,
956
1215
  moduleSha256: artifact.moduleSha256,
957
1216
  byteLength: artifact.loadableBytes.length,
1217
+ declaredRuntimeTargets: artifact.declaredRuntimeTargets,
958
1218
  structural: {
959
1219
  verdict: artifact.structural.verdict,
960
1220
  summary: describeClassification(artifact.structural),
@@ -972,7 +1232,50 @@ export async function runParityGate(options = {}) {
972
1232
  const behavioral = [];
973
1233
  for (const artifact of artifacts) {
974
1234
  if (!artifact.fixture) continue;
975
- const harnessLanes = ["browser", ...(wasmedgeLanes.includes("wasmedge-native") ? ["wasmedge"] : []), "docker-wasmedge"];
1235
+ // Behavioral lanes are scoped by the artifact's OWN declaration too: a
1236
+ // WasmEdge-only artifact has no browser behaviour to compare, and running
1237
+ // it there would score the harness's (correct) refusal as a divergence.
1238
+ // The scoping is RECORDED — a skipped lane is evidence, never silence.
1239
+ const harnessLanes = [
1240
+ "browser",
1241
+ ...(wasmedgeLanes.includes("wasmedge-native") ? ["wasmedge"] : []),
1242
+ "docker-wasmedge",
1243
+ ].filter((laneName) =>
1244
+ laneIsInDeclaredScope(
1245
+ laneName === "wasmedge"
1246
+ ? "wasmedge-native"
1247
+ : laneName === "docker-wasmedge"
1248
+ ? "wasmedge-docker"
1249
+ : laneName,
1250
+ artifact.declaredRuntimeTargets,
1251
+ artifact.declaredCapabilities,
1252
+ ),
1253
+ );
1254
+ const outOfScopeLanes = ["browser", "wasmedge", "docker-wasmedge"].filter(
1255
+ (laneName) => !harnessLanes.includes(laneName),
1256
+ );
1257
+ if (harnessLanes.length < 2) {
1258
+ // An artifact that declared a FIXTURE asked to be compared. If its own
1259
+ // declaration leaves nothing to compare it against, that is a defect in
1260
+ // the certified set, not a free pass.
1261
+ const message =
1262
+ `behavioral parity needs two comparable lanes; this artifact declares ` +
1263
+ `runtimeTargets [${artifact.declaredRuntimeTargets.join(", ")}] and only ` +
1264
+ `${harnessLanes.length} lane(s) remain in scope`;
1265
+ behavioral.push({
1266
+ artifact: artifact.id,
1267
+ ok: false,
1268
+ outOfDeclaredScope: outOfScopeLanes,
1269
+ declaredRuntimeTargets: artifact.declaredRuntimeTargets,
1270
+ note: message,
1271
+ });
1272
+ failures.push({
1273
+ artifact: artifact.id,
1274
+ kind: "behavioral-not-comparable",
1275
+ message,
1276
+ });
1277
+ continue;
1278
+ }
976
1279
  try {
977
1280
  const report = await runParityHarness({
978
1281
  wasmPath: artifact.artifactPath,
@@ -984,7 +1287,17 @@ export async function runParityGate(options = {}) {
984
1287
  timeoutMs: context.timeoutMs,
985
1288
  log,
986
1289
  });
987
- behavioral.push({ artifact: artifact.id, ok: report.ok, report });
1290
+ behavioral.push({
1291
+ artifact: artifact.id,
1292
+ ok: report.ok,
1293
+ report,
1294
+ ...(outOfScopeLanes.length > 0
1295
+ ? {
1296
+ outOfDeclaredScope: outOfScopeLanes,
1297
+ declaredRuntimeTargets: artifact.declaredRuntimeTargets,
1298
+ }
1299
+ : {}),
1300
+ });
988
1301
  if (!report.ok) {
989
1302
  for (const failure of report.failures) {
990
1303
  failures.push({