@glubean/sdk 0.5.1 → 0.7.0

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 (100) hide show
  1. package/dist/contract-artifacts.d.ts +39 -21
  2. package/dist/contract-artifacts.d.ts.map +1 -1
  3. package/dist/contract-artifacts.js +80 -24
  4. package/dist/contract-artifacts.js.map +1 -1
  5. package/dist/contract-core.d.ts +10 -21
  6. package/dist/contract-core.d.ts.map +1 -1
  7. package/dist/contract-core.js +26 -812
  8. package/dist/contract-core.js.map +1 -1
  9. package/dist/contract-http/adapter.d.ts +13 -0
  10. package/dist/contract-http/adapter.d.ts.map +1 -1
  11. package/dist/contract-http/adapter.js +182 -23
  12. package/dist/contract-http/adapter.js.map +1 -1
  13. package/dist/contract-http/factory.d.ts +2 -2
  14. package/dist/contract-http/factory.d.ts.map +1 -1
  15. package/dist/contract-http/factory.js +5 -0
  16. package/dist/contract-http/factory.js.map +1 -1
  17. package/dist/contract-http/inbound-match.d.ts +47 -0
  18. package/dist/contract-http/inbound-match.d.ts.map +1 -0
  19. package/dist/contract-http/inbound-match.js +136 -0
  20. package/dist/contract-http/inbound-match.js.map +1 -0
  21. package/dist/contract-http/inbound-verify.d.ts +46 -0
  22. package/dist/contract-http/inbound-verify.d.ts.map +1 -0
  23. package/dist/contract-http/inbound-verify.js +101 -0
  24. package/dist/contract-http/inbound-verify.js.map +1 -0
  25. package/dist/contract-http/inbound.d.ts +45 -0
  26. package/dist/contract-http/inbound.d.ts.map +1 -0
  27. package/dist/contract-http/inbound.js +89 -0
  28. package/dist/contract-http/inbound.js.map +1 -0
  29. package/dist/contract-http/index.d.ts +5 -0
  30. package/dist/contract-http/index.d.ts.map +1 -1
  31. package/dist/contract-http/index.js +3 -0
  32. package/dist/contract-http/index.js.map +1 -1
  33. package/dist/contract-http/openapi.d.ts +0 -7
  34. package/dist/contract-http/openapi.d.ts.map +1 -1
  35. package/dist/contract-http/openapi.js +20 -14
  36. package/dist/contract-http/openapi.js.map +1 -1
  37. package/dist/contract-http/types.d.ts +106 -2
  38. package/dist/contract-http/types.d.ts.map +1 -1
  39. package/dist/contract-http/types.js +33 -0
  40. package/dist/contract-http/types.js.map +1 -1
  41. package/dist/contract-types.d.ts +148 -446
  42. package/dist/contract-types.d.ts.map +1 -1
  43. package/dist/index.d.ts +10 -6
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/internal.d.ts +7 -0
  48. package/dist/internal.d.ts.map +1 -1
  49. package/dist/internal.js +14 -0
  50. package/dist/internal.js.map +1 -1
  51. package/dist/{contract-flow-poll.d.ts → poll-primitives.d.ts} +8 -81
  52. package/dist/poll-primitives.d.ts.map +1 -0
  53. package/dist/{contract-flow-poll.js → poll-primitives.js} +10 -64
  54. package/dist/poll-primitives.js.map +1 -0
  55. package/dist/{contract-flow-condition.d.ts → predicates.d.ts} +34 -71
  56. package/dist/predicates.d.ts.map +1 -0
  57. package/dist/{contract-flow-condition.js → predicates.js} +86 -80
  58. package/dist/predicates.js.map +1 -0
  59. package/dist/test/builder.js +2 -2
  60. package/dist/test/builder.js.map +1 -1
  61. package/dist/test/utils.d.ts +7 -0
  62. package/dist/test/utils.d.ts.map +1 -1
  63. package/dist/test/utils.js +22 -17
  64. package/dist/test/utils.js.map +1 -1
  65. package/dist/types.d.ts +26 -14
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/dist/workflow/builder.d.ts +386 -0
  69. package/dist/workflow/builder.d.ts.map +1 -0
  70. package/dist/workflow/builder.js +1150 -0
  71. package/dist/workflow/builder.js.map +1 -0
  72. package/dist/workflow/execute.d.ts +277 -0
  73. package/dist/workflow/execute.d.ts.map +1 -0
  74. package/dist/workflow/execute.js +1489 -0
  75. package/dist/workflow/execute.js.map +1 -0
  76. package/dist/workflow/index.d.ts +11 -0
  77. package/dist/workflow/index.d.ts.map +1 -0
  78. package/dist/workflow/index.js +15 -0
  79. package/dist/workflow/index.js.map +1 -0
  80. package/dist/workflow/project.d.ts +18 -0
  81. package/dist/workflow/project.d.ts.map +1 -0
  82. package/dist/workflow/project.js +321 -0
  83. package/dist/workflow/project.js.map +1 -0
  84. package/dist/workflow/retry.d.ts +13 -0
  85. package/dist/workflow/retry.d.ts.map +1 -0
  86. package/dist/workflow/retry.js +15 -0
  87. package/dist/workflow/retry.js.map +1 -0
  88. package/dist/workflow/types.d.ts +512 -0
  89. package/dist/workflow/types.d.ts.map +1 -0
  90. package/dist/workflow/types.js +2 -0
  91. package/dist/workflow/types.js.map +1 -0
  92. package/package.json +5 -2
  93. package/dist/contract-flow-condition.d.ts.map +0 -1
  94. package/dist/contract-flow-condition.js.map +0 -1
  95. package/dist/contract-flow-poll.d.ts.map +0 -1
  96. package/dist/contract-flow-poll.js.map +0 -1
  97. package/dist/contract-http/flow-helpers.d.ts +0 -12
  98. package/dist/contract-http/flow-helpers.d.ts.map +0 -1
  99. package/dist/contract-http/flow-helpers.js +0 -34
  100. package/dist/contract-http/flow-helpers.js.map +0 -1
@@ -6,9 +6,6 @@
6
6
  * - `contract.register(protocol, adapter)` — plugin extension point
7
7
  * - `contract[protocol](id, spec)` dispatcher — validates 1:1 case keys,
