automategraph 1.5.0 → 1.5.3

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 (88) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/UPGRADE-PROPOSAL.md +141 -0
  3. package/docs/diagrams/c19-resilience-scenario.md +61 -0
  4. package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
  5. package/docs/diagrams/c19-resilience-scenario.svg +181 -0
  6. package/examples/README.md +206 -0
  7. package/examples/branching-edges.ts +192 -0
  8. package/examples/branching-graph.ts +333 -0
  9. package/examples/branching-router.ts +475 -0
  10. package/examples/json-graph.ts +231 -0
  11. package/examples/json-scripts.ts +152 -0
  12. package/examples/order-flow.functions.cjs +32 -0
  13. package/examples/order-flow.graph.json +27 -0
  14. package/examples/order-flow.scripted.graph.json +68 -0
  15. package/js/examples/branching-edges.js +133 -0
  16. package/js/examples/branching-edges.js.map +1 -0
  17. package/js/examples/branching-graph.js +232 -0
  18. package/js/examples/branching-graph.js.map +1 -0
  19. package/js/examples/branching-router.js +305 -0
  20. package/js/examples/branching-router.js.map +1 -0
  21. package/js/examples/json-graph.js +197 -0
  22. package/js/examples/json-graph.js.map +1 -0
  23. package/js/examples/json-scripts.js +131 -0
  24. package/js/examples/json-scripts.js.map +1 -0
  25. package/js/tests/async.test.js +592 -0
  26. package/js/tests/async.test.js.map +1 -0
  27. package/js/tests/complex.test.js +972 -0
  28. package/js/tests/complex.test.js.map +1 -0
  29. package/js/tests/conformance.test.js +365 -0
  30. package/js/tests/conformance.test.js.map +1 -0
  31. package/js/tests/edges.test.js +702 -0
  32. package/js/tests/edges.test.js.map +1 -0
  33. package/js/tests/graphjson.test.js +504 -0
  34. package/js/tests/graphjson.test.js.map +1 -0
  35. package/js/tests/harness.js +192 -0
  36. package/js/tests/harness.js.map +1 -0
  37. package/js/tests/helpers.js +136 -0
  38. package/js/tests/helpers.js.map +1 -0
  39. package/js/tests/lint.test.js +317 -0
  40. package/js/tests/lint.test.js.map +1 -0
  41. package/js/tests/registry.test.js +705 -0
  42. package/js/tests/registry.test.js.map +1 -0
  43. package/js/tests/report.js +422 -0
  44. package/js/tests/report.js.map +1 -0
  45. package/js/tests/runner.js +67 -0
  46. package/js/tests/runner.js.map +1 -0
  47. package/js/tests/scripts.test.js +552 -0
  48. package/js/tests/scripts.test.js.map +1 -0
  49. package/js/tests/unit.test.js +1239 -0
  50. package/js/tests/unit.test.js.map +1 -0
  51. package/js/tests/upgrade.test.js +618 -0
  52. package/js/tests/upgrade.test.js.map +1 -0
  53. package/package.json +2 -9
  54. package/report/index.html +3727 -0
  55. package/report/report.json +3053 -0
  56. package/tests/async.test.ts +668 -0
  57. package/tests/complex.test.ts +1123 -0
  58. package/tests/conformance.test.ts +442 -0
  59. package/tests/edges.test.ts +848 -0
  60. package/tests/fixtures/scripts/bad-registration.cjs +4 -0
  61. package/tests/fixtures/scripts/broken.cjs +2 -0
  62. package/tests/fixtures/scripts/cjs-default.js +4 -0
  63. package/tests/fixtures/scripts/collide-a.cjs +4 -0
  64. package/tests/fixtures/scripts/collide-b.cjs +4 -0
  65. package/tests/fixtures/scripts/empty.cjs +2 -0
  66. package/tests/fixtures/scripts/esm-functions.mjs +10 -0
  67. package/tests/fixtures/scripts/esm-syntax.js +3 -0
  68. package/tests/fixtures/scripts/global-channel.cjs +6 -0
  69. package/tests/fixtures/scripts/global-channel.mjs +7 -0
  70. package/tests/fixtures/scripts/mismatch.cjs +4 -0
  71. package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
  72. package/tests/fixtures/scripts/order.cjs +14 -0
  73. package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
  74. package/tests/fixtures/scripts/throwing-global.mjs +6 -0
  75. package/tests/graphjson.test.ts +737 -0
  76. package/tests/harness.ts +291 -0
  77. package/tests/helpers.ts +216 -0
  78. package/tests/lint.test.ts +381 -0
  79. package/tests/registry.test.ts +1134 -0
  80. package/tests/report.ts +452 -0
  81. package/tests/runner.ts +58 -0
  82. package/tests/scripts.test.ts +970 -0
  83. package/tests/unit.test.ts +1192 -0
  84. package/tests/upgrade.test.ts +725 -0
  85. package/tools/server.mjs +152 -0
  86. package/tsconfig.browser.json +19 -0
  87. package/tsconfig.json +18 -0
  88. package/tsconfig.publish.json +13 -0
