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,475 @@
1
+ /* ============================================================
2
+ * Branching router example — the a → b/c/d/e/f picture
3
+ * ------------------------------------------------------------
4
+ * The graph under discussion:
5
+ *
6
+ * a ── result === 1 ────────────────▶ b ──(forces)──▶ f
7
+ * a ── test(result) === true ───────▶ c
8
+ * a ── otherwise ───────────────────▶ d
9
+ * a ── on throw ────────────────────▶ e
10
+ * a ── on retry ────────────────────▶ f
11
+ *
12
+ * WHY THIS IS NOT A SINGLE GraphDefinition
13
+ * GraphDefinition.next is an UNCONDITIONAL fan-out: every target is
14
+ * scheduled, every time. The engine also has no "goto" and no
15
+ * per-attempt transition. So the arrows above cannot be encoded in
16
+ * the definition — they are routing *decisions*, and decisions need
17
+ * a place to run. This file keeps src/automategraph.ts untouched and
18
+ * puts the decisions in a small host-side router: one engine
19
+ * execution per step, the router inspects the finished execution
20
+ * (output / attempts / errors / control events) and chooses the next
21
+ * hop.
22
+ *
23
+ * Arrow → mechanism used here (all engine-native)
24
+ * ------------------------------------------------------------------
25
+ * result === 1 → returned value + ordered `routes` predicate
26
+ * test(...) === true→ returned value + ordered `routes` predicate
27
+ * otherwise → `otherwise` fallback target
28
+ * on throw → report.status / unhandled error → `onThrow`
29
+ * on retry → context.attempt > 1 (seen via node.started) → `onRetry`
30
+ * b forces f → b returns void and emits "router.goto" on the
31
+ * public event bus; the router honours the request
32
+ *
33
+ * Decision precedence (first match wins): goto → throw → retry →
34
+ * routes → otherwise → stop. Flip `onRetry` before `onThrow` in
35
+ * decide() if you prefer "any retry goes to f, even a failed one".
36
+ *
37
+ * Run it: npm run example:branching
38
+ * Exits non-zero if any scenario deviates from its expected path, so
39
+ * it doubles as a smoke test.
40
+ * ============================================================ */
41
+
42
+ import {
43
+ AutomateGraphError,
44
+ ErrorPolicy,
45
+ ExecutionReport,
46
+ GraphDefinition,
47
+ GraphStatus,
48
+ InMemoryStorage,
49
+ NodeContext,
50
+ NodeDefinition,
51
+ RetryPolicy,
52
+ GraphExecutionEngine
53
+ } from "../src/automategraph";
54
+
55
+ /* =========================
56
+ * 1. The graph as data
57
+ * ========================= */
58
+
59
+ /** What node `a` may return: 1, 2 or 3 — or it may fail at runtime. */
60
+ export type ScenarioSeed = 1 | 2 | 3 | "throw" | "retry";
61
+
62
+ /** The spec's `test(result)`: decides between c and d. */
63
+ export const test = (result: number): boolean => result === 2;
64
+
65
+ /** Mutable context threaded through the whole walk. */
66
+ export interface WalkState {
67
+ seed: ScenarioSeed;
68
+ visited: string[];
69
+ lastError?: { nodeId: string; code: string; message: string };
70
+ notes: string[];
71
+ }
72
+
73
+ export interface RouteTable {
74
+ /** Ordered conditional arrows, evaluated against the returned value. */
75
+ readonly routes?: ReadonlyArray<{
76
+ readonly when: (value: unknown) => boolean;
77
+ readonly to: string;
78
+ }>;
79
+ /** The `else` arrow. */
80
+ readonly otherwise?: string;
81
+ /** Arrow taken when the step failed with no attempts left. */
82
+ readonly onThrow?: string;
83
+ /** Arrow taken when the step needed more than one attempt. */
84
+ readonly onRetry?: string;
85
+ }
86
+
87
+ /** One node in the picture: a callback plus its outgoing arrows. */
88
+ export interface StepDefinition {
89
+ readonly run: (state: WalkState, context: NodeContext) => unknown | Promise<unknown>;
90
+ readonly retry?: RetryPolicy;
91
+ readonly errorPolicy?: ErrorPolicy;
92
+ readonly routes?: RouteTable;
93
+ }
94
+
95
+ /** Event a node emits when it wants to force the next hop from inside. */
96
+ export const GOTO_EVENT = "router.goto";
97
+
98
+ export interface GotoRequest {
99
+ to: string;
100
+ reason?: string;
101
+ }
102
+
103
+ /* =========================
104
+ * 2. The nodes
105
+ * ========================= */
106
+
107
+ export function buildSteps(state: WalkState): Record<string, StepDefinition> {
108
+ return {
109
+ /**
110
+ * a — produces 1 | 2 | 3, or throws.
111
+ * It carries the whole arrow table from the picture.
112
+ */
113
+ a: {
114
+ run: (_state, context) => {
115
+ if (state.seed === "throw") {
116
+ throw new Error("a: upstream exploded");
117
+ }
118
+
119
+ if (state.seed === "retry" && context.attempt === 1) {
120
+ throw new Error(`a: transient failure on attempt ${context.attempt}`);
121
+ }
122
+
123
+ // On the retry path, attempt 2 succeeds with value 2.
124
+ return state.seed === "retry" ? 2 : state.seed;
125
+ },
126
+ retry: { maxAttempts: 2, delayMs: 10 },
127
+ errorPolicy: "RETRY",
128
+ routes: {
129
+ routes: [
130
+ { when: value => value === 1, to: "b" },
131
+ { when: value => typeof value === "number" && test(value), to: "c" }
132
+ ],
133
+ otherwise: "d",
134
+ onThrow: "e",
135
+ onRetry: "f"
136
+ }
137
+ },
138
+
139
+ /**
140
+ * b — returns void, but forces the walk to f *from inside its own
141
+ * execution* by emitting a control message. The engine never
142
+ * schedules the jump; the router (host code) performs it.
143
+ */
144
+ b: {
145
+ run: async (_state, context) => {
146
+ await context.emit(GOTO_EVENT, {
147
+ to: "f",
148
+ reason: "b short-circuits to f"
149
+ } satisfies GotoRequest);
150
+
151
+ return undefined; // void, as specified
152
+ },
153
+ // Belt and braces: also true if a goto is ever ignored.
154
+ routes: { otherwise: "f" }
155
+ },
156
+
157
+ c: { run: () => "c: even result handled" },
158
+ d: { run: () => "d: fallback branch handled" },
159
+
160
+ /** e — the throw route; can read the failure the router recorded. */
161
+ e: {
162
+ run: () => `e: recovered from ${state.lastError?.nodeId ?? "?"} (${state.lastError?.code ?? "?"})`
163
+ },
164
+
165
+ f: { run: () => "f: retry/forced path handled" }
166
+ };
167
+ }
168
+
169
+ /* =========================
170
+ * 3. One step = one execution
171
+ * ========================= */
172
+
173
+ /**
174
+ * Build the single-node graph for a hop. The node is BOTH the entry
175
+ * and the terminal, which keeps the graph lint-clean (the v1.2
176
+ * SUCCESS_DEAD_END rule needs each non-terminal node to have a
177
+ * `next`) and guarantees the engine schedules nothing on its own.
178
+ *
179
+ * `onThrow` is deliberately NOT declared on the engine node: its
180
+ * target would have to exist inside this one-node graph, and error
181
+ * routing is the router's job here (see decide()).
182
+ */
183
+ function stepGraph(id: string, step: StepDefinition, state: WalkState): GraphDefinition {
184
+ const definition: NodeDefinition = {
185
+ id,
186
+ run: (context: NodeContext) => step.run(state, context),
187
+ retry: step.retry,
188
+ errorPolicy: step.errorPolicy
189
+ };
190
+
191
+ return {
192
+ id: `branching-step:${id}`,
193
+ version: "1.0.0",
194
+ entryNodes: [id],
195
+ terminalNodes: [id],
196
+ nodes: { [id]: definition }
197
+ };
198
+ }
199
+
200
+ export type DecisionBy = "goto" | "throw" | "retry" | "return" | "otherwise" | "none";
201
+
202
+ export interface HopRecord {
203
+ step: number;
204
+ nodeId: string;
205
+ executionId: string;
206
+ status: GraphStatus;
207
+ attempts: number;
208
+ output: unknown;
209
+ errorCode?: string;
210
+ decision: { by: DecisionBy; to?: string; reason: string };
211
+ durationMs: number;
212
+ }
213
+
214
+ interface RawRun {
215
+ report: ExecutionReport;
216
+ attempts: number;
217
+ gotoRequests: GotoRequest[];
218
+ }
219
+
220
+ function isGotoRequest(value: unknown): value is GotoRequest {
221
+ return (
222
+ typeof value === "object" &&
223
+ value !== null &&
224
+ typeof (value as GotoRequest).to === "string"
225
+ );
226
+ }
227
+
228
+ async function runOne(
229
+ nodeId: string,
230
+ step: StepDefinition,
231
+ state: WalkState,
232
+ storage: InMemoryStorage
233
+ ): Promise<RawRun> {
234
+ const engine = new GraphExecutionEngine(stepGraph(nodeId, step, state), {
235
+ storage,
236
+ maxActivations: 1 // a hop starts exactly one activation
237
+ });
238
+
239
+ let attempts = 0;
240
+ const gotoRequests: GotoRequest[] = [];
241
+
242
+ // Required listeners are awaited by the bus, so by the time run()
243
+ // resolves we have the true attempt count and every goto request.
244
+ engine.events.on(
245
+ "node.started",
246
+ payload => {
247
+ const { attempt } = payload as { attempt: number };
248
+ attempts = Math.max(attempts, attempt);
249
+ },
250
+ { required: true }
251
+ );
252
+
253
+ engine.events.on(
254
+ GOTO_EVENT,
255
+ payload => {
256
+ if (isGotoRequest(payload)) gotoRequests.push(payload);
257
+ },
258
+ { required: true }
259
+ );
260
+
261
+ const report = await engine.run();
262
+ return { report, attempts, gotoRequests };
263
+ }
264
+
265
+ /* =========================
266
+ * 4. The router
267
+ * ========================= */
268
+
269
+ export interface WalkOptions {
270
+ readonly maxSteps?: number;
271
+ readonly storage?: InMemoryStorage;
272
+ readonly onHop?: (hop: HopRecord) => void;
273
+ }
274
+
275
+ export interface WalkResult {
276
+ hops: HopRecord[];
277
+ path: string[];
278
+ executionIds: string[];
279
+ }
280
+
281
+ /**
282
+ * Decision precedence: goto → throw → retry → returned value → stop.
283
+ * A *failed* step goes to `onThrow` even when it consumed retries;
284
+ * `onRetry` is for a step that recovered on a later attempt.
285
+ */
286
+ export function decide(
287
+ routes: RouteTable | undefined,
288
+ run: RawRun
289
+ ): { by: DecisionBy; to?: string; reason: string } {
290
+ const { report, attempts, gotoRequests } = run;
291
+
292
+ if (gotoRequests.length > 0) {
293
+ const request = gotoRequests[0];
294
+ return {
295
+ by: "goto",
296
+ to: request.to,
297
+ reason: `forced from inside the node (${request.reason ?? "no reason given"})`
298
+ };
299
+ }
300
+
301
+ const failed = report.status !== "COMPLETED";
302
+
303
+ if (failed) {
304
+ if (routes?.onThrow) {
305
+ return { by: "throw", to: routes.onThrow, reason: `step ended ${report.status}` };
306
+ }
307
+ return { by: "none", reason: `step ended ${report.status} and no onThrow is declared` };
308
+ }
309
+
310
+ if (attempts > 1 && routes?.onRetry) {
311
+ return { by: "retry", to: routes.onRetry, reason: `recovered on attempt ${attempts}` };
312
+ }
313
+
314
+ const output = report.results[0]?.output;
315
+
316
+ for (const route of routes?.routes ?? []) {
317
+ if (route.when(output)) {
318
+ return { by: "return", to: route.to, reason: "value route matched" };
319
+ }
320
+ }
321
+
322
+ if (routes?.otherwise) {
323
+ return { by: "otherwise", to: routes.otherwise, reason: "no value route matched" };
324
+ }
325
+
326
+ return { by: "none", reason: "no transition declared; walk complete" };
327
+ }
328
+
329
+ /* =========================
330
+ * 5. The walk factory (public entry point)
331
+ * ========================= */
332
+
333
+ /**
334
+ * Seeds the walk, builds the steps against the shared state, and runs
335
+ * the router loop. This is the whole "interpreter": run one node, read
336
+ * its outcome, follow the matching arrow.
337
+ */
338
+ export async function runWalk(
339
+ seed: ScenarioSeed,
340
+ options: WalkOptions = {}
341
+ ): Promise<{ result: WalkResult; state: WalkState }> {
342
+ const storage = options.storage ?? new InMemoryStorage();
343
+ const maxSteps = options.maxSteps ?? 64;
344
+
345
+ const state: WalkState = { seed, visited: [], notes: [] };
346
+ const steps = buildSteps(state);
347
+ const hops: HopRecord[] = [];
348
+ const executionIds: string[] = [];
349
+
350
+ let current: string | undefined = "a";
351
+
352
+ while (current !== undefined) {
353
+ if (hops.length >= maxSteps) {
354
+ throw new AutomateGraphError(
355
+ `Walk exceeded maxSteps=${maxSteps} (possible cycle)`,
356
+ "WALK_STEP_LIMIT",
357
+ { visited: [...state.visited] }
358
+ );
359
+ }
360
+
361
+ const step: StepDefinition | undefined = steps[current];
362
+
363
+ if (!step) {
364
+ throw new AutomateGraphError(
365
+ `Route target "${current}" has no node definition`,
366
+ "ROUTE_TARGET_UNKNOWN",
367
+ { visited: [...state.visited] }
368
+ );
369
+ }
370
+
371
+ const startedAt = Date.now();
372
+ const run = await runOne(current, step, state, storage);
373
+ const { report, attempts, gotoRequests } = run;
374
+
375
+ // Record the failure so the next hop (e) can read it.
376
+ const unhandled = report.errors.find(error => !error.handled);
377
+ if (unhandled) {
378
+ state.lastError = {
379
+ nodeId: unhandled.nodeId ?? current,
380
+ code: unhandled.code,
381
+ message: unhandled.message
382
+ };
383
+ }
384
+
385
+ const decision = decide(step.routes, run);
386
+
387
+ const hop: HopRecord = {
388
+ step: hops.length + 1,
389
+ nodeId: current,
390
+ executionId: report.executionId,
391
+ status: report.status,
392
+ attempts,
393
+ output: report.results[0]?.output,
394
+ errorCode: report.errors[0]?.code,
395
+ decision,
396
+ durationMs: Date.now() - startedAt
397
+ };
398
+
399
+ hops.push(hop);
400
+ executionIds.push(report.executionId);
401
+ state.visited.push(current);
402
+ options.onHop?.(hop);
403
+
404
+ if (gotoRequests.length > 1) {
405
+ state.notes.push(`${current} requested ${gotoRequests.length} jumps; using the first`);
406
+ }
407
+
408
+ current = decision.to;
409
+ }
410
+
411
+ return { result: { hops, path: state.visited, executionIds }, state };
412
+ }
413
+
414
+ /* =========================
415
+ * 6. Demo — proves the picture
416
+ * ========================= */
417
+
418
+ const SCENARIOS: ReadonlyArray<{ label: string; seed: ScenarioSeed; expected: string[] }> = [
419
+ { label: "result === 1 → b, then b forces f", seed: 1, expected: ["a", "b", "f"] },
420
+ { label: "test(result) === true → c", seed: 2, expected: ["a", "c"] },
421
+ { label: "otherwise → d", seed: 3, expected: ["a", "d"] },
422
+ { label: "on throw → e", seed: "throw", expected: ["a", "e"] },
423
+ { label: "on retry (recovered) → f", seed: "retry", expected: ["a", "f"] }
424
+ ];
425
+
426
+ function show(value: unknown): string {
427
+ return value === undefined ? "undefined" : JSON.stringify(value);
428
+ }
429
+
430
+ async function main(): Promise<void> {
431
+ const storage = new InMemoryStorage();
432
+ let failures = 0;
433
+
434
+ console.log("=".repeat(96));
435
+ console.log("Branching router — engine untouched, arrows resolved by the host-side router");
436
+ console.log("=".repeat(96));
437
+
438
+ for (const scenario of SCENARIOS) {
439
+ const { result } = await runWalk(scenario.seed, { storage });
440
+ const path = result.path.join(" → ");
441
+ const expected = scenario.expected.join(" → ");
442
+ const ok = path === expected;
443
+ if (!ok) failures += 1;
444
+
445
+ console.log(`\n▶ seed = ${JSON.stringify(scenario.seed)} ${scenario.label}`);
446
+
447
+ for (const hop of result.hops) {
448
+ const target = hop.decision.to ? `→ ${hop.decision.to}` : "stop";
449
+ console.log(
450
+ ` hop ${hop.step} ${hop.nodeId} ${hop.status.padEnd(9)}` +
451
+ ` attempts=${hop.attempts} output=${show(hop.output).padEnd(42)}` +
452
+ ` ${hop.decision.by.padEnd(9)} ${target} (${hop.decision.reason})`
453
+ );
454
+ }
455
+
456
+ console.log(` path: ${path} ${ok ? "✓ as expected" : `✗ expected ${expected}`}`);
457
+ }
458
+
459
+ const snapshotIds = await storage.listExecutionIds();
460
+
461
+ console.log(`\n${"-".repeat(96)}`);
462
+ console.log(
463
+ `scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched · ` +
464
+ `engine executions (one per hop): ${snapshotIds.length} · ` +
465
+ `checkpoints retrievable via storage.load(executionId)`
466
+ );
467
+
468
+ if (failures > 0) {
469
+ process.exitCode = 1;
470
+ }
471
+ }
472
+
473
+ if (require.main === module) {
474
+ void main();
475
+ }
@@ -0,0 +1,231 @@
1
+ /* ============================================================
2
+ * Graph as a DATA FILE (v1.4)
3
+ * ------------------------------------------------------------
4
+ * examples/order-flow.graph.json declares nodes and edges, but no
5
+ * JavaScript. Every function is referenced by name and supplied to the
6
+ * engine at construction, so the SAME document runs against different
7
+ * implementations:
8
+ *
9
+ * classify ── isPriority ──▶ charge ──▶ ship
10
+ * ── "manual" ────▶ manual
11
+ * ── else ────────▶ reject
12
+ *
13
+ * Scenario D runs the document with a registry that is missing
14
+ * `shipOrder` — nothing throws at build time; the activation fails with
15
+ * UNKNOWN_FUNCTION and the run settles FAILED.
16
+ *
17
+ * Run: npm run example:graphjson (after npm run build)
18
+ * ============================================================ */
19
+
20
+ import * as fs from "fs";
21
+ import * as path from "path";
22
+
23
+ import {
24
+ FunctionRegistryInput,
25
+ NodeContext
26
+ } from "../src/automategraph";
27
+ import {
28
+ createEngineFromJSON,
29
+ parseGraphJSON
30
+ } from "../src/graph-json";
31
+
32
+ const DOCUMENT_PATH = path.join(
33
+ __dirname,
34
+ "..",
35
+ "..",
36
+ "examples",
37
+ "order-flow.graph.json"
38
+ );
39
+
40
+ /* ---------------------------------------------------------------
41
+ * The implementations, keyed by the names the document uses.
42
+ * ------------------------------------------------------------- */
43
+
44
+ function isPriority(value: unknown): boolean {
45
+ return value === "priority";
46
+ }
47
+
48
+ function chargeCard(context: NodeContext): string {
49
+ if (context.attempt === 1) {
50
+ throw new Error("chargeCard: gateway timeout (transient)");
51
+ }
52
+
53
+ return "charged";
54
+ }
55
+
56
+ function shipOrder(): string {
57
+ return "shipped";
58
+ }
59
+
60
+ function manualReview(): string {
61
+ return "queued for a human";
62
+ }
63
+
64
+ function rejectOrder(): string {
65
+ return "rejected";
66
+ }
67
+
68
+ /*
69
+ * Three classifier IMPLEMENTATIONS — the document itself never changes.
70
+ * Each has its own source, so the registry fingerprint moves with it.
71
+ */
72
+ function classifyAsPriority(): string {
73
+ return "priority";
74
+ }
75
+
76
+ function classifyAsManual(): string {
77
+ return "manual";
78
+ }
79
+
80
+ function classifyAsStandard(): string {
81
+ return "standard";
82
+ }
83
+
84
+ const SCENARIOS: ReadonlyArray<{
85
+ label: string;
86
+ registry: FunctionRegistryInput;
87
+ expect: string;
88
+ expectStatus: string;
89
+ }> = [
90
+ {
91
+ label: "priority order → charge → ship",
92
+ registry: {
93
+ classifyOrder: classifyAsPriority,
94
+ isPriority,
95
+ chargeCard,
96
+ shipOrder,
97
+ manualReview,
98
+ rejectOrder
99
+ },
100
+ expect: "classify → charge → ship",
101
+ expectStatus: "COMPLETED"
102
+ },
103
+ {
104
+ label: 'returned "manual" → manual review',
105
+ registry: {
106
+ classifyOrder: classifyAsManual,
107
+ isPriority,
108
+ chargeCard,
109
+ shipOrder,
110
+ manualReview,
111
+ rejectOrder
112
+ },
113
+ expect: "classify → manual",
114
+ expectStatus: "COMPLETED"
115
+ },
116
+ {
117
+ label: "anything else → else edge → reject",
118
+ registry: {
119
+ classifyOrder: classifyAsStandard,
120
+ isPriority,
121
+ chargeCard,
122
+ shipOrder,
123
+ manualReview,
124
+ rejectOrder
125
+ },
126
+ expect: "classify → reject",
127
+ expectStatus: "COMPLETED"
128
+ },
129
+ {
130
+ label: "registry missing shipOrder → UNKNOWN_FUNCTION",
131
+ registry: {
132
+ classifyOrder: classifyAsPriority,
133
+ isPriority,
134
+ chargeCard,
135
+ manualReview,
136
+ rejectOrder
137
+ },
138
+ expect: "classify → charge",
139
+ expectStatus: "FAILED"
140
+ }
141
+ ];
142
+
143
+ async function main(): Promise<void> {
144
+ const document = fs.readFileSync(DOCUMENT_PATH, "utf8");
145
+
146
+ console.log("=".repeat(96));
147
+ console.log(`graph as a data file — ${path.basename(DOCUMENT_PATH)}`);
148
+ console.log("=".repeat(96));
149
+
150
+ /* Parsing needs no registry at all: names resolve lazily. */
151
+ const definition = parseGraphJSON(document);
152
+ console.log(
153
+ `parsed: ${definition.id} v${definition.version}, ` +
154
+ `${Object.keys(definition.nodes).length} nodes, ` +
155
+ `${definition.entryNodes.join(",")} → ${definition.terminalNodes.join(",")}\n`
156
+ );
157
+
158
+ let failures = 0;
159
+ let previousFingerprint: string | undefined;
160
+
161
+ for (const scenario of SCENARIOS) {
162
+ const engine = createEngineFromJSON(document, {
163
+ functions: scenario.registry,
164
+ lint: "error",
165
+ onLog: (level, message) => {
166
+ if (level === "warn" || level === "error") {
167
+ console.log(` log[${level}] ${message}`);
168
+ }
169
+ }
170
+ });
171
+
172
+ const report = await engine.run();
173
+
174
+ const path2 = report.results
175
+ .filter(result => result.status === "SUCCEEDED")
176
+ .map(result => result.nodeId)
177
+ .join(" → ");
178
+
179
+ const ok =
180
+ report.status === scenario.expectStatus &&
181
+ path2 === scenario.expect;
182
+
183
+ if (!ok) failures += 1;
184
+
185
+ const fingerprint = report.registry?.fingerprint ?? "(none)";
186
+ const drift =
187
+ previousFingerprint !== undefined &&
188
+ previousFingerprint !== fingerprint
189
+ ? " ← registry changed (fingerprint moved)"
190
+ : "";
191
+
192
+ previousFingerprint = fingerprint;
193
+
194
+ console.log(`▶ ${scenario.label}`);
195
+ console.log(
196
+ ` ${report.status} path: ${path2 || "(none)"} ` +
197
+ `${ok ? "✓" : `✗ expected ${scenario.expectStatus} / ${scenario.expect}`}`
198
+ );
199
+ console.log(` registry ${fingerprint}${drift}`);
200
+ console.log(` names: ${(report.registry?.names ?? []).join(", ")}`);
201
+
202
+ for (const result of report.results) {
203
+ if (result.fn) {
204
+ console.log(
205
+ ` ${result.nodeId.padEnd(9)} fn=${result.fn} ` +
206
+ `attempt=${result.attempt} ${result.status}`
207
+ );
208
+ }
209
+ }
210
+
211
+ for (const error of report.errors) {
212
+ console.log(
213
+ ` error ${error.code} @${error.nodeId ?? "?"} ` +
214
+ `handled=${error.handled} ${error.message}`
215
+ );
216
+ }
217
+
218
+ console.log("");
219
+ }
220
+
221
+ console.log("-".repeat(96));
222
+ console.log(
223
+ `scenarios: ${SCENARIOS.length - failures}/${SCENARIOS.length} matched`
224
+ );
225
+
226
+ if (failures > 0) process.exitCode = 1;
227
+ }
228
+
229
+ if (require.main === module) {
230
+ void main();
231
+ }