8
8
  * invokes adapter.execute per case, registers tests + ContractRegistryMeta
9
- * - `contract.flow(id)` — protocol-agnostic FlowBuilder
10
- * - `runFlow(flow, ctx)` — core flow execution helper (Rule 1/2 teardown)
11
- * - `normalizeFlow(runtime)` — Runtime → ExtractedFlowProjection
12
9
  * - Permissive Proxy tracer for `compute` nodes (best-effort reads/writes)
13
10
  *
14
11
  * HTTP is NOT handled here — it registers itself in `./contract-http/`.
@@ -19,8 +16,6 @@
19
16
  * - `internal/40-discovery/proposals/contract-flow.md` v9
20
17
  */
21
18
  import { registerTest } from "./internal.js";
22
- import { selectBranchSteps, extractBranchStep, predicateScope, assertSelectorSource, assertSwitchCaseValues, assertL2Predicate, } from "./contract-flow-condition.js";
23
- import { quarantinedCtx, raceBudget, evalPollExit, extractPollStep, validatePollBounds, PollExhaustedError, BACKOFF_CAP_MS, DEFAULT_EVERY_MS, } from "./contract-flow-poll.js";
24
19
  import { getBootstrap, registerBootstrap } from "./bootstrap-registry.js";
25
20
  import { getExplicitInput, getBootstrapInput, isForceStandalone, } from "./runner-input-channel.js";
26
21
  /**
@@ -58,7 +53,7 @@ async function runCleanupsLifo(cleanups, testId) {
58
53
  }
59
54
  }
60
55
  }
61
- function validateNeedsOutput(needsSchema, value, ctx) {
56
+ export function validateNeedsOutput(needsSchema, value, ctx) {
62
57
  const sp = needsSchema.safeParse;
63
58
  if (typeof sp === "function") {
64
59
  const result = sp(value);
@@ -68,7 +63,8 @@ function validateNeedsOutput(needsSchema, value, ctx) {
68
63
  const sourceLabel = ctx.source === "bootstrap" ? "Bootstrap output"
69
64
  : ctx.source === "explicit" ? "Explicit input"
70
65
  : ctx.source === "bootstrap-params" ? "Bootstrap params"
71
- : "Flow `in` output";
66
+ : ctx.source === "workflow" ? "Workflow `in` output"
67
+ : "Flow `in` output";
72
68
  const schemaLabel = ctx.source === "bootstrap-params" ? "params schema" : "needs schema";
73
69
  throw new Error(`${sourceLabel} for case "${ctx.testId}" does not satisfy ${schemaLabel}:\n${lines.join("\n")}`);
74
70
  }
@@ -191,10 +187,15 @@ function dispatchContract(protocol, adapter, id, spec) {
191
187
  ? [contractTagsRaw]
192
188
  : [];
193
189
  const projCaseMap = new Map(projection.cases.map((c) => [c.key, c]));
194
- const tests = Object.entries(cases).map(([caseKey, caseSpec]) => {
190
+ const tests = Object.entries(cases).flatMap(([caseKey, caseSpec]) => {
195
191
  const testId = `${id}.${caseKey}`;
196
192
  const testName = `${id} — ${caseKey}`;
197
193
  const projCase = projCaseMap.get(caseKey);
194
+ // Non-runnable cases (adapter sets `runnable: false`, e.g. inbound
195
+ // cases — inbound-contract-design §9.5): no Test, no runnable-inventory
196
+ // entry. The case exists in projection/_extracted only.
197
+ if (projCase.runnable === false)
198
+ return [];
198
199
  const caseTags = caseSpec.tags ?? [];
199
200
  const allTags = [...contractTags, ...caseTags];
200
201
  // Projection lifecycle/severity are authoritative
@@ -476,7 +477,13 @@ function dispatchContract(protocol, adapter, id, spec) {
476
477
  defaultRun,
477
478
  contract: registryMeta,
478
479
  });
479
- return testDef;
480
+ // Keep contract cases on the LEGACY harness under the runner-on-engine cutover
481
+ // (engineSupports excludes marked tests). Outbound cases are common HTTP the
482
+ // engine could drive, but inbound (node:http webhook + node:crypto signing) is
483
+ // node-only, and the runner golden suite can't validate the engine path for
484
+ // contracts — so conservatively all contract cases stay legacy (plan 0005 §scope).
485
+ testDef.__glubean_kind = "contract";
486
+ return [testDef];
480
487
  });
481
488
  // Core injects id into both _projection and _spec carrier
482
489
  const enrichedProjection = { ...projection, id };
@@ -498,7 +505,7 @@ function dispatchContract(protocol, adapter, id, spec) {
498
505
  // ref used via contract.bootstrap(overlay) legitimately uses
499
506
  // function-valued fields with `resolvedInput` — v9's
500
507
  // validateCaseForFlow incorrectly rejected that at .case() time.
501
- return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec);
508
+ return makeContractCaseRef(protocol, id, projection.target, key, contractObj, spec, projCaseMap.get(key)?.direction);
502
509
  },
503
510
  });
504
511
  return contractObj;
@@ -536,7 +543,7 @@ function validateCaseKeys(protocol, specCases, projectionCases) {
536
543
  * should enforce it in their own `.case()` wrapper if needed. For
537
544
  * built-in HTTP adapter, see `./contract-http/adapter.ts`.
538
545
  */
539
- function makeContractCaseRef(protocol, contractId, target, caseKey, contract, spec) {
546
+ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, spec, direction) {
540
547
  const caseSpec = spec.cases?.[caseKey];
541
548
  return {
542
549
  __glubean_type: "contract-case-ref",
@@ -546,6 +553,7 @@ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, sp
546
553
  target,
547
554
  contract,
548
555
  ...(caseSpec?.runnability ? { runnability: caseSpec.runnability } : {}),
556
+ ...(direction ? { direction } : {}),
549
557
  };
550
558
  }
551
559
  /**
@@ -559,818 +567,24 @@ function makeContractCaseRef(protocol, contractId, target, caseKey, contract, sp
559
567
  * compatible Needs, masking real type errors (Spike 0 Finding 1).
560
568
  */