@@ -0,0 +1,970 @@
1
+ /* ============================================================
2
+ * SCRIPT LOADER PACK (v1.5)
3
+ * ------------------------------------------------------------
4
+ * A document may declare scripts that PROVIDE the functions its nodes
5
+ * and edges name:
6
+ *
7
+ * "scripts": [
8
+ * { "namespace": "order", "path": "./order.cjs" },
9
+ * { "namespace": "inline", "source": "module.exports = { … }" },
10
+ * { "namespace": "remote", "url": "https://host/fns.mjs", "format": "esm" }
11
+ * ]
12
+ *
13
+ * Two-step API (the loader is Node-only, the core stays isomorphic):
14
+ *
15
+ * const { functions, namespaces } = await loadGraphScripts(document);
16
+ * const engine = createEngineFromJSON(document, { functions });
17
+ *
18
+ * Under test:
19
+ * • strategies — EXPORTS (cjs / esm / default-object) and SELF-REGISTRATION
20
+ * through globalThis.GraphEngine.registerEngineNamespace(namespace, fns),
21
+ * including both in one script
22
+ * • format auto-detection (.cjs → require, .mjs → import, .js → hint +
23
+ * fallback) and the explicit "format" override, which is never retried
24
+ * • the freeze — a namespace is claimed once per PROCESS; a second claim
25
+ * throws, from inside the script when the script registers itself
26
+ * • plain names: the namespace is a claim slot, never a name prefix
27
+ * • validation runs before any code executes
28
+ * ============================================================ */
29
+
30
+ import * as path from "path";
31
+
32
+ import { GraphExecutionEngine } from "../src/automategraph";
33
+ import {
34
+ GraphJSONError,
35
+ createEngineFromJSON,
36
+ parseGraphJSON,
37
+ parseGraphJSONDocument
38
+ } from "../src/graph-json";
39
+ import {
40
+ GraphScriptError,
41
+ loadGraphScripts,
42
+ registeredNamespaces,
43
+ resetGraphScriptNamespaces
44
+ } from "../src/node/graph-scripts";
45
+
46
+ import { BIG, logCollector } from "./helpers";
47
+ import { suite } from "./harness";
48
+
49
+ const scripts = suite("scripts", "Script loading");
50
+
51
+ /* ------------------------------ helpers ------------------------------ */
52
+
53
+ const FIXTURES = path.join(
54
+ __dirname,
55
+ "..",
56
+ "..",
57
+ "tests",
58
+ "fixtures",
59
+ "scripts"
60
+ );
61
+
62
+ function doc(declarations: unknown[]): Record<string, unknown> {
63
+ return {
64
+ id: "scripted",
65
+ version: "1.5.0",
66
+ entryNodes: ["a"],
67
+ terminalNodes: ["end"],
68
+ scripts: declarations,
69
+ nodes: {
70
+ a: { run: "start", next: ["end"] },
71
+ end: { run: "finish" }
72
+ }
73
+ };
74
+ }
75
+
76
+ function ok(condition: unknown, label: string): void {
77
+ if (!condition) throw new Error(label);
78
+ }
79
+
80
+ function eq(actual: unknown, expected: unknown, label: string): void {
81
+ if (actual !== expected) {
82
+ throw new Error(
83
+ `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
84
+ );
85
+ }
86
+ }
87
+
88
+ function deepEq(actual: unknown, expected: unknown, label: string): void {
89
+ const a = JSON.stringify(actual ?? null);
90
+ const b = JSON.stringify(expected ?? null);
91
+
92
+ if (a !== b) throw new Error(`${label}: expected ${b}, got ${a}`);
93
+ }
94
+
95
+ async function loadError(
96
+ declaration: unknown
97
+ ): Promise<GraphScriptError> {
98
+ try {
99
+ await loadGraphScripts(doc([declaration]), { baseDir: FIXTURES });
100
+ } catch (error) {
101
+ if (error instanceof GraphScriptError) return error;
102
+ throw error;
103
+ }
104
+
105
+ throw new Error("expected a GraphScriptError");
106
+ }
107
+
108
+ /** Claims are process-global: every test starts from a clean slate. */
109
+ function fresh(): void {
110
+ resetGraphScriptNamespaces();
111
+ }
112
+
113
+ /* ------------------------------ 1. media & formats ------------------------------ */
114
+
115
+ scripts.t(
116
+ "a .cjs script is required and its export table becomes the registry",
117
+ async t => {
118
+ fresh();
119
+
120
+ const loaded = await loadGraphScripts(
121
+ doc([{ namespace: "order", path: "./order.cjs" }]),
122
+ { baseDir: FIXTURES }
123
+ );
124
+
125
+ deepEq(
126
+ loaded.registry.names,
127
+ [
128
+ "chargeCard",
129
+ "classifyOrder",
130
+ "isPriority",
131
+ "manualReview",
132
+ "rejectOrder",
133
+ "shipOrder"
134
+ ],
135
+ "names: sorted, non-function export excluded"
136
+ );
137
+ deepEq(loaded.namespaces, ["order"], "namespaces");
138
+ eq(loaded.scripts[0].format, "cjs", "format");
139
+ eq(loaded.scripts[0].source, "path:./order.cjs", "source label");
140
+ deepEq(loaded.scripts[0].ignoredExports, ["STATUS"], "ignored exports");
141
+ eq(Object.isFrozen(loaded.functions), true, "the table is frozen");
142
+ ok(
143
+ loaded.registry.fingerprint.startsWith("fnr1:6:"),
144
+ loaded.registry.fingerprint
145
+ );
146
+
147
+ /* the functions are the real things, callable with real contexts */
148
+ eq((loaded.functions.classifyOrder as () => string)(), "priority", "call");
149
+ eq(
150
+ (loaded.functions.isPriority as (v: unknown) => boolean)("priority"),
151
+ true,
152
+ "matcher"
153
+ );
154
+
155
+ t.log("finding: a registered name is not rewritten — `classifyOrder` stays `classifyOrder`, the namespace only owns the claim");
156
+ },
157
+ BIG
158
+ );
159
+
160
+ scripts.t(
161
+ "an .mjs script is imported as an ES module",
162
+ async t => {
163
+ fresh();
164
+
165
+ const loaded = await loadGraphScripts(
166
+ doc([{ namespace: "esm", path: "./esm-functions.mjs" }]),
167
+ { baseDir: FIXTURES }
168
+ );
169
+
170
+ eq(loaded.scripts[0].format, "esm", "format");
171
+ eq((loaded.functions.shipOrder as () => string)(), "shipped", "named export works");
172
+ deepEq(loaded.scripts[0].ignoredExports, ["version"], "non-function export ignored");
173
+ },
174
+ BIG
175
+ );
176
+
177
+ scripts.t(
178
+ "a .js script with ESM syntax is detected as ESM; without it, as CommonJS",
179
+ async t => {
180
+ fresh();
181
+
182
+ const esm = await loadGraphScripts(
183
+ doc([{ namespace: "detected", path: "./esm-syntax.js" }]),
184
+ { baseDir: FIXTURES }
185
+ );
186
+
187
+ eq(esm.scripts[0].format, "esm", ".js with export syntax");
188
+ eq((esm.functions.greetFromJs as () => string)(), "hello from .js ESM", "works");
189
+
190
+ fresh();
191
+
192
+ const cjs = await loadGraphScripts(
193
+ doc([{ namespace: "defaulted", path: "./cjs-default.js" }]),
194
+ { baseDir: FIXTURES }
195
+ );
196
+
197
+ eq(cjs.scripts[0].format, "cjs", ".js without export syntax");
198
+ eq((cjs.functions.plainCjs as () => string)(), "plain cjs", "works");
199
+
200
+ t.log("finding: .js detection is a source hint with a fallback — the other format is attempted before failing, so a mis-hint costs one retry, never a failed load");
201
+ },
202
+ BIG
203
+ );
204
+
205
+ scripts.t(
206
+ "an explicit format is honoured and never retried in the other format",
207
+ async t => {
208
+ fresh();
209
+
210
+ /* CJS import() of a CJS file is legal: the override to ESM succeeds */
211
+ const forcedEsm = await loadGraphScripts(
212
+ doc([{ namespace: "forcedEsm", path: "./cjs-default.js", format: "esm" }]),
213
+ { baseDir: FIXTURES }
214
+ );
215
+
216
+ eq(forcedEsm.scripts[0].format, "esm", "forced esm");
217
+ ok("plainCjs" in forcedEsm.functions, Object.keys(forcedEsm.functions).join(","));
218
+
219
+ fresh();
220
+
221
+ /* .mjs forced to CJS cannot work — and must fail WITHOUT trying ESM */
222
+ const error = await loadError({
223
+ namespace: "forcedCjs",
224
+ path: "./esm-functions.mjs",
225
+ format: "cjs"
226
+ });
227
+
228
+ eq(error.code, "SCRIPT_LOAD_FAILED", "code");
229
+ deepEq(
230
+ (error.details as { attempts: string[] }).attempts.length,
231
+ 1,
232
+ "exactly one attempt: no fallback for an explicit format"
233
+ );
234
+ },
235
+ BIG
236
+ );
237
+
238
+ scripts.t(
239
+ "inline CommonJS source runs with a real require and module",
240
+ async t => {
241
+ fresh();
242
+
243
+ const loaded = await loadGraphScripts(
244
+ doc([
245
+ {
246
+ namespace: "inlineCjs",
247
+ source:
248
+ "module.exports = { inlineCjs: () => 'inline cjs', " +
249
+ "isInline: value => value === 'inline cjs' };"
250
+ }
251
+ ]),
252
+ { baseDir: FIXTURES }
253
+ );
254
+
255
+ eq(loaded.scripts[0].format, "cjs", "format");
256
+ eq(loaded.scripts[0].source, "inline source", "label");
257
+ eq((loaded.functions.inlineCjs as () => string)(), "inline cjs", "works");
258
+ },
259
+ BIG
260
+ );
261
+
262
+ scripts.t(
263
+ "inline ESM source is evaluated from a data URL",
264
+ async t => {
265
+ fresh();
266
+
267
+ const loaded = await loadGraphScripts(
268
+ doc([
269
+ {
270
+ namespace: "inlineEsm",
271
+ source: "export const inlineEsm = () => 'inline esm';"
272
+ }
273
+ ]),
274
+ { baseDir: FIXTURES }
275
+ );
276
+
277
+ eq(loaded.scripts[0].format, "esm", "format");
278
+ eq((loaded.functions.inlineEsm as () => string)(), "inline esm", "works");
279
+ },
280
+ BIG
281
+ );
282
+
283
+ scripts.t(
284
+ "URL scripts are fetched and evaluated (CJS by default, ESM by hint)",
285
+ async t => {
286
+ fresh();
287
+
288
+ const cjsText = "module.exports = { fromUrl: () => 'url cjs' };";
289
+
290
+ const cjs = await loadGraphScripts(
291
+ doc([{ namespace: "remoteCjs", url: "https://example.test/fns.cjs" }]),
292
+ {
293
+ baseDir: FIXTURES,
294
+ fetchText: async url => {
295
+ eq(url, "https://example.test/fns.cjs", "the loader fetched the declared URL");
296
+ return cjsText;
297
+ }
298
+ }
299
+ );
300
+
301
+ eq(cjs.scripts[0].format, "cjs", "format");
302
+ eq((cjs.functions.fromUrl as () => string)(), "url cjs", "works");
303
+
304
+ fresh();
305
+
306
+ const esm = await loadGraphScripts(
307
+ doc([{ namespace: "remoteEsm", url: "https://example.test/thing.mjs" }]),
308
+ {
309
+ baseDir: FIXTURES,
310
+ fetchText: async () => "export const fromUrlEsm = () => 'url esm';"
311
+ }
312
+ );
313
+
314
+ eq(esm.scripts[0].format, "esm", ".mjs URL");
315
+ eq((esm.functions.fromUrlEsm as () => string)(), "url esm", "works");
316
+ },
317
+ BIG
318
+ );
319
+
320
+ /* ------------------------------ 2. the global channel ------------------------------ */
321
+
322
+ scripts.t(
323
+ "a script can register itself through globalThis.GraphEngine",
324
+ async t => {
325
+ fresh();
326
+
327
+ const loaded = await loadGraphScripts(
328
+ doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
329
+ { baseDir: FIXTURES }
330
+ );
331
+
332
+ deepEq(loaded.registry.names, ["invoiceOrder", "isBillable"], "registered names");
333
+ eq((loaded.functions.invoiceOrder as () => string)(), "invoiced", "works");
334
+
335
+ const claim = registeredNamespaces().find(entry => entry.namespace === "billing");
336
+
337
+ ok(claim !== undefined, "the claim is recorded");
338
+ deepEq(claim?.names, ["invoiceOrder", "isBillable"], "claim names, sorted");
339
+ eq(claim?.source, "path:./global-channel.cjs", "claim source");
340
+
341
+ t.log("finding: the engine global is a per-CALL slot (registerEngineNamespace), not a shared mutable table — a script can only ever add its own namespace, and only once");
342
+ },
343
+ BIG
344
+ );
345
+
346
+ scripts.t(
347
+ "the realm is restored after the load — the engine global does not leak",
348
+ async t => {
349
+ fresh();
350
+
351
+ const marker = { marker: "pre-existing" };
352
+ (globalThis as Record<string, unknown>).GraphEngine = marker;
353
+
354
+ await loadGraphScripts(
355
+ doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
356
+ { baseDir: FIXTURES }
357
+ );
358
+
359
+ const after = (globalThis as Record<string, unknown>)
360
+ .GraphEngine as Record<string, unknown>;
361
+
362
+ ok(after === marker, "the previous global object is restored by identity");
363
+ eq(
364
+ typeof after.registerEngineNamespace,
365
+ "undefined",
366
+ "our registrar is gone"
367
+ );
368
+
369
+ delete (globalThis as Record<string, unknown>).GraphEngine;
370
+
371
+ await loadGraphScripts(
372
+ doc([{ namespace: "restoreTest", path: "./order.cjs" }]),
373
+ { baseDir: FIXTURES }
374
+ );
375
+
376
+ eq(
377
+ (globalThis as Record<string, unknown>).GraphEngine,
378
+ undefined,
379
+ "with no previous global, the loader removes what it installed"
380
+ );
381
+ },
382
+ BIG
383
+ );
384
+
385
+ scripts.t(
386
+ "a script can both self-register and export; both land in the namespace",
387
+ async t => {
388
+ fresh();
389
+
390
+ const loaded = await loadGraphScripts(
391
+ doc([{ namespace: "telemetry", path: "./global-channel.mjs" }]),
392
+ { baseDir: FIXTURES }
393
+ );
394
+
395
+ deepEq(
396
+ loaded.registry.names,
397
+ ["recordRun", "traceId"],
398
+ "registered + exported names"
399
+ );
400
+ eq((loaded.functions.traceId as () => string)(), "trace-1", "exported");
401
+ eq((loaded.functions.recordRun as () => string)(), "recorded", "registered");
402
+ },
403
+ BIG
404
+ );
405
+
406
+ scripts.t(
407
+ "the declared namespace must match what the script registers",
408
+ async t => {
409
+ fresh();
410
+
411
+ const error = await loadError({
412
+ namespace: "declared-here",
413
+ path: "./mismatch.cjs"
414
+ });
415
+
416
+ eq(error.code, "NAMESPACE_MISMATCH", "code");
417
+ deepEq(
418
+ (error.details as { declared: string; received: string }).declared,
419
+ "declared-here",
420
+ "declared"
421
+ );
422
+ deepEq(
423
+ (error.details as { received: string }).received,
424
+ "other",
425
+ "received"
426
+ );
427
+ ok(error.message.includes("declared-here"), error.message);
428
+ },
429
+ BIG
430
+ );
431
+
432
+ scripts.t(
433
+ "a registration error from inside the script surfaces unwrapped",
434
+ async t => {
435
+ fresh();
436
+
437
+ const error = await loadError({
438
+ namespace: "badmin",
439
+ path: "./bad-registration.cjs"
440
+ });
441
+
442
+ /* NOT SCRIPT_LOAD_FAILED: the script's verdict is preserved */
443
+ eq(error.code, "SCRIPT_EXPORT_INVALID", "code");
444
+ ok(error.message.includes("notAFunction"), error.message);
445
+ eq(
446
+ (error.details as { name: string }).name,
447
+ "notAFunction",
448
+ "the offending name"
449
+ );
450
+ },
451
+ BIG
452
+ );
453
+
454
+ /* ------------------------------ 3. the freeze ------------------------------ */
455
+
456
+ scripts.t(
457
+ "a namespace can be claimed only once per process (exports path)",
458
+ async t => {
459
+ fresh();
460
+
461
+ const document = doc([{ namespace: "once", path: "./order.cjs" }]);
462
+
463
+ const first = await loadGraphScripts(document, { baseDir: FIXTURES });
464
+ eq(first.namespaces[0], "once", "first load");
465
+
466
+ let second: GraphScriptError | undefined;
467
+
468
+ try {
469
+ await loadGraphScripts(document, { baseDir: FIXTURES });
470
+ } catch (error) {
471
+ second = error as GraphScriptError;
472
+ }
473
+
474
+ ok(second instanceof GraphScriptError, "the second load throws");
475
+ eq(second?.code, "NAMESPACE_ALREADY_REGISTERED", "code");
476
+ eq(
477
+ (second?.details as { claimedBy: string; requestedBy: string }).claimedBy,
478
+ "path:./order.cjs",
479
+ "the error names the original claim"
480
+ );
481
+ ok(
482
+ second?.message.includes("once per process") === true,
483
+ second?.message ?? "no message"
484
+ );
485
+
486
+ resetGraphScriptNamespaces();
487
+
488
+ const third = await loadGraphScripts(document, { baseDir: FIXTURES });
489
+ eq(third.namespaces[0], "once", "after reset it loads again");
490
+
491
+ t.log("finding: the freeze is PROCESS-global by design — one document, one engine per namespace. Loading the same graph file into a second engine throws until the claims are reset");
492
+ },
493
+ BIG
494
+ );
495
+
496
+ scripts.t(
497
+ "a self-registering script throws on the duplicate claim FROM INSIDE the script",
498
+ async t => {
499
+ fresh();
500
+
501
+ await loadGraphScripts(
502
+ doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
503
+ { baseDir: FIXTURES }
504
+ );
505
+
506
+ /* a second document declaring the same namespace: the script itself
507
+ * calls the registrar and the registrar refuses */
508
+ const error = await loadError({
509
+ namespace: "billing",
510
+ path: "./global-channel.cjs"
511
+ });
512
+
513
+ eq(error.code, "NAMESPACE_ALREADY_REGISTERED", "code");
514
+ eq(
515
+ (error.details as { requestedBy: string }).requestedBy,
516
+ "path:./global-channel.cjs",
517
+ "requestedBy"
518
+ );
519
+ },
520
+ BIG
521
+ );
522
+
523
+ scripts.t(
524
+ "a claim made before a crashing script survives; the load still fails",
525
+ async t => {
526
+ fresh();
527
+
528
+ const error = await loadError({
529
+ namespace: "halfway",
530
+ path: "./throwing-global.mjs"
531
+ });
532
+
533
+ eq(error.code, "SCRIPT_LOAD_FAILED", "the load failed");
534
+ ok(error.message.includes("exploded after registering"), error.message);
535
+
536
+ deepEq(
537
+ registeredNamespaces().map(claim => claim.namespace),
538
+ ["halfway"],
539
+ "the claim was already made when the script threw"
540
+ );
541
+
542
+ t.log("finding: claims are made at registration time, not at load end — a script that registers and then throws leaves its namespace claimed (reload needs resetGraphScriptNamespaces)");
543
+ },
544
+ BIG
545
+ );
546
+
547
+ scripts.t(
548
+ "a script that throws is not replayed in the other format",
549
+ async t => {
550
+ fresh();
551
+
552
+ let executions = 0;
553
+
554
+ /* self-registration makes a replay detectable: the second run would
555
+ * hit the namespace freeze, and the counter proves the first */
556
+ const source = [
557
+ "globalThis.__replayProbe = (globalThis.__replayProbe || 0) + 1;",
558
+ "globalThis.GraphEngine.registerEngineNamespace('replayProbe', { probe: () => 1 });",
559
+ "throw new Error('fixture: fails after side effects');"
560
+ ].join("\n");
561
+
562
+ /* run the real probe: inline source, format auto (cjs → esm fallback
563
+ * would be attempted for a FORMAT error only) */
564
+ fresh();
565
+
566
+ let caught: GraphScriptError | undefined;
567
+
568
+ try {
569
+ await loadGraphScripts(doc([{ namespace: "replayProbe", source }]), {
570
+ baseDir: FIXTURES
571
+ });
572
+ } catch (thrown) {
573
+ caught = thrown as GraphScriptError;
574
+ }
575
+
576
+ executions = Number(
577
+ (globalThis as Record<string, unknown>).__replayProbe ?? 0
578
+ );
579
+
580
+ delete (globalThis as Record<string, unknown>).__replayProbe;
581
+
582
+ eq(caught?.code, "SCRIPT_LOAD_FAILED", "the load failed");
583
+ eq(executions, 1, "the script body ran EXACTLY once");
584
+
585
+ t.log("finding: format fallback replays the script body, so it is reserved for module-format failures (ERR_REQUIRE_ESM, ESM syntax in CJS). A script that throws — or registers and then throws — is never re-executed; it ran once and its claim stands");
586
+ },
587
+ BIG
588
+ );
589
+
590
+ /* ------------------------------ 4. merging & collisions ------------------------------ */
591
+
592
+ scripts.t(
593
+ "two scripts providing the same name collide; override is opt-in",
594
+ async t => {
595
+ fresh();
596
+
597
+ const document = doc([
598
+ { namespace: "teamA", path: "./collide-a.cjs" },
599
+ { namespace: "teamB", path: "./collide-b.cjs" }
600
+ ]);
601
+
602
+ let error: GraphScriptError | undefined;
603
+
604
+ try {
605
+ await loadGraphScripts(document, { baseDir: FIXTURES });
606
+ } catch (thrown) {
607
+ error = thrown as GraphScriptError;
608
+ }
609
+
610
+ ok(error instanceof GraphScriptError, "a collision throws by default");
611
+ eq(error?.code, "FUNCTION_NAME_COLLISION", "code");
612
+ deepEq(
613
+ error?.details as { name: string; first: string; second: string },
614
+ { name: "shared", first: "teamA", second: "teamB" },
615
+ "the error names both sides"
616
+ );
617
+
618
+ /* claims made before the collision stay — that is the freeze working */
619
+ fresh();
620
+
621
+ const log = logCollector();
622
+
623
+ const loaded = await loadGraphScripts(document, {
624
+ baseDir: FIXTURES,
625
+ collisions: "override",
626
+ onLog: log.onLog
627
+ });
628
+
629
+ eq(
630
+ (loaded.functions.shared as () => string)(),
631
+ "from B",
632
+ "with override, the later script wins"
633
+ );
634
+ eq((loaded.functions.onlyA as () => string)(), "A", "the first script still contributed");
635
+ eq((loaded.functions.onlyB as () => string)(), "B", "and so did the second");
636
+ ok(
637
+ log.lines.some(line => line.message.includes("overrides")),
638
+ "the override is logged"
639
+ );
640
+
641
+ t.log("finding: claims and name collisions are separate gates — the freeze stops the namespace, collisions stop the merged table, and only the second one has an override");
642
+ },
643
+ BIG
644
+ );
645
+
646
+ /* ------------------------------ 5. validation ------------------------------ */
647
+
648
+ scripts.t(
649
+ "script declarations are validated, with JSON paths",
650
+ async t => {
651
+ const cases: Array<[unknown, string]> = [
652
+ [{ namespace: "a" }, "scripts[0]"],
653
+ [
654
+ { namespace: "b", path: "./x.cjs", url: "https://x" },
655
+ "scripts[0]"
656
+ ],
657
+ [{ namespace: "c", path: "" }, "scripts[0].path"],
658
+ [{ namespace: "d", path: "./x.cjs", format: "wasm" }, "scripts[0].format"],
659
+ [{ namespace: "has space", path: "./x.cjs" }, "scripts[0].namespace"],
660
+ [{ namespace: "e", path: "./x.cjs", loader: "y" }, "scripts[0].loader"]
661
+ ];
662
+
663
+ for (const [declaration, expectedPath] of cases) {
664
+ try {
665
+ parseGraphJSONDocument(doc([declaration]));
666
+ throw new Error(`expected an issue for ${JSON.stringify(declaration)}`);
667
+ } catch (error) {
668
+ if (!(error instanceof GraphJSONError)) throw error;
669
+
670
+ ok(
671
+ error.issues.some(issue => issue.path === expectedPath),
672
+ `${expectedPath} missing from ${error.issues
673
+ .map(issue => issue.path)
674
+ .join(",")}`
675
+ );
676
+ }
677
+ }
678
+ },
679
+ BIG
680
+ );
681
+
682
+ scripts.t(
683
+ "the same namespace twice in one document is a parse issue",
684
+ async t => {
685
+ try {
686
+ parseGraphJSONDocument(
687
+ doc([
688
+ { namespace: "twice", path: "./a.cjs" },
689
+ { namespace: "twice", source: "module.exports = {};" }
690
+ ])
691
+ );
692
+ throw new Error("expected a GraphJSONError");
693
+ } catch (error) {
694
+ if (!(error instanceof GraphJSONError)) throw error;
695
+
696
+ eq(error.issues.length, 1, "one issue");
697
+ eq(error.issues[0].path, "scripts[1].namespace", "path");
698
+ ok(error.issues[0].message.includes("twice in this document"), error.issues[0].message);
699
+ }
700
+ },
701
+ BIG
702
+ );
703
+
704
+ scripts.t(
705
+ "a scripted document still parses with the v1.4 entry point",
706
+ async t => {
707
+ const document = doc([{ namespace: "parseOnly", path: "./order.cjs" }]);
708
+
709
+ const definition = parseGraphJSON(document);
710
+ const parsed = parseGraphJSONDocument(document);
711
+
712
+ deepEq(definition, parsed.definition, "parseGraphJSON is the definition alone");
713
+ eq(parsed.scripts.length, 1, "the declaration is surfaced");
714
+ deepEq(
715
+ parsed.scripts[0],
716
+ { namespace: "parseOnly", path: "./order.cjs" },
717
+ "declaration shape"
718
+ );
719
+ eq(Object.isFrozen(parsed.scripts), true, "scripts are frozen");
720
+
721
+ t.log("finding: `scripts` is validated by the isomorphic parser and executed by the Node loader — parseGraphJSON (sync, v1.4) keeps working on scripted documents");
722
+ },
723
+ BIG
724
+ );
725
+
726
+ scripts.t(
727
+ "the document is validated BEFORE any script executes",
728
+ async t => {
729
+ fresh();
730
+
731
+ const broken = doc([{ namespace: "neverRuns", path: "./order.cjs" }]);
732
+
733
+ /* break the graph, not the script */
734
+ (broken.nodes as Record<string, { next: string[] }>).a.next = ["ghost"];
735
+
736
+ let error: GraphJSONError | undefined;
737
+
738
+ try {
739
+ await loadGraphScripts(broken, { baseDir: FIXTURES });
740
+ } catch (thrown) {
741
+ error = thrown as GraphJSONError;
742
+ }
743
+
744
+ ok(error instanceof GraphJSONError, "the document is rejected");
745
+ ok(
746
+ error?.issues.some(issue => issue.path === "nodes.a.next[0]"),
747
+ error?.issues.map(issue => issue.path).join(",") ?? "no issues"
748
+ );
749
+ deepEq(
750
+ registeredNamespaces(),
751
+ [],
752
+ "no namespace was claimed: no code ran"
753
+ );
754
+ },
755
+ BIG
756
+ );
757
+
758
+ scripts.t(
759
+ "a missing file, a crashing script and a function-less script fail clearly",
760
+ async t => {
761
+ fresh();
762
+
763
+ const missing = await loadError({
764
+ namespace: "missing",
765
+ path: "./does-not-exist.cjs"
766
+ });
767
+ eq(missing.code, "SCRIPT_LOAD_FAILED", "missing code");
768
+ ok(missing.message.includes("not found"), missing.message);
769
+ ok(
770
+ (missing.details as { resolved: string }).resolved.includes("does-not-exist.cjs"),
771
+ "the resolved path is reported"
772
+ );
773
+
774
+ fresh();
775
+
776
+ const broken = await loadError({
777
+ namespace: "broken",
778
+ path: "./broken.cjs"
779
+ });
780
+ eq(broken.code, "SCRIPT_LOAD_FAILED", "broken code");
781
+ ok(broken.message.includes("fixture-broken"), "the cause message is kept");
782
+
783
+ fresh();
784
+
785
+ const empty = await loadError({
786
+ namespace: "empty",
787
+ path: "./empty.cjs"
788
+ });
789
+ eq(empty.code, "SCRIPT_NO_FUNCTIONS", "empty code");
790
+ ok(empty.message.includes("only non-function exports: answer"), empty.message);
791
+
792
+ fresh();
793
+
794
+ const silent = await loadError({
795
+ namespace: "silent",
796
+ source: "/* nothing at all */"
797
+ });
798
+ eq(silent.code, "SCRIPT_NO_FUNCTIONS", "silent code");
799
+ ok(silent.message.includes("never called registerEngineNamespace"), silent.message);
800
+ },
801
+ BIG
802
+ );
803
+
804
+ /* ------------------------------ 6. end to end ------------------------------ */
805
+
806
+ scripts.t(
807
+ "a scripted document runs: the script provides the node and edge functions",
808
+ async t => {
809
+ fresh();
810
+
811
+ const document = {
812
+ id: "order-flow-scripted",
813
+ version: "1.5.0",
814
+ entryNodes: ["classify"],
815
+ terminalNodes: ["ship", "manual"],
816
+ completionPolicy: "FIRST_TERMINAL",
817
+ scripts: [{ namespace: "order", path: "./order.cjs" }],
818
+ nodes: {
819
+ classify: {
820
+ run: "classifyOrder",
821
+ next: [
822
+ { test: "isPriority", nextNode: "charge" },
823
+ { nextNode: "manual" }
824
+ ]
825
+ },
826
+ charge: {
827
+ run: "chargeCard",
828
+ next: ["ship"],
829
+ onThrow: "manual"
830
+ },
831
+ ship: { run: "shipOrder" },
832
+ manual: { run: "manualReview" }
833
+ }
834
+ };
835
+
836
+ const loaded = await loadGraphScripts(document, { baseDir: FIXTURES });
837
+ const engine = createEngineFromJSON(document, {
838
+ functions: loaded.functions,
839
+ lint: "error"
840
+ });
841
+
842
+ const report = await engine.run();
843
+
844
+ eq(report.status, "COMPLETED", "status");
845
+ deepEq(
846
+ report.results.map(result => result.nodeId),
847
+ ["classify", "charge", "ship"],
848
+ "path"
849
+ );
850
+ deepEq(
851
+ report.results.map(result => result.fn),
852
+ ["classifyOrder", "chargeCard", "shipOrder"],
853
+ "every activation names its script function"
854
+ );
855
+ eq(report.outputs.ship, "shipped", "output from the script");
856
+ deepEq(report.lintIssues, [], "lint-clean under lint: \"error\"");
857
+ eq(
858
+ report.registry?.fingerprint,
859
+ loaded.registry.fingerprint,
860
+ "the engine reports the identity of the loaded table"
861
+ );
862
+
863
+ t.log(
864
+ `finding: end-to-end, ${loaded.registry.names.length} functions arrive from ${loaded.scripts[0].source} and the engine reports ${loaded.registry.fingerprint}`
865
+ );
866
+ },
867
+ BIG
868
+ );
869
+
870
+ scripts.t(
871
+ "a script's own sibling requires resolve from the script's directory",
872
+ async t => {
873
+ fresh();
874
+
875
+ const loaded = await loadGraphScripts(
876
+ doc([{ namespace: "sibling", path: "./needs-sibling.cjs" }]),
877
+ { baseDir: FIXTURES }
878
+ );
879
+
880
+ eq(
881
+ (loaded.functions.usesSibling as () => string)(),
882
+ "sibling said hi",
883
+ "real require() with real relative resolution"
884
+ );
885
+ },
886
+ BIG
887
+ );
888
+
889
+ scripts.t(
890
+ "inline scripts compose with path scripts in one document",
891
+ async t => {
892
+ fresh();
893
+
894
+ const loaded = await loadGraphScripts(
895
+ doc([
896
+ { namespace: "filePart", path: "./cjs-default.js" },
897
+ {
898
+ namespace: "inlinePart",
899
+ source:
900
+ "globalThis.GraphEngine.registerEngineNamespace('inlinePart', " +
901
+ "{ inlineRegistered: () => 'via global' });"
902
+ }
903
+ ]),
904
+ { baseDir: FIXTURES }
905
+ );
906
+
907
+ deepEq(
908
+ loaded.namespaces,
909
+ ["filePart", "inlinePart"],
910
+ "declaration order"
911
+ );
912
+ deepEq(
913
+ loaded.registry.names,
914
+ ["inlineRegistered", "plainCjs"],
915
+ "merged table"
916
+ );
917
+ eq((loaded.functions.plainCjs as () => string)(), "plain cjs", "file part");
918
+ eq((loaded.functions.inlineRegistered as () => string)(), "via global", "inline part");
919
+
920
+ t.log("finding: the three media (path, source, url) are interchangeable — one pipeline merges them into a single table with one fingerprint");
921
+ },
922
+ BIG
923
+ );
924
+
925
+ scripts.t(
926
+ "the loaded table is frozen and the engine can be built from it directly",
927
+ async t => {
928
+ fresh();
929
+
930
+ const loaded = await loadGraphScripts(
931
+ doc([{ namespace: "frozen", path: "./order.cjs" }]),
932
+ { baseDir: FIXTURES }
933
+ );
934
+
935
+ eq(Object.isFrozen(loaded.functions), true, "frozen");
936
+ eq(Object.isFrozen(loaded.scripts), true, "scripts frozen");
937
+ eq(Object.isFrozen(loaded.namespaces), true, "namespaces frozen");
938
+
939
+ let mutated = false;
940
+
941
+ try {
942
+ (loaded.functions as Record<string, unknown>).sneaky = () => 1;
943
+ } catch {
944
+ mutated = true;
945
+ }
946
+
947
+ eq(
948
+ mutated || !("sneaky" in loaded.functions),
949
+ true,
950
+ "the table cannot be extended"
951
+ );
952
+
953
+ const engine = new GraphExecutionEngine(
954
+ {
955
+ id: "direct",
956
+ version: "1.0.0",
957
+ entryNodes: ["a"],
958
+ terminalNodes: ["a"],
959
+ nodes: { a: { id: "a", run: () => "direct" } }
960
+ },
961
+ { functions: loaded.functions }
962
+ );
963
+
964
+ const report = await engine.run();
965
+
966
+ eq(report.status, "COMPLETED", "engine built from the loaded table");
967
+ eq(report.registry?.fingerprint, loaded.registry.fingerprint, "same identity");
968
+ },
969
+ BIG
970
+ );