@uipath/maestro-builder-sdk 6.18.10 → 6.20.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 (134) hide show
  1. package/dist/api-index.md +185 -184
  2. package/dist/api-members.md +506 -473
  3. package/dist/check.js +358 -695
  4. package/dist/cli/commands/flow.js +3 -0
  5. package/dist/compile-cli.js +51 -0
  6. package/dist/core/actions.d.ts +132 -765
  7. package/dist/core/actions.js +1 -307
  8. package/dist/core/binding-identity.d.ts +22 -0
  9. package/dist/core/binding-identity.js +28 -0
  10. package/dist/core/context-index.d.ts +34 -0
  11. package/dist/core/context-index.js +38 -0
  12. package/dist/core/definitionless-node-types.d.ts +16 -0
  13. package/dist/core/definitionless-node-types.js +16 -0
  14. package/dist/core/error-port.d.ts +6 -0
  15. package/dist/core/error-port.js +9 -0
  16. package/dist/core/form-field-type.d.ts +33 -0
  17. package/dist/core/form-field-type.js +30 -0
  18. package/dist/core/hitl-routing.d.ts +8 -0
  19. package/dist/core/hitl-routing.js +17 -1
  20. package/dist/core/node-classes.d.ts +4 -63
  21. package/dist/core/node-classes.js +0 -112
  22. package/dist/core/nodes/family.d.ts +36 -0
  23. package/dist/core/nodes/family.js +1 -0
  24. package/dist/core/nodes/node-type.d.ts +136 -0
  25. package/dist/core/nodes/node-type.js +1 -0
  26. package/dist/core/nodes/registry.d.ts +57 -0
  27. package/dist/core/nodes/registry.js +122 -0
  28. package/dist/core/nodes/versioned.d.ts +50 -0
  29. package/dist/core/nodes/versioned.js +46 -0
  30. package/dist/core/process-resource-sort-order.d.ts +40 -0
  31. package/dist/core/process-resource-sort-order.js +44 -0
  32. package/dist/core/typed-raw-node.d.ts +13 -62
  33. package/dist/core/typed-raw-node.js +40 -154
  34. package/dist/core/wire-expr.d.ts +27 -0
  35. package/dist/core/wire-expr.js +34 -0
  36. package/dist/decompile-cli.js +11 -1
  37. package/dist/decompile.d.ts +8 -3
  38. package/dist/decompile.js +665 -354
  39. package/dist/flow-definitions.d.ts +10 -0
  40. package/dist/flow-definitions.js +35 -0
  41. package/dist/flow-expr-check.js +1 -1
  42. package/dist/flow-node-registry.d.ts +13 -0
  43. package/dist/flow-node-registry.js +19 -0
  44. package/dist/flow-node-type.d.ts +192 -0
  45. package/dist/flow-node-type.js +1 -0
  46. package/dist/flow-sdk.d.ts +40 -1
  47. package/dist/flow-sdk.js +29 -2
  48. package/dist/flow-step-tree.js +2 -1
  49. package/dist/index.d.ts +2 -1
  50. package/dist/index.js +1 -0
  51. package/dist/merge-cli.js +25 -8
  52. package/dist/merge-roundtrip.d.ts +81 -0
  53. package/dist/merge-roundtrip.js +404 -0
  54. package/dist/merge.d.ts +35 -6
  55. package/dist/merge.js +124 -24
  56. package/dist/node-placement.d.ts +93 -0
  57. package/dist/node-placement.js +163 -0
  58. package/dist/nodes/data-fabric/flow/check.d.ts +13 -0
  59. package/dist/nodes/data-fabric/flow/check.js +164 -0
  60. package/dist/nodes/data-fabric/flow/factories.d.ts +113 -0
  61. package/dist/nodes/data-fabric/flow/factories.js +134 -0
  62. package/dist/nodes/data-fabric/flow/family.d.ts +7 -0
  63. package/dist/nodes/data-fabric/flow/family.js +9 -0
  64. package/dist/nodes/data-fabric/flow/index.d.ts +8 -0
  65. package/dist/nodes/data-fabric/flow/index.js +8 -0
  66. package/dist/nodes/data-fabric/flow/raw.d.ts +32 -0
  67. package/dist/nodes/data-fabric/flow/raw.js +100 -0
  68. package/dist/nodes/data-fabric/flow/shared.d.ts +77 -0
  69. package/dist/nodes/data-fabric/flow/shared.js +174 -0
  70. package/dist/nodes/data-fabric/flow/spec.d.ts +238 -0
  71. package/dist/nodes/data-fabric/flow/spec.js +1 -0
  72. package/dist/nodes/data-fabric/flow/verbs.d.ts +29 -0
  73. package/dist/nodes/data-fabric/flow/verbs.js +239 -0
  74. package/dist/nodes/delay/flow/check.d.ts +16 -0
  75. package/dist/nodes/delay/flow/check.js +86 -0
  76. package/dist/nodes/delay/flow/factories.d.ts +28 -0
  77. package/dist/nodes/delay/flow/factories.js +38 -0
  78. package/dist/nodes/delay/flow/family.d.ts +3 -0
  79. package/dist/nodes/delay/flow/family.js +8 -0
  80. package/dist/nodes/delay/flow/index.d.ts +3 -0
  81. package/dist/nodes/delay/flow/index.js +3 -0
  82. package/dist/nodes/delay/flow/node-type.d.ts +13 -0
  83. package/dist/nodes/delay/flow/node-type.js +71 -0
  84. package/dist/nodes/delay/flow/spec.d.ts +43 -0
  85. package/dist/nodes/delay/flow/spec.js +16 -0
  86. package/dist/nodes/document/flow/factories.d.ts +27 -0
  87. package/dist/nodes/document/flow/factories.js +30 -0
  88. package/dist/nodes/document/flow/family.d.ts +3 -0
  89. package/dist/nodes/document/flow/family.js +7 -0
  90. package/dist/nodes/document/flow/index.d.ts +3 -0
  91. package/dist/nodes/document/flow/index.js +3 -0
  92. package/dist/nodes/document/flow/node-type.d.ts +14 -0
  93. package/dist/nodes/document/flow/node-type.js +150 -0
  94. package/dist/nodes/document/flow/spec.d.ts +65 -0
  95. package/dist/nodes/document/flow/spec.js +1 -0
  96. package/dist/nodes/mock/flow/factories.d.ts +51 -0
  97. package/dist/nodes/mock/flow/factories.js +47 -0
  98. package/dist/nodes/mock/flow/family.d.ts +3 -0
  99. package/dist/nodes/mock/flow/family.js +7 -0
  100. package/dist/nodes/mock/flow/index.d.ts +2 -0
  101. package/dist/nodes/mock/flow/index.js +2 -0
  102. package/dist/nodes/mock/flow/node-type.d.ts +11 -0
  103. package/dist/nodes/mock/flow/node-type.js +51 -0
  104. package/dist/nodes/script/flow/check.d.ts +19 -0
  105. package/dist/nodes/script/flow/check.js +49 -0
  106. package/dist/nodes/script/flow/factories.d.ts +14 -0
  107. package/dist/nodes/script/flow/factories.js +15 -0
  108. package/dist/nodes/script/flow/family.d.ts +3 -0
  109. package/dist/nodes/script/flow/family.js +8 -0
  110. package/dist/nodes/script/flow/index.d.ts +3 -0
  111. package/dist/nodes/script/flow/index.js +3 -0
  112. package/dist/nodes/script/flow/node-type.d.ts +7 -0
  113. package/dist/nodes/script/flow/node-type.js +125 -0
  114. package/dist/nodes/script/flow/spec.d.ts +52 -0
  115. package/dist/nodes/script/flow/spec.js +1 -0
  116. package/dist/nodes/transform/flow/check.d.ts +6 -0
  117. package/dist/nodes/transform/flow/check.js +229 -0
  118. package/dist/nodes/transform/flow/factories.d.ts +52 -0
  119. package/dist/nodes/transform/flow/factories.js +53 -0
  120. package/dist/nodes/transform/flow/family.d.ts +3 -0
  121. package/dist/nodes/transform/flow/family.js +8 -0
  122. package/dist/nodes/transform/flow/index.d.ts +3 -0
  123. package/dist/nodes/transform/flow/index.js +3 -0
  124. package/dist/nodes/transform/flow/node-type.d.ts +7 -0
  125. package/dist/nodes/transform/flow/node-type.js +245 -0
  126. package/dist/nodes/transform/flow/spec.d.ts +116 -0
  127. package/dist/nodes/transform/flow/spec.js +10 -0
  128. package/dist/raw-node-advice.d.ts +16 -0
  129. package/dist/raw-node-advice.js +36 -0
  130. package/dist/raw-node.d.ts +71 -0
  131. package/dist/raw-node.js +116 -0
  132. package/dist/registry/uip/pipeline-front-door.js +5 -3
  133. package/dist/serialize.js +246 -549
  134. package/package.json +2 -2
package/dist/check.js CHANGED
@@ -5,11 +5,12 @@
5
5
  * duplicate step names, references to undeclared variables/steps, empty branch
6
6
  * arms, and missing terminals.
7
7
  */
8
- import { Expr, SCHEDULE_PRESETS, DELAY_PRESETS, VARIANT_OPERATION, parseIxpProjectType, flatNameOf, flatNameSegmentsOf, FLAT_SEP } from './flow-sdk.js';
8
+ import { Expr, SCHEDULE_PRESETS, parseIxpProjectType, flatNameOf, flatNameSegmentsOf, FLAT_SEP } from './flow-sdk.js';
9
9
  import { FlowNode } from './core/node-classes.js';
10
10
  import { planContributionBindings } from './core/contribution-bindings.js';
11
11
  import { definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
12
12
  import { bundledActionNodeType, bundledDefinition, resolveContributionDefinition } from './serialize.js';
13
+ import { sdkFormFieldType } from './core/form-field-type.js';
13
14
  import { QUEUE_ITEM_RECORD_SCHEMA } from './core/queue-record.js';
14
15
  import { AGENTIC_PROCESS_FORMS } from './core/agentic-process-forms.js';
15
16
  import { checkEventResolution, checkEventSubscription, eventParamsFor, } from './core/event-checks.js';
@@ -19,13 +20,16 @@ import { prepareCommand } from './core/cli-spelling.js';
19
20
  import { commentText } from './core/comment-text.js';
20
21
  import { editDistance } from './core/edit-distance.js';
21
22
  import { isAnswerField } from './core/hitl-answers.js';
22
- import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome } from './core/hitl-routing.js';
23
+ import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome, outcomeIdOf } from './core/hitl-routing.js';
23
24
  import { declaredExits, hasExits } from './core/step-ports.js';
24
25
  import { stopsAtEnd } from './flow-step-tree.js';
25
26
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
26
27
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
27
28
  import { planFlowGlobals, planSharedInputs, sharedInputRoots } from './flow-variables.js';
28
29
  import { ownEntry } from './core/own-entry.js';
30
+ import { FLOW_NODE_TYPES } from './flow-node-registry.js';
31
+ import { resolveVersion } from './core/nodes/versioned.js';
32
+ import { declaredErrorPortRefusal } from './core/error-port.js';
29
33
  export function check(built, opts = {}) {
30
34
  let diags = [];
31
35
  const uses = collectConnectionUses(() => { diags = checkFlow(built, [], opts); });
@@ -115,6 +119,12 @@ function checkFlow(built, ancestors, opts = {}) {
115
119
  ]);
116
120
  const outputType = new Map(built.outputs.map((x) => [x.name, String(x.type)]));
117
121
  const stepNames = new Set();