561
569
  export function bootstrap(ref, spec) {
570
+ if (ref.direction === "inbound") {
571
+ throw new Error(`contract.bootstrap: case "${ref.contractId}.${ref.caseKey}" is inbound — ` +
572
+ `it is awaited (workflow inbound poll), never executed, so a bootstrap ` +
573
+ `overlay has no meaning for it.`);
574
+ }
562
575
  return registerBootstrap(ref, spec);
563
576
  }
564
577
  export const contract = {
565
578
  register,
566
- flow,
567
579
  bootstrap,
568
580
  };
569
- // =============================================================================
570
- // Flow: FlowBuilder + runFlow + normalizeFlow + tracePureFn
571
- // =============================================================================
572
- /**
573
- * Protocol-agnostic flow builder. See contract-flow.md v9 §4.1.
574
- */
575
- // =============================================================================
576
- // Branch desugar helpers (condition / switchOn / switchCond → one "branch" step)
577
- //
578
- // Shared by both FlowBuilder (top-level steps) and FlowFragmentBuilder (branch
579
- // sub-steps). Each `then`/`else`/`default`/case callback is run against a fresh
580
- // step sink whose accumulated RuntimeFlowStep[] becomes that branch's steps.
581
- // =============================================================================
582
- /** Build a contract-call runtime step (adapter lookup + flow-safety validation). */
583
- function buildContractCallStep(flowId, ref, bindings) {
584
- const adapter = _adapters.get(ref.protocol);
585
- if (!adapter) {
586
- throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: unknown protocol "${ref.protocol}". ` +
587
- `Did you forget to import a contract plugin package (e.g. "@glubean/grpc")?`);
588
- }
589
- if (!adapter.executeCaseInFlow) {
590
- throw new Error(`contract.flow(${JSON.stringify(flowId)}).step: adapter for "${ref.protocol}" ` +
591
- `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
592
- }
593
- // v10: flow-safety validation fires at step-declaration time (not .case()).
594
- const contractRef = ref.contract;
595
- adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
596
- return {
597
- kind: "contract-call",
598
- name: bindings?.name,
599
- ref,
600
- caseKey: ref.caseKey,
601
- contract: ref.contract,
602
- bindings: bindings
603
- ? {
604
- in: bindings.in,
605
- out: bindings.out,
606
- ...(bindings.accept ? { accept: bindings.accept } : {}),
607
- }
608
- : undefined,
609
- };
610
- }
611
- /** Module-private key holding a fragment sink's accumulated step list. */
612
- const FRAGMENT_STEPS = Symbol("glubean.fragment-steps");
613
- /**
614
- * Run a branch-body callback and return the steps of the fragment it RETURNS
615
- * (not a closure-shared array). Because the sink is persistent/immutable (each
616
- * method returns a NEW sink carrying `[...prev, step]`), the executed steps are
617
- * exactly the chain that produced the returned, type-checked fragment. A block
618
- * body that mutates then returns an earlier sink — e.g.
619
- * `(b) => { b.compute(reshape); return b; }` — yields that earlier sink's
620
- * (shorter) step list, so runtime matches what the type system saw. This keeps
621
- * the `NoExtraKeys` / invariant convergence guarantees honest.
622
- */
623
- function collectFragmentSteps(flowId, fn) {
624
- const result = fn(makeStepSink(flowId, []));
625
- const out = result?.[FRAGMENT_STEPS];
626
- if (!Array.isArray(out)) {
627
- throw new Error(`flow "${flowId}": a branch body must return the fragment builder ` +
628
- `(e.g. \`(b) => b.compute(...)\` or \`(b) => b\`); it returned a non-builder value`);
629
- }
630
- return out;
631
- }
632
- /**
633
- * A persistent (immutable) FlowFragmentBuilder over a steps array. Every method
634
- * returns a NEW sink carrying `[...steps, newStep]` — it never mutates `steps`.
635
- * The accumulated list is read back (by `collectFragmentSteps`) via the
636
- * module-private `FRAGMENT_STEPS` key, so only the RETURNED fragment's steps
637
- * run. The phantom invariant brand exists only in the type.
638
- */
639
- function makeStepSink(flowId, steps) {
640
- const extend = (step) => makeStepSink(flowId, [...steps, step]);
641
- // Typed `any` so methods can return sinks; the public type is the cast below.
642
- const sink = {
643
- step(ref, bindings) {
644
- return extend(buildContractCallStep(flowId, ref, bindings));
645
- },
646
- compute(fn) {
647
- return extend({ kind: "compute", fn });
648
- },
649
- condition(spec, thenB, elseB) {
650
- return extend(buildConditionStep(flowId, "L2", spec, thenB, elseB));
651
- },
652
- conditionFn(spec, thenB, elseB) {
653
- return extend(buildConditionStep(flowId, "L1", spec, thenB, elseB));
654
- },
655
- conditionAsync(spec, thenB, elseB) {
656
- return extend(buildConditionStep(flowId, "L0", spec, thenB, elseB));
657
- },
658
- switchOn(lens) {
659
- return (cases, deflt) => extend(buildSwitchOnStep(flowId, lens, cases, deflt));
660
- },
661
- switchCond(cases, deflt) {
662
- return extend(buildSwitchCondStep(flowId, cases, deflt));
663
- },
664
- poll(ref, opts) {
665
- return extend(buildPollStep(flowId, "L2", ref, opts));
666
- },
667
- pollFn(ref, opts) {
668
- return extend(buildPollStep(flowId, "L1", ref, opts));
669
- },
670
- pollAsync(ref, opts) {
671
- return extend(buildPollStep(flowId, "L0", ref, opts));
672
- },
673
- };
674
- // Non-enumerable, frozen step list snapshot for this sink.
675
- Object.defineProperty(sink, FRAGMENT_STEPS, {
676
- value: Object.freeze(steps),
677
- enumerable: false,
678
- });
679
- return sink;
680
- }
681
- /**
682
- * condition / conditionFn / conditionAsync → predicate-mode branch (1 case +
683
- * default). L2 builds a declarative `BranchPredicate` via `predicateScope`;
684
- * L1/L0 wrap the opaque fn and REQUIRE a message (type-level + this runtime
685
- * guard, so `as any` / JS callers can't produce an unlabeled opaque gate).
686
- */
687
- function buildConditionStep(flowId, tier, spec, thenB, elseB) {
688
- const thenSteps = collectFragmentSteps(flowId, thenB);
689
- const elseSteps = elseB ? collectFragmentSteps(flowId, elseB) : [];
690
- let predicate;
691
- let message;
692
- if (tier === "L2") {
693
- predicate = spec.predicate(predicateScope());
694
- assertL2Predicate(predicate, "condition"); // runtime brand: reject forged/opaque/non-JSON trees
695
- message = spec.message;
696
- }
697
- else {
698
- if (typeof spec.message !== "string" || spec.message.length === 0) {
699
- throw new LensPurityError(tier === "L1" ? "conditionFn" : "conditionAsync", `opaque (${tier}) predicate requires a non-empty \`message\` — the projection marks ` +
700
- `this branch as an opaque/dynamic gate and the message is its only human-facing label`);
701
- }
702
- message = spec.message;
703
- predicate = { kind: "opaque", sync: tier === "L1", fn: spec.predicate };
704
- }
705
- return {
706
- kind: "branch",
707
- mode: "predicate",
708
- cases: [{ ...(message ? { message } : {}), predicate, steps: thenSteps }],
709
- default: elseSteps,
710
- };
711
- }
712
- /** switchCond → predicate-mode branch (N cases + default), each case a declarative predicate. */
713
- function buildSwitchCondStep(flowId, cases, deflt) {
714
- return {
715
- kind: "branch",
716
- mode: "predicate",
717
- cases: cases.map((c) => {
718
- const predicate = c.when(predicateScope());
719
- assertL2Predicate(predicate, "switchCond"); // switch is always L2 — reject forged/opaque trees
720
- return { predicate, steps: collectFragmentSteps(flowId, c.then) };
721
- }),
722
- default: collectFragmentSteps(flowId, deflt),
723
- };
724
- }
725
- /**
726
- * switchOn → value-mode branch. The subject lens runs the same P0 source gate +
727
- * strict-Proxy path extraction as `w.when` (so the decision-table projection
728
- * can't lie), values must be unique + finite, and the lens is normalized to
729
- * `(ctx, s)` for uniform runtime evaluation (it is evaluated exactly once).
730
- */
731
- function buildSwitchOnStep(flowId, lens, cases, deflt) {
732
- void flowId;
733
- assertSelectorSource(lens);
734
- const path = extractSelectorPath(lens);
735
- assertSwitchCaseValues(cases.map((c) => c.value));
736
- return {
737
- kind: "branch",
738
- mode: "value",
739
- subject: { lens: (_ctx, s) => lens(s), path },
740
- cases: cases.map((c) => ({ value: c.value, steps: collectFragmentSteps(flowId, c.then) })),
741
- default: collectFragmentSteps(flowId, deflt),
742
- };
743
- }
744
- /**
745
- * poll / pollFn / pollAsync → a bounded poll-until step. Validates flow-safety
746
- * (like a contract-call step) + bounds (validatePollBounds), builds the exit
747
- * predicate (L2 declarative over the response via predicateScope + brand gate;
748
- * L1/L0 opaque, message REQUIRED), and applies the every/backoff defaults.
749
- */
750
- function buildPollStep(flowId, tier, ref, opts) {
751
- const adapter = _adapters.get(ref.protocol);
752
- if (!adapter) {
753
- throw new Error(`contract.flow(${JSON.stringify(flowId)}).poll: unknown protocol "${ref.protocol}". ` +
754
- `Did you forget to import a contract plugin package?`);
755
- }
756
- if (!adapter.executeCaseInFlow) {
757
- throw new Error(`contract.flow(${JSON.stringify(flowId)}).poll: adapter for "${ref.protocol}" ` +
758
- `does not implement executeCaseInFlow — this protocol cannot appear in a flow.`);
759
- }
760
- const contractRef = ref.contract;
761
- adapter.validateCaseForFlow?.(contractRef._spec, ref.caseKey, ref.contractId);
762
- const stepLabel = opts.name ?? `${ref.contractId}#${ref.caseKey}`;
763
- validatePollBounds({
764
- timeout: opts.timeout,
765
- maxAttempts: opts.maxAttempts,
766
- perAttemptTimeout: opts.perAttemptTimeout,
767
- every: opts.every,
768
- backoff: opts.backoff,
769
- }, stepLabel);
770
- let until;
771
- let message;
772
- if (tier === "L2") {
773
- until = opts.until(predicateScope());
774
- assertL2Predicate(until, "poll"); // runtime brand: reject forged/opaque/non-JSON exit predicates
775
- message = opts.message;
776
- }
777
- else {
778
- if (typeof opts.message !== "string" || opts.message.length === 0) {
779
- throw new LensPurityError(tier === "L1" ? "pollFn" : "pollAsync", `opaque (${tier}) poll exit predicate requires a non-empty \`message\` — the projection marks ` +
780
- `this poll as an opaque/dynamic gate and the message is its only human-facing label`);
781
- }
782
- message = opts.message;
783
- until = { kind: "opaque", sync: tier === "L1", fn: opts.until };
784
- }
785
- return {
786
- kind: "poll",
787
- name: opts.name,
788
- ref,
789
- caseKey: ref.caseKey,
790
- contract: ref.contract,
791
- bindings: {
792
- in: opts.in,
793
- out: opts.out,
794
- ...(opts.accept ? { accept: opts.accept } : {}),
795
- },
796
- until,
797
- ...(message ? { message } : {}),
798
- every: opts.every ?? DEFAULT_EVERY_MS,
799
- backoff: opts.backoff ?? 1,
800
- ...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
801
- ...(opts.perAttemptTimeout !== undefined ? { perAttemptTimeoutMs: opts.perAttemptTimeout } : {}),
802
- ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
803
- };
804
- }
805
- export function flow(idOrMeta) {
806
- const meta = typeof idOrMeta === "string"
807
- ? { id: idOrMeta }
808
- : idOrMeta;
809
- const steps = [];
810
- let setupFn;
811
- let teardownFn;
812
- let built = false;
813
- let extraMeta = {};
814
- const builder = {
815
- __glubean_type: "flow-builder",
816
- meta(m) {
817
- extraMeta = { ...extraMeta, ...m };
818
- return builder;
819
- },
820
- setup(fn) {
821
- setupFn = fn;
822
- return builder;
823
- },
824
- teardown(fn) {
825
- teardownFn = fn;
826
- return builder;
827
- },
828
- step(ref, bindings) {
829
- steps.push(buildContractCallStep(meta.id, ref, bindings));
830
- return builder;
831
- },
832
- compute(fn) {
833
- const step = {
834
- kind: "compute",
835
- fn: fn,
836
- };
837
- steps.push(step);
838
- return builder;
839
- },
840
- condition(spec, thenB, elseB) {
841
- steps.push(buildConditionStep(meta.id, "L2", spec, thenB, elseB));
842
- return builder;
843
- },
844
- conditionFn(spec, thenB, elseB) {
845
- steps.push(buildConditionStep(meta.id, "L1", spec, thenB, elseB));
846
- return builder;
847
- },
848
- conditionAsync(spec, thenB, elseB) {
849
- steps.push(buildConditionStep(meta.id, "L0", spec, thenB, elseB));
850
- return builder;
851
- },
852
- switchOn(lens) {
853
- return (cases, deflt) => {
854
- steps.push(buildSwitchOnStep(meta.id, lens, cases, deflt));
855
- return builder;
856
- };
857
- },
858
- switchCond(cases, deflt) {
859
- steps.push(buildSwitchCondStep(meta.id, cases, deflt));
860
- return builder;
861
- },
862
- poll(ref, opts) {
863
- steps.push(buildPollStep(meta.id, "L2", ref, opts));
864
- return builder;
865
- },
866
- pollFn(ref, opts) {
867
- steps.push(buildPollStep(meta.id, "L1", ref, opts));
868
- return builder;
869
- },
870
- pollAsync(ref, opts) {
871
- steps.push(buildPollStep(meta.id, "L0", ref, opts));
872
- return builder;
873
- },
874
- build() {
875
- return finalize();
876
- },
877
- };
878
- // Auto-finalize via microtask (mirrors TestBuilder pattern)
879
- queueMicrotask(() => {
880
- if (!built)
881
- finalize();
882
- });
883
- function finalize() {
884
- if (built)
885
- return builtResult;
886
- built = true;
887
- const runtime = {
888
- protocol: "flow",
889
- description: extraMeta.description,
890
- tags: extraMeta.tags,
891
- extensions: extraMeta.extensions,
892
- ...(extraMeta.skip !== undefined ? { skip: extraMeta.skip } : {}),
893
- ...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
894
- setup: setupFn,
895
- teardown: teardownFn,
896
- steps,
897
- };
898
- // Build the single Test that runFlow orchestrates.
899
- const flowTest = {
900
- meta: {
901
- id: meta.id,
902
- name: extraMeta.name ?? meta.id,
903
- tags: extraMeta.tags,
904
- description: extraMeta.description,
905
- // `FlowMeta.skip: string` (the reason) → `TestMeta.deferred: string`
906
- // mirrors the ContractCase.deferred convention so downstream
907
- // reporters render the skip reason consistently.
908
- ...(extraMeta.skip !== undefined ? { deferred: extraMeta.skip } : {}),
909
- ...(extraMeta.only !== undefined ? { only: extraMeta.only } : {}),
910
- },
911
- type: "simple",
912
- fn: async (ctx) => {
913
- // Belt-and-suspenders: runtime ctx.skip in case the runner didn't
914
- // filter on meta.deferred (e.g. the user ran with an explicit
915
- // target that bypasses skip filters).
916
- if (extraMeta.skip)
917
- ctx.skip(extraMeta.skip);
918
- await runFlow(resultHandle, ctx);
919
- },
920
- };
921
- const arr = [flowTest];
922
- const runtimeWithId = { ...runtime, id: meta.id };
923
- // Pre-compute extracted projection so downstream consumers (scanner,
924
- // CLI, MCP, Cloud) get full field mappings + compute reads/writes
925
- // without having to import the SDK to call normalizeFlow themselves.
926
- const extracted = normalizeFlow(runtimeWithId);
927
- const resultHandle = Object.assign(arr, {
928
- _flow: runtimeWithId,
929
- _extracted: extracted,
930
- });
931
- // Register flow in the registry
932
- const flowRegistryMeta = {
933
- id: extracted.id,
934
- description: extracted.description,
935
- tags: extracted.tags,
936
- steps: extracted.steps.map(stepProjectionToRegistry),
937
- setupDynamic: extracted.setupDynamic,
938
- };
939
- registerTest({
940
- id: meta.id,
941
- name: extraMeta.name ?? meta.id,
942
- type: "simple",
943
- tags: extraMeta.tags,
944
- description: extraMeta.description,
945
- flow: flowRegistryMeta,
946
- });
947
- builtResult = resultHandle;
948
- return resultHandle;
949
- }
950
- let builtResult;
951
- return builder;
952
- }
953
- function stepProjectionToRegistry(step) {
954
- if (step.kind === "compute") {
955
- return {
956
- kind: "compute",
957
- name: step.name,
958
- reads: step.reads,
959
- writes: step.writes,
960
- };
961
- }
962
- if (step.kind === "branch") {
963
- if (step.mode === "value") {
964
- return {
965
- kind: "branch",
966
- mode: "value",
967
- name: step.name,
968
- subjectPath: step.subjectPath,
969
- cases: step.cases.map((c) => ({
970
- value: c.value,
971
- steps: c.steps.map(stepProjectionToRegistry),
972
- })),
973
- default: step.default.map(stepProjectionToRegistry),
974
- };
975
- }
976
- return {
977
- kind: "branch",
978
- mode: "predicate",
979
- name: step.name,
980
- cases: step.cases.map((c) => ({
981
- message: c.message,
982
- predicate: c.predicate,
983
- steps: c.steps.map(stepProjectionToRegistry),
984
- })),
985
- default: step.default.map(stepProjectionToRegistry),
986
- };
987
- }
988
- if (step.kind === "poll") {
989
- return {
990
- kind: "poll",
991
- name: step.name,
992
- contractId: step.contractId,
993
- caseKey: step.caseKey,
994
- protocol: step.protocol,
995
- target: step.target,
996
- inputs: step.inputs,
997
- outputs: step.outputs,
998
- ...(step.accept ? { accept: step.accept } : {}),
999
- ...(step.until !== undefined ? { until: step.until } : {}),
1000
- ...(step.message !== undefined ? { message: step.message } : {}),
1001
- every: step.every,
1002
- backoff: step.backoff,
1003
- ...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
1004
- ...(step.perAttemptTimeoutMs !== undefined ? { perAttemptTimeoutMs: step.perAttemptTimeoutMs } : {}),
1005
- ...(step.maxAttempts !== undefined ? { maxAttempts: step.maxAttempts } : {}),
1006
- };
1007
- }
1008
- return {
1009
- kind: "contract-call",
1010
- name: step.name,
1011
- contractId: step.contractId,
1012
- caseKey: step.caseKey,
1013
- protocol: step.protocol,
1014
- target: step.target,
1015
- inputs: step.inputs,
1016
- outputs: step.outputs,
1017
- ...(step.accept ? { accept: step.accept } : {}),
1018
- };
1019
- }
1020
- /**
1021
- * Core flow execution helper. Implements the Rule 1 / Rule 2 teardown
1022
- * semantics from contract-flow.md §7.
1023
- */
1024
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1025
- /**
1026
- * Execute ONE contract-call against its adapter (shared by the contract-call
1027
- * step and each poll attempt). Resolves `in`, enforces `needs` at the flow
1028
- * boundary, and calls `adapter.executeCaseInFlow`. Returns the raw response;
1029
- * the caller applies `out`. `opts.signal` is threaded to honoring adapters.
1030
- */
1031
- async function executeContractCaseInFlow(step, state, ctx, runtimeId, opts) {
1032
- const adapter = _adapters.get(step.ref.protocol);
1033
- if (!adapter) {
1034
- throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1035
- `no registered adapter for protocol "${step.ref.protocol}". ` +
1036
- `Did you forget to import a contract plugin package?`);
1037
- }
1038
- if (!adapter.executeCaseInFlow) {
1039
- throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1040
- `adapter for "${step.ref.protocol}" does not implement executeCaseInFlow`);
1041
- }
1042
- // Enforce `needs` at the flow boundary (the conditional-tuple step() signature
1043
- // catches authoring shape, this catches `as any` / JS bypass + Zod transforms).
1044
- const contractSpec = step.contract._spec;
1045
- const caseSpec = contractSpec?.cases?.[step.caseKey];
1046
- const needsSchema = caseSpec?.needs;
1047
- const hasIn = typeof step.bindings?.in === "function";
1048
- if (needsSchema && !hasIn) {
1049
- throw new Error(`flow "${runtimeId}" step "${step.name ?? step.caseKey}": ` +
1050
- `case "${step.ref.contractId}.${step.caseKey}" declares \`needs\` ` +
1051
- `but the step has no \`bindings.in\`. Provide an ` +
1052
- `\`in: (state) => <logical input>\` binding or remove the \`needs\` schema.`);
1053
- }
1054
- let resolvedInputs = step.bindings?.in?.(state);
1055
- if (needsSchema) {
1056
- resolvedInputs = validateNeedsOutput(needsSchema, resolvedInputs, { testId: `${step.ref.contractId}.${step.caseKey}`, source: "flow" });
1057
- }
1058
- return adapter.executeCaseInFlow({
1059
- ctx,
1060
- contract: step.contract,
1061
- caseKey: step.caseKey,
1062
- resolvedInputs,
1063
- ...(step.bindings?.accept ? { accept: step.bindings.accept } : {}),
1064
- ...(opts?.signal ? { signal: opts.signal } : {}),
1065
- });
1066
- }
1067
- /**
1068
- * Bounded-retry execution of a poll step. Repeats the case until `until` holds
1069
- * (→ apply `out`, return next state) or a bound is exhausted (→ throw
1070
- * PollExhaustedError, failing the flow). Each attempt's request + exit predicate
1071
- * are budget-raced (so the loop is bounded regardless of whether the adapter
1072
- * honors `signal`) and run in quarantined contexts (probe noise / timed-out
1073
- * orphans discarded; the satisfying attempt's validation + in-budget predicate
1074
- * effects flushed). See contract-flow-poll.md §4.2.
1075
- */
1076
- async function runPollStep(step, state, ctx, runtimeId) {
1077
- const label = step.name ?? `${step.contract._projection.id}#${step.caseKey}`;
1078
- // Runtime bound guard — the builder validates at construction, but `as any` /
1079
- // JS callers can reach runFlow with an unbounded poll step. Re-validate so a
1080
- // missing stop condition / per-attempt budget fails fast instead of looping
1081
- // forever (mirrors the runtime `needs` guard for contract-call steps).
1082
- validatePollBounds({
1083
- timeout: step.timeoutMs,
1084
- maxAttempts: step.maxAttempts,
1085
- perAttemptTimeout: step.perAttemptTimeoutMs,
1086
- every: step.every,
1087
- backoff: step.backoff,
1088
- }, label);
1089
- // Default interval/backoff — validatePollBounds permits them to be omitted, but
1090
- // the loop must not read `undefined` (sleep(undefined) hot-loops; delay*undefined
1091
- // is NaN). The builder applies the same defaults; this covers as-any/JS callers.
1092
- const everyMs = step.every ?? DEFAULT_EVERY_MS;
1093
- const backoff = step.backoff ?? 1;
1094
- const now = () => performance.now();
1095
- const start = now();
1096
- const deadline = step.timeoutMs !== undefined ? start + step.timeoutMs : Infinity;
1097
- const perAttempt = step.perAttemptTimeoutMs ?? Infinity;
1098
- let attempt = 0;
1099
- let delay = everyMs;
1100
- let lastRes;
1101
- for (;;) {
1102
- attempt += 1;
1103
- const remainingTotal = deadline - now();
1104
- if (remainingTotal <= 0) {
1105
- throw new PollExhaustedError(label, attempt - 1, "total timeout reached before next attempt");
1106
- }
1107
- const attemptBudget = Math.min(perAttempt, remainingTotal); // build-time validation ⇒ finite
1108
- const attemptStart = now();
1109
- // Request: quarantined ctx + abort signal; budget-raced so a non-honoring
1110
- // adapter can't block past the budget.
1111
- const ac = new AbortController();
1112
- let budgetAborted = false;
1113
- const budgetTimer = Number.isFinite(attemptBudget)
1114
- ? setTimeout(() => {
1115
- budgetAborted = true;
1116
- ac.abort();
1117
- }, Math.max(0, attemptBudget))
1118
- : undefined;
1119
- const reqCtx = quarantinedCtx(ctx);
1120
- const exhausted = () => new PollExhaustedError(label, attempt, `attempt budget ${Math.round(attemptBudget)}ms exceeded`);
1121
- try {
1122
- lastRes = await raceBudget(executeContractCaseInFlow(step, state, reqCtx, runtimeId, { signal: ac.signal }), attemptBudget, exhausted);
1123
- }
1124
- catch (err) {
1125
- // A signal-honoring adapter (HTTP) rejects with AbortError when OUR budget
1126
- // timer fired — convert it to the poll exhaustion error (with the attempt
1127
- // count) rather than leaking a raw AbortError.
1128
- if (budgetAborted && err instanceof Error && err.name === "AbortError") {
1129
- throw exhausted();
1130
- }
1131
- // A budget timeout discards this attempt's ctx (orphan); a genuine request
1132
- // error (incl. fatal validation / ctx.fail in the adapter) is in-budget —
1133
- // flush its buffered effects (the failure assertion lands) then fail the poll.
1134
- if (!(err instanceof PollExhaustedError))
1135
- reqCtx.flushTo(ctx);
1136
- throw err;
1137
- }
1138
- finally {
1139
- if (budgetTimer)
1140
- clearTimeout(budgetTimer);
1141
- }
1142
- // A sync-heavy request the timer couldn't preempt may have overrun — re-check.
1143
- if (now() > deadline)
1144
- throw new PollExhaustedError(label, attempt, "total timeout reached");
1145
- if (now() - attemptStart > perAttempt)
1146
- throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1147
- // Exit predicate — quarantined ctx, budget-raced, flushed only on in-budget completion.
1148
- const predBudget = Math.min(deadline - now(), perAttempt - (now() - attemptStart));
1149
- const predCtx = quarantinedCtx(ctx);
1150
- let done;
1151
- try {
1152
- done = await raceBudget(evalPollExit(step.until, lastRes, predCtx, state), Math.max(0, predBudget), () => new PollExhaustedError(label, attempt, "exit-predicate budget exceeded"));
1153
- }
1154
- catch (err) {
1155
- // Timed-out predicate orphan → discard predCtx. An in-budget predicate that
1156
- // threw (ctx.fail / ctx.skip / a thrown error) → flush its buffered effects
1157
- // (the failure assertion lands) then propagate (the poll fails / skips).
1158
- if (!(err instanceof PollExhaustedError))
1159
- predCtx.flushTo(ctx);
1160
- throw err;
1161
- }
1162
- if (now() > deadline)
1163
- throw new PollExhaustedError(label, attempt, "total timeout reached");
1164
- if (now() - attemptStart > perAttempt)
1165
- throw new PollExhaustedError(label, attempt, "attempt budget exceeded");
1166
- predCtx.flushTo(ctx); // in-budget: user's deliberate skip/fail/assert count
1167
- if (done) {
1168
- reqCtx.flushTo(ctx); // satisfying attempt: final-response validation failures surface (P1)
1169
- return step.bindings?.out ? step.bindings.out(state, lastRes) : state;
1170
- }
1171
- // probe (not satisfied): reqCtx discarded (pending validation noise).
1172
- if (step.maxAttempts && attempt >= step.maxAttempts) {
1173
- throw new PollExhaustedError(label, attempt, "maxAttempts reached");
1174
- }
1175
- // A wait that would cross the deadline is pointless — fail now, don't sleep.
1176
- if (Number.isFinite(deadline) && now() + delay >= deadline) {
1177
- throw new PollExhaustedError(label, attempt, "next wait would exceed timeout");
1178
- }
1179
- await sleep(delay);
1180
- delay = Math.min(delay * backoff, BACKOFF_CAP_MS);
1181
- }
1182
- }
1183
- export async function runFlow(flowContract, ctx) {
1184
- const runtime = flowContract._flow;
1185
- // If flow.setup throws → flow.teardown does NOT run (Rule 2).
1186
- let state = runtime.setup
1187
- ? await runtime.setup(ctx)
1188
- : undefined;
1189
- try {
1190
- // Recursive step executor. `state` is the closure-level committed cell —
1191
- // branch sub-steps read and write the same `state`, so a taken branch can
1192
- // observe and mutate flow state exactly like a top-level step. Non-taken
1193
- // branches never run (and so never touch state). Branches nest via recursion.
1194
- const runSteps = async (steps) => {
1195
- for (const step of steps) {
1196
- if (step.kind === "branch") {
1197
- // Evaluate selector/predicates against the current committed state,
1198
- // then execute only the matched case's sub-steps (or default).
1199
- const taken = await selectBranchSteps(step, state, ctx);
1200
- await runSteps(taken);
1201
- continue;
1202
- }
1203
- if (step.kind === "compute") {
1204
- // Compute: synchronous pure function. Enforce both syntactic and
1205
- // value-level async rejection.
1206
- if (step.fn.constructor?.name === "AsyncFunction") {
1207
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
1208
- `async functions are not allowed — compute must be synchronous and I/O-free`);
1209
- }
1210
- const result = step.fn(state);
1211
- if (result && typeof result.then === "function") {
1212
- throw new Error(`flow "${runtime.id}" compute step "${step.name ?? "<unnamed>"}": ` +
1213
- `returned a thenable (Promise or Promise-like) — compute must not return async values. ` +
1214
- `If you need async initialization, use flow.setup() instead.`);
1215
- }
1216
- state = result;
1217
- continue;
1218
- }
1219
- // poll branch — bounded retry of ONE case until the exit predicate holds
1220
- // (or a bound is exhausted → the poll step fails). Shares the committed
1221
- // `state` cell like every other step.
1222
- if (step.kind === "poll") {
1223
- state = (await runPollStep(step, state, ctx, runtime.id));
1224
- continue;
1225
- }
1226
- // contract-call branch
1227
- const response = await executeContractCaseInFlow(step, state, ctx, runtime.id);
1228
- if (step.bindings?.out) {
1229
- state = step.bindings.out(state, response);
1230
- }
1231
- }
1232
- };
1233
- await runSteps(runtime.steps);
1234
- }
1235
- finally {
1236
- if (runtime.teardown) {
1237
- // flow.teardown runs in Rule 2 outer-finally. Its errors are logged
1238
- // but must not mask the primary exception.
1239
- try {
1240
- await runtime.teardown(ctx, state);
1241
- }
1242
- catch (teardownErr) {
1243
- ctx.log?.(`flow.teardown failed: ${String(teardownErr)}`);
1244
- }
1245
- }
1246
- }
1247
- }
1248
- // =============================================================================
1249
- // normalizeFlow + permissive Proxy tracer
1250
- // =============================================================================
1251
- /**
1252
- * Normalize a RuntimeFlowProjection to JSON-safe ExtractedFlowProjection.
1253
- * Runs Proxy dry-run of lens functions to extract FieldMappings.
1254
- *
1255
- * Lens purity is enforced here: if a `step.bindings.in` or `.out` lens
1256
- * violates purity (method call, `new`, etc.), `LensPurityError` is thrown
1257
- * with step context so the author sees which step needs fixing.
1258
- */
1259
- export function normalizeFlow(runtime) {
1260
- // Recursive single-step normalizer. Branch sub-steps (cases + default) are
1261
- // normalized through the same helper, so nested branches and any lens-purity
1262
- // diagnostics inside them are handled identically at every depth.
1263
- const normalizeStep = (s, idx) => {
1264
- const stepLabel = s.name ??
1265
- (s.kind === "contract-call" || s.kind === "poll"
1266
- ? `${s.contract._projection.id}#${s.caseKey}`
1267
- : `step-${idx + 1}`);
1268
- if (s.kind === "branch") {
1269
- // Recurse over each case/default sub-step list. We re-index from 0 within
1270
- // the branch for labeling; the branch name (if any) carries outer context.
1271
- return extractBranchStep(s, (steps) => steps.map((sub, i) => normalizeStep(sub, i)));
1272
- }
1273
- if (s.kind === "poll") {
1274
- // Same Proxy dry-run as a contract-call step → the poll node carries
1275
- // input/output data-flow edges; the bounded-retry + exit predicate are
1276
- // carried by extractPollStep.
1277
- return extractPollStep(s, (ps) => {
1278
- let pInputs;
1279
- if (ps.bindings?.in) {
1280
- try {
1281
- pInputs = extractMappings(ps.bindings.in);
1282
- }
1283
- catch (err) {
1284
- if (err instanceof LensPurityError) {
1285
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (poll in lens): ${err.message}`);
1286
- }
1287
- throw err;
1288
- }
1289
- }
1290
- let pOutputs;
1291
- if (ps.bindings?.out) {
1292
- try {
1293
- pOutputs = extractMappingsOut(ps.bindings.out);
1294
- }
1295
- catch (err) {
1296
- if (err instanceof LensPurityError) {
1297
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (poll out lens): ${err.message}`);
1298
- }
1299
- throw err;
1300
- }
1301
- }
1302
- return { inputs: pInputs, outputs: pOutputs };
1303
- });
1304
- }
1305
- if (s.kind === "compute") {
1306
- try {
1307
- const { reads, writes } = traceComputeFn(s.fn);
1308
- return { kind: "compute", name: s.name, reads, writes };
1309
- }
1310
- catch (err) {
1311
- // Wrap compute-tracer failures with the same step-context format
1312
- // used for lens errors, so authoring mistakes are equally easy
1313
- // to localize regardless of which tracer caught them.
1314
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (compute): ${err instanceof Error ? err.message : String(err)}`);
1315
- }
1316
- }
1317
- let inputs;
1318
- if (s.bindings?.in) {
1319
- try {
1320
- inputs = extractMappings(s.bindings.in);
1321
- }
1322
- catch (err) {
1323
- if (err instanceof LensPurityError) {
1324
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (in lens): ${err.message}`);
1325
- }
1326
- throw err;
1327
- }
1328
- }
1329
- let outputs;
1330
- if (s.bindings?.out) {
1331
- try {
1332
- outputs = extractMappingsOut(s.bindings.out);
1333
- }
1334
- catch (err) {
1335
- if (err instanceof LensPurityError) {
1336
- throw new Error(`flow "${runtime.id}" step ${idx + 1} "${stepLabel}" (out lens): ${err.message}`);
1337
- }
1338
- throw err;
1339
- }
1340
- }
1341
- return {
1342
- kind: "contract-call",
1343
- name: s.name,
1344
- contractId: s.contract._projection.id,
1345
- caseKey: s.caseKey,
1346
- protocol: s.ref.protocol,
1347
- target: s.ref.target,
1348
- inputs,
1349
- outputs,
1350
- ...(s.bindings?.accept
1351
- ? { accept: [...s.bindings.accept] }
1352
- : {}),
1353
- };
1354
- };
1355
- return {
1356
- id: runtime.id,
1357
- protocol: "flow",
1358
- description: runtime.description,
1359
- tags: runtime.tags,
1360
- extensions: runtime.extensions,
1361
- ...(runtime.skip !== undefined ? { skip: runtime.skip } : {}),
1362
- ...(runtime.only !== undefined ? { only: runtime.only } : {}),
1363
- setupDynamic: runtime.setup ? true : undefined,
1364
- steps: runtime.steps.map(normalizeStep),
1365
- };
1366
- }
1367
581
  /**
1368
582
  * Run a pure lens `fn: (state) => output` with a tracing Proxy, extracting
1369
583
  * FieldMappings from state paths to output paths.
1370
584
  *
1371
585
  * Pure-lens enforcement: method calls, `new`, and coercion on the state
1372
586
  * proxy raise `LensPurityError`. Errors are **not** swallowed — the caller
1373
- * (typically `normalizeFlow`) wraps them with step context and re-throws,
587
+ * (legacy flow's normalizer used to) wraps them with step context and re-throws,
1374
588
  * so authors see the failure at flow build time rather than silently
1375
589
  * losing projection data.
1376
590
  */
@@ -1466,7 +680,7 @@ function isTracedValue(v) {
1466
680
  /**
1467
681
  * Raised by the strict lens Proxy when a user lens fn attempts an operation
1468
682
  * that breaks the "pure field access + repack" contract. Caught and re-
1469
- * thrown with step context by `normalizeFlow`.
683
+ * thrown with step context by callers.
1470
684
  */
1471
685
  export class LensPurityError extends Error {
1472
686
  path;