space-data-module-sdk 0.8.11 → 0.8.13

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.
Files changed (40) hide show
  1. package/README.md +92 -0
  2. package/bin/space-data-module.js +85 -1
  3. package/docs/module-publication-standard.md +7 -3
  4. package/docs/propagator-abi.md +477 -0
  5. package/include/orbpro/orbpro_propagator_abi.h +312 -0
  6. package/package.json +7 -1
  7. package/schemas/PluginManifest.fbs +46 -1
  8. package/schemas/orbpro/Propagator.fbs +161 -3
  9. package/src/browser.js +11 -0
  10. package/src/bundle/index.js +1 -0
  11. package/src/bundle/sigdomain.js +22 -0
  12. package/src/capabilities.js +91 -0
  13. package/src/compliance/index.js +8 -0
  14. package/src/compliance/pluginCompliance.js +76 -32
  15. package/src/flow/flowCompiler.js +231 -3
  16. package/src/flow/flowRuntimeHost.js +26 -0
  17. package/src/flow/isomorphicFlowHost.js +8 -0
  18. package/src/generated/orbpro/manifest/plugin-family.d.ts +9 -1
  19. package/src/generated/orbpro/manifest/plugin-family.js +8 -0
  20. package/src/generated/orbpro/manifest/plugin-family.ts +8 -0
  21. package/src/generated/orbpro/propagator-abi.js +118 -0
  22. package/src/generated/orbpro/propagator-abi.ts +199 -0
  23. package/src/host/browserModuleHarness.js +26 -0
  24. package/src/host/isomorphicLoader.js +57 -11
  25. package/src/host/runtimeTargetGate.js +256 -0
  26. package/src/host/workerModuleHarness.js +7 -0
  27. package/src/index.d.ts +47 -0
  28. package/src/index.js +11 -0
  29. package/src/manifest/normalize.js +113 -4
  30. package/src/scaffold/copyTemplate.js +71 -0
  31. package/src/scaffold/index.js +150 -0
  32. package/src/scaffold/tokens.js +90 -0
  33. package/src/testing/parityBrowserRunner.js +11 -0
  34. package/src/testing/parityGate.js +287 -27
  35. package/templates/propagator-module/README.md +99 -0
  36. package/templates/propagator-module/build.js +103 -0
  37. package/templates/propagator-module/package.json +19 -0
  38. package/templates/propagator-module/plugin-manifest.json +66 -0
  39. package/templates/propagator-module/src/__MODULE_NAME_SNAKE__.cpp +450 -0
  40. package/templates/propagator-module/tests/module.build.test.mjs +103 -0
@@ -58,6 +58,8 @@ 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,
@@ -110,8 +112,56 @@ export const ContractVerdict = Object.freeze({
110
112
  ShimGap: "shim-gap",
111
113
  /** The lane could not run at all. */
112
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",
113
125
  });
114
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",
132
+ });
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
+
115
165
  // --- Manifest ------------------------------------------------------------------
116
166
 
