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
@@ -0,0 +1,256 @@
1
+ /**
2
+ * THE RUNTIME-TARGET GATE — one assert, called by every loader that stands for
3
+ * a runtime leg.
4
+ *
5
+ * Composed flows derive their `runtimeTargets` from their parts (see
6
+ * `collectFlowRuntimeTargets` in `src/flow/flowCompiler.js`), so an artifact
7
+ * that legitimately runs on only ONE leg now exists. That artifact must never
8
+ * be loaded QUIETLY on a leg it does not declare: the failure would surface as
9
+ * a hostcall trap several frames deep in a capability that leg cannot serve,
10
+ * which is indistinguishable from the cross-runtime divergence the isomorphism
11
+ * invariant exists to catch. Refuse at the door, by name, quoting the
12
+ * declaration that made it ineligible.
13
+ *
14
+ * The gate lives here rather than inside one harness because there are two
15
+ * doors into a composed artifact — `createBrowserModuleHarness` (a single
16
+ * module) and `createFlowRuntimeHost` (the composed flow runtime) — and a gate
17
+ * on only one of them is not a gate.
18
+ *
19
+ * Browser-safe: no node builtins, no bundle/signing surface, and the embedded
20
+ * declaration is read with `WebAssembly.Module.customSections`, which needs no
21
+ * copy of the source bytes (they are scrubbed by the time this runs).
22
+ */
23
+
24
+ import { SDS_MANIFEST_SECTION_NAME } from "../bundle/constants.js";
25
+ import { decodePlgManifest, isPlgManifestBuffer } from "../manifest/plgCodec.js";
26
+ import { LegIncompatibleCapabilityIds } from "../capabilities.js";
27
+
28
+ /**
29
+ * `wasi` is the STRICT PORTABILITY BASELINE, not a fourth runtime: compliance
30
+ * confines a `wasi`-declaring artifact to the pure WASI capability subset and
31
+ * the `command` invoke surface. So a declaration of `wasi` admits either leg —
32
+ * WITH ONE EXCEPTION, which is why capabilities are part of this question.
33
+ * `pipe` is in both the standalone-WASI subset and the browser-incompatible
34
+ * set, so `runtimeTargets:["wasi"] + capabilities:["pipe"]` passes compliance
35
+ * (it names no browser target for the rule to fire on) and would otherwise be
36
+ * admitted to the browser leg against the SDK's own policy. The baseline
37
+ * admits a leg only when the artifact carries nothing that leg cannot serve.
38
+ *
39
+ * DO NOT "UNIFY" THIS WITH THE LITERAL PATH. A declaration that names the leg
40
+ * outright is NOT capability-checked here, and that asymmetry is deliberate:
41
+ *
42
+ * declaration -> trust the author; inference -> prove it.
43
+ *
44
+ * An explicit `browser` target is the author's statement and the embedder's
45
+ * business — five of the browser-incompatible capabilities (`network`, `ipfs`,
46
+ * `protocol_dial`, `protocol_handle`, `wallet_sign`) ARE served by
47
+ * `BrowserHost` when the embedder injects an adapter, so refusing them at load
48
+ * time would break legitimate embedders. Publish-time compliance already
49
+ * refuses the incoherent declaration. The `wasi` path, by contrast, is an
50
+ * inference the SDK makes on the author's behalf, and an inference must be
51
+ * conservative.
52
+ */
53
+ const LEG_SATISFYING_TARGETS = Object.freeze({
54
+ browser: Object.freeze(["browser", "wasi"]),
55
+ wasmedge: Object.freeze(["wasmedge", "wasi"]),
56
+ });
57
+
58
+ const WASI_PORTABILITY_TARGET = "wasi";
59
+
60
+ function legIncompatibleCapabilities(capabilities, leg) {
61
+ const incompatible = LegIncompatibleCapabilityIds[leg];
62
+ if (!incompatible || !Array.isArray(capabilities)) return [];
63
+ return capabilities
64
+ .map((capability) =>
65
+ typeof capability === "string"
66
+ ? capability
67
+ : (capability?.capabilityId ?? capability?.name ?? ""),
68
+ )
69
+ .filter((capability) => incompatible.includes(capability))
70
+ .sort();
71
+ }
72
+
73
+ /**
74
+ * Does a declared target set admit this leg?
75
+ *
76
+ * ONE RULE, used by the loaders AND by the flow compiler's derivation. Two
77
+ * rules for one field is how a manifest comes to mean different things to the
78
+ * compiler and to the door: the compiler stripped the browser leg from a
79
+ * `["wasi","wasmedge"]` part while the gate happily admitted the same
80
+ * declaration to the browser.
81
+ *
82
+ * An empty/absent declaration admits everything, matching compliance, which
83
+ * skips the runtime-target rule on an absent field.
84
+ *
85
+ * @param {string[]|undefined} targets declared runtimeTargets
86
+ * @param {string} leg the runtime target being asked about
87
+ * @param {Array<string|{capabilityId?: string, name?: string}>} [capabilities]
88
+ * the artifact's declared capabilities; consulted only to keep the `wasi`
89
+ * baseline from admitting a leg that cannot serve them
90
+ */
91
+ export function runtimeTargetSatisfies(targets, leg, capabilities) {
92
+ if (!Array.isArray(targets) || targets.length === 0) return true;
93
+ const accepted = LEG_SATISFYING_TARGETS[leg] ?? [leg];
94
+ if (targets.includes(leg)) return true;
95
+ if (!targets.some((target) => accepted.includes(target))) return false;
96
+ // Admitted only via the portability baseline — prove it, do not assume it.
97
+ return (
98
+ targets.includes(WASI_PORTABILITY_TARGET) &&
99
+ legIncompatibleCapabilities(capabilities, leg).length === 0
100
+ );
101
+ }
102
+
103
+ export class RuntimeTargetError extends Error {
104
+ constructor(message, { declaredTargets, source, leg }) {
105
+ super(message);
106
+ this.name = "RuntimeTargetError";
107
+ this.code = "runtime-target-out-of-scope";
108
+ this.declaredTargets = declaredTargets;
109
+ this.leg = leg;
110
+ // "embedded" (the artifact's own signed record) or "caller" (the manifest
111
+ // the loader was handed). Which one refused is evidence, not trivia.
112
+ this.declarationSource = source;
113
+ }
114
+ }
115
+
116
+ export function normalizedRuntimeTargets(manifest) {
117
+ return Array.isArray(manifest?.runtimeTargets)
118
+ ? manifest.runtimeTargets
119
+ .map((target) => String(target ?? "").trim().toLowerCase())
120
+ .filter(Boolean)
121
+ : [];
122
+ }
123
+
124
+ /**
125
+ * The artifact's OWN declaration, read straight out of the embedded `$PLG`
126
+ * custom section, so a caller that hands a loader nothing but bytes still gets
127
+ * the refusal.
128
+ *
129
+ * @param {WebAssembly.Module} wasmModule
130
+ * @returns {string[]} declared targets, or [] when the artifact declares none
131
+ */
132
+ export function embeddedPlgManifest(wasmModule) {
133
+ let sections;
134
+ try {
135
+ sections = WebAssembly.Module.customSections(
136
+ wasmModule,
137
+ SDS_MANIFEST_SECTION_NAME,
138
+ );
139
+ } catch {
140
+ return null;
141
+ }
142
+ for (const section of sections ?? []) {
143
+ try {
144
+ const bytes = new Uint8Array(section);
145
+ if (!isPlgManifestBuffer(bytes)) continue;
146
+ // FIRST DECODABLE MANIFEST WINS — the same rule the node-side locator
147
+ // (`locateEmbeddedPlgManifest`) applies. Skipping a manifest merely
148
+ // because its runtimeTargets are empty made this reader disagree with
149
+ // that one on the very same bytes, so an artifact could be scoped by one
150
+ // declaration and enforced against another.
151
+ return decodePlgManifest(bytes);
152
+ } catch {
153
+ // An UNDECODABLE section is not a declaration. Keep looking.
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export function embeddedRuntimeTargets(wasmModule) {
160
+ return normalizedRuntimeTargets(embeddedPlgManifest(wasmModule));
161
+ }
162
+
163
+ /**
164
+ * Resolve which declaration refuses this leg, if either does.
165
+ *
166
+ * EITHER source can refuse, and the EMBEDDED one wins on conflict. The
167
+ * embedded `$PLG` is what the artifact's signature covers; `manifest` is
168
+ * caller input. Trusting the caller first would make
169
+ * `{manifest: {runtimeTargets: ["browser"]}}` a bypass for a signed
170
+ * WasmEdge-only artifact — and the isomorphic flow host passes exactly such a
171
+ * caller-side manifest for every child it mounts.
172
+ *
173
+ * An artifact that declares NOTHING is unconstrained, matching compliance,
174
+ * which skips the runtime-target rule on an absent field.
175
+ *
176
+ * @returns {{targets: string[], source: "embedded"|"caller"}|null}
177
+ */
178
+ export function resolveRuntimeTargetRefusal({
179
+ wasmModule,
180
+ manifest,
181
+ leg,
182
+ embeddedManifest: providedEmbeddedManifest,
183
+ }) {
184
+ // A caller that already located the artifact's own manifest from BYTES (the
185
+ // node leg does, via `locateEmbeddedPlgManifest`) passes it here rather than
186
+ // making this gate compile the module just to read a custom section. The two
187
+ // locators apply the same first-decodable-wins rule; the byte-side one can
188
+ // additionally reach a manifest carried in a bundle entry or a data segment,
189
+ // which only ever makes that leg MORE likely to refuse — the safe direction.
190
+ const embeddedManifest =
191
+ providedEmbeddedManifest ??
192
+ (wasmModule ? embeddedPlgManifest(wasmModule) : null);
193
+ const embedded = normalizedRuntimeTargets(embeddedManifest);
194
+ if (!runtimeTargetSatisfies(embedded, leg, embeddedManifest?.capabilities)) {
195
+ return { targets: embedded, source: "embedded" };
196
+ }
197
+ const caller = normalizedRuntimeTargets(manifest);
198
+ if (!runtimeTargetSatisfies(caller, leg, manifest?.capabilities)) {
199
+ return { targets: caller, source: "caller" };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * @param {object} options
206
+ * @param {WebAssembly.Module} [options.wasmModule] compiled artifact
207
+ * @param {object} [options.manifest] caller-supplied manifest
208
+ * @param {string} options.leg the runtime target this loader stands for
209
+ * @param {string} [options.what] noun used in the message ("module" by default)
210
+ */
211
+ export function assertArtifactRuntimeTarget({
212
+ wasmModule,
213
+ manifest,
214
+ embeddedManifest,
215
+ leg,
216
+ what = "module",
217
+ }) {
218
+ const refusal = resolveRuntimeTargetRefusal({
219
+ wasmModule,
220
+ manifest,
221
+ embeddedManifest,
222
+ leg,
223
+ });
224
+ if (!refusal) return;
225
+ const { targets, source } = refusal;
226
+ throw new RuntimeTargetError(
227
+ `Refusing to load a ${what} that does not target "${leg}": its ` +
228
+ `${source === "embedded" ? "embedded manifest" : "supplied manifest"} declares ` +
229
+ `runtimeTargets [${targets.join(", ")}], which does not admit this leg. Loading it ` +
230
+ `here would trap on the first capability the "${leg}" host cannot serve. Run it on ` +
231
+ "a host that provides one of its declared targets, or split the out-of-scope " +
232
+ "surface into its own module.",
233
+ { declaredTargets: targets, source, leg },
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Is this JavaScript running in a browser (window or a Worker)? Used only to
239
+ * DEFAULT the leg for loaders that are genuinely runtime-agnostic; every
240
+ * caller may state its leg explicitly and that always wins.
241
+ */
242
+ export function detectBrowserLeg() {
243
+ try {
244
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") {
245
+ return true;
246
+ }
247
+ const workerScope = globalThis.WorkerGlobalScope;
248
+ return (
249
+ typeof workerScope === "function" &&
250
+ typeof self !== "undefined" &&
251
+ self instanceof workerScope
252
+ );
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from "./sabHostcallChannel.js";
28
28
  import { WASI_THREAD_HOSTCALL_MESSAGE } from "./wasiThreadWorkerRuntime.js";
29
29
  import { importNodeBuiltin } from "./nodeBuiltinSpecifier.js";
30
+ import { assertBrowserRuntimeTarget } from "./browserModuleHarness.js";
30
31
 
31
32
  const WORKER_URL = new URL("./workerModuleHarnessWorker.js", import.meta.url);
32
33
 
@@ -130,6 +131,12 @@ async function toWasmModule(source, label) {
130
131
  */
131
132
  export async function createWorkerModuleHarness(options = {}) {
132
133
  const wasmModule = await toWasmModule(options.wasmSource, "wasmSource");
134
+ // Fail on THIS thread, before a worker, a SAB channel and a BroadcastChannel
135
+ // are stood up for an artifact the browser leg must not run. The in-worker
136
+ // createBrowserModuleHarness would refuse it too, but a rejection that
137
+ // arrives after the whole rig exists reads like a worker fault rather than
138
+ // the declaration it actually is.
139
+ assertBrowserRuntimeTarget(wasmModule, options.harnessOptions?.manifest);
133
140
  const host = options.host ?? createBrowserHost(options.hostOptions);
134
141
  const dispatch = options.dispatchHost ?? createAsyncHostDispatcher(host);
135
142
  const buffer = createSabHostcallBuffer({
package/src/index.d.ts CHANGED
@@ -2337,8 +2337,55 @@ export interface FlowRuntimeHostOptions {
2337
2337
  engineLink?: {
2338
2338
  exports: WebAssembly.Exports & { memory: WebAssembly.Memory };
2339
2339
  } | null;
2340
+ /**
2341
+ * The runtime leg this host stands for. A composed flow DERIVES its
2342
+ * runtimeTargets from its parts, so a single-leg artifact exists and must be
2343
+ * refused by name on any other leg. Omit and a real browser is detected and
2344
+ * gated; pass `null` to opt out explicitly.
2345
+ */
2346
+ runtimeTarget?: string | null;
2347
+ /** Caller-side manifest; the artifact's embedded record wins on conflict. */
2348
+ manifest?: PluginManifest | Record<string, unknown>;
2349
+ }
2350
+
2351
+ export declare class RuntimeTargetError extends Error {
2352
+ code: "runtime-target-out-of-scope";
2353
+ declaredTargets: string[];
2354
+ leg: string;
2355
+ declarationSource: "embedded" | "caller";
2340
2356
  }
2341
2357
 
2358
+ /**
2359
+ * Does a declared target set admit this leg? `wasi` is the portability
2360
+ * baseline and admits either leg, but only when `capabilities` carries nothing
2361
+ * that leg cannot serve.
2362
+ */
2363
+ export function runtimeTargetSatisfies(
2364
+ targets: string[] | undefined,
2365
+ leg: string,
2366
+ capabilities?: Array<string | { capabilityId?: string; name?: string }>,
2367
+ ): boolean;
2368
+
2369
+ /** The artifact's own declared targets, read from its embedded `$PLG`. */
2370
+ export function embeddedRuntimeTargets(wasmModule: WebAssembly.Module): string[];
2371
+
2372
+ /**
2373
+ * Which declaration refuses this leg, if either does. The artifact's embedded
2374
+ * record wins over a caller-supplied manifest.
2375
+ */
2376
+ export function resolveRuntimeTargetRefusal(options: {
2377
+ wasmModule?: WebAssembly.Module;
2378
+ manifest?: PluginManifest | Record<string, unknown>;
2379
+ embeddedManifest?: PluginManifest | Record<string, unknown> | null;
2380
+ leg: string;
2381
+ }): { targets: string[]; source: "embedded" | "caller" } | null;
2382
+
2383
+ /** Throws a RuntimeTargetError when the artifact does not target the browser. */
2384
+ export function assertBrowserRuntimeTarget(
2385
+ wasmModule: WebAssembly.Module,
2386
+ manifest?: PluginManifest | Record<string, unknown>,
2387
+ ): void;
2388
+
2342
2389
  export const FLOW_INVALID_INDEX: number;
2343
2390
  export function createFlowRuntimeHost(
2344
2391
  options: FlowRuntimeHostOptions,
package/src/index.js CHANGED
@@ -16,6 +16,17 @@ export * from "./deployment/index.js";
16
16
  export * from "./app/index.js";
17
17
  export { FLOW_INVALID_INDEX, createFlowRuntimeHost } from "./flow/flowRuntimeHost.js";
18
18
  export { createIsomorphicFlowRuntimeHost } from "./flow/isomorphicFlowHost.js";
19
+ // The runtime-target gate: a composed flow derives its runtimeTargets, so a
20
+ // single-leg artifact exists and every loader refuses one that is not its own.
21
+ // Exported so a consumer can catch the refusal by CLASS rather than by
22
+ // matching a message string, and can ask the same question before offering a
23
+ // module to a leg.
24
+ export {
25
+ RuntimeTargetError,
26
+ runtimeTargetSatisfies,
27
+ resolveRuntimeTargetRefusal,
28
+ embeddedRuntimeTargets,
29
+ } from "./host/runtimeTargetGate.js";
19
30
  export {
20
31
  DefaultInvokeExports,
21
32
  DefaultManifestExports,
@@ -15,7 +15,16 @@ import {
15
15
  import { FlatBufferTypeRefT } from "../generated/orbpro/stream/flat-buffer-type-ref.js";
16
16
  import { ProtocolRole, ProtocolTransportKind } from "../runtime/constants.js";
17
17
 
18
- const pluginFamilyByName = Object.freeze({
18
+ /**
19
+ * The manifest-string → PluginFamily vocabulary. This is the WHOLE vocabulary:
20
+ * anything not in here is refused by name, never coerced.
21
+ *
22
+ * `datasource` is the ONE alias, kept because manifests in the field spell it
23
+ * both ways. Do not add aliases casually — an alias is a second spelling of a
24
+ * family, and every one of them is a place two modules can disagree about what
25
+ * they are.
26
+ */
27
+ export const pluginFamilyByName = Object.freeze({
19
28
  sensor: PluginFamily.SENSOR,
20
29
  propagator: PluginFamily.PROPAGATOR,
21
30
  renderer: PluginFamily.RENDERER,
@@ -28,8 +37,89 @@ const pluginFamilyByName = Object.freeze({
28
37
  infrastructure: PluginFamily.INFRASTRUCTURE,
29
38
  flow: PluginFamily.FLOW,
30
39
  bridge: PluginFamily.BRIDGE,
40
+ maneuver: PluginFamily.MANEUVER,
41
+ orbit_determination: PluginFamily.ORBIT_DETERMINATION,
42
+ foundation: PluginFamily.FOUNDATION,
43
+ parser: PluginFamily.PARSER,
44
+ validator: PluginFamily.VALIDATOR,
45
+ exporter: PluginFamily.EXPORTER,
46
+ publisher: PluginFamily.PUBLISHER,
47
+ basilisk: PluginFamily.BASILISK,
31
48
  });
32
49
 
50
+ /** Every accepted family string, sorted — the vocabulary a refusal names. */
51
+ export const PluginFamilyNames = Object.freeze(
52
+ Object.keys(pluginFamilyByName).sort(),
53
+ );
54
+
55
+ /**
56
+ * SDK family → authoritative SDS `pluginCategory` member name.
57
+ *
58
+ * SDS (spacedatastandards.org `schema/PLG/main.fbs`) is the source of truth for
59
+ * what families exist; this SDK enum is a projection of it. The two DO NOT
60
+ * share ordinals — they diverge from index 5 (SDK `COMMS = 5`, SDS `EW = 5`) —
61
+ * so this mapping is BY NAME and must stay explicit. Never convert one enum to
62
+ * the other numerically.
63
+ *
64
+ * Two entries have no 1:1 SDS member and are recorded honestly rather than
65
+ * hidden. Both are filed in graph/tasks/sds-plugin-category-projection-gaps.md:
66
+ * - ORBIT_DETERMINATION → Analysis (SDS has no OD category yet)
67
+ * - SDF, BRIDGE → Analysis / Infrastructure (SDK-local concepts)
68
+ */
69
+ export const sdsPluginCategoryByFamily = Object.freeze({
70
+ [PluginFamily.SENSOR]: "Sensor",
71
+ [PluginFamily.PROPAGATOR]: "Propagator",
72
+ [PluginFamily.RENDERER]: "Renderer",
73
+ [PluginFamily.ANALYSIS]: "Analysis",
74
+ [PluginFamily.DATA_SOURCE]: "DataSource",
75
+ [PluginFamily.COMMS]: "Comms",
76
+ [PluginFamily.SHADER]: "Shader",
77
+ [PluginFamily.SDF]: "Analysis",
78
+ [PluginFamily.INFRASTRUCTURE]: "Infrastructure",
79
+ [PluginFamily.FLOW]: "Flow",
80
+ [PluginFamily.BRIDGE]: "Infrastructure",
81
+ [PluginFamily.MANEUVER]: "Maneuver",
82
+ [PluginFamily.ORBIT_DETERMINATION]: "Analysis",
83
+ [PluginFamily.FOUNDATION]: "Foundation",
84
+ [PluginFamily.PARSER]: "Parser",
85
+ [PluginFamily.VALIDATOR]: "Validator",
86
+ [PluginFamily.EXPORTER]: "Exporter",
87
+ [PluginFamily.PUBLISHER]: "Publisher",
88
+ [PluginFamily.BASILISK]: "Basilisk",
89
+ });
90
+
91
+ const PluginFamilyValues = Object.freeze(
92
+ new Set(Object.values(pluginFamilyByName)),
93
+ );
94
+
95
+ /**
96
+ * Thrown when a manifest declares a family the SDK does not know.
97
+ *
98
+ * It is a THROW and not a fallback on purpose. The old code returned
99
+ * `PluginFamily.ANALYSIS` for anything unrecognized, which is why 19
100
+ * first-party modules shipped mislabelled and family-typed resolution only
101
+ * ever worked for propagators: a typo and a deliberate new family were
102
+ * indistinguishable, and both were silent.
103
+ *
104
+ * Ruling: graph/findings/official-harness-shapes.md §4.7 / §8.3
105
+ */
106
+ export class UnknownPluginFamilyError extends Error {
107
+ constructor(value) {
108
+ super(
109
+ `Unknown pluginFamily ${JSON.stringify(value)}. ` +
110
+ `The manifest vocabulary is: ${PluginFamilyNames.join(", ")}. ` +
111
+ `Families are NOT invented in a manifest — the SDK enum ` +
112
+ `(schemas/PluginManifest.fbs PluginFamily) is a projection of the ` +
113
+ `authoritative SDS pluginCategory vocabulary, and a new family is ` +
114
+ `appended there first.`,
115
+ );
116
+ this.name = "UnknownPluginFamilyError";
117
+ this.code = "unknown-plugin-family";
118
+ this.value = value;
119
+ this.validFamilies = PluginFamilyNames;
120
+ }
121
+ }
122
+
33
123
  const drainPolicyByName = Object.freeze({
34
124
  "single-shot": ManifestDrainPolicy.SINGLE_SHOT,
35
125
  "drain-until-yield": ManifestDrainPolicy.DRAIN_UNTIL_YIELD,
@@ -145,14 +235,33 @@ function normalizeUnsignedInteger(value, fallback = 0) {
145
235
  return Math.max(0, Math.trunc(normalized));
146
236
  }
147
237
 
148
- function normalizePluginFamily(value) {
238
+ /**
239
+ * Resolve a manifest `pluginFamily` to its enum value, or REFUSE.
240
+ *
241
+ * There is no fallback. See {@link UnknownPluginFamilyError} for why.
242
+ *
243
+ * @param {string|number} value
244
+ * @returns {number} a PluginFamily member
245
+ * @throws {UnknownPluginFamilyError} on an unknown string, an out-of-range
246
+ * number, or a missing/blank value.
247
+ */
248
+ export function normalizePluginFamily(value) {
149
249
  if (typeof value === "number") {
250
+ // A numeric family still has to BE one. An out-of-range ordinal is the
251
+ // same defect as an unknown string, and used to sail straight through.
252
+ if (!PluginFamilyValues.has(value)) {
253
+ throw new UnknownPluginFamilyError(value);
254
+ }
150
255
  return value;
151
256
  }
152
- const normalized = String(value ?? "analysis")
257
+ const normalized = String(value ?? "")
153
258
  .trim()
154
259
  .toLowerCase();
155
- return pluginFamilyByName[normalized] ?? PluginFamily.ANALYSIS;
260
+ const resolved = pluginFamilyByName[normalized];
261
+ if (resolved === undefined) {
262
+ throw new UnknownPluginFamilyError(value);
263
+ }
264
+ return resolved;
156
265
  }
157
266
 
158
267
  function normalizeDrainPolicy(value) {
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { substituteTokens } from "./tokens.js";
5
+
6
+ /**
7
+ * Refuse to scaffold into a non-empty directory unless `force` is set. Never
8
+ * deletes anything — `force` only lifts the refusal, it does not clear the
9
+ * directory first, so pre-existing unrelated files are left alone and
10
+ * template files land on top of (overwrite) any same-named files.
11
+ */
12
+ export async function ensureWritableOutputDir(outDir, force) {
13
+ let entries;
14
+ try {
15
+ entries = await fs.readdir(outDir);
16
+ } catch (error) {
17
+ if (error && error.code === "ENOENT") {
18
+ return;
19
+ }
20
+ throw error;
21
+ }
22
+ if (entries.length > 0 && !force) {
23
+ throw new Error(
24
+ `Refusing to scaffold into non-empty directory ${outDir} ` +
25
+ `(${entries.length} existing ${entries.length === 1 ? "entry" : "entries"}). ` +
26
+ `Pass --force to scaffold into it anyway.`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Copy every file under `templateDir` into `outDir`, applying token
33
+ * substitution to BOTH file contents and file/directory names. Every
34
+ * template file is treated as UTF-8 text — correct for this SDK's templates
35
+ * (JSON/JS/C/C++/Markdown), and deliberate: a template that ever needs a
36
+ * binary asset is a signal to reconsider, not something this copier should
37
+ * silently support.
38
+ *
39
+ * Returns the sorted list of output-relative (posix-style) file paths that
40
+ * were written.
41
+ */
42
+ export async function copyTemplateTree(templateDir, outDir, tokens) {
43
+ const created = [];
44
+
45
+ async function walk(currentTemplateDir, currentOutDir) {
46
+ const entries = await fs.readdir(currentTemplateDir, {
47
+ withFileTypes: true,
48
+ });
49
+ for (const entry of entries) {
50
+ const destName = substituteTokens(entry.name, tokens);
51
+ const srcPath = path.join(currentTemplateDir, entry.name);
52
+ const destPath = path.join(currentOutDir, destName);
53
+ if (entry.isDirectory()) {
54
+ await fs.mkdir(destPath, { recursive: true });
55
+ await walk(srcPath, destPath);
56
+ } else if (entry.isFile()) {
57
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
58
+ const raw = await fs.readFile(srcPath, "utf8");
59
+ await fs.writeFile(destPath, substituteTokens(raw, tokens), "utf8");
60
+ created.push(
61
+ path.relative(outDir, destPath).split(path.sep).join("/"),
62
+ );
63
+ }
64
+ }
65
+ }
66
+
67
+ await fs.mkdir(outDir, { recursive: true });
68
+ await walk(templateDir, outDir);
69
+ created.sort();
70
+ return created;
71
+ }