122
+ // Steps whose kind is a registered node type (flow-node-registry.ts), by name:
123
+ // their node-local rules, expressions and output contract come from the node
124
+ // type, and their family's rules run over all of them after the walk.
125
+ const registeredSteps = new Map();
126
+ /** Steps of a registered kind whose version did not resolve (NODE_VERSION_UNREGISTERED), so a family rule can stay silent about them. */
127
+ const unresolvedSteps = new Set();
118
128
  /**
119
129
  * Every expression the flow carries, with the step that failed when the
120
130
  * expression sits inside that step's error handler. The failed step's own
@@ -133,8 +143,6 @@ function checkFlow(built, ancestors, opts = {}) {
133
143
  // `core.logic.merge` definition declares no outputs at all (see
134
144
  // PARALLEL_HAS_NO_OUTPUT below).
135
145
  const parallelNames = new Set();
136
- const delayNames = new Set();
137
- const dataFabricReads = new Set();
138
146
  // Per-outcome routing bookkeeping: which hitl steps opted into outcome ports
139
147
  // (and their declared outcome ids), and which ids actually got a route.
140
148
  const hitlOutcomeSteps = new Map();
@@ -151,22 +159,6 @@ function checkFlow(built, ancestors, opts = {}) {
151
159
  // waits until the expressions are collected (see HITL_OUTCOMES_UNREACHABLE
152
160
  // below).
153
161
  const hitlChoiceNoPorts = new Map();
154
- // Data Fabric writes that target a record `{ fromRead: '<step>' }` — update and
155
- // delete share the selector, so they share the check.
156
- const dataFabricRefs = [];
157
- // Delete step names. `core.datafabric.delete` is the one verb of the family
158
- // whose definition declares NO outputDefinition, so `$vars.<delete>.output` is
159
- // undefined at run time (see DATAFABRIC_DELETE_HAS_NO_OUTPUT below).
160
- const dataFabricDeletes = new Set();
161
- // Reads that return a LIST. A multi-record read is refused as a write target
162
- // (see DATAFABRIC_FROMREAD_MULTIPLE below) — the serializer emits a plain
163
- // no-op task for the write, and nothing at any rung reports it.
164
- const dataFabricMultiReads = new Set();
165
- // Placeholder (`mock()`) step names. The platform's `core.logic.mock` declares
166
- // one output whose `source` is the literal `"null"` — it publishes nothing —
167
- // so `$vars.<mock>.output` is null at run time, always (see
168
- // MOCK_HAS_NO_OUTPUT below).
169
- const mockNames = new Set();
170
162
  // Terminate step names. A terminate declares no action output in any registry
171
163
  // version, so `$vars.<terminate>` is undefined at run time. The terminal
172
164
  // flow-output mappings the serializer emits are validation metadata, not a
@@ -188,6 +180,8 @@ function checkFlow(built, ancestors, opts = {}) {
188
180
  // OBJECT of the child's declared outputs, so a read of it is checked against
189
181
  // that child rather than waved through (see SUBFLOW_* below).
190
182
  const subflowChildren = new Map();
183
+ // `inLoop`: inside a loop/do-while body; `'arm'` when, counting from the
184
+ // innermost container, the path also sits in a `.parallel` arm.
191
185
  const walk = (steps, inLoop = false, errorSource) => {
192
186
  // Nothing may follow a terminate. `.return()` merely ends its own path, so a
193
187
  // later step there is at worst dead weight — but a terminate ends the RUN,
@@ -206,6 +200,22 @@ function checkFlow(built, ancestors, opts = {}) {
206
200
  step: term.name,
207
201
  });
208
202
  }
203
+ // Nothing may follow a `.break()` or `.continue()` on the same path: the
204
+ // path leaves through the container's handle, so a later step would get no
205
+ // incoming edge.
206
+ const exitAt = steps.findIndex((st) => st.kind === 'break' || st.kind === 'continue');
207
+ if (exitAt !== -1 && exitAt < steps.length - 1) {
208
+ const after = steps[exitAt + 1];
209
+ const kind = steps[exitAt].kind;
210
+ diags.push({
211
+ level: 'error',
212
+ code: 'LOOP_EXIT_UNREACHABLE_AFTER',
213
+ message: `${steps.length - exitAt - 1} step(s) follow .${kind}(), starting with `
214
+ + `"${'name' in after ? after.name : after.kind}". .${kind}() leaves this path through the container's `
215
+ + `${kind} handle, so none of them can ever run. Move them before the .${kind}().`,
216
+ ...('name' in after ? { step: String(after.name) } : {}),
217
+ });
218
+ }
209
219
  // Nothing may follow a human task whose EVERY outcome ends the run, except a
210
220
  // `.return()`. Each outcome gets its own End node — the flow runtime never
211
221
  // reads `action`, so only the graph can end the run there — which leaves
@@ -260,7 +270,7 @@ function checkFlow(built, ancestors, opts = {}) {
260
270
  declared.add(s.name); // a step's output is referenceable by name
261
271
  }
262
272
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'error')
263
- checkOnError(s, prevPortSource, diags);
273
+ checkOnError(s, prevPortSource, diags, prevPortSource && registeredSteps.get(prevPortSource.name));
264
274
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port.startsWith('branch-')) {
265
275
  const want = s.port.slice('branch-'.length);
266
276
  const prevSpec = prevAction?.spec;
@@ -320,7 +330,7 @@ function checkFlow(built, ancestors, opts = {}) {
320
330
  }
321
331
  else {
322
332
  const model = hitlExitModel(prevSpec.inputs, prevAction?.options?.version);
323
- const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeSlug(typeof o === 'string' ? o : o?.name, i));
333
+ const ids = (prevSpec.inputs.outcomes ?? []).map((o, i) => outcomeIdOf(o, i));
324
334
  if (model.shape === 'placeholder') {
325
335
  diags.push({
326
336
  level: 'error', code: 'HITL_OUTCOME_PORTS_OFF', step: prevAction?.name,
@@ -363,7 +373,17 @@ function checkFlow(built, ancestors, opts = {}) {
363
373
  if (s.kind === 'action') {
364
374
  prevAction = s;
365
375
  prevPortSource = s;
366
- collectActionExprs(s, exprs, errorSource);
376
+ const registered = registeredKind(s.spec);
377
+ if (registered) {
378
+ const placed = checkRegisteredStep(registered, s, exprs, errorSource, opts, diags, typeOf, triggerId);
379
+ if (placed)
380
+ registeredSteps.set(s.name, placed);
381
+ else
382
+ unresolvedSteps.add(s.name);
383
+ }
384
+ else {
385
+ collectActionExprs(s, exprs, errorSource);
386
+ }
367
387
  if (s.spec.kind === 'connector') {
368
388
  connectorSteps.set(s.name, s.spec);
369
389
  checkConnectorLookups(s.name, s.spec, opts, diags);
@@ -380,8 +400,6 @@ function checkFlow(built, ancestors, opts = {}) {
380
400
  }
381
401
  checkContribution(maybeCustom, s.options?.version, s.name, own.some((d) => d.severity === 'error'), diags);
382
402
  }
383
- if (s.spec.kind === 'transform')
384
- checkTransform(s.name, s.spec.inputs, diags, typeOf, triggerId);
385
403
  if (s.spec.kind === 'http') {
386
404
  checkHttp(s.name, s.spec.inputs, diags);
387
405
  // Only a complete pair: an empty half is HTTP_MANAGED_BINDING_INCOMPLETE, which
@@ -419,21 +437,13 @@ function checkFlow(built, ancestors, opts = {}) {
419
437
  }
420
438
  }
421
439
  }
422
- if (s.spec.kind === 'script')
423
- checkScript(s.name, s.spec.inputs, diags);
424
- if (s.spec.kind === 'delay') {
425
- checkDelay(s.name, s.spec.inputs, diags);
426
- delayNames.add(s.name);
427
- }
428
- if (s.spec.kind === 'mock')
429
- mockNames.add(s.name);
430
440
  // `.stepSwitch` routes its exits as ARMS, so the `.stepToList` bookkeeping
431
441
  // would report every one of them unrouted. STEP_SWITCH_EXIT_UNROUTED is
432
442
  // that step's version of the same question.
433
443
  if (s0.kind !== 'stepSwitch'
434
444
  && s.spec.kind === 'hitl' && hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
435
445
  hitlOutcomeSteps.set(s.name, (s.spec.inputs.outcomes ?? []).map((o, i) => ({
436
- id: outcomeSlug(typeof o === 'string' ? o : o?.name, i),
446
+ id: outcomeIdOf(o, i),
437
447
  endsProcess: typeof o !== 'string' && o?.action === 'End',
438
448
  })));
439
449
  }
@@ -451,6 +461,7 @@ function checkFlow(built, ancestors, opts = {}) {
451
461
  && !hitlRoutesPerOutcome(s.spec.inputs, s.options?.version)) {
452
462
  hitlChoiceNoPorts.set(s.name, {
453
463
  names: outcomes.map((o) => String((typeof o === 'string' ? o : o?.name) ?? '').trim()),
464
+ ids: outcomes.map((o, i) => outcomeIdOf(o, i)),
454
465
  ends: outcomes.filter((o) => typeof o !== 'string' && o?.action === 'End')
455
466
  .map((o) => String(o.name ?? '').trim()),
456
467
  variant: s.spec.inputs?.variant,
@@ -497,71 +508,6 @@ function checkFlow(built, ancestors, opts = {}) {
497
508
  outputReaders.set(stepName, (field, rest, at, d) => rule(stepName, field, rest, inputs, at, d));
498
509
  }
499
510
  }
500
- if (s.spec.kind === 'documentClassify' || s.spec.kind === 'dynamicExtract') {
501
- const fr = s.spec.inputs.fileRef;
502
- const empty = fr === undefined || fr === null || (typeof fr === 'string' && fr.trim() === '');
503
- if (empty) {
504
- diags.push({
505
- level: 'error', code: 'DOC_NO_FILEREF', step: s.name,
506
- message: `"${s.name}" has no fileRef — nothing to ${s.spec.kind === 'documentClassify' ? 'classify' : 'extract from'}. Reference a file input or an upstream attachment.`,
507
- });
508
- }
509
- if (s.spec.kind === 'dynamicExtract' && s.spec.inputs.schema !== undefined
510
- && (typeof s.spec.inputs.schema !== 'object' || Array.isArray(s.spec.inputs.schema))) {
511
- diags.push({
512
- level: 'error', code: 'EXTRACT_SCHEMA_SHAPE', step: s.name,
513
- message: `"${s.name}"'s schema must be an object (the inline extraction schema document).`,
514
- });
515
- }
516
- }
517
- if (s.spec.kind === 'dataFabricRead' || s.spec.kind === 'dataFabricCreate'
518
- || s.spec.kind === 'dataFabricUpdate' || s.spec.kind === 'dataFabricDelete') {
519
- if (!String(s.spec.inputs.entity ?? '').trim()) {
520
- diags.push({
521
- level: 'error', code: 'DATAFABRIC_NO_ENTITY', step: s.name,
522
- message: `"${s.name}" names no Data Fabric entity. Set { entity: '<entity name>' }.`,
523
- });
524
- }
525
- if (s.spec.kind === 'dataFabricRead') {
526
- dataFabricReads.add(s.name);
527
- if (s.spec.inputs.resultMode === 'multiple')
528
- dataFabricMultiReads.add(s.name);
529
- checkDataFabricQuery(s.name, s.spec.inputs, diags);
530
- }
531
- if (s.spec.kind === 'dataFabricCreate') {
532
- // Not the same failure as an empty update: a BLANK value is omitted
533
- // from the insert so the column default applies, so an all-blank
534
- // `fieldValues` serializes to an empty body and runs green having
535
- // inserted nothing. The node's schema rejects that — and the fix is
536
- // a real value, never a placeholder to satisfy the rule.
537
- const written = Object.values(s.spec.inputs.values ?? {})
538
- .filter((v) => (v instanceof Expr ? true : String(v ?? '').trim() !== ''));
539
- if (written.length === 0) {
540
- diags.push({
541
- level: 'error', code: 'DATAFABRIC_EMPTY_VALUES', step: s.name,
542
- message: `"${s.name}" sets no column to a non-blank value, so it would insert nothing and run green. `
543
- + 'The node\'s schema rejects that — give a real column a real value rather than a placeholder.',
544
- });
545
- }
546
- checkDataFabricColumns(s.name, Object.keys(s.spec.inputs.values ?? {}), 'values', diags);
547
- }
548
- if (s.spec.kind === 'dataFabricUpdate') {
549
- if (Object.keys(s.spec.inputs.set ?? {}).length === 0) {
550
- diags.push({
551
- level: 'error', code: 'DATAFABRIC_EMPTY_SET', step: s.name,
552
- message: `"${s.name}" writes no columns — { set } is empty, so the update would no-op green.`,
553
- });
554
- }
555
- checkDataFabricColumns(s.name, Object.keys(s.spec.inputs.set ?? {}), 'set', diags);
556
- }
557
- checkDataFabricScope(s.name, s.spec.inputs, diags);
558
- if (s.spec.kind === 'dataFabricUpdate' || s.spec.kind === 'dataFabricDelete') {
559
- const factory = s.spec.kind === 'dataFabricUpdate' ? 'dataFabricUpdate' : 'dataFabricDelete';
560
- dataFabricRefs.push({ step: s.name, factory, record: s.spec.inputs.record });
561
- if (s.spec.kind === 'dataFabricDelete')
562
- dataFabricDeletes.add(s.name);
563
- }
564
- }
565
511
  if (s.spec.kind === 'ixpExtract')
566
512
  checkIxpExtract(s.name, s.spec.inputs, diags);
567
513
  if (s.spec.kind === 'waitEvent') {
@@ -637,6 +583,23 @@ function checkFlow(built, ancestors, opts = {}) {
637
583
  // Two arms with the same value: the second can never be reached, since
638
584
  // the runtime takes the first matching case.
639
585
  const seenValues = new Set();
586
+ // Two cases on one id share one `case-<id>` port (#1130: an explicit
587
+ // `id` can collide with a positional `case_<n>`).
588
+ const seenIds = new Set();
589
+ s.cases.forEach((c, i) => {
590
+ const caseId = c.id ?? `case_${i + 1}`;
591
+ if (typeof caseId !== 'string' || caseId === '' || seenIds.has(caseId)) {
592
+ diags.push({
593
+ level: 'error',
594
+ code: 'SWITCH_CASE_ID',
595
+ message: typeof caseId !== 'string' || caseId === ''
596
+ ? `Switch "${s.name}" case ${JSON.stringify(c.value)} has an id that is not a non-empty string. Leave \`id\` out for the positional case_<n>.`
597
+ : `Switch "${s.name}" has two cases with the id "${caseId}", so both would leave from one 'case-${caseId}' port. Give one a different \`id\`.`,
598
+ step: s.name,
599
+ });
600
+ }
601
+ seenIds.add(String(caseId));
602
+ });
640
603
  for (const c of s.cases) {
641
604
  const key = `${typeof c.value}:${String(c.value)}`;
642
605
  if (seenValues.has(key))
@@ -675,7 +638,7 @@ function checkFlow(built, ancestors, opts = {}) {
675
638
  parallelNames.add(s.name);
676
639
  checkParallel(s, diags);
677
640
  for (const arm of s.arms)
678
- walk(arm, inLoop);
641
+ walk(arm, inLoop ? 'arm' : false);
679
642
  }
680
643
  else if (s.kind === 'loop') {
681
644
  // A container is a legal ERROR-port source (its definition declares the
@@ -723,6 +686,17 @@ function checkFlow(built, ancestors, opts = {}) {
723
686
  });
724
687
  }
725
688
  }
689
+ else if (s.kind === 'continue') {
690
+ if (!inLoop) {
691
+ diags.push({
692
+ level: 'error', code: 'CONTINUE_OUTSIDE_LOOP',
693
+ message: '.continue() is only valid inside a .loop() or .doWhile() body: it goes on to the container\'s next iteration.',
694
+ });
695
+ }
696
+ else if (inLoop === 'arm') {
697
+ diags.push(loopExitInArm());
698
+ }
699
+ }
726
700
  else if (s.kind === 'return') {
727
701
  for (const [name, e] of Object.entries(s.values)) {
728
702
  addExpr(e.js, 'return');
@@ -839,30 +813,7 @@ function checkFlow(built, ancestors, opts = {}) {
839
813
  }
840
814
  }
841
815
  reportUnreachableHitlOutcomes(hitlChoiceNoPorts, exprs, diags);
842
- for (const { step, factory, record } of dataFabricRefs) {
843
- const fromRead = record?.fromRead;
844
- if (typeof fromRead !== 'string')
845
- continue;
846
- if (!dataFabricReads.has(fromRead)) {
847
- diags.push({
848
- level: 'error', code: 'DATAFABRIC_FROMREAD_UNKNOWN', step,
849
- message: `"${step}" targets record: { fromRead: ${JSON.stringify(fromRead)} }, but no dataFabricRead step has that name.`,
850
- suggestion: `${factory}({ …, record: { byId: '<record id>' } })`,
851
- });
852
- continue;
853
- }
854
- // A LIST is not a write target: the serializer refuses it and emits a plain
855
- // no-op task, so the flow validates, runs green, and writes nothing.
856
- if (dataFabricMultiReads.has(fromRead)) {
857
- diags.push({
858
- level: 'error', code: 'DATAFABRIC_FROMREAD_MULTIPLE', step,
859
- message: `"${step}" targets record: { fromRead: ${JSON.stringify(fromRead)} }, but "${fromRead}" is a `
860
- + "resultMode: 'multiple' query. A multi-record read is refused as a write target — the step compiles to "
861
- + 'a no-op and the run goes green having written nothing.',
862
- suggestion: `loop the results and target each record with ${factory}({ …, record: { byId: … } })`,
863
- });
864
- }
865
- }
816
+ checkRegisteredFamilies(registeredSteps, unresolvedSteps, stepNames, diags);
866
817
  // Entry points: unique roots with valid triggers, colliding with nothing.
867
818
  const epIds = new Set();
868
819
  for (const ep of built.entryPoints ?? []) {
@@ -1144,6 +1095,16 @@ function checkFlow(built, ancestors, opts = {}) {
1144
1095
  });
1145
1096
  continue;
1146
1097
  }
1098
+ // A registered node type declares what its output is; `none` reports here,
1099
+ // at the same point as the per-family no-output rules below.
1100
+ const placed = registeredSteps.get(name);
1101
+ if (placed) {
1102
+ const contract = placed.impl.output(placed.spec);
1103
+ if (contract.kind === 'none') {
1104
+ diags.push(catalogued(placed.entry, step, contract.code, contract.message({ node: name, ref: `$vars.${name}${field ? '.' + field : ''}`, reader: step })));
1105
+ continue;
1106
+ }
1107
+ }
1147
1108
  // A switch only routes; it has no output of its own, so any read of it is
1148
1109
  // undefined at run time. Point the author at the arm's step instead.
1149
1110
  if (switchNames.has(name)) {
@@ -1186,62 +1147,6 @@ function checkFlow(built, ancestors, opts = {}) {
1186
1147
  });
1187
1148
  continue;
1188
1149
  }
1189
- // A Data Fabric delete is the one verb of that family whose definition
1190
- // declares no outputDefinition at all — the record is gone, so there is
1191
- // nothing to publish. Read the record on the step that FOUND it instead,
1192
- // which on the `{ fromRead }` form is already in the flow.
1193
- if (dataFabricDeletes.has(name)) {
1194
- diags.push({
1195
- level: 'error',
1196
- code: 'DATAFABRIC_DELETE_HAS_NO_OUTPUT',
1197
- message: `Delete "${name}" produces no output, but "$vars.${name}${field ? '.' + field : ''}" reads one in "${step}". `
1198
- + 'A Data Fabric delete declares no outputs — take the value off the dataFabricRead step that found the record, before the delete.',
1199
- step,
1200
- });
1201
- continue;
1202
- }
1203
- if (delayNames.has(name)) {
1204
- diags.push({
1205
- level: 'error',
1206
- code: 'DELAY_HAS_NO_OUTPUT',
1207
- message: `Delay "${name}" produces no output, but "$vars.${name}${field ? '.' + field : ''}" reads one in "${step}". `
1208
- + 'A delay only waits — read the step that computed the value instead.',
1209
- step,
1210
- });
1211
- continue;
1212
- }
1213
- // A `mock()` is a PLACEHOLDER: `core.logic.mock`'s definition declares one
1214
- // output whose `source` is the literal `"null"`, so the value is null at run
1215
- // time whatever the flow does. Unlike the four rules above, the node DOES
1216
- // declare an output slot — which is exactly why the read is silent: it
1217
- // resolves in the designer, it resolves in `variables.nodes`, and it hands
1218
- // the downstream step a null.
1219
- //
1220
- // WHY THIS RULE LIVES HERE, in the authoring checker rather than in a
1221
- // pass over the compiled artifact (the measurement,
1222
- // convention #191). Reading a placeholder's output is the ARCHIVE's
1223
- // dominant idiom, not a rarity: 55 of the 62 archived mock-carrying
1224
- // artifacts do it — 110 read sites, 87 of them inside a script's body and
1225
- // 19 in a decision's expression — and **39 of those 55 come from runs that
1226
- // scored 1.0**. That is an author writing the downstream code for the real
1227
- // node the placeholder stands in for, and a `.flow`-level rule refusing it
1228
- // would refuse the deployed corpus (lesson 47). An SDK author is in the
1229
- // other position: they wrote `mock()` here, in this file, and the SDK can
1230
- // say at compile time that the read is dead.
1231
- // Measured 2026-08-02 over 4,033 distinct archived artifacts —
1232
- // runs/flow-sdk-cleanup-2026-08-02/step0/mock-output-read-sites.txt.
1233
- if (mockNames.has(name)) {
1234
- diags.push({
1235
- level: 'error',
1236
- code: 'MOCK_HAS_NO_OUTPUT',
1237
- message: `Placeholder "${name}" publishes nothing, but "$vars.${name}${field ? '.' + field : ''}" reads it in "${step}". `
1238
- + 'A mock() is a marker for a node that does not exist yet — its output is null at run time, always. '
1239
- + 'Keep the placeholder while the real node does not exist, and have the reader use a value you supply instead (a flow input, as example/ClaimScaffold.flow.ts does). '
1240
- + 'Replace the placeholder only with the real node, once `registry search` finds it; a script returning fixed values is not the real node.',
1241
- step,
1242
- });
1243
- continue;
1244
- }
1245
1150
  // A subflow hands back an OBJECT keyed by the child flow's declared
1246
1151
  // outputs — even when the child declares exactly one. So the reference
1247
1152
  // needs the output's NAME (`out('call', 'reversed')`), and the name has to
@@ -1499,6 +1404,13 @@ function writtenTails(steps, incoming, sinks) {
1499
1404
  breaks.push(...tails);
1500
1405
  return [];
1501
1406
  }
1407
+ // A `.continue()` path goes to the container's next pass, not to the steps
1408
+ // after it: for a do-while that is the condition test, one of the ways out,
1409
+ // exactly like reaching the end of the body — so it joins the exits too.
1410
+ if (st.kind === 'continue') {
1411
+ breaks.push(...tails);
1412
+ return [];
1413
+ }
1502
1414
  if (st.kind === 'return' || st.kind === 'terminate')
1503
1415
  return [];
1504
1416
  const before = surelyWritten(tails);
@@ -1985,26 +1897,6 @@ function checkTrigger(spec, diags, opts = {}, entryPointId) {
1985
1897
  + `comes round. Use a non-zero interval.`, `every: 'R/PT1H'`);
1986
1898
  }
1987
1899
  }
1988
- /**
1989
- * An EXACT ISO-8601 duration: weeks/days plus a time part, at least one
1990
- * component. Copied in shape from the delay definition's own
1991
- * `timerValue.pattern`, minus the years and months — see DELAY_CALENDAR below.
1992
- */
1993
- const DURATION_RE = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
1994
- /** Grammatical ISO-8601 duration, including the calendar-relative parts. */
1995
- const ANY_DURATION_RE = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
1996
- /** Natural-language guesses an author might reach for, mapped to a duration. */
1997
- const DURATION_WORDS = [
1998
- ['second', 'PT30S'], ['minute', 'PT15M'], ['hour', 'PT1H'],
1999
- ['day', 'P1D'], ['daily', 'P1D'], ['week', 'P1W'], ['weekly', 'P1W'],
2000
- ];
2001
- /**
2002
- * Diagnose a `delay` step's wait.
2003
- *
2004
- * Rung 1 is the only place an author hears about a bad duration in time to fix
2005
- * it cheaply. Anything downstream speaks in terms of `timerPreset`/`timerValue`,
2006
- * the two fields the serializer writes, rather than the one field the author typed.
2007
- */
2008
1900
  /** A release key is a GUID — the shape 45/45 deployed rpa node types carry. */