117
167
  /**
@@ -181,6 +231,16 @@ export async function loadGateManifest(manifestPath = DEFAULT_GATE_MANIFEST) {
181
231
  ? path.resolve(path.dirname(resolved), String(spec.fixture))
182
232
  : null,
183
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
+ ),
184
244
  note: spec.note ? String(spec.note) : null,
185
245
  negativeControl: spec.negativeControl === true,
186
246
  }),
@@ -205,6 +265,11 @@ export function makeExternalArtifact(spec) {
205
265
  profile: String(spec.profile ?? "library"),
206
266
  fixture: spec.fixture ? path.resolve(String(spec.fixture)) : null,
207
267
  required: spec.required !== false,
268
+ expectedLanes: Object.freeze(
269
+ Array.isArray(spec.expectedLanes)
270
+ ? spec.expectedLanes.map((lane) => String(lane))
271
+ : [],
272
+ ),
208
273
  note: spec.note ? String(spec.note) : "injected by caller (external artifact)",
209
274
  negativeControl: spec.negativeControl === true,
210
275
  });
@@ -690,7 +755,49 @@ export function deriveContractVerdict({
690
755
  * under the bare WasmEdge CLI is NOT a divergence, and the report says so in
691
756
  * those words rather than pretending the lanes matched.
692
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
+
693
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
+ }
694
801
  const equivalence = (verdict) =>
695
802
  verdict === ContractVerdict.RunnerCannotSupplyCapability
696
803
  ? ContractVerdict.Satisfied
@@ -786,6 +893,10 @@ export async function runParityGate(options = {}) {
786
893
  // --- resolve + load artifacts
787
894
  const artifacts = [];
788
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 ?? {};
789
900
  for (const spec of declared) {
790
901
  try {
791
902
  const rawBytes = new Uint8Array(await readFile(spec.artifactPath));
@@ -797,6 +908,11 @@ export async function runParityGate(options = {}) {
797
908
  artifactSha256: sha256Hex(rawBytes),
798
909
  moduleSha256: sha256Hex(loadableBytes),
799
910
  structural: classifyArtifactImports(loadableBytes, spec.surface),
911
+ declaredRuntimeTargets: declaredRuntimeTargets(loadableBytes),
912
+ declaredCapabilities: declaredCapabilities(loadableBytes),
913
+ ...(expectedLanesById[spec.id]
914
+ ? { expectedLanes: expectedLanesById[spec.id] }
915
+ : {}),
800
916
  });
801
917
  } catch (error) {
802
918
  if (spec.required) {
@@ -877,10 +993,25 @@ export async function runParityGate(options = {}) {
877
993
  // --- Tier A: real instantiation probes
878
994
  const laneProbes = new Map(); // lane -> Map(artifactId -> probe)
879
995
 
880
- 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) {
881
1012
  const probes = new Map();
882
1013
  try {
883
- const results = await runBrowserProbeLane(context, artifacts);
1014
+ const results = await runBrowserProbeLane(context, browserArtifacts);
884
1015
  for (const result of results) {
885
1016
  probes.set(result.id, {
886
1017
  outcome: result.outcome,
@@ -890,7 +1021,9 @@ export async function runParityGate(options = {}) {
890
1021
  crossOriginIsolated: result.crossOriginIsolated === true,
891
1022
  });
892
1023
  }
893
- const missing = artifacts.filter((artifact) => !probes.has(artifact.id));
1024
+ const missing = browserArtifacts.filter(
1025
+ (artifact) => !probes.has(artifact.id),
1026
+ );
894
1027
  for (const artifact of missing) {
895
1028
  probes.set(artifact.id, {
896
1029
  outcome: "lane-unavailable",
@@ -899,7 +1032,7 @@ export async function runParityGate(options = {}) {
899
1032
  });
900
1033
  }
901
1034
  } catch (error) {
902
- for (const artifact of artifacts) {
1035
+ for (const artifact of browserArtifacts) {
903
1036
  probes.set(artifact.id, {
904
1037
  outcome: "lane-unavailable",
905
1038
  detail: error?.message ?? String(error),
@@ -917,7 +1050,7 @@ export async function runParityGate(options = {}) {
917
1050
 
918
1051
  for (const lane of wasmedgeLanes) {
919
1052
  const probes = new Map();
920
- for (const artifact of artifacts) {
1053
+ for (const artifact of inScopeArtifacts(lane)) {
921
1054
  const staged = await stageArtifact(artifact);
922
1055
  try {
923
1056
  const probe =
@@ -942,26 +1075,105 @@ export async function runParityGate(options = {}) {
942
1075
  const artifactReports = [];
943
1076
  for (const artifact of artifacts) {
944
1077
  const lanes = [];
945
- for (const lane of laneProbes.keys()) {
946
- 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) ?? {
947
1083
  outcome: "lane-unavailable",
948
1084
  detail: "no probe recorded",
949
1085
  missingImport: null,
950
1086
  };
951
- const derived = deriveContractVerdict({
952
- laneSuppliesCapabilities: lane === "browser",
953
- probe,
954
- structural: artifact.structural,
955
- });
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
+ };
956
1106
  lanes.push({
957
1107
  lane,
958
- evidence: LANE_EVIDENCE[lane] ?? "unlabelled lane",
959
- outcome: probe.outcome,
960
- missingImport: probe.missingImport ?? null,
961
- 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,
962
1114
  contractVerdict: derived.verdict,
963
1115
  reason: derived.reason,
964
- 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",
965
1177
  });
966
1178
  }
967
1179
 
@@ -990,14 +1202,8 @@ export async function runParityGate(options = {}) {
990
1202
  }
991
1203
  }
992
1204
 
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
- }
1205
+ for (const message of laneDivergences(comparedLanes)) {
1206
+ failures.push({ artifact: artifact.id, kind: "lane-divergence", message });
1001
1207
  }
1002
1208
 
1003
1209
  artifactReports.push({
@@ -1008,6 +1214,7 @@ export async function runParityGate(options = {}) {
1008
1214
  artifactSha256: artifact.artifactSha256,
1009
1215
  moduleSha256: artifact.moduleSha256,
1010
1216
  byteLength: artifact.loadableBytes.length,
1217
+ declaredRuntimeTargets: artifact.declaredRuntimeTargets,
1011
1218
  structural: {
1012
1219
  verdict: artifact.structural.verdict,
1013
1220
  summary: describeClassification(artifact.structural),
@@ -1025,7 +1232,50 @@ export async function runParityGate(options = {}) {
1025
1232
  const behavioral = [];
1026
1233
  for (const artifact of artifacts) {
1027
1234
  if (!artifact.fixture) continue;
1028
- 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
+ }
1029
1279
  try {
1030
1280
  const report = await runParityHarness({
1031
1281
  wasmPath: artifact.artifactPath,
@@ -1037,7 +1287,17 @@ export async function runParityGate(options = {}) {
1037
1287
  timeoutMs: context.timeoutMs,
1038
1288
  log,
1039
1289
  });
1040
- 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
+ });
1041
1301
  if (!report.ok) {
1042
1302
  for (const failure of report.failures) {
1043
1303
  failures.push({
@@ -0,0 +1,99 @@
1
+ # __MODULE_NAME__
2
+
3
+ Scaffolded by `space-data-module init --family propagator --name __MODULE_NAME__`
4
+ from space-data-module-sdk's `templates/propagator-module/` template.
5
+
6
+ This is a **minimal but building** skeleton of an SDN propagator module: every
7
+ ABI obligation is already implemented — exports, wire layout, units, frames,
8
+ identity, threading discipline, error codes, lifetime — and the orbital
9
+ mechanics are a placeholder. It compiles and passes the SDK's own compliance
10
+ checks as-is; it just doesn't propagate anything real yet.
11
+
12
+ ## Files
13
+
14
+ - `plugin-manifest.json` — the module manifest. `pluginId` defaults to
15
+ `com.orbpro.<name-with-dots>`; `pluginFamily` is `propagator`; declares one
16
+ invoke method (`ingest_omm`) plus the propagator ABI exports below.
17
+ - `src/__MODULE_NAME_SNAKE__.cpp` — the module source. Search for
18
+ `TODO: your propagation goes here` — there are two spots (element adoption
19
+ in `adopt_omm()`, and the actual propagation in `propagate_entity()`).
20
+ Everything else in the file is ABI plumbing; you should not need to touch
21
+ export names, signatures, error codes, or the state-vector write pattern.
22
+ - `build.js` — compiles through `compileModuleFromSource` (the SDK compiler
23
+ lane). Inlines the generated `orbpro_propagator_abi.h` from your pinned
24
+ `space-data-module-sdk` dependency — never hand-copy that header.
25
+ - `tests/module.build.test.mjs` — manifest shape check (always runs) plus
26
+ compliance/export checks that skip until you've built the module.
27
+ - `package.json` — `"sdn-module"` points at the canonical isomorphic
28
+ artifact; `space-data-module-sdk` is a normal npm dependency.
29
+
30
+ ## Naming
31
+
32
+ This module was scaffolded with `--name __MODULE_NAME__`. `space-data-module
33
+ init` substituted four spellings of that name into this tree; if you need to
34
+ introduce your own file or identifier later, reuse the same shapes rather
35
+ than inventing a fifth:
36
+
37
+ | Spelling | This module's value | Used for |
38
+ | --------------------- | -------------------- | ------------------------------------------ |
39
+ | kebab-case | `__MODULE_NAME__` | display text, kebab-case filenames |
40
+ | reverse-DNS plugin id | `__PLUGIN_ID__` | `plugin-manifest.json`'s `pluginId` |
41
+ | snake_case | `__MODULE_NAME_SNAKE__` | C/C++ file and symbol names |
42
+ | camelCase | `__MODULE_NAME_CAMEL__` | a JS-safe identifier (e.g. a bindings key) |
43
+
44
+ ## Next steps
45
+
46
+ 1. `npm install` (pulls `space-data-module-sdk` and its `spacedatastandards.org`
47
+ dependency).
48
+ 2. Fill in the physics: replace the two `TODO: your propagation goes here`
49
+ blocks in `src/__MODULE_NAME_SNAKE__.cpp`. Keep every export, error code,
50
+ and the "zero the struct, set frame explicitly, set VALID last" write
51
+ pattern — those are the ABI contract, not style.
52
+ 3. `npm run build` — writes `dist/isomorphic/module.wasm` +
53
+ `dist/plugin-manifest.json`. The build fails loudly if the compiled
54
+ artifact does not pass the SDK's own manifest/artifact validation.
55
+ 4. `npm test` — the manifest-shape test always runs; the compliance and
56
+ export-surface tests turn on once step 3 has produced a wasm artifact.
57
+ 5. Replace the TODO test at the bottom of `tests/module.build.test.mjs` with
58
+ a real assertion once you have physics to check (ingest a known OMM,
59
+ propagate to a known epoch, compare against an independent reference —
60
+ e.g. another propagator or published ephemeris).
61
+ 6. Update `description` in `plugin-manifest.json` and `package.json` — both
62
+ still say "TODO" / a generic scaffold description.
63
+
64
+ ## The ABI, in one paragraph
65
+
66
+ A propagator module exports `plugin_init`, `plugin_init_omm`,
67
+ `plugin_ingest_omm_one`, `plugin_propagate`, `plugin_propagate_batch`,
68
+ `plugin_entity_count`, and `plugin_destroy` against the generated
69
+ `OrbProStateVector` / `OrbProOMMRecord` / `OrbProOrbitalElements` structs
70
+ (`orbpro/orbpro_propagator_abi.h` in your pinned `space-data-module-sdk`,
71
+ generated from `schemas/orbpro/Propagator.fbs` — never hand-retype these
72
+ structs, that is the exact drift this generated header exists to end).
73
+ Position/velocity output is always METERS / METERS-PER-SECOND with an
74
+ explicit `reference_frame`; identity is carried by `NORAD_CAT_ID`, never
75
+ derived from array position; every failure returns a named negative code;
76
+ `plugin_destroy` must actually free, not no-op.
77
+
78
+ **Threading.** This module declares `threadModel: "wasi-sequential"` — it
79
+ never spawns a thread of its own, which is the *strong default* for a
80
+ propagator: propagation is embarrassingly parallel across entities but
81
+ sequential within one, and the ABI puts the sharding decision on the HOST
82
+ (e.g. a frame-worker pool), not the module. `build.js` passes
83
+ `threadModel: manifest.threadModel` to the compiler EXPLICITLY — do not
84
+ remove that. `resolveThreadModel` reads the compile option, not
85
+ `manifest.threadModel`, and otherwise infers the model from
86
+ `runtimeTargets`, where `"wasmedge"` infers the OTHER model
87
+ (`emscripten-pthreads`, which in this SDK means the clang
88
+ `wasm32-wasip1-threads` / wasi-threads contract — never `emcc -pthread`,
89
+ which cannot thread under WasmEdge at all). Passing `threadModel` in
90
+ `build.js` sidesteps that inference and is what keeps this manifest's
91
+ declared model and the compiled artifact in agreement — `build.js` also
92
+ asserts they agree after compiling. See `docs/propagator-abi.md`
93
+ "Threading" if you ever need the other model.
94
+
95
+ The one SDN invoke method, `ingest_omm`, is separate from the propagator ABI
96
+ exports above: it is how a flow graph feeds this module SDS `$OMM` records
97
+ over the generic invoke surface, while `plugin_propagate` /
98
+ `plugin_propagate_batch` are called directly by a host that has already
99
+ linked this module as a propagator.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Build __MODULE_NAME__ through the SDK compiler lane.
3
+ *
4
+ * `compileModuleFromSource` takes ONE translation unit, so the generated
5
+ * OrbPro propagator ABI header is INLINED into the source before compiling.
6
+ * That inlining is mechanical and one-directional: the header is read from
7
+ * the pinned `space-data-module-sdk` package, never copied into this repo.
8
+ * If the SDK's ABI changes, this build picks it up on the next `npm run
9
+ * build` — do not hand-vendor a copy of the header beside this file.
10
+ *
11
+ * Thread model: the manifest declares `threadModel: "wasi-sequential"` (see
12
+ * plugin-manifest.json's `sequentialJustification`) — this module never
13
+ * spawns a thread, which is the strong default for a propagator (sharding a
14
+ * batch belongs to the HOST, not the module; see docs/propagator-abi.md
15
+ * "Threading"). Both `wasi-sequential` and the threaded `emscripten-pthreads`
16
+ * model compile through the SAME clang `wasm32-wasip1-threads` toolchain —
17
+ * never `emcc -pthread`, which is browser-only and cannot thread under
18
+ * WasmEdge — they differ only in which link-time contract the SDK's
19
+ * post-link artifact guard then validates against the emitted wasm.
20
+ */
21
+
22
+ import fs from "node:fs/promises";
23
+ import { createRequire } from "node:module";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ import { compileModuleFromSource } from "space-data-module-sdk/compiler";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const packageRoot = fileURLToPath(new URL(".", import.meta.url));
31
+ const manifestPath = path.join(packageRoot, "plugin-manifest.json");
32
+ const sourcePath = path.join(packageRoot, "src", "__MODULE_NAME_SNAKE__.cpp");
33
+ const distRoot = path.join(packageRoot, "dist");
34
+ const outputPath = path.join(distRoot, "isomorphic", "module.wasm");
35
+
36
+ const standardsRoot = path.dirname(
37
+ require.resolve("spacedatastandards.org/package.json"),
38
+ );
39
+ process.env.SPACE_DATA_STANDARDS_ROOT ??= `${standardsRoot}${path.sep}`;
40
+
41
+ /** The ONE source of the ABI, resolved from the pinned SDK package. */
42
+ const abiHeaderPath = require.resolve(
43
+ "space-data-module-sdk/include/orbpro/orbpro_propagator_abi.h",
44
+ );
45
+
46
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
47
+ const abiHeader = await fs.readFile(abiHeaderPath, "utf8");
48
+ const rawSource = await fs.readFile(sourcePath, "utf8");
49
+
50
+ const INCLUDE_LINE = '#include "orbpro/orbpro_propagator_abi.h"';
51
+ if (!rawSource.includes(INCLUDE_LINE)) {
52
+ throw new Error(
53
+ `${path.relative(packageRoot, sourcePath)} no longer includes the generated ABI header. ` +
54
+ `A propagator module must build against the ONE generated ABI, not a local copy.`,
55
+ );
56
+ }
57
+
58
+ const sourceCode = rawSource.replace(
59
+ INCLUDE_LINE,
60
+ [
61
+ `// --- BEGIN INLINED ${path.basename(abiHeaderPath)} (from ${manifest.pluginId}'s pinned SDK) ---`,
62
+ abiHeader,
63
+ `// --- END INLINED ${path.basename(abiHeaderPath)} ---`,
64
+ ].join("\n"),
65
+ );
66
+
67
+ await fs.rm(distRoot, { recursive: true, force: true });
68
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
69
+
70
+ const compilation = await compileModuleFromSource({
71
+ manifest,
72
+ sourceCode,
73
+ language: "c++",
74
+ outputPath,
75
+ // PASSED EXPLICITLY ON PURPOSE. `resolveThreadModel` reads the compile
76
+ // OPTION, not `manifest.threadModel`, and otherwise infers the model from
77
+ // `runtimeTargets` — where "wasmedge" infers pthreads. A manifest that
78
+ // declares `wasi-sequential` and does not pass it here would be silently
79
+ // compiled under the OTHER model and then rejected by the post-link
80
+ // artifact guard for not spawning a thread it never claimed to spawn.
81
+ // Filed as `sdk-manifest-threadmodel-silently-ignored`; keep this explicit
82
+ // until that lands.
83
+ threadModel: manifest.threadModel,
84
+ });
85
+
86
+ if (compilation.threadModel !== manifest.threadModel) {
87
+ throw new Error(
88
+ `threadModel drift: the manifest declares ${manifest.threadModel} but the ` +
89
+ `compiler resolved ${compilation.threadModel}.`,
90
+ );
91
+ }
92
+
93
+ await fs.copyFile(manifestPath, path.join(distRoot, "plugin-manifest.json"));
94
+
95
+ if (!compilation.report?.ok) {
96
+ const issues = JSON.stringify(compilation.report?.issues ?? [], null, 2);
97
+ throw new Error(`Compiled __MODULE_NAME__ artifact failed SDK validation:\n${issues}`);
98
+ }
99
+
100
+ console.log(
101
+ `Built ${path.relative(packageRoot, outputPath)} ` +
102
+ `(${compilation.compiler}, threadModel=${compilation.threadModel})`,
103
+ );
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "space-data-network-module-propagator-__MODULE_NAME__",
3
+ "version": "0.1.0",
4
+ "description": "__MODULE_NAME__ — an SDN propagator module scaffolded from the space-data-module-sdk propagator-module template.",
5
+ "type": "module",
6
+ "sdn-module": "./dist/isomorphic/module.wasm",
7
+ "exports": {
8
+ "./plugin-manifest.json": "./plugin-manifest.json",
9
+ "./dist/*": "./dist/*"
10
+ },
11
+ "scripts": {
12
+ "build": "node build.js",
13
+ "test": "node --test tests/*.test.mjs"
14
+ },
15
+ "dependencies": {
16
+ "space-data-module-sdk": "^0.8.12"
17
+ },
18
+ "private": true
19
+ }