automategraph 1.5.1 → 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 +18 -5
  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,737 @@
1
+ /* ============================================================
2
+ * GRAPH JSON LOADER PACK (v1.4)
3
+ * ------------------------------------------------------------
4
+ * src/graph-json.ts turns a data document into a GraphDefinition:
5
+ *
6
+ * { "id", "version", "entryNodes", "terminalNodes",
7
+ * "nodes": { "a": { "run": "fnName",
8
+ * "next": [ { "test": "predName", "nextNode": "b" },
9
+ * { "nextNode": "c" } ] } } }
10
+ *
11
+ * Under test:
12
+ * • parsing needs NO registry (names resolve lazily in the engine)
13
+ * • validation is total — every problem is collected with its JSON
14
+ * path and thrown as one GraphJSONError carrying `issues`
15
+ * • createEngineFromJSON(document, { functions }) is the whole
16
+ * pipeline: data file → running engine → report with fingerprint
17
+ * ============================================================ */
18
+
19
+ import { GraphExecutionEngine, GraphValidationError } from "../src/automategraph";
20
+ import {
21
+ GraphJSONError,
22
+ createEngineFromJSON,
23
+ isGraphJSON,
24
+ parseGraphJSON
25
+ } from "../src/graph-json";
26
+
27
+ import { BIG, capture } from "./helpers";
28
+ import { suite } from "./harness";
29
+
30
+ const json = suite("graphjson", "Graph JSON loader");
31
+
32
+ /* ------------------------------ helpers ------------------------------ */
33
+
34
+ const DOCUMENT = {
35
+ id: "order-flow",
36
+ version: "1.0.0",
37
+ entryNodes: ["classify"],
38
+ terminalNodes: ["ship", "manual", "reject"],
39
+ completionPolicy: "FIRST_TERMINAL",
40
+ nodes: {
41
+ classify: {
42
+ run: "classifyOrder",
43
+ next: [
44
+ { test: "isPriority", nextNode: "charge" },
45
+ { result: "manual", nextNode: "manual" },
46
+ { nextNode: "reject" }
47
+ ]
48
+ },
49
+ charge: {
50
+ run: "chargeCard",
51
+ next: ["ship"],
52
+ onThrow: "manual",
53
+ errorPolicy: "RETRY",
54
+ retry: { maxAttempts: 2, delayMs: 1 }
55
+ },
56
+ ship: { run: "shipOrder" },
57
+ manual: { run: "manualReview" },
58
+ reject: { run: "rejectOrder" }
59
+ }
60
+ };
61
+
62
+ const FUNCTIONS = {
63
+ classifyOrder: () => "priority",
64
+ isPriority: (value: unknown) => value === "priority",
65
+ chargeCard: () => "charged",
66
+ shipOrder: () => "shipped",
67
+ manualReview: () => "queued",
68
+ rejectOrder: () => "rejected"
69
+ };
70
+
71
+ function issuesOf(input: unknown): Array<{ path: string; message: string }> {
72
+ try {
73
+ parseGraphJSON(input);
74
+ } catch (error) {
75
+ if (error instanceof GraphJSONError) return [...error.issues];
76
+ throw error;
77
+ }
78
+
79
+ throw new Error("expected a GraphJSONError");
80
+ }
81
+
82
+ function issuePaths(input: unknown): string[] {
83
+ return issuesOf(input).map(issue => issue.path);
84
+ }
85
+
86
+ function catchJSONError(input: unknown): GraphJSONError {
87
+ try {
88
+ parseGraphJSON(input);
89
+ } catch (error) {
90
+ if (error instanceof GraphJSONError) return error;
91
+ throw error;
92
+ }
93
+
94
+ throw new Error("expected a GraphJSONError");
95
+ }
96
+
97
+ function ok(condition: unknown, label: string): void {
98
+ if (!condition) throw new Error(label);
99
+ }
100
+
101
+ function eq(actual: unknown, expected: unknown, label: string): void {
102
+ if (actual !== expected) {
103
+ throw new Error(
104
+ `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
105
+ );
106
+ }
107
+ }
108
+
109
+ function stable(value: unknown): string {
110
+ return JSON.stringify(sortKeys(value)) ?? "undefined";
111
+ }
112
+
113
+ function sortKeys(value: unknown): unknown {
114
+ if (Array.isArray(value)) return value.map(sortKeys);
115
+ if (value && typeof value === "object") {
116
+ return Object.fromEntries(
117
+ Object.keys(value as Record<string, unknown>)
118
+ .sort()
119
+ .map(key => [key, sortKeys((value as Record<string, unknown>)[key])])
120
+ );
121
+ }
122
+ return value;
123
+ }
124
+
125
+ function deepEq(actual: unknown, expected: unknown, label: string): void {
126
+ const a = stable(actual);
127
+ const b = stable(expected);
128
+
129
+ if (a !== b) throw new Error(`${label}: expected ${b}, got ${a}`);
130
+ }
131
+
132
+ /* ------------------------------ 1. parsing ------------------------------ */
133
+
134
+ json.t(
135
+ "a valid document parses into a GraphDefinition (string and object input agree)",
136
+ async t => {
137
+ const fromObject = parseGraphJSON(DOCUMENT);
138
+ const fromText = parseGraphJSON(JSON.stringify(DOCUMENT));
139
+
140
+ deepEq(fromText, fromObject, "text and object input agree");
141
+
142
+ eq(fromObject.id, "order-flow", "id");
143
+ eq(fromObject.version, "1.0.0", "version");
144
+ deepEq(fromObject.entryNodes, ["classify"], "entryNodes");
145
+ deepEq(
146
+ fromObject.terminalNodes,
147
+ ["ship", "manual", "reject"],
148
+ "terminalNodes"
149
+ );
150
+ eq(fromObject.completionPolicy, "FIRST_TERMINAL", "completionPolicy");
151
+ deepEq(Object.keys(fromObject.nodes), ["classify", "charge", "ship", "manual", "reject"], "nodes");
152
+
153
+ eq(fromObject.nodes.classify.run, "classifyOrder", "run stays a name");
154
+ eq(
155
+ typeof fromObject.nodes.classify.next?.[0],
156
+ "object",
157
+ "edge objects survive parsing"
158
+ );
159
+ deepEq(
160
+ fromObject.nodes.charge.next,
161
+ ["ship"],
162
+ "string edges stay strings"
163
+ );
164
+ eq(fromObject.nodes.charge.onThrow, "manual", "onThrow");
165
+ eq(fromObject.nodes.charge.errorPolicy, "RETRY", "errorPolicy");
166
+ deepEq(
167
+ fromObject.nodes.charge.retry,
168
+ { maxAttempts: 2, delayMs: 1 },
169
+ "retry"
170
+ );
171
+
172
+ t.log("finding: parsing never needs the registry — a data file is valid even when a function name is unknown; the engine decides that at run time");
173
+ },
174
+ BIG
175
+ );
176
+
177
+ json.t(
178
+ "node ids default to their keys, and a mismatching explicit id is an issue",
179
+ async t => {
180
+ const parsed = parseGraphJSON({
181
+ id: "g",
182
+ version: "1",
183
+ entryNodes: ["a"],
184
+ terminalNodes: ["a"],
185
+ nodes: { a: { run: "fn" } }
186
+ });
187
+
188
+ eq(parsed.nodes.a.id, "a", "id defaults to the key");
189
+
190
+ const paths = issuePaths({
191
+ id: "g",
192
+ version: "1",
193
+ entryNodes: ["a"],
194
+ terminalNodes: ["a"],
195
+ nodes: { a: { id: "elsewhere", run: "fn" } }
196
+ });
197
+
198
+ ok(paths.includes("nodes.a.id"), paths.join(","));
199
+ },
200
+ BIG
201
+ );
202
+
203
+ json.t(
204
+ "deep-equality results, named tests and parallel flags survive the round trip",
205
+ async t => {
206
+ const parsed = parseGraphJSON({
207
+ id: "g",
208
+ version: "1",
209
+ entryNodes: ["a"],
210
+ terminalNodes: ["b", "c", "d"],
211
+ nodes: {
212
+ a: {
213
+ run: "fn",
214
+ next: [
215
+ { test: "isBig", nextNode: "b" },
216
+ { result: { nested: [1, { two: "2" }], nil: null }, nextNode: "c" },
217
+ "d"
218
+ ]
219
+ },
220
+ b: { run: "fn" },
221
+ c: { run: "fn" },
222
+ d: { run: "fn" }
223
+ }
224
+ });
225
+
226
+ const edges = parsed.nodes.a.next ?? [];
227
+
228
+ deepEq(edges[0], { test: "isBig", nextNode: "b" }, "named test edge");
229
+ deepEq(
230
+ edges[1],
231
+ { result: { nested: [1, { two: "2" }], nil: null }, nextNode: "c" },
232
+ "structured result matcher"
233
+ );
234
+ eq(edges[2], "d", "string edge");
235
+ },
236
+ BIG
237
+ );
238
+
239
+ /* ------------------------------ 2. end-to-end ------------------------------ */
240
+
241
+ json.t(
242
+ "createEngineFromJSON runs the data file and reports the registry fingerprint",
243
+ async t => {
244
+ const engine = createEngineFromJSON(JSON.stringify(DOCUMENT), {
245
+ functions: FUNCTIONS,
246
+ lint: "error"
247
+ });
248
+
249
+ const captured = capture(engine, ["node.succeeded"]);
250
+ const report = await engine.run();
251
+
252
+ eq(report.status, "COMPLETED", "status");
253
+ deepEq(
254
+ report.results.map(result => result.nodeId),
255
+ ["classify", "charge", "ship"],
256
+ "path"
257
+ );
258
+ deepEq(
259
+ report.results.map(result => result.fn),
260
+ ["classifyOrder", "chargeCard", "shipOrder"],
261
+ "fn per activation"
262
+ );
263
+ deepEq(report.lintIssues, [], "the data file is lint-clean");
264
+ deepEq(
265
+ report.registry?.names,
266
+ [
267
+ "chargeCard",
268
+ "classifyOrder",
269
+ "isPriority",
270
+ "manualReview",
271
+ "rejectOrder",
272
+ "shipOrder"
273
+ ],
274
+ "registry names are sorted"
275
+ );
276
+ ok(
277
+ (report.registry?.fingerprint ?? "").startsWith("fnr1:6:"),
278
+ `fingerprint: ${report.registry?.fingerprint}`
279
+ );
280
+ eq(
281
+ (captured.at(-1)?.payload as { fn?: string }).fn,
282
+ "shipOrder",
283
+ "events carry the function name"
284
+ );
285
+ },
286
+ BIG
287
+ );
288
+
289
+ json.t(
290
+ "a named test in the document routes via the supplied registry",
291
+ async t => {
292
+ const engine = createEngineFromJSON(DOCUMENT, {
293
+ functions: { ...FUNCTIONS, classifyOrder: () => "standard" }
294
+ });
295
+
296
+ const report = await engine.run();
297
+
298
+ eq(report.status, "COMPLETED", "status");
299
+ deepEq(
300
+ report.results.map(result => result.nodeId),
301
+ ["classify", "reject"],
302
+ "the else edge caught the unmatched value"
303
+ );
304
+ eq(report.outputs.reject, "rejected", "output");
305
+ },
306
+ BIG
307
+ );
308
+
309
+ json.t(
310
+ "a data file with an unknown name runs and fails with UNKNOWN_FUNCTION",
311
+ async t => {
312
+ const engine = createEngineFromJSON(DOCUMENT, {
313
+ functions: { ...FUNCTIONS, shipOrder: undefined }
314
+ });
315
+
316
+ const report = await engine.run();
317
+
318
+ eq(report.status, "FAILED", "status");
319
+ eq(report.errors[0].code, "UNKNOWN_FUNCTION", "code");
320
+ eq(report.errors[0].nodeId, "ship", "nodeId");
321
+ eq(report.errors[0].handled, false, "handled");
322
+ eq(
323
+ (report.errors[0].details as { name: string }).name,
324
+ "shipOrder",
325
+ "the missing name is named"
326
+ );
327
+ deepEq(
328
+ report.results.map(result => result.status),
329
+ ["SUCCEEDED", "SUCCEEDED", "FAILED"],
330
+ "everything before the missing node ran"
331
+ );
332
+ },
333
+ BIG
334
+ );
335
+
336
+ json.t(
337
+ "the parsed definition is accepted by the engine unchanged",
338
+ async t => {
339
+ const definition = parseGraphJSON(DOCUMENT);
340
+ const engine = new GraphExecutionEngine(definition, {
341
+ functions: FUNCTIONS
342
+ });
343
+
344
+ const report = await engine.run();
345
+
346
+ eq(report.status, "COMPLETED", "the loader output needs no fixing up");
347
+ eq(engine.registry.names.length, 6, "registry intact");
348
+
349
+ /* the engine's own validation would have thrown on a malformed graph */
350
+ ok(definition.nodes.classify.run === "classifyOrder", "run name preserved");
351
+ },
352
+ BIG
353
+ );
354
+
355
+ /* ------------------------------ 3. validation: structure ------------------------------ */
356
+
357
+ json.t(
358
+ "invalid JSON text is a single, clear GraphJSONError",
359
+ async t => {
360
+ let caught: unknown;
361
+
362
+ try {
363
+ parseGraphJSON("{ not json ");
364
+ } catch (error) {
365
+ caught = error;
366
+ }
367
+
368
+ ok(caught instanceof GraphJSONError, "GraphJSONError expected");
369
+ eq((caught as GraphJSONError).code, "GRAPH_JSON_ERROR", "code");
370
+ deepEq(
371
+ (caught as GraphJSONError).issues.map(issue => issue.path),
372
+ ["$"],
373
+ "path is the document root"
374
+ );
375
+ ok(
376
+ (caught as GraphJSONError).message.includes("not parseable JSON"),
377
+ (caught as GraphJSONError).message
378
+ );
379
+ },
380
+ BIG
381
+ );
382
+
383
+ json.t(
384
+ "a non-object document is rejected with one issue",
385
+ async t => {
386
+ const paths = issuePaths([1, 2, 3]);
387
+
388
+ deepEq(paths, ["$"], "one issue at the root");
389
+ ok(
390
+ issuesOf([1, 2, 3])[0].message.includes("array"),
391
+ "the message names what arrived"
392
+ );
393
+
394
+ deepEq(issuePaths(null), ["$"], "null");
395
+ deepEq(issuePaths(42), ["$"], "a number");
396
+ },
397
+ BIG
398
+ );
399
+
400
+ json.t(
401
+ "unknown properties are rejected at every level",
402
+ async t => {
403
+ const paths = issuePaths({
404
+ id: "g",
405
+ version: "1",
406
+ entryNodes: ["a"],
407
+ terminalNodes: ["a"],
408
+ extra: true,
409
+ nodes: {
410
+ a: { run: "fn", wat: 1, next: [{ nextNode: "a", nope: 2 }] }
411
+ }
412
+ });
413
+
414
+ ok(paths.includes("extra"), paths.join(","));
415
+ ok(paths.includes("nodes.a.wat"), paths.join(","));
416
+ ok(paths.includes("nodes.a.next[0].nope"), paths.join(","));
417
+ },
418
+ BIG
419
+ );
420
+
421
+ json.t(
422
+ "functions cannot live in the document — the issue points at the engine options",
423
+ async t => {
424
+ const issues = issuesOf({ ...DOCUMENT, functions: { a: 1 } });
425
+
426
+ eq(issues.length, 1, "one issue");
427
+ eq(issues[0].path, "functions", "path");
428
+ ok(
429
+ issues[0].message.includes("createEngineFromJSON(json, { functions })"),
430
+ issues[0].message
431
+ );
432
+ },
433
+ BIG
434
+ );
435
+
436
+ /* ------------------------------ 4. validation: semantics ------------------------------ */
437
+
438
+ json.t(
439
+ "dangling references are reported with their paths",
440
+ async t => {
441
+ const paths = issuePaths({
442
+ id: "g",
443
+ version: "1",
444
+ entryNodes: ["ghost"],
445
+ terminalNodes: ["a"],
446
+ nodes: {
447
+ a: {
448
+ run: "fn",
449
+ next: ["nowhere", { nextNode: "void" }],
450
+ onThrow: "nobody",
451
+ fallback: "nobody"
452
+ }
453
+ }
454
+ });
455
+
456
+ ok(paths.includes("entryNodes[0]"), paths.join(","));
457
+ ok(paths.includes("nodes.a.next[0]"), paths.join(","));
458
+ ok(paths.includes("nodes.a.next[1]"), paths.join(","));
459
+ ok(paths.includes("nodes.a.onThrow"), paths.join(","));
460
+ ok(paths.includes("nodes.a.fallback"), paths.join(","));
461
+ },
462
+ BIG
463
+ );
464
+
465
+ json.t(
466
+ "malformed edges are rejected entry by entry",
467
+ async t => {
468
+ const paths = issuePaths({
469
+ id: "g",
470
+ version: "1",
471
+ entryNodes: ["a"],
472
+ terminalNodes: ["a"],
473
+ nodes: {
474
+ a: {
475
+ run: "fn",
476
+ next: [
477
+ 42,
478
+ {},
479
+ { nextNode: "" },
480
+ { nextNode: "a", parallel: "yes" },
481
+ { nextNode: "a", test: 7 },
482
+ { nextNode: "a", test: "not a name!" }
483
+ ]
484
+ }
485
+ }
486
+ });
487
+
488
+ for (const expected of [
489
+ "nodes.a.next[0]",
490
+ "nodes.a.next[1].nextNode",
491
+ "nodes.a.next[2].nextNode",
492
+ "nodes.a.next[3].parallel",
493
+ "nodes.a.next[4].test",
494
+ "nodes.a.next[5].test"
495
+ ]) {
496
+ ok(paths.includes(expected), `${expected} missing from ${paths.join(",")}`);
497
+ }
498
+
499
+ /* `next: {}` is not an array either */
500
+ const scalarPaths = issuePaths({
501
+ id: "g",
502
+ version: "1",
503
+ entryNodes: ["a"],
504
+ terminalNodes: ["a"],
505
+ nodes: { a: { run: "fn", next: {} } }
506
+ });
507
+
508
+ ok(scalarPaths.includes("nodes.a.next"), scalarPaths.join(","));
509
+ },
510
+ BIG
511
+ );
512
+
513
+ json.t(
514
+ "invalid scalars, policies and retry blocks are rejected",
515
+ async t => {
516
+ const paths = issuePaths({
517
+ id: "",
518
+ version: "",
519
+ defaultNodeTimeoutMs: 0,
520
+ errorPolicy: "NOPE",
521
+ checkpointEveryNode: "yes",
522
+ completionPolicy: "SOMETIMES",
523
+ entryNodes: "a",
524
+ terminalNodes: ["a"],
525
+ nodes: {
526
+ a: {
527
+ run: 42,
528
+ timeoutMs: -5,
529
+ errorPolicy: "MAYBE",
530
+ retry: { maxAttempts: 0, delayMs: -1, extra: true }
531
+ }
532
+ }
533
+ });
534
+
535
+ for (const expected of [
536
+ "id",
537
+ "version",
538
+ "defaultNodeTimeoutMs",
539
+ "errorPolicy",
540
+ "checkpointEveryNode",
541
+ "completionPolicy",
542
+ "entryNodes",
543
+ "nodes.a.run",
544
+ "nodes.a.timeoutMs",
545
+ "nodes.a.errorPolicy",
546
+ "nodes.a.retry.maxAttempts",
547
+ "nodes.a.retry.delayMs",
548
+ "nodes.a.retry.extra"
549
+ ]) {
550
+ ok(paths.includes(expected), `${expected} missing from ${paths.join(",")}`);
551
+ }
552
+
553
+ /* a run name with illegal characters is a shape error too */
554
+ const namePaths = issuePaths({
555
+ id: "g",
556
+ version: "1",
557
+ entryNodes: ["a"],
558
+ terminalNodes: ["a"],
559
+ nodes: { a: { run: "has space" } }
560
+ });
561
+
562
+ ok(namePaths.includes("nodes.a.run"), namePaths.join(","));
563
+ },
564
+ BIG
565
+ );
566
+
567
+ json.t(
568
+ "EVERY issue is collected — one throw carries them all",
569
+ async t => {
570
+ const issues = issuesOf({
571
+ id: "",
572
+ version: "1",
573
+ entryNodes: ["ghost"],
574
+ terminalNodes: ["a"],
575
+ extra: 1,
576
+ nodes: {
577
+ a: { run: "fn", next: [{ nextNode: "nowhere" }, 7] },
578
+ b: { run: 12 }
579
+ }
580
+ });
581
+
582
+ ok(issues.length >= 6, `expected several issues, got ${issues.length}`);
583
+ eq(
584
+ new Set(issues.map(issue => issue.path)).size,
585
+ issues.length,
586
+ "paths are distinct"
587
+ );
588
+ ok(
589
+ issues.every(issue => issue.message.length > 0),
590
+ "every issue has a message"
591
+ );
592
+
593
+ const caught = catchJSONError({
594
+ id: "",
595
+ version: "",
596
+ entryNodes: [],
597
+ terminalNodes: [],
598
+ nodes: {}
599
+ });
600
+
601
+ ok(/problem\(s\)/.test(caught.message), caught.message);
602
+ ok(caught.issues.length >= 5, `issues=${caught.issues.length}`);
603
+
604
+ t.log(`finding: one document reported ${caught.issues.length} problems in a single throw — no fix-one-rerun loop`);
605
+ },
606
+ BIG
607
+ );
608
+
609
+ /* ------------------------------ 5. surface ------------------------------ */
610
+
611
+ json.t(
612
+ "isGraphJSON() is a cheap shape probe, not a validator",
613
+ async t => {
614
+ eq(isGraphJSON(JSON.stringify(DOCUMENT)), true, "text");
615
+ eq(isGraphJSON(DOCUMENT), true, "object");
616
+ eq(isGraphJSON({ id: "g" }), false, "no nodes");
617
+ eq(isGraphJSON("{ not json"), false, "broken text");
618
+ eq(isGraphJSON(null), false, "null");
619
+ eq(isGraphJSON([1]), false, "array");
620
+ eq(isGraphJSON(parseGraphJSON(DOCUMENT)), true, "parser output");
621
+ },
622
+ BIG
623
+ );
624
+
625
+ json.t(
626
+ "an in-memory object may pass real functions through (escape hatch)",
627
+ async t => {
628
+ const parsed = parseGraphJSON({
629
+ id: "in-memory",
630
+ version: "1.0.0",
631
+ entryNodes: ["a"],
632
+ terminalNodes: ["b"],
633
+ nodes: {
634
+ a: { run: () => "inline through the loader", next: ["b"] },
635
+ b: { run: () => "B" }
636
+ }
637
+ });
638
+
639
+ const engine = new GraphExecutionEngine(parsed);
640
+
641
+ const report = await engine.run();
642
+
643
+ eq(report.status, "COMPLETED", "status");
644
+ eq(report.outputs.a, "inline through the loader", "output");
645
+ eq(report.registry, undefined, "no names, no registry");
646
+ eq(report.results[0].fn, undefined, "inline activation has no fn");
647
+ },
648
+ BIG
649
+ );
650
+
651
+ json.t(
652
+ "a parsed document with dangling ids is still caught by the engine if it is hand-edited",
653
+ async t => {
654
+ const definition = parseGraphJSON(DOCUMENT);
655
+ const tampered = {
656
+ ...definition,
657
+ nodes: {
658
+ ...definition.nodes,
659
+ classify: { ...definition.nodes.classify, next: ["ghost"] }
660
+ }
661
+ };
662
+
663
+ try {
664
+ new GraphExecutionEngine(tampered as typeof definition, {
665
+ functions: FUNCTIONS
666
+ });
667
+ throw new Error("expected the engine to reject the tampered graph");
668
+ } catch (error) {
669
+ if (!(error instanceof GraphValidationError)) throw error;
670
+ ok(
671
+ error.message.includes('unknown next Node "ghost"'),
672
+ error.message
673
+ );
674
+ }
675
+
676
+ t.log("finding: the loader and the engine validate different layers (paths vs. final shape) — both stay in force");
677
+ },
678
+ BIG
679
+ );
680
+
681
+ json.t(
682
+ "the loader example document is valid (regression: the shipped .json file)",
683
+ async t => {
684
+ /* mirrors examples/order-flow.graph.json */
685
+ const shipped = {
686
+ id: "order-flow",
687
+ version: "1.0.0",
688
+ entryNodes: ["classify"],
689
+ terminalNodes: ["ship", "manual", "reject"],
690
+ completionPolicy: "FIRST_TERMINAL",
691
+ nodes: {
692
+ classify: {
693
+ run: "classifyOrder",
694
+ next: [
695
+ { test: "isPriority", nextNode: "charge" },
696
+ { result: "manual", nextNode: "manual" },
697
+ { nextNode: "reject" }
698
+ ]
699
+ },
700
+ charge: {
701
+ run: "chargeCard",
702
+ next: ["ship"],
703
+ onThrow: "manual",
704
+ errorPolicy: "RETRY",
705
+ retry: { maxAttempts: 2, delayMs: 5 }
706
+ },
707
+ ship: { run: "shipOrder" },
708
+ manual: { run: "manualReview" },
709
+ reject: { run: "rejectOrder" }
710
+ }
711
+ };
712
+
713
+ const engine = createEngineFromJSON(shipped, {
714
+ functions: FUNCTIONS,
715
+ lint: "error"
716
+ });
717
+
718
+ const report = await engine.run();
719
+
720
+ eq(report.status, "COMPLETED", "status");
721
+ deepEq(report.lintIssues, [], "lint-clean");
722
+
723
+ /* and the same document, missing one name, is still lint-clean but fails
724
+ * exactly once at the missing node */
725
+ const partial = createEngineFromJSON(shipped, {
726
+ functions: { ...FUNCTIONS, chargeCard: undefined },
727
+ lint: "error"
728
+ });
729
+
730
+ const partialReport = await partial.run();
731
+
732
+ eq(partialReport.errors.length, 1, "one defect");
733
+ eq(partialReport.errors[0].code, "UNKNOWN_FUNCTION", "code");
734
+ eq(partialReport.status, "FAILED", "status");
735
+ },
736
+ BIG
737
+ );