2009
1901
  /**
2010
1902
  * The DECLARED name a single reference resolves to, or undefined.
@@ -2805,8 +2697,22 @@ function checkAgenticProcess(step, inputs, diags) {
2805
2697
  }
2806
2698
  const inSolution = inputs?.location === 'in-solution';
2807
2699
  if (!inSolution && blank(inputs?.folderPath)) {
2808
- at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has no \`folderPath\`. The runtime starts the job by name + folder, so `
2809
- + `the Orchestrator folder holding the process is required.`, `folderPath: 'Shared/uipath-agents/ProcurementProcess'`);
2700
+ // `registry list --local` shows an in-solution Flow sibling with folderPath: "".
2701
+ // An explicit `form` other than 'flow' keeps the published hint: switching it would change the node type.
2702
+ const meantInSolution = inputs?.form === 'flow'
2703
+ || (typeof inputs?.folderPath === 'string' && inputs?.form === undefined);
2704
+ if (meantInSolution) {
2705
+ const formPart = inputs?.form === 'flow' ? '' : `, form: 'flow'`;
2706
+ at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has an empty \`folderPath\`. If the process is an unpublished sibling `
2707
+ + `Flow in this solution, call it in-solution: set \`location: 'in-solution'\`${formPart} and \`projectId\` `
2708
+ + `(the sibling's \`resource.projectKey\` in \`resources/solution_folder/process/flow/<project>.json\`), and `
2709
+ + `drop \`folderPath\`. If it is published, set \`folderPath\` to the Orchestrator folder that holds it — `
2710
+ + `the runtime starts the job by name + folder.`, `location: 'in-solution'${formPart}, projectId: '<resource.projectKey>'`);
2711
+ }
2712
+ else {
2713
+ at('AGENTIC_NO_FOLDER', `Agentic process step "${step}" has no \`folderPath\`. The runtime starts the job by name + folder, so `
2714
+ + `the Orchestrator folder holding the process is required.`, `folderPath: 'Shared/uipath-agents/ProcurementProcess'`);
2715
+ }
2810
2716
  }
2811
2717
  const form = inputs?.form ?? 'bpmn';
2812
2718
  if (inSolution && form !== 'flow') {
@@ -3278,13 +3184,31 @@ function checkInlineAgent(step, inputs, diags, opts = {}) {
3278
3184
  + `run-dependent search needs.`);
3279
3185
  }
3280
3186
  }
3187
+ // The index id is half of the resource node's TYPE
3188
+ // (`…context.index.<slug>.<id>`), and the platform resolves the index by it.
3189
+ // A blank, padded or non-GUID id builds a type no tenant serves, and nothing
3190
+ // downstream says so: the flow compiles, merges and validates, and the agent
3191
+ // runs ungrounded. So it is an error, not a warning. The id is tested as
3192
+ // written, not trimmed, because serialize emits it as written.
3193
+ for (const cx of contexts) {
3194
+ const id = typeof cx?.id === 'string' ? cx.id : '';
3195
+ if (!GUID_RE.test(id)) {
3196
+ at('AGENT_CONTEXT_NO_ID', `Inline agent step "${step}" grounds on context index "${cx?.name ?? ''}" with `
3197
+ + (id.trim() === '' ? 'no `id`'
3198
+ : GUID_RE.test(id.trim()) ? `\`id\` ${JSON.stringify(cx?.id)}, which has whitespace around the GUID`
3199
+ : `\`id\` ${JSON.stringify(cx?.id)}, which is not a GUID`) + '. '
3200
+ + `The id is the index's key: it is the tail of the resource node's type and how the runtime finds `
3201
+ + `the index, so without a real one the agent runs ungrounded and \`validate\` does not notice. `
3202
+ + `\`uip solution resources list --kind Index --source remote --search "<name>"\` returns it as \`Key\`.`);
3203
+ }
3204
+ }
3281
3205
  // An index outside the deployment folder needs its folder named. The resource
3282
3206
  // file carries no `indexId`, so `indexName` + `folderPath` is the whole locator,
3283
3207
  // and `@uipath/tool-agent`'s `createIndexBinding` leaves the folder off the
3284
3208
  // binding when `folderPath` is empty. A WARNING, not an error: an index in the
3285
3209
  // deployment folder resolves without it.
3286
3210
  for (const cx of contexts) {
3287
- if (cx !== undefined && cx.folderPath === undefined) {
3211
+ if (cx != null && cx.folderPath === undefined) {
3288
3212
  warn('AGENT_CONTEXT_NO_FOLDER_PATH', `Inline agent step "${step}" grounds on context index "${cx.name ?? ''}" without a \`folderPath\`. `
3289
3213
  + `The emitted resource carries no index id, so the folder is half of how the runtime finds the `
3290
3214
  + `index, and the binding is written without one. Give the index's folder — `
@@ -3611,8 +3535,8 @@ function checkInlineAgent(step, inputs, diags, opts = {}) {
3611
3535
  for (const f of Array.isArray(e.fields) ? e.fields : []) {
3612
3536
  if (!FORM_FIELD_TYPES.includes(String(f?.type))) {
3613
3537
  at('INLINE_AGENT_ESCALATION_FIELD_TYPE_UNKNOWN', `Inline agent step "${step}"'s quick-form escalation gives the field "${String(f?.id ?? '?')}" the type `
3614
- + `${JSON.stringify(f?.type)}, which the form does not recognize — it would be published as a plain `
3615
- + `string. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
3538
+ + `${JSON.stringify(f?.type)}, ${formFieldTypeWhy(f?.type, `which the form does not recognize — it would `
3539
+ + `be published as a plain string`)}. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
3616
3540
  }
3617
3541
  }
3618
3542
  continue;
@@ -3870,64 +3794,6 @@ function checkQueueItem(step, inputs, diags) {
3870
3794
  }
3871
3795
  /** The three priorities `core.action.queue.create[-and-wait]`'s form offers. */
3872
3796
  const QUEUE_PRIORITIES = ['Low', 'Normal', 'High'];
3873
- function checkDelay(step, inputs, diags) {
3874
- const at = (code, message, suggestion) => {
3875
- diags.push({ level: 'error', code, message, step, ...(suggestion ? { suggestion } : {}) });
3876
- };
3877
- const untilRaw = inputs?.until;
3878
- if (typeof untilRaw === 'string' && untilRaw.trim() !== '') {
3879
- // The absolute-date arm: an ISO-8601 date-time, e.g. 2026-09-01T09:00:00Z.
3880
- const until = untilRaw.trim();
3881
- if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(until)) {
3882
- at('DELAY_UNTIL_NOT_ISO', `Delay "${step}"'s until ${JSON.stringify(untilRaw)} is not an ISO-8601 date-time. `
3883
- + `Use e.g. '2026-09-01T09:00:00Z' (a timezone-less value is read in the tenant's zone).`, `until: '2026-09-01T09:00:00Z'`);
3884
- }
3885
- return;
3886
- }
3887
- const raw = inputs?.duration;
3888
- if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) {
3889
- at('DELAY_NO_DURATION', `Delay "${step}" has no \`duration\`, so it carries no wait at all and the flow runs straight `
3890
- + `past it. Give it an ISO-8601 duration — ${DELAY_PRESETS.slice(0, 8).join(', ')} … are the `
3891
- + `designer's presets, and any other exact duration works too.`, `duration: 'PT15M'`);
3892
- return;
3893
- }
3894
- const d = String(raw).trim();
3895
- if (!ANY_DURATION_RE.test(d)) {
3896
- at('DELAY_NOT_ISO', `Delay "${step}"'s duration ${JSON.stringify(raw)} is not an ISO-8601 duration. The format is `
3897
- + `"P" plus a date part and/or "T" plus a time part — PT30S, PT15M, PT2H, P1D, P1W. Note the `
3898
- + `"T": P1M is a MONTH, PT1M is a minute.`, nearestDuration(d));
3899
- return;
3900
- }
3901
- if (!/[1-9]/.test(d)) {
3902
- at('DELAY_ZERO', `Delay "${step}"'s duration ${JSON.stringify(d)} is zero-length, so the step waits for no time `
3903
- + `at all. Use a non-zero duration, or drop the step.`, `duration: 'PT15M'`);
3904
- return;
3905
- }
3906
- // Grammatical, and the platform runs it — but a month is not a fixed number
3907
- // of milliseconds, so NO local rung can run the flow at all.
3908
- // Rejected here rather than approximated: guessing 30 days would
3909
- // put a silent disagreement with the deployed engine back into the pipeline.
3910
- if (!DURATION_RE.test(d)) {
3911
- at('DELAY_CALENDAR', `Delay "${step}"'s duration ${JSON.stringify(d)} is calendar-relative: how long a month or a `
3912
- + `year is depends on when it starts. The platform resolves that at run time, but the local `
3913
- + `timer takes a fixed millisecond count, so this flow could not be replayed or run offline.`, `duration: 'P30D'`);
3914
- }
3915
- }
3916
- /** Best guess at what the author meant, for a `DELAY_NOT_ISO` suggestion. */
3917
- function nearestDuration(d) {
3918
- // The likeliest slip: a time-part duration written without its "T".
3919
- if (/^P\d+[HMS]$/.test(d) && ANY_DURATION_RE.test(`PT${d.slice(1)}`))
3920
- return `duration: 'PT${d.slice(1)}'`;
3921
- const lower = d.toLowerCase();
3922
- const n = /(\d+)/.exec(lower)?.[1];
3923
- const word = DURATION_WORDS.find(([w]) => lower.includes(w));
3924
- if (!word)
3925
- return undefined;
3926
- if (!n)
3927
- return `duration: '${word[1]}'`;
3928
- const unit = word[1].replace(/\d+/, '');
3929
- return `duration: '${unit.startsWith('PT') ? `PT${n}${unit.slice(2)}` : `P${n}${unit.slice(1)}`}'`;
3930
- }
3931
3797
  /** Best guess at what the author meant, for a `SCHEDULE_NOT_ISO` suggestion. */
3932
3798
  function nearestInterval(every) {
3933
3799
  // By far the likeliest slip: a correct duration missing the "R/" repeat marker.
@@ -4052,7 +3918,7 @@ function armTails(steps, incoming) {
4052
3918
  tails = [];
4053
3919
  continue;
4054
3920
  }
4055
- if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break')
3921
+ if (st.kind === 'return' || st.kind === 'terminate' || st.kind === 'break' || st.kind === 'continue')
4056
3922
  return [];
4057
3923
  if (st.kind === 'action') {
4058
3924
  tails = actionTails(st.name, st.spec, st.options?.version);
@@ -4249,70 +4115,6 @@ const RETRY_COUNT_SOFT_MAX = 5;
4249
4115
  * not a shorter timeout, it is a value the request node fails to configure with,
4250
4116
  * and nothing downstream of `check` would say so in the author's own vocabulary.
4251
4117
  */
4252
- /** The type vocabulary a script's `returns` may name — what the wire format accepts. */
4253
- const SCRIPT_RETURN_TYPES = new Set(['string', 'number', 'boolean', 'object', 'array']);
4254
- /**
4255
- * A script step's `returns` — the author overriding what the body looks like.
4256
- *
4257
- * TypeScript already refuses a bad value at the call site, so this is the fence
4258
- * for everything that does NOT go through the type checker: a `.flow.ts` loaded
4259
- * as plain JS, a generated flow, a value read from config. Both mistakes below
4260
- * reach the emitted file silently and are then the designer's problem instead of
4261
- * the author's, which is precisely the failure this whole feature exists to undo.
4262
- */
4263
- function checkScript(step, inputs, diags) {
4264
- const returns = inputs?.returns;
4265
- if (returns === undefined)
4266
- return;
4267
- if (typeof returns === 'string') {
4268
- if (!SCRIPT_RETURN_TYPES.has(returns)) {
4269
- diags.push({
4270
- level: 'error',
4271
- code: 'SCRIPT_RETURNS_UNKNOWN_TYPE',
4272
- message: `Script step "${step}" declares \`returns: ${JSON.stringify(returns)}\`, which is not a type the `
4273
- + `wire format knows. Use one of ${[...SCRIPT_RETURN_TYPES].map((t) => `"${t}"`).join(', ')}, or a `
4274
- + `field map like \`{ total: "number" }\` to name an object's fields.`,
4275
- step,
4276
- });
4277
- }
4278
- return;
4279
- }
4280
- if (typeof returns !== 'object' || returns === null || Array.isArray(returns)) {
4281
- diags.push({
4282
- level: 'error',
4283
- code: 'SCRIPT_RETURNS_UNKNOWN_TYPE',
4284
- message: `Script step "${step}"'s \`returns\` is ${JSON.stringify(returns)}. It takes either a type name or a `
4285
- + `map of field names to types (\`{ total: "number" }\`).`,
4286
- step,
4287
- });
4288
- return;
4289
- }
4290
- const fields = returns;
4291
- // Same call as `APIWF_EMPTY_RETURNS` one surface over: an empty map declares
4292
- // nothing, emits nothing, and reads exactly like omitting the key — so it is
4293
- // almost always a half-finished edit rather than a statement.
4294
- if (Object.keys(fields).length === 0) {
4295
- diags.push({
4296
- level: 'warning',
4297
- code: 'SCRIPT_EMPTY_RETURNS',
4298
- message: `Script step "${step}" declares \`returns: {}\`, which names no fields and emits nothing — the same `
4299
- + `as omitting it. Name the fields, or drop the key and let the body speak for itself.`,
4300
- step,
4301
- });
4302
- return;
4303
- }
4304
- for (const [name, type] of Object.entries(fields)) {
4305
- if (typeof type !== 'string' || !SCRIPT_RETURN_TYPES.has(type)) {
4306
- diags.push({
4307
- level: 'error',
4308
- code: 'SCRIPT_RETURNS_UNKNOWN_TYPE',
4309
- message: `Script step "${step}" declares field "${name}" as ${JSON.stringify(type)}, which is not a type the `
4310
- + `wire format knows. Use one of ${[...SCRIPT_RETURN_TYPES].map((t) => `"${t}"`).join(', ')}.`,
4311
- step,
4312
- });
4313
- }
4314
- }
4315
- }
4316
4118
  function checkHttp(step, inputs, diags) {
4317
4119
  // `core.action.http` is retired on the platform side: after `registry pull
4318
4120
  // --force` the tenant serves only `core.action.http.v2` (2.3), and a
@@ -4450,12 +4252,28 @@ function checkHttp(step, inputs, diags) {
4450
4252
  }
4451
4253
  }
4452
4254
  }
4255
+ /**
4256
+ * Why a form-field type is refused: a product spelling of a type the SDK
4257
+ * models (`"string"` is the platform's own name for text), or a type
4258
+ * the SDK does not model at all. `unknownWhy` is the caller's reason for the
4259
+ * second case; the default is a human task's.
4260
+ */
4261
+ function formFieldTypeWhy(type, unknownWhy = `which the platform does not recognize. Older runtimes read it as text, newer ones reject `
4262
+ + `the form`) {
4263
+ const alias = sdkFormFieldType(type);
4264
+ return alias !== undefined
4265
+ ? `which is the platform's spelling of the SDK's '${alias}' — the SDK writes '${alias}'`
4266
+ : unknownWhy;
4267
+ }
4453
4268
  /** Every FormFieldType, once; `Record<FormFieldType, …>` makes tsc refuse a missing or extra one. */
4454
4269
  const FORM_FIELD_TYPE_SET = { text: true, number: true, boolean: true, 'date-time': true };
4455
- /** Form-field types the platform recognizes; anything else normalizes to text. */
4270
+ /** The form-field types the SDK models. */
4456
4271
  const FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPE_SET);
4457
4272
  /** The `suggestion` for an unknown form-field type: the nearest valid one, else `type: 'text'`. */
4458
4273
  function formFieldTypeHint(type) {
4274
+ const alias = sdkFormFieldType(type);
4275
+ if (alias !== undefined)
4276
+ return `type: '${alias}'`;
4459
4277
  const nearest = nearestName(String(type), FORM_FIELD_TYPES);
4460
4278
  return nearest !== undefined ? `type: '${nearest}'` : `type: 'text'`;
4461
4279
  }
@@ -4467,18 +4285,6 @@ const RESERVED_ANSWER_IDS = new Set(['output', 'status', 'Action']);
4467
4285
  * family — same availability note as do-while).
4468
4286
  */
4469
4287
  const HITL_VARIANTS = ['quick-form', 'action-app', 'document-validation'];
4470
- /**
4471
- * An outcome's stable id — the serializer's own slug rule, mirrored so the
4472
- * `outcome-<id>` port names this file validates are the ones that get emitted.
4473
- */
4474
- function outcomeSlug(name, index) {
4475
- const slug = String(name ?? '')
4476
- .trim()
4477
- .toLowerCase()
4478
- .replace(/[^a-z0-9]+/g, '-')
4479
- .replace(/^-+|-+$/g, '');
4480
- return slug || `outcome-${index + 1}`;
4481
- }
4482
4288
  /**
4483
4289
  * Why a base or quick-form task continues every outcome to the next step, as a
4484
4290
  * clause for a diagnostic. `pinned` is the step's `{ version }`.
@@ -4548,10 +4354,9 @@ function reportUnreachableHitlOutcomes(choices, exprs, diags) {
4548
4354
  for (const m of js.matchAll(/\$vars\.([A-Za-z0-9_]+)\s*\[/g))
4549
4355
  readsOutcome.add(m[1]);
4550
4356
  }
4551
- for (const [step, { names, ends, variant, pinned, portsOff }] of choices) {
4357
+ for (const [step, { names, ids, ends, variant, pinned, portsOff }] of choices) {
4552
4358
  if (readsOutcome.has(step))
4553
4359
  continue;
4554
- const ids = names.map((n, i) => outcomeSlug(n, i));
4555
4360
  // The base node and quick-form draw a handle per outcome, so both can route
4556
4361
  // it — `.stepSwitch`, or `outcomePorts: true` (a pin keeps its version). An
4557
4362
  // app-backed variant cannot: the Action read is its only remedy.
@@ -4734,10 +4539,8 @@ function checkHitl(step, inputs, diags) {
4734
4539
  return;
4735
4540
  }
4736
4541
  if (!FORM_FIELD_TYPES.includes(String(f?.type))) {
4737
- at('HITL_FIELD_TYPE_UNKNOWN', `Human task "${step}" gives the form field "${id}" the type ${JSON.stringify(f?.type)}, which `
4738
- + `the platform does not recognize — it normalizes an unknown type to text, so the reviewer `
4739
- + `gets a text box and the value comes back as a string. Valid: `
4740
- + `${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
4542
+ at('HITL_FIELD_TYPE_UNKNOWN', `Human task "${step}" gives the form field "${id}" the type ${JSON.stringify(f?.type)}, `
4543
+ + `${formFieldTypeWhy(f?.type)}. Valid: ${FORM_FIELD_TYPES.join(', ')}.`, formFieldTypeHint(f?.type));
4741
4544
  }
4742
4545
  if (isAnswerField(f))
4743
4546
  answers++; // an `output`, or an `inOut` whose reviewed value comes back
@@ -4776,8 +4579,26 @@ function checkHitl(step, inputs, diags) {
4776
4579
  return;
4777
4580
  }
4778
4581
  const names = new Set();
4582
+ const ids = new Map();
4779
4583
  outcomes.forEach((o, i) => {
4780
4584
  const name = (typeof o === 'string' ? o : o?.name)?.trim();
4585
+ // Two outcomes on one id would share one `outcome-<id>` port, so the
4586
+ // second's edge could never be told apart. An explicit `id` can collide
4587
+ // with another outcome's derived slug.
4588
+ const id = outcomeIdOf(o, i);
4589
+ const explicitId = typeof o === 'object' && o !== null ? o.id : undefined;
4590
+ if (explicitId !== undefined && (typeof explicitId !== 'string' || explicitId === '')) {
4591
+ at('HITL_OUTCOME_BAD_ID', `Human task "${step}" outcome ${JSON.stringify(name ?? i)} has an id that is not a non-empty string. `
4592
+ + `Leave \`id\` out to use the slug of the name.`, `{ name: ${JSON.stringify(name ?? 'Approve')} }`);
4593
+ }
4594
+ else if (ids.has(id) && ids.get(id) !== String(name ?? '')) {
4595
+ // Same name, same id: HITL_DUP_OUTCOME below reports it once, as a name clash.
4596
+ at('HITL_DUP_OUTCOME_ID', `Human task "${step}" gives outcomes "${ids.get(id)}" and "${name}" the same id "${id}", so both would `
4597
+ + `leave from one 'outcome-${id}' port. Give one of them a different \`id\`.`);
4598
+ }
4599
+ else {
4600
+ ids.set(id, String(name ?? ''));
4601
+ }
4781
4602
  if (!name) {
4782
4603
  at('HITL_OUTCOME_NO_NAME', `Human task "${step}" has an outcome with no name (outcomes[${i}]). The result carries the `
4783
4604
  + `outcome's NAME — it is what "$vars.${step}.output.Action" holds and what the platform's `
@@ -4907,211 +4728,6 @@ function checkHitlRead(name, field, rest, form, step, diags) {
4907
4728
  ...(nearest !== undefined ? { suggestion: `out('${name}', '${nearest}')` } : {}),
4908
4729
  });
4909
4730
  }
4910
- function checkTransform(step, inputs, diags, typeOf = new Map(), triggerId = 'start') {
4911
- const at = (code, message, suggestion) => {
4912
- diags.push({ level: 'error', code, message, step, ...(suggestion ? { suggestion } : {}) });
4913
- };
4914
- // ── collection must be a bare variable path ──
4915
- const collection = inputs?.collection;
4916
- if (collection === undefined || collection === null) {
4917
- at('TRANSFORM_NO_COLLECTION', `Transform "${step}" has no collection. Point it at the variable or step output holding the array.`, `collection: v('catalog')`);
4918
- }
4919
- else if (!(collection instanceof Expr)) {
4920
- // Types are stripped at run time, so `collection: [{…}]` or
4921
- // `collection: 'catalog'` reaches here as a raw value.
4922
- at('TRANSFORM_COLLECTION_NOT_REF', `Transform "${step}" was given a ${Array.isArray(collection) ? 'raw array' : `raw ${typeof collection}`} as its `
4923
- + `collection. That field is a variable PATH — the runtime turns the string into a lookup, so a value there `
4924
- + `resolves to nothing and the transform silently sees an empty collection.`, typeof collection === 'string' ? `collection: v('${collection}')` : `collection: v('<varName>')`);
4925
- }
4926
- else if (collection.literal) {
4927
- at('TRANSFORM_COLLECTION_LITERAL', `Transform "${step}" has a literal collection. Inline data cannot be a path — keep the array in a variable `
4928
- + `default (\`.var('items', types.array, [...])\`) and reference it, or read an upstream step's output.`, `collection: v('items')`);
4929
- }
4930
- else if (!/^\$vars\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/.test(collection.js.trim())) {
4931
- // A computed expression (js`…`, tmpl`…`, `.concat(…)`) is the trap that
4932
- // survives every local rung: a local run resolves it to a value, so
4933
- // it runs correctly there and resolves to NOTHING once deployed, where the
4934
- // string is used as a lookup path.
4935
- at('TRANSFORM_COLLECTION_NOT_PATH', `Transform "${step}"'s collection must be a plain reference, but it is the expression `
4936
- + `"${collection.js.trim().slice(0, 60)}". The deployed runtime uses that string as a lookup PATH, so a `
4937
- + `computed expression resolves to nothing there even though it runs locally. Compute it in a script step `
4938
- + `first, then point the transform at that step's output.`, `collection: out('<scriptStep>')`);
4939
- }
4940
- else {
4941
- // A collection that is not an array is PASSED THROUGH untouched — no error,
4942
- // no operations applied (faithful to v1). The result is a transform that
4943
- // looks like it ran and changed nothing.
4944
- // Only a DECLARED scalar can be judged here: a step output's type is not
4945
- // known offline, so this stays quiet about those rather than guessing.
4946
- const refName = declaredRefName(collection.js, triggerId);
4947
- const declaredType = refName ? typeOf.get(refName) : undefined;
4948
- if (declaredType && ['string', 'number', 'boolean'].includes(declaredType)) {
4949
- at('TRANSFORM_COLLECTION_SCALAR', `Transform "${step}"'s collection is "${refName}", declared as ${declaredType}. A transform runs its `
4950
- + `operations over an ARRAY; anything else is passed through untouched — no error, nothing applied — so `
4951
- + `the step would look like it ran and change nothing.`, `Declare it as types.array, or point the collection at the array field: collection: out('<step>', '<field>')`);
4952
- }
4953
- }
4954
- // ── the operation chain ──
4955
- const ops = inputs?.operations;
4956
- if (!Array.isArray(ops) || ops.length === 0) {
4957
- at('TRANSFORM_NO_OPERATIONS', `Transform "${step}" has no operations, so it passes its collection straight through. Add at least one `
4958
- + `filter / map / groupBy operation, or drop the node.`, `operations: [{ type: 'filter', filters: [{ field: 'status', condition: 'equals', value: 'open' }] }]`);
4959
- return;
4960
- }
4961
- checkVariant(step, inputs, ops, at);
4962
- ops.forEach((op, i) => {
4963
- const where = `operations[${i}]`;
4964
- // Read the discriminant before the narrowing below exhausts the union — at
4965
- // run time it can be anything (an author typo survives type erasure).
4966
- const kind = op?.type;
4967
- if (op?.type === 'filter') {
4968
- if (!Array.isArray(op.filters) || op.filters.length === 0) {
4969
- at('TRANSFORM_EMPTY_FILTER', `Transform "${step}" ${where} is a filter with no rules — the runtime passes every item through it.`);
4970
- return;
4971
- }
4972
- for (const [j, f] of op.filters.entries()) {
4973
- if (!f?.field && f?.field !== '')
4974
- at('TRANSFORM_FILTER_NO_FIELD', `Transform "${step}" ${where}.filters[${j}] has no field, so the rule is dropped and the filter `
4975
- + `matches everything.`);
4976
- checkVocab(step, `${where}.filters[${j}].condition`, f?.condition, FILTER_CONDITIONS, 'filter condition', 'passes every item through the rule', diags);
4977
- if (typeof f?.value === 'string' && LOOKS_LIKE_EXPRESSION.test(f.value.trim()))
4978
- at('TRANSFORM_FILTER_VALUE_EXPR', `Transform "${step}" ${where}.filters[${j}] compares against ${JSON.stringify(f.value)}. Filter values `
4979
- + `are LITERAL only — the runtime evaluates nothing here, so every item would be compared against that `
4980
- + `text and the filter would return nothing. Use a literal, or do the comparison in a script step.`, `value: 500`);
4981
- // `contains` is TWO operators wearing one name — the runtime does
4982
- // `Array.includes` when the FIELD holds an array and `String.includes`
4983
- // otherwise, faithful to v1. Which
4984
- // one runs is decided by the data, so nothing static can pick for you —
4985
- // but an ARRAY on the compare side is wrong under both readings
4986
- // (`['a','b']` is stringified to `a,b` for the substring test, and
4987
- // `includes` looks for the array itself as one element), and it is the
4988
- // shape an author reaches for when they mean "any of these".
4989
- if ((f?.condition === 'contains' || f?.condition === 'not_contains') && Array.isArray(f?.value))
4990
- at('TRANSFORM_CONTAINS_ARRAY_VALUE', `Transform "${step}" ${where}.filters[${j}] uses "${f.condition}" against an ARRAY. "contains" compares `
4991
- + `ONE value: if the item's field holds an array it tests membership of that value, otherwise it tests `
4992
- + `for that value as a substring — either way an array on the right matches nothing, silently. For `
4993
- + `"any of these", use one rule per value with the filter's "or" operation.`, `condition: 'contains', value: ${JSON.stringify(f.value[0] ?? 'x')}`);
4994
- }
4995
- return;
4996
- }
4997
- if (op?.type === 'map') {
4998
- if (!Array.isArray(op.mappings) || op.mappings.length === 0) {
4999
- at('TRANSFORM_EMPTY_MAP', `Transform "${step}" ${where} is a map with no mappings — with keepOriginalFields off that turns every `
5000
- + `item into {}.`);
5001
- return;
5002
- }
5003
- for (const [j, m] of op.mappings.entries()) {
5004
- if (!m?.field)
5005
- at('TRANSFORM_MAP_NO_FIELD', `Transform "${step}" ${where}.mappings[${j}] has no field, so nothing is carried into the output.`);
5006
- // Omitted is fine — it defaults to `copy`.
5007
- if (m?.transformation !== undefined)
5008
- checkVocab(step, `${where}.mappings[${j}].transformation`, m.transformation, TRANSFORMATIONS, 'transformation', 'copies the value unchanged', diags);
5009
- }
5010
- return;
5011
- }
5012
- if (op?.type === 'groupBy') {
5013
- if (!op.groupByField)
5014
- at('TRANSFORM_NO_GROUP_FIELD', `Transform "${step}" ${where} is a groupBy with no groupByField, so every item lands in one anonymous `
5015
- + `group.`);
5016
- for (const [j, a] of (op.aggregations ?? []).entries()) {
5017
- if (!a?.alias?.trim())
5018
- at('TRANSFORM_AGG_NO_ALIAS', `Transform "${step}" ${where}.aggregations[${j}] has no alias. The runtime drops an unaliased `
5019
- + `aggregation, so its result would simply be absent from the output.`);
5020
- if (a?.operation !== 'count' && !a?.field?.trim())
5021
- at('TRANSFORM_AGG_NO_FIELD', `Transform "${step}" ${where}.aggregations[${j}] is "${a?.operation}" with no field. Only "count" `
5022
- + `works without one; the runtime drops the rest, so the aggregate would be missing from the output.`);
5023
- checkVocab(step, `${where}.aggregations[${j}].operation`, a?.operation, AGGREGATION_OPERATIONS, 'aggregation operation', 'yields null for that aggregate', diags);
5024
- }
5025
- return;
5026
- }
5027
- at('TRANSFORM_UNKNOWN_OP', `Transform "${step}" ${where} has type ${JSON.stringify(kind)}. The runtime skips an operation it does `
5028
- + `not recognize, so the collection would pass through unchanged. Use 'filter', 'map', or 'groupBy'.`);
5029
- });
5030
- }
5031
- /**
5032
- * Diagnose the `variant` discriminator on a transform step.
5033
- *
5034
- * A variant emits a single-purpose node type (`core.action.transform.filter`,
5035
- * `.map`, `.group-by`) instead of the generic one. The platform runs all four
5036
- * node types through the SAME script generators and dispatches on the
5037
- * OPERATION's type, never the node's — so a mismatch here does not fault and
5038
- * does not change the answer. It produces a node that lies: a `.filter` node
5039
- * that actually maps, whose operation the designer's filter editor cannot even
5040
- * show. That is exactly the class this SDK catches at rung 1, because no rung
5041
- * below it ever will.
5042
- */
5043
- function checkVariant(step, inputs, ops, at) {
5044
- const variant = inputs?.variant;
5045
- if (variant === undefined || variant === null)
5046
- return; // generic — chains freely
5047
- const variants = Object.keys(VARIANT_OPERATION);
5048
- if (typeof variant !== 'string' || !variants.includes(variant)) {
5049
- at('TRANSFORM_VARIANT_UNKNOWN', `Transform "${step}" has variant ${JSON.stringify(variant)}, which is not a Transform node type. `
5050
- + `The emitted node would be "core.action.transform.${String(variant)}", which no runtime resolves. `
5051
- + `Valid: ${variants.join(', ')} — or omit \`variant\` for the generic chaining Transform.`, nearestIn(variant, variants));
5052
- return;
5053
- }
5054
- const expected = VARIANT_OPERATION[variant];
5055
- if (ops.length !== 1) {
5056
- at('TRANSFORM_VARIANT_ONE_OP', `Transform "${step}" is the "${variant}" variant but carries ${ops.length} operations. A variant node runs `
5057
- + `exactly one — the designer's ${variant} editor shows a single ${expected} operation and nothing else. `
5058
- + `Drop \`variant\` to chain them in one generic Transform, or split them across one node per step.`);
5059
- return;
5060
- }
5061
- const got = ops[0]?.type;
5062
- if (got !== expected) {
5063
- at('TRANSFORM_VARIANT_OP_MISMATCH', `Transform "${step}" is the "${variant}" variant but its operation is ${JSON.stringify(got)}, not `
5064
- + `"${expected}". The node type does not drive the runtime — the OPERATION does — so this would emit a `
5065
- + `node labelled "${variant}" that actually does ${JSON.stringify(got)}, and the designer's ${variant} `
5066
- + `editor could not show it. Match them, or drop \`variant\` for the generic Transform.`, typeof got === 'string' && Object.values(VARIANT_OPERATION).includes(got)
5067
- ? `variant: '${variants.find((v) => VARIANT_OPERATION[v] === got)}'`
5068
- : `type: '${expected}'`);
5069
- }
5070
- }
5071
- /** Nearest member of a closed vocabulary, for a "did you mean" suggestion. */
5072
- function nearestIn(value, vocab) {
5073
- const v = typeof value === 'string' ? value.toLowerCase() : '';
5074
- return vocab.find((c) => c.replace(/[-_]/g, '') === v.replace(/[^a-z]/g, ''))
5075
- ?? vocab.find((c) => c.startsWith(v) || (v !== '' && v.startsWith(c)));
5076
- }
5077
- /** A string that is an expression the transform runtime will NOT evaluate. */
5078
- const LOOKS_LIKE_EXPRESSION = /^(=js:|\$vars\.|\{\s*\$vars\.)/;
5079
- // The three closed vocabularies a transform operation carries. TypeScript types
5080
- // them, but an authored `.flow.ts` runs with types ERASED — so `condition:
5081
- // 'greater'` reaches the runtime, whose generated script falls through on a name
5082
- // it does not know and is silent about it:
5083
- // condition → the rule passes every item
5084
- // transformation → the value is copied unchanged
5085
- // aggregation → the aggregate is null
5086
- // Green at every rung, wrong answer. Checked here so the author hears it at
5087
- // rung 1 — a flow that never went through this SDK gets no such warning.
5088
- const FILTER_CONDITIONS = [
5089
- 'equals', 'not_equals',
5090
- 'greater_than', 'less_than', 'greater_equal', 'less_equal',
5091
- 'contains', 'not_contains',
5092
- 'starts_with', 'not_starts_with',
5093
- 'ends_with', 'not_ends_with',
5094
- 'matches_regex',
5095
- 'in', 'not_in',
5096
- 'is_null', 'is_not_null',
5097
- 'is_empty', 'is_not_empty',
5098
- ];
5099
- const TRANSFORMATIONS = ['copy', 'uppercase', 'lowercase', 'trim'];
5100
- const AGGREGATION_OPERATIONS = ['count', 'sum', 'average', 'min', 'max', 'first', 'last', 'collect'];
5101
- /** Flag a value that is not a member of a closed vocabulary, suggesting the nearest. */
5102
- function checkVocab(step, at, value, vocab, what, effect, diags) {
5103
- if (typeof value === 'string' && vocab.includes(value))
5104
- return;
5105
- const near = nearestIn(value, vocab);
5106
- diags.push({
5107
- level: 'error',
5108
- code: 'TRANSFORM_BAD_VOCAB',
5109
- message: `Transform "${step}" ${at} is ${JSON.stringify(value)}, which is not a valid ${what}. The runtime does not `
5110
- + `recognize it and ${effect}, so the result would be silently wrong. Valid: ${vocab.join(', ')}.`,
5111
- step,
5112
- ...(near ? { suggestion: near } : {}),
5113
- });
5114
- }
5115
4731
  /**
5116
4732
  * Flag a case value whose type cannot match the discriminant's declared type.
5117
4733
  *
@@ -5174,100 +4790,116 @@ const LOOP_FIELDS = new Set(['currentItem', 'currentIteration', 'collection', 'o
5174
4790
  * before the 2.4 default (#868); its own fields are not audited here.
5175
4791
  */
5176
4792
  const LOOP_FIELDS_1_0_0 = new Set(['currentItem', 'currentIndex', 'collection', 'output', 'error']);
5177
- /**
5178
- * Columns Data Fabric assigns and never accepts a write for.
5179
- *
5180
- * The canvas hides them from both write editors, but the serializer has no
5181
- * guard: a hand-authored `Id` row satisfies the node's own "at least one field"
5182
- * rule, ships in the body, and is rejected — and the rejection is only logged,
5183
- * so the row comes back unchanged and the run goes green.
5184
- */
5185
- const DATAFABRIC_SYSTEM_COLUMNS = new Set(['Id', 'CreateTime', 'CreatedBy', 'UpdateTime', 'UpdatedBy']);
5186
- function checkDataFabricColumns(step, columns, option, diags) {
5187
- const system = columns.filter((c) => DATAFABRIC_SYSTEM_COLUMNS.has(c));
5188
- if (system.length) {
5189
- diags.push({
5190
- level: 'error', code: 'DATAFABRIC_SYSTEM_COLUMN', step,
5191
- message: `"${step}" writes ${system.map((c) => JSON.stringify(c)).join(', ')} in { ${option} }. `
5192
- + 'Data Fabric assigns its system columns (Id, CreateTime, CreatedBy, UpdateTime, UpdatedBy) and rejects a '
5193
- + 'write to them — and it only LOGS the rejection, so the row comes back unchanged and the run goes green.',
5194
- });
5195
- }
4793
+ /** The registered node type for a spec's kind, or undefined (a custom FlowAction has no kind). */
4794
+ function registeredKind(spec) {
4795
+ const kind = spec?.kind;
4796
+ return typeof kind === 'string' ? FLOW_NODE_TYPES.byKind(kind, spec) : undefined;
5196
4797
  }
5197
4798
  /**
5198
- * A folder-scoped entity is portable only with its `Entity` bindings.
4799
+ * Check one step whose kind is registered: its version, its node-local rules
4800
+ * (reported through the family's catalog), and the expressions it carries.
4801
+ * Returns its placement, or undefined when the version cannot be resolved — the
4802
+ * step is then reported and not checked further.
5199
4803
  *
5200
- * `_folderKey` on its own still serializes — it falls back to a source-org
5201
- * literal and logs a warning — so the flow looks fine until it is deployed to
5202
- * another org. Refusing the half-authored pair here is the whole point: the
5203
- * alternative is a defect that only appears at deploy time, in a different org,
5204
- * to somebody else.
4804
+ * Its bindings are not checked here: unlike a contribution (`checkContribution`),
4805
+ * a registered type's bindings exist only once it serializes. Closing every
4806
+ * `<bindings.NAME>` its definition references is the node type's own contract,
4807
+ * which serialize enforces (#942) and the type's tests pin; an author cannot
4808
+ * cause the gap, so there is no author diagnostic to report.
5205
4809
  */
5206
- function checkDataFabricScope(step, inputs, diags) {
5207
- if (inputs.folderKey !== undefined && inputs.resourceKey === undefined) {
5208
- diags.push({
5209
- level: 'error', code: 'DATAFABRIC_FOLDER_NO_RESOURCE_KEY', step,
5210
- message: `"${step}" sets { folderKey } without { resourceKey }. A folder-scoped entity needs both — with only `
5211
- + 'the folder key the entity serializes as a source-org literal, which validates, runs in this org, and '
5212
- + 'breaks on deploy to another one.',
5213
- suggestion: 'resolve the key with `uip solution resources list --kind Entity`, or drop { folderKey } and keep '
5214
- + 'the entity tenant-scoped',
5215
- });
4810
+ function checkRegisteredStep(entry, s, exprs, errorSource, opts, diags, typeOf, triggerId) {
4811
+ const spec = s.spec;
4812
+ /** The step's node-local rules and expressions, as `impl` defines them. */
4813
+ const runRules = (impl) => {
4814
+ const ctx = {
4815
+ step: s.name,
4816
+ version: impl.version,
4817
+ report: (code, message, suggestion) => diags.push(catalogued(entry, s.name, code, message, suggestion)),
4818
+ declaredRef: (js) => {
4819
+ const name = declaredRefName(js, triggerId);
4820
+ return name ? { name, type: typeOf.get(name) } : undefined;
4821
+ },
4822
+ options: opts,
4823
+ };
4824
+ impl.check?.(spec, ctx);
4825
+ for (const site of impl.expressions(spec)) {
4826
+ if ('code' in site) {
4827
+ // A script body: checked for its `$vars.` refs although it is a string. No
4828
+ // errorSource, as for `script()` (collectActionExprs).
4829
+ exprs.push({ js: site.code, step: s.name });
4830
+ }
4831
+ else {
4832
+ pushDeepExprs(site.value, s.name, errorSource, exprs);
4833
+ }
4834
+ }
4835
+ return ctx;
4836
+ };
4837
+ let impl;
4838
+ try {
4839
+ impl = resolveVersion(entry.type, spec, s.options?.version, `step "${s.name}"`);
5216
4840
  }
5217
- if (inputs.resourceKey !== undefined && inputs.folderKey === undefined) {
5218
- diags.push({
5219
- level: 'error', code: 'DATAFABRIC_RESOURCE_KEY_NO_FOLDER', step,
5220
- message: `"${step}" sets { resourceKey } without { folderKey }. The pair exists to scope a FOLDER entity; a `
5221
- + 'tenant-scoped entity needs neither and serializes as a dotted literal.',
5222
- });
4841
+ catch (e) {
4842
+ // A select() that picks an unregistered version is the node type's bug, not the author's pin.
4843
+ if (isInternalSdkError(e))
4844
+ throw e;
4845
+ diags.push({ level: 'error', code: 'NODE_VERSION_UNREGISTERED', step: s.name, message: e.message });
4846
+ // Still report the step's other mistakes in this same pass, with the version it
4847
+ // would get unpinned (select(), else the default) — as decompile lifts an
4848
+ // unregistered version — so fixing the pin is not followed by a second round.
4849
+ // It stays unplaced: the pin is wrong, so no family rule may rely on it.
4850
+ runRules(resolveVersion(entry.type, spec, undefined, `step "${s.name}"`));
4851
+ return undefined;
5223
4852
  }
4853
+ const ctx = runRules(impl);
4854
+ return { entry, impl, spec, placement: { step: s.name, kind: impl.kind, spec, version: impl.version }, ctx };
5224
4855
  }
5225
- /**
5226
- * The query options, none of which the node's `inputDefinition` declares — they
5227
- * are `_`-prefixed extras, so `uip maestro flow validate` checks none of them
5228
- * and every mistake here is silent at run time.
5229
- */
5230
- function checkDataFabricQuery(step, inputs, diags) {
5231
- const listOnly = ['limit', 'skip', 'sort', 'fields'].filter((k) => inputs[k] !== undefined);
5232
- if (listOnly.length && inputs.resultMode !== 'multiple') {
5233
- diags.push({
5234
- level: 'error', code: 'DATAFABRIC_QUERY_OPTS_WITHOUT_MULTIPLE', step,
5235
- message: `"${step}" sets { ${listOnly.join(', ')} }, which only a list query uses, but its resultMode is `
5236
- + `${inputs.resultMode ? JSON.stringify(inputs.resultMode) : 'unset (a single-record read)'}.`,
5237
- suggestion: "resultMode: 'multiple'",
5238
- });
4856
+ /** Every non-literal Expr reachable in `value`, as expressions to check. */
4857
+ function pushDeepExprs(value, step, errorSource, out) {
4858
+ if (value instanceof Expr) {
4859
+ if (!value.literal)
4860
+ out.push({ js: value.js, step, errorSource });
5239
4861
  }
5240
- // The emitted query ALWAYS carries a limit — omitted it resolves to 100 — and
5241
- // 1000 is a hard ceiling that TRUNCATES rather than erroring above it.
5242
- if (inputs.limit !== undefined && (!Number.isInteger(inputs.limit) || inputs.limit < 1 || inputs.limit > 1000)) {
5243
- diags.push({
5244
- level: 'error', code: 'DATAFABRIC_LIMIT_RANGE', step,
5245
- message: `"${step}": limit must be a whole number between 1 and 1000 (got ${inputs.limit}). 1000 is a hard `
5246
- + 'ceiling — a larger value truncates silently, so page with { skip } instead.',
5247
- });
4862
+ else if (Array.isArray(value)) {
4863
+ for (const v of value)
4864
+ pushDeepExprs(v, step, errorSource, out);
5248
4865
  }
5249
- // `in` is honoured only on a list query; on a single read the row compiles
5250
- // away, and a read whose filters compiled away returns EVERY record.
5251
- if (inputs.resultMode !== 'multiple' && (inputs.filters ?? []).some((f) => f.operator === 'in')) {
5252
- diags.push({
5253
- level: 'error', code: 'DATAFABRIC_IN_REQUIRES_MULTIPLE', step,
5254
- message: `"${step}" filters with the 'in' operator, which is honoured only when resultMode is 'multiple'. `
5255
- + 'On a single-record read that row compiles away — and a read whose filters compiled away matches every record.',
5256
- });
4866
+ else if (value && typeof value === 'object') {
4867
+ for (const v of Object.values(value))
4868
+ pushDeepExprs(v, step, errorSource, out);
5257
4869
  }
5258
- // A filter expression switched on and left blank is refused by the serializer,
5259
- // and it refuses the WHOLE query rather than dropping the row, which strands
5260
- // every downstream reference to this step.
5261
- for (const f of inputs.filters ?? []) {
5262
- const blank = f.value instanceof Expr ? f.value.js.trim() === '' : String(f.value ?? '').trim() === '';
5263
- if (blank) {
5264
- diags.push({
5265
- level: 'error', code: 'DATAFABRIC_FILTER_BLANK_VALUE', step,
5266
- message: `"${step}" has a filter on ${JSON.stringify(f.field)} with a blank value. The serializer refuses the `
5267
- + 'ENTIRE query rather than dropping the row, so the step emits nothing and every downstream $vars read of '
5268
- + 'it breaks.',
5269
- });
5270
- }
4870
+ }
4871
+ /** Whether `e` is one of the SDK's own `internal SDK error:` throws, which `check` never turns into a diagnostic. */
4872
+ function isInternalSdkError(e) {
4873
+ return e instanceof Error && e.message.startsWith('internal SDK error:');
4874
+ }
4875
+ /** A diagnostic a registered node type reports, at its family catalog's severity. */
4876
+ function catalogued(entry, step, code, message, suggestion) {
4877
+ const known = Object.hasOwn(entry.family.diagnostics, code) ? entry.family.diagnostics[code] : undefined;
4878
+ if (!known) {
4879
+ throw new Error(`internal SDK error: node family "${entry.family.id}" reports ${code}, which its diagnostics catalog does not list.`);
4880
+ }
4881
+ // Key order as the per-family rules always wrote it — `check --json` prints it.
4882
+ return { level: known.severity, code, step, message, ...(suggestion !== undefined ? { suggestion } : {}) };
4883
+ }
4884
+ /** Run each registered family's own rules over all of its placements in this flow. */
4885
+ function checkRegisteredFamilies(steps, unresolved, stepNames, diags) {
4886
+ const byFamily = new Map();
4887
+ for (const placed of steps.values()) {
4888
+ const list = byFamily.get(placed.entry.family.id) ?? [];
4889
+ list.push(placed);
4890
+ byFamily.set(placed.entry.family.id, list);
4891
+ }
4892
+ for (const placed of byFamily.values()) {
4893
+ const family = placed[0].entry.family;
4894
+ if (!family.check)
4895
+ continue;
4896
+ const ctx = {
4897
+ report: (step, code, message, suggestion) => diags.push(catalogued(placed[0].entry, step, code, message, suggestion)),
4898
+ hasStep: (step) => stepNames.has(step),
4899
+ placementOf: (step) => steps.get(step)?.placement,
4900
+ versionUnresolved: (step) => unresolved.has(step),
4901
+ };
4902
+ family.check(placed.map((p) => p.placement), ctx);
5271
4903
  }
5272
4904
  }
5273
4905
  function collectActionExprs(s, out, errorSource) {
@@ -5294,20 +4926,6 @@ function collectActionExprs(s, out, errorSource) {
5294
4926
  push(val);
5295
4927
  return;
5296
4928
  }
5297
- if (s.spec.kind === 'script') {
5298
- // A script body is a plain STRING, not an Expr — so it never went through
5299
- // this check, and a bad `$vars.…` ref inside one used to surface only rungs
5300
- // later, as a type error about generated code (flow-builder-sdk#20). The body
5301
- // isn't parsed; the `$vars.<name>` refs it contains are checked like any other.
5302
- out.push({ js: s.spec.inputs.code, step: s.name });
5303
- return;
5304
- }
5305
- if (s.spec.kind === 'transform') {
5306
- // The only expression a transform carries is its collection; everything
5307
- // else is a literal or a field name (see checkTransform).
5308
- push(s.spec.inputs.collection);
5309
- return;
5310
- }
5311
4929
  if (s.spec.kind === 'hitl') {
5312
4930
  // A human task's only expressions are the values its shown fields display.
5313
4931
  // Without this a typo'd reference inside a form field would reach the
@@ -5326,30 +4944,6 @@ function collectActionExprs(s, out, errorSource) {
5326
4944
  push(val);
5327
4945
  return;
5328
4946
  }
5329
- if (s.spec.kind === 'dataFabricRead' || s.spec.kind === 'dataFabricCreate'
5330
- || s.spec.kind === 'dataFabricUpdate' || s.spec.kind === 'dataFabricDelete') {
5331
- // Every value a Data Fabric step carries is authored against flow scope: a
5332
- // filter's comparison value, the record id, the columns written. The family
5333
- // was missing here entirely, so a `$vars.` typo in any of them travelled to
5334
- // the platform unchecked — and the delete verb's no-output rule below has
5335
- // nothing to fire on until these are collected.
5336
- if (s.spec.kind === 'dataFabricRead') {
5337
- for (const f of s.spec.inputs.filters ?? [])
5338
- push(f.value);
5339
- return;
5340
- }
5341
- if (s.spec.kind === 'dataFabricCreate') {
5342
- for (const val of Object.values(s.spec.inputs.values ?? {}))
5343
- push(val);
5344
- return;
5345
- }
5346
- push(s.spec.inputs.record?.byId);
5347
- if (s.spec.kind === 'dataFabricUpdate') {
5348
- for (const val of Object.values(s.spec.inputs.set ?? {}))
5349
- push(val);
5350
- }
5351
- return;
5352
- }
5353
4947
  if (s.spec.kind === 'connector') {
5354
4948
  // Same gap on the connector surface: `messageToSend: tmpl`…${v('typo')}``
5355
4949
  // reached the runtime unchecked.
@@ -5376,6 +4970,17 @@ function collectActionExprs(s, out, errorSource) {
5376
4970
  const PER_TENANT_ERROR_PORT_KINDS = new Set([
5377
4971
  'connector', 'rpaWorkflow', 'apiWorkflow', 'agenticProcess', 'agent', 'ixpExtract',
5378
4972
  ]);
4973
+ /**
4974
+ * The ONERROR_NO_ERROR_PORT sentence for a typed step — a built-in family or a
4975
+ * registered node type — whose node type has no error port. One spelling for
4976
+ * both, so porting a family onto the registry changes no message.
4977
+ */
4978
+ function typedNoErrorPortMessage(stepName, kind, def) {
4979
+ const nodeType = def ? ` (${def.nodeType}@${def.version})` : '';
4980
+ return `Step "${stepName}" is a ${kind} step${nodeType}, and that node type has no error port — its definition does not `
4981
+ + `set supportsErrorHandling, so the edge would leave a handle the node does not have. Handle the failure `
4982
+ + `downstream instead (a delay cannot fail; a wait resumes or does not).`;
4983
+ }
5379
4984
  /** The five fields the platform's error envelope declares — no more, no less. */
5380
4985
  // The five the definitions declare, plus the two every probed runtime envelope
5381
4986
  // carried anyway (`response`, `element`) — see ErrorEnvelopeField in core/expr.ts.
@@ -5385,7 +4990,9 @@ const ERROR_ENVELOPE_FIELDS = ['code', 'message', 'detail', 'category', 'status'
5385
4990
  * mistakes at build time (no previous step, two handlers on one node, an empty
5386
4991
  * handler); these are the ones only a checker can see.
5387
4992
  */
5388
- function checkOnError(_handler, step, diags) {
4993
+ function checkOnError(_handler, step, diags,
4994
+ /** The step's registry record, when its kind is registered and its version resolved. */
4995
+ placed) {
5389
4996
  // The builder refuses a handler with no eligible step before it, so a missing
5390
4997
  // source here means a hand-built `BuiltFlow`; there is nothing to say about it.
5391
4998
  if (!step)
@@ -5439,6 +5046,35 @@ function checkOnError(_handler, step, diags) {
5439
5046
  return;
5440
5047
  }
5441
5048
  const spec = step.spec;
5049
+ // A registered node type: a bundled one asks its definition at the version it
5050
+ // will be emitted at; a per-tenant one answers for itself (its errorPort).
5051
+ if (registeredKind(spec)) {
5052
+ // No record: the version did not resolve, which NODE_VERSION_UNREGISTERED
5053
+ // already reports where the step is checked.
5054
+ if (!placed)
5055
+ return;
5056
+ const { impl } = placed;
5057
+ if (impl.definition === 'per-tenant') {
5058
+ const port = typeof impl.errorPort === 'function' ? impl.errorPort(spec) : impl.errorPort === true;
5059
+ if (!port) {
5060
+ diags.push({
5061
+ level: 'error', code: 'ONERROR_NO_ERROR_PORT', step: step.name,
5062
+ message: declaredErrorPortRefusal(step.name, impl.typeName, impl.version),
5063
+ });
5064
+ return;
5065
+ }
5066
+ }
5067
+ else {
5068
+ const def = bundledDefinition(impl.typeName, impl.version);
5069
+ if (def !== undefined && !definitionHasErrorPort(def)) {
5070
+ diags.push({ level: 'error', code: 'ONERROR_NO_ERROR_PORT', message: typedNoErrorPortMessage(step.name, impl.kind, def), step: step.name });
5071
+ return;
5072
+ }
5073
+ }
5074
+ // The port exists; the node type may still refuse the handler (plain HTTP's rule, once ported).
5075
+ impl.checkErrorHandler?.(placed.spec, placed.ctx);
5076
+ return;
5077
+ }
5442
5078
  // A typed family with a bundled definition: ask the definition serialize will
5443
5079
  // emit, at the version it will emit (#1011). For instance `voiceAgent()`
5444
5080
  // defaults to uipath.agent.voice@1.0 (no error port) and `{ version: '1.1' }`
@@ -5451,13 +5087,10 @@ function checkOnError(_handler, step, diags) {
5451
5087
  ? bundledDef !== undefined && !definitionHasErrorPort(bundledDef)
5452
5088
  : !PER_TENANT_ERROR_PORT_KINDS.has(spec.kind);
5453
5089
  if (noPort) {
5454
- const nodeType = bundledDef ? ` (${bundledDef.nodeType}@${bundledDef.version})` : '';
5455
5090
  diags.push({
5456
5091
  level: 'error',
5457
5092
  code: 'ONERROR_NO_ERROR_PORT',
5458
- message: `Step "${step.name}" is a ${spec.kind} step${nodeType}, and that node type has no error port — its definition does not `
5459
- + `set supportsErrorHandling, so the edge would leave a handle the node does not have. Handle the failure `
5460
- + `downstream instead (a delay cannot fail; a wait resumes or does not).`,
5093
+ message: typedNoErrorPortMessage(step.name, spec.kind, bundledDef),
5461
5094
  step: step.name,
5462
5095
  });
5463
5096
  return;
@@ -5700,14 +5333,14 @@ function checkStepRefs(built, diags) {
5700
5333
  index(built.steps, false, '', undefined);
5701
5334
  /** Every ref, with where it sits. */
5702
5335
  const found = [];
5703
- const collect = (steps, from, scope, arm) => {
5336
+ const collect = (steps, from, scope, arm, enclosing = []) => {
5704
5337
  let source = from;
5705
5338
  for (const [i, s] of steps.entries()) {
5706
5339
  if (s.kind === 'action')
5707
5340
  source = s.name;
5708
5341
  if (s.kind === 'stepToRef') {
5709
5342
  found.push({
5710
- target: s.target, port: s.port, from: source, scope, arm,
5343
+ target: s.target, port: s.port, from: source, scope, arm, enclosing,
5711
5344
  // Only a default-port ref hands the path off, so only it can strand
5712
5345
  // what follows; a side exit leaves the chain running.
5713
5346
  after: s.port === 'output' ? steps.length - i - 1 : 0,
@@ -5715,23 +5348,23 @@ function checkStepRefs(built, diags) {
5715
5348
  continue;
5716
5349
  }
5717
5350
  if (s.kind === 'stepToList')
5718
- collect(s.body, source, scope, arm);
5351
+ collect(s.body, source, scope, arm, enclosing);
5719
5352
  else if (s.kind === 'branch') {
5720
- collect(s.then, source, scope, arm);
5721
- collect(s.otherwise, source, scope, arm);
5353
+ collect(s.then, source, scope, arm, enclosing);
5354
+ collect(s.otherwise, source, scope, arm, enclosing);
5722
5355
  }
5723
5356
  else if (s.kind === 'switch') {
5724
- s.cases.forEach((c) => collect(c.body, source, scope, arm));
5725
- collect(s.default ?? [], source, scope, arm);
5357
+ s.cases.forEach((c) => collect(c.body, source, scope, arm, enclosing));
5358
+ collect(s.default ?? [], source, scope, arm, enclosing);
5726
5359
  }
5727
5360
  else if (s.kind === 'parallel')
5728
- s.arms.forEach((a, i) => collect(a, source, scope, `${s.name}#${i}`));
5361
+ s.arms.forEach((a, i) => collect(a, source, scope, `${s.name}#${i}`, enclosing));
5729
5362
  else if (s.kind === 'loop' || s.kind === 'doWhile')
5730
- collect(s.body, source, s.name, arm);
5363
+ collect(s.body, source, s.name, arm, [...enclosing, s.name]);
5731
5364
  }
5732
5365
  };
5733
5366
  collect(built.steps, undefined, '', undefined);
5734
- for (const { target, from, after, scope, arm } of found) {
5367
+ for (const { target, from, after, scope, arm, enclosing } of found) {
5735
5368
  const where = from ? `after "${from}"` : 'at the start of the flow';
5736
5369
  const say = (level, code, message, suggestion) => {
5737
5370
  diags.push({ level, code, message, step: from ?? 'stepToRef', ...(suggestion ? { suggestion } : {}) });
@@ -5742,6 +5375,16 @@ function checkStepRefs(built, diags) {
5742
5375
  + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step().`, near ? `.stepToRef('${near}')` : undefined);
5743
5376
  continue;
5744
5377
  }
5378
+ // A ref to a container the path is INSIDE names it as a step, so the edge
5379
+ // enters it from its outer input: it starts the loop over from its first
5380
+ // item, while the iteration the path was in never finishes. What the author
5381
+ // means is almost always the next item, or out of the loop (#1129).
5382
+ if (enclosing.includes(target)) {
5383
+ say('error', 'REF_TO_OWN_LOOP', `.stepToRef('${target}') ${where} names the loop it is inside. That edge enters "${target}" from its outer `
5384
+ + `input, which starts the loop over instead of going on to the next item. Use .continue() to go on to the `
5385
+ + `next iteration, or .break() to leave the loop.`, '.continue()');
5386
+ continue;
5387
+ }
5745
5388
  if (inPortList.has(target)) {
5746
5389
  say('error', 'REF_INTO_PORT_LIST', `.stepToRef('${target}') ${where} targets a step inside another port's path. That path runs only when ITS `
5747
5390
  + `port fired — it is not somewhere another edge can jump into. Point at a step on the main path, or end `
@@ -5760,12 +5403,15 @@ function checkStepRefs(built, diags) {
5760
5403
  + `iteration, so those have no value. Point at a step after the loop, or do this work inside the body.`);
5761
5404
  continue;
5762
5405
  }
5763
- // OUT of a loop body is a `break`. Flow has no break or continue node, and
5764
- // whether the runtime accepts an edge leaving a bpmn:SubProcess this way is
5765
- // not measured — so this warns and leaves the verdict to `validate`.
5766
- say('warning', 'REF_OUT_OF_LOOP', `.stepToRef('${target}') ${where} leaves the body of loop "${scope}" — a break. Flow has no break or `
5767
- + `continue node, and whether the runtime honours an edge out of a loop body is not something this checker `
5768
- + `can prove. Verify with uip maestro flow validate before relying on it.`);
5406
+ // OUT of a loop body to a step after it. The container's own handles are
5407
+ // the measured ways out — `.break()` (its break handle) and `.continue()`
5408
+ // (its continue handle). Whether the runtime accepts an edge leaving a
5409
+ // bpmn:SubProcess some other way is not measured, so this warns and leaves
5410
+ // the verdict to `validate`.
5411
+ say('warning', 'REF_OUT_OF_LOOP', `.stepToRef('${target}') ${where} leaves the body of loop "${scope}" by an edge straight to a step outside it. `
5412
+ + `To leave the loop, use .break() (the loop's break handle); to go on to the next item, use .continue(). `
5413
+ + `Whether the runtime honours any other edge out of a loop body is not something this checker can prove: `
5414
+ + `verify with uip maestro flow validate before relying on it.`);
5769
5415
  }
5770
5416
  // Best effort from here: the mechanism is understood, the runtime's behaviour
5771
5417
  // is not measured, so these WARN and leave the verdict to `validate`.
@@ -5789,6 +5435,23 @@ function checkStepRefs(built, diags) {
5789
5435
  }
5790
5436
  }
5791
5437
  }
5438
+ /**
5439
+ * LOOP_EXIT_IN_PARALLEL_ARM: a `.continue()` inside a `.parallel` arm sends
5440
+ * that arm's token to the container's continue handle, so it never reaches the
5441
+ * arm's Merge. The Merge is a bpmn:ParallelGateway that waits for every branch
5442
+ * it forked, and the iteration it belongs to is still running: the join never
5443
+ * fires and the iteration hangs. (`.break()` leaves the whole container, so
5444
+ * whether the runtime cancels the pending Merge is a different, unmeasured
5445
+ * question; it is not refused here.)
5446
+ */
5447
+ function loopExitInArm() {
5448
+ return {
5449
+ level: 'error', code: 'LOOP_EXIT_IN_PARALLEL_ARM',
5450
+ message: `.continue() inside a .parallel() arm sends that arm's path to the loop's continue handle, so it never `
5451
+ + `reaches the arm's Merge, which waits for every branch it forked: the join never fires and the iteration `
5452
+ + `hangs. Put the .continue() after the .parallel(), on a .branch() that reads what the arms produced.`,
5453
+ };
5454
+ }
5792
5455
  /**
5793
5456
  * A field read off `$vars.<step>.error` — the envelope, and the one shape the
5794
5457
  * authoring surface writes.