@uipath/maestro-builder-sdk 6.11.5 → 6.12.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 (56) hide show
  1. package/dist/api-index.md +83 -56
  2. package/dist/api-members.md +362 -261
  3. package/dist/bpmn/bpmn-expr-check.js +8 -1
  4. package/dist/bpmn/bpmn-sdk.d.ts +1026 -27
  5. package/dist/bpmn/bpmn-sdk.js +1328 -83
  6. package/dist/bpmn/check-cli.js +19 -4
  7. package/dist/bpmn/check.d.ts +1 -1
  8. package/dist/bpmn/check.js +529 -13
  9. package/dist/bpmn/connector.d.ts +2 -2
  10. package/dist/bpmn/connector.js +74 -37
  11. package/dist/bpmn/data-service.d.ts +124 -0
  12. package/dist/bpmn/data-service.js +98 -0
  13. package/dist/bpmn/decompile-cli.js +70 -8
  14. package/dist/bpmn/decompile.d.ts +71 -0
  15. package/dist/bpmn/decompile.js +845 -31
  16. package/dist/bpmn/graph.d.ts +69 -0
  17. package/dist/bpmn/graph.js +98 -0
  18. package/dist/bpmn/intsvc.js +2 -2
  19. package/dist/bpmn/known-methods.d.ts +15 -0
  20. package/dist/bpmn/known-methods.js +271 -0
  21. package/dist/bpmn/lift-verify.d.ts +40 -0
  22. package/dist/bpmn/lift-verify.js +133 -0
  23. package/dist/bpmn/serialize.d.ts +65 -1
  24. package/dist/bpmn/serialize.js +169 -22
  25. package/dist/bpmn/typed-node.d.ts +37 -6
  26. package/dist/bpmn/typed-node.js +81 -19
  27. package/dist/check-cli.js +2 -1
  28. package/dist/check.d.ts +13 -0
  29. package/dist/check.js +11 -0
  30. package/dist/cli/commands/bpmn.js +7 -1
  31. package/dist/cli-library.d.ts +7 -0
  32. package/dist/cli-library.js +19 -0
  33. package/dist/compile-cli.js +8 -5
  34. package/dist/core/byoa.d.ts +69 -0
  35. package/dist/core/byoa.js +98 -0
  36. package/dist/core/library.d.ts +23 -0
  37. package/dist/core/library.js +47 -1
  38. package/dist/core/lookup-store.d.ts +12 -0
  39. package/dist/core/lookup-store.js +25 -0
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +3 -0
  42. package/dist/registry/generators/descriptors.d.ts +1 -1
  43. package/dist/registry/generators/descriptors.js +20 -2
  44. package/dist/registry/generators/markdown/page.js +3 -1
  45. package/dist/registry/generators/markdown/sections/event-facts.d.ts +2 -0
  46. package/dist/registry/generators/markdown/sections/event-facts.js +45 -0
  47. package/dist/registry/library-build/events.d.ts +32 -3
  48. package/dist/registry/library-build/events.js +48 -19
  49. package/dist/registry/prepare/byoa-ledger.d.ts +12 -0
  50. package/dist/registry/prepare/byoa-ledger.js +26 -0
  51. package/dist/registry/prepare/event.d.ts +5 -1
  52. package/dist/registry/prepare/event.js +22 -8
  53. package/dist/registry/prepare/messages.d.ts +2 -0
  54. package/dist/registry/prepare/messages.js +4 -0
  55. package/dist/serialize.js +24 -1
  56. package/package.json +2 -2
@@ -1,5 +1,55 @@
1
- import { processId } from './serialize.js';
1
+ /**
2
+ * bpmn/check — fast static validation of a BuiltBpmn before serialization.
3
+ *
4
+ * Surfaces the common Maestro canvas-rule failures early (dangling flow refs,
5
+ * duplicate ids, a gateway branch with no condition, a superfluous gateway, an
6
+ * implicit join, a missing start event, a boundary event attached to nothing, an
7
+ * empty timer) so authoring gets instant feedback. The authoritative gate is
8
+ * still `uip maestro bpmn validate` (`validateBpmnCanvas`) on the emitted
9
+ * `.bpmn`.
10
+ *
11
+ * **An `error` here must mirror a real platform rule.** Being stricter than
12
+ * `validate` is worse than being looser: it refuses processes that deploy and run
13
+ * correctly, and the author has no way to appeal. Where this file is deliberately
14
+ * stricter than the platform — an unconditioned inclusive-gateway branch, an
15
+ * implicit join — the diagnostic is a `warning`, and its comment says which
16
+ * platform rule it corresponds to and how the two differ.
17
+ */
18
+ import { nodeDeclaredVars } from './bpmn-sdk.js';
19
+ import { folderCompanionKeys, implicitNodeVars, processId } from './serialize.js';
20
+ import { KNOWN_METHOD_SET } from './known-methods.js';
2
21
  import { bindingRequiredFields, missingRequiredContext, missingRequiredInputs, registryType, REGISTRY_GAPS, unsatisfiableRequiredInputs, } from './typed-node.js';
22
+ /**
23
+ * Service types the platform expects to name a concrete Orchestrator resource.
24
+ *
25
+ * From the canvas rule `MISSING_RESOURCE`, copied rather than inferred: nothing in the
26
+ * registry marks these, and the rule turns on the service type alone.
27
+ */
28
+ const SERVICE_TYPES_REQUIRING_RESOURCE = new Set([
29
+ 'Orchestrator.StartAgentJob',
30
+ 'Orchestrator.StartJob',
31
+ 'Orchestrator.CreateQueueItem',
32
+ 'Orchestrator.CreateAndWaitForQueueItem',
33
+ 'Orchestrator.BulkAddQueueItems',
34
+ 'Orchestrator.QueueItemAdded',
35
+ 'Orchestrator.ExecuteApiWorkflowAsync',
36
+ 'Orchestrator.StartFlowProcess',
37
+ 'Orchestrator.StartFlowProcessAsync',
38
+ 'Actions.HITL',
39
+ 'Orchestrator.StartAgenticProcess',
40
+ 'Orchestrator.StartAgenticProcessAsync',
41
+ 'Orchestrator.StartCaseMgmtProcess',
42
+ 'Orchestrator.StartCaseMgmtProcessAsync',
43
+ ]);
44
+ /**
45
+ * A HITL task carrying its form inline needs no Action-app resource — the platform's
46
+ * own carve-out from `MISSING_RESOURCE`, and the reason this rule cannot be written as
47
+ * "these types need a resource".
48
+ */
49
+ const HITL_QUICK_SCHEMA_CONTEXT = new Set(['_schemaFileId', 'hitlSchemaId', '__hitlFormSchema']);
50
+ /** The bounds the platform puts on a human task's `Timer`, in MINUTES. */
51
+ const MIN_TASK_TIMER_MINUTES = 15;
52
+ const MAX_TASK_TIMER_MINUTES = 90 * 24 * 60;
3
53
  const GATEWAY_KINDS = new Set(['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway']);
4
54
  /**
5
55
  * What a boundary event may attach to. `connector` belongs here: it serializes to
@@ -15,8 +65,174 @@ export function check(built) {
15
65
  checkScope(built.nodes, built.flows, 'process', diags, seenIds, true);
16
66
  checkScopedVars(built, diags);
17
67
  checkDocumentIds(built, diags);
68
+ checkFolderBindings(built, diags);
69
+ checkEntryPointId(built, diags);
70
+ checkMethodParentheses(built, diags);
18
71
  return diags;
19
72
  }
73
+ /**
74
+ * A known method NAMED but not CALLED — `=vars.s.ToUpper` where `ToUpper()` was meant.
75
+ *
76
+ * A faithful port of the canvas rule `METHOD_MISSING_PARENTHESES`, down to its regex
77
+ * and its vocabulary (`known-methods.ts`, generated from the platform's own list). The
78
+ * platform reports it as an ERROR, so it gates — and the message it gives back names
79
+ * the method and an XML element id, which the author then has to map to the
80
+ * TypeScript that produced it.
81
+ *
82
+ * Reported here rather than refused at `.build()`, unlike the structural collisions in
83
+ * `bpmn-sdk.ts`. Those are two objects the builder is holding; this is a textual
84
+ * heuristic over expression source, and the sites this walks are the SDK's, not the
85
+ * platform's. If the two lists of sites ever diverge, a diagnostic is a message the
86
+ * author can weigh and a refusal is a wall — so the weaker rung is the honest one.
87
+ */
88
+ function checkMethodParentheses(built, diags) {
89
+ const report = (expr, where) => {
90
+ if (!expr)
91
+ return;
92
+ // The platform's own pattern: a member access whose name is a known method and
93
+ // which is not followed by `(`. Kept character-for-character so the two agree.
94
+ for (const m of expr.matchAll(/\.(\w+)(?!\s*\()/g)) {
95
+ const method = m[1];
96
+ if (!KNOWN_METHOD_SET.has(method))
97
+ continue;
98
+ diags.push({
99
+ level: 'error',
100
+ code: 'METHOD_MISSING_PARENTHESES',
101
+ message: `${where} names method "${method}" without calling it. Write "${method}()" — the platform's ` +
102
+ `expression evaluator refuses a method referenced as a property.`,
103
+ where,
104
+ });
105
+ }
106
+ };
107
+ const deep = (val, where) => {
108
+ if (typeof val === 'string')
109
+ return report(val, where);
110
+ if (Array.isArray(val))
111
+ return val.forEach((v) => deep(v, where));
112
+ if (val && typeof val === 'object')
113
+ for (const v of Object.values(val))
114
+ deep(v, where);
115
+ };
116
+ const walk = (nodes, flows) => {
117
+ for (const f of flows)
118
+ report(f.condition, `flow "${f.id}" condition`);
119
+ for (const n of nodes) {
120
+ const at = `${n.kind} "${n.id}"`;
121
+ for (const row of ('errorMapping' in n ? n.errorMapping : undefined) ?? [])
122
+ report(row.condition, `${at} errorMapping "${row.code}" condition`);
123
+ if ('loop' in n && n.loop) {
124
+ report(n.loop.collection, `${at} loop collection`);
125
+ report(n.loop.completion, `${at} loop completion`);
126
+ }
127
+ if (n.kind === 'scriptTask') {
128
+ // The script BODY is deliberately not walked. `obj.Trim` inside a program is a
129
+ // property access the platform never reads, and reporting it would be the SDK
130
+ // refusing what the platform accepts.
131
+ deep(n.inputs, `${at} input`);
132
+ deep(n.outputs, `${at} output`);
133
+ }
134
+ else if (n.kind === 'task') {
135
+ deep(n.set, `${at} assignment`);
136
+ }
137
+ else if (n.kind === 'connector') {
138
+ deep(n.inputs, `${at} input`);
139
+ }
140
+ else if (n.kind === 'typed') {
141
+ deep(n.context, `${at} context`);
142
+ deep(n.inputs, `${at} input`);
143
+ for (const row of n.contextRows ?? [])
144
+ report(row.value, `${at} context "${row.name}"`);
145
+ for (const row of n.inputRows ?? [])
146
+ report(row.value, `${at} input "${row.name}"`);
147
+ }
148
+ if (n.kind === 'subProcess')
149
+ walk(n.nodes, n.flows);
150
+ }
151
+ };
152
+ walk(built.nodes, built.flows);
153
+ }
154
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
155
+ /**
156
+ * `.metadata({ entryPointId })` must be a GUID.
157
+ *
158
+ * The product assigns one (`bpmn init` writes it, `decompile` carries it) and
159
+ * `uip maestro bpmn validate` refuses anything else — `Root start event
160
+ * uipath:entryPointId value "WikiPageviews_Start" must be a GUID` — which an author who
161
+ * hand-wrote a readable id only learns at the last rung. In the 2026-09-27 A/B against
162
+ * main one agent spent its whole turn budget there. Reported here, at the first rung.
163
+ */
164
+ function checkEntryPointId(built, diags) {
165
+ const id = built.metadata?.entryPointId;
166
+ if (id === undefined || GUID.test(id))
167
+ return;
168
+ diags.push({
169
+ level: 'error',
170
+ code: 'ENTRYPOINT_ID_NOT_GUID',
171
+ message: `metadata.entryPointId "${id}" is not a GUID; \`uip maestro bpmn validate\` refuses it. Use the id \`bpmn init\` ` +
172
+ `assigned (decompile the stub it wrote to carry it), or a fresh one from \`crypto.randomUUID()\`.`,
173
+ });
174
+ }
175
+ /**
176
+ * A `folderKey` binding must be keyed by its CONNECTION, not by the folder.
177
+ *
178
+ * The pair every real export writes is `ConnectionId {resourceKey: C, default: C}` +
179
+ * `folderKey {resourceKey: C, default: F}`: the folder is where the connection is
180
+ * resolved, so its resource key is the connection's. `uip maestro bpmn validate`
181
+ * reports `MISSING_BINDING` for a folder binding keyed by its own folder — which is
182
+ * what `.binding('f', { propertyAttribute: 'folderKey', value: F })` used to write, and
183
+ * what every run-6 agent that reached for Data Fabric or Test Manager shipped. The
184
+ * serializer now pairs a folder with its connection itself (`folderCompanionKeys`); this
185
+ * reports the two cases it cannot fix: an explicit `resourceKey` equal to the folder key,
186
+ * and a folder no node pairs with a connection when more than one connection exists.
187
+ */
188
+ function checkFolderBindings(built, diags) {
189
+ const folders = built.bindings.filter((b) => String(b.propertyAttribute ?? '').toLowerCase() === 'folderkey');
190
+ if (!folders.length)
191
+ return;
192
+ // `serialize` pairs against its binding SINK — the connections a connector, connector
193
+ // event or external task declares as a side effect of being emitted, which never
194
+ // appear in `built.bindings` because the author named them symbolically. `check` runs
195
+ // before serialization and has no sink, and passing an empty one made it warn about
196
+ // folders the serializer goes on to pair correctly: a `.connector(..., { connection:
197
+ // 'conn', folder: 'myFolder' })` node contributes the only ConnectionId there is, and
198
+ // the one-connection rung inside `folderCompanionKeys` finds it. So the sink is
199
+ // reconstructed from the nodes here, which is the same set serialize will build.
200
+ const sink = new Map();
201
+ const collectConnections = (nodes) => {
202
+ for (const n of nodes) {
203
+ const named = n.kind === 'connector' || n.kind === 'connectorEvent' || n.kind === 'externalTask' ? n.connection : undefined;
204
+ if (named)
205
+ sink.set(named, { id: named, propertyAttribute: 'ConnectionId', default: named });
206
+ if (n.kind === 'subProcess')
207
+ collectConnections(n.nodes);
208
+ }
209
+ };
210
+ collectConnections(built.nodes);
211
+ const paired = folderCompanionKeys(built, sink);
212
+ for (const b of folders) {
213
+ if (b.resourceKey !== undefined && b.resourceKey === b.default) {
214
+ diags.push({
215
+ level: 'warning',
216
+ code: 'FOLDER_BINDING_KEYED_BY_FOLDER',
217
+ message: `Binding "${b.id}" (folderKey) has resourceKey equal to its own folder key. A folder binding is keyed by the ` +
218
+ `CONNECTION it belongs to (\`uip maestro bpmn validate\` reports MISSING_BINDING otherwise). Drop \`resourceKey\` ` +
219
+ `and the builder pairs it with the connection the node reads, or set it to that connection's key.`,
220
+ where: b.id,
221
+ });
222
+ }
223
+ else if (b.resourceKey === undefined && !paired.has(b.id)) {
224
+ diags.push({
225
+ level: 'warning',
226
+ code: 'FOLDER_BINDING_UNPAIRED',
227
+ message: `Binding "${b.id}" (folderKey) could not be paired with a connection: no node reads it beside a ` +
228
+ `\`=bindings.<connection>\`, and the process does not declare exactly one ConnectionId binding. Its resourceKey ` +
229
+ `will be the folder key, which \`uip maestro bpmn validate\` reports as MISSING_BINDING. Read both bindings from ` +
230
+ `the same node, or set \`resourceKey\` to the connection's key.`,
231
+ where: b.id,
232
+ });
233
+ }
234
+ }
235
+ }
20
236
  /**
21
237
  * Every `id` in the document must be unique — including across KINDS.
22
238
  *
@@ -33,7 +249,14 @@ export function check(built) {
33
249
  * is the only gate that can catch it, and it has to be an error.
34
250
  *
35
251
  * The ids in play are every element and flow, every variable and binding, the
36
- * synthesized `Message_n`/`Error_n` declarations, and the process itself.
252
+ * synthesized `Message_n`/`Error_n` declarations, and the process itself — and the
253
+ * declarations serialize DERIVES from output rows (a connector's `<id>_response`, a
254
+ * script task's `outputs`), which are variables in the document too. Those were the one
255
+ * kind this check could not see: a script output named after the gateway that routes
256
+ * on it (`outputs: { confidence }` beside `.choose('confidence', …)`) passed here and
257
+ * every SDK gate, and `uip maestro bpmn validate` answered with three
258
+ * CROSSING_SUBPROCESS_BOUNDARY errors on the gateway's flows — the validator had
259
+ * resolved the id to the variable. Measured on the nested_review port.
37
260
  */
38
261
  function checkDocumentIds(built, diags) {
39
262
  const owners = new Map();
@@ -52,12 +275,16 @@ function checkDocumentIds(built, diags) {
52
275
  });
53
276
  };
54
277
  claim(built.id, 'the process');
278
+ const owner = (nodes, id) => nodes.find((n) => nodeDeclaredVars(n).includes(id))?.id ?? '?';
55
279
  const walk = (nodes, flows) => {
56
280
  for (const n of nodes) {
57
281
  claim(n.id, `element "${n.id}"`);
58
282
  if (n.kind === 'subProcess') {
59
283
  for (const v of n.variables)
60
284
  claim(v.id, `variable "${v.id}"`);
285
+ for (const v of implicitNodeVars(n.nodes, n.variables)) {
286
+ claim(v.id, `the variable serialize declares for the output row of "${owner(n.nodes, v.id)}"`);
287
+ }
61
288
  walk(n.nodes, n.flows);
62
289
  }
63
290
  }
@@ -67,6 +294,9 @@ function checkDocumentIds(built, diags) {
67
294
  walk(built.nodes, built.flows);
68
295
  for (const v of built.variables)
69
296
  claim(v.id, `variable "${v.id}"`);
297
+ for (const v of implicitNodeVars(built.nodes, built.variables)) {
298
+ claim(v.id, `the variable serialize declares for the output row of "${owner(built.nodes, v.id)}"`);
299
+ }
70
300
  for (const b of built.bindings)
71
301
  claim(b.id, `binding "${b.id}"`);
72
302
  for (const mm of built.messages)
@@ -106,7 +336,7 @@ function checkScopedVars(built, diags) {
106
336
  };
107
337
  collect(built.nodes);
108
338
  for (const v of allVars) {
109
- if (v.elementId !== undefined && !ids.has(v.elementId))
339
+ if (typeof v.elementId === 'string' && !ids.has(v.elementId))
110
340
  diags.push({
111
341
  level: 'error',
112
342
  code: 'SCOPED_VAR_UNKNOWN_ELEMENT',
@@ -124,6 +354,57 @@ function checkScopedVars(built, diags) {
124
354
  * its own `TIMER_DURATION_WEEK_UNSUPPORTED` rule.
125
355
  */
126
356
  const RUNTIME_DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/i;
357
+ /**
358
+ * A spec-legal ISO-8601 duration, which is WIDER than the runtime's parser: weeks,
359
+ * months and years are valid here and unparseable there. The platform splits the same
360
+ * way — `TIMER_DURATION_INVALID` is an error against ISO-8601 (it calls luxon's
361
+ * `Duration.fromISO`), and `TIMER_DURATION_WEEK_UNSUPPORTED` is a separate warning — so
362
+ * the two tests are kept apart here for the same reason rather than collapsed.
363
+ */
364
+ const ISO_DURATION = /^P(?!$)(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?!$)(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/i;
365
+ function isIsoDuration(v) {
366
+ return ISO_DURATION.test(v) && /\d/.test(v);
367
+ }
368
+ /**
369
+ * A timer value the author supplied but left blank. The platform's `isTimerValueMissing`
370
+ * is `value == null || value === ''`, so an EMPTY STRING is missing — which is the case
371
+ * `check` used to pass, because it only asked whether all three timer fields were
372
+ * `undefined`. `.intermediateCatchEvent('w', { timer: '' })` reached `validate`.
373
+ */
374
+ function isBlankTimerValue(v) {
375
+ return v !== undefined && v.trim() === '';
376
+ }
377
+ /**
378
+ * Whether an expression ASSIGNS rather than evaluates — `=vars.n = 5` where `==` was
379
+ * meant. A faithful port of the platform's `expressionHasAssignment`: string, template
380
+ * and regex literals are blanked first, then a compound assignment operator or a bare
381
+ * `=` that is not part of `==`, `!=`, `<=`, `>=`, `=>` or a compound operator is a hit.
382
+ *
383
+ * Scoped where the platform scopes it — flow conditions and error-mapping conditions —
384
+ * and reported at its severity, WARNING, because the artifact is still accepted. The
385
+ * point is that the author hears it at the rung they iterate on rather than the last one.
386
+ */
387
+ const ASSIGNMENT_OP = /(\?\?=|&&=|\|\|=|>>>=|<<=|>>=|\*\*=|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|(?<![!=<>+\-*/%.&|^?])=(?![=>]))/;
388
+ function expressionHasAssignment(expr) {
389
+ if (!expr)
390
+ return false;
391
+ const body = expr.startsWith('=') ? expr.slice(1) : expr;
392
+ if (!body.trim())
393
+ return false;
394
+ const blank = (m) => ' '.repeat(m.length);
395
+ const sanitized = body
396
+ .replace(/"(?:[^"\\]|\\.)*"/g, blank)
397
+ .replace(/'(?:[^'\\]|\\.)*'/g, blank)
398
+ .replace(/`(?:[^`\\]|\\.)*`/g, blank)
399
+ // A regex literal can only START where a value can start. Matching any run between
400
+ // two `/` blanked real code whenever one expression divided twice:
401
+ // `=vars.done / vars.total > 0.5 && vars.ratio = vars.x / vars.y` had everything
402
+ // from the first slash to the last blanked, and the stray `=` went unreported. So
403
+ // the opening `/` has to sit in regex-legal position — start of expression, or after
404
+ // an operator or opening bracket — which is never true of a division.
405
+ .replace(/(^|[(,=:[!&|?{};+\-*%^~]\s*)(\/(?:[^/\\\n]|\\.)*\/[gimsuy]*)/g, (_m, pre, lit) => pre + blank(lit));
406
+ return ASSIGNMENT_OP.test(sanitized);
407
+ }
127
408
  function isRuntimeDuration(v) {
128
409
  // A bare `P`/`PT` matches the shape but carries no components — the same
129
410
  // "validates, then no runtime can parse it" trap the timer eval card exists for.
@@ -181,6 +462,18 @@ function checkActivityFields(n, diags) {
181
462
  where: n.id,
182
463
  });
183
464
  }
465
+ // An error-mapping condition is the platform's other ASSIGNMENT_NOT_ALLOWED site.
466
+ const mappings = 'errorMapping' in n ? (n.errorMapping ?? []) : [];
467
+ for (const row of mappings) {
468
+ if (expressionHasAssignment(row.condition))
469
+ diags.push({
470
+ level: 'warning',
471
+ code: 'ASSIGNMENT_NOT_ALLOWED',
472
+ message: `Error-mapping condition on "${n.id}" (code "${row.code}") contains an assignment operator; it must only ` +
473
+ `evaluate. \`=\` assigns and \`==\` compares.`,
474
+ where: n.id,
475
+ });
476
+ }
184
477
  const loop = 'loop' in n ? n.loop : undefined;
185
478
  if (loop) {
186
479
  if (!loop.collection)
@@ -224,12 +517,66 @@ inEventSubProcess = false) {
224
517
  // NO_START on a process that has one, refusing an artifact the platform accepts.
225
518
  if (nodes.length && !nodes.some(isStartElement))
226
519
  diags.push({ level: isTop ? 'error' : 'warning', code: 'NO_START', message: `${where} has no start event.`, where });
520
+ // At most one BLANK start event per scope — one with no event definition and no
521
+ // trigger, the "start this manually" event. The platform counts them per process and
522
+ // per sub-process (`validateSingleBlankStartEvent`) and errors on a second; a
523
+ // `sequence` scope refuses one at build, but an explicit-mode scope did not, so the
524
+ // shape reached `validate`.
525
+ const blankStarts = nodes.filter((n) => n.kind === 'startEvent' && n.event === undefined && n.trigger === undefined);
526
+ if (blankStarts.length > 1)
527
+ diags.push({
528
+ level: 'error',
529
+ code: 'MULTIPLE_BLANK_START_EVENTS',
530
+ message: `${where} has ${blankStarts.length} blank start events (${blankStarts.map((n) => `"${n.id}"`).join(', ')}); ` +
531
+ `a scope may have at most one. Give the others an event definition, or merge the paths.`,
532
+ where: blankStarts[1].id,
533
+ });
227
534
  // Flow endpoints must resolve within this scope.
535
+ const byId = new Map(nodes.map((n) => [n.id, n]));
228
536
  for (const f of flows) {
229
537
  if (!nodeIds.has(f.source))
230
538
  diags.push({ level: 'error', code: 'DANGLING_FLOW', message: `Flow "${f.id}" source "${f.source}" is not an element in ${where}.`, where: f.id });
231
539
  if (!nodeIds.has(f.target))
232
540
  diags.push({ level: 'error', code: 'DANGLING_FLOW', message: `Flow "${f.id}" target "${f.target}" is not an element in ${where}.`, where: f.id });
541
+ // A flow from an element to itself. The platform reports it as INVALID_CONNECTION;
542
+ // an error here, to match DANGLING_FLOW, which is the same class of broken edge.
543
+ if (f.source === f.target)
544
+ diags.push({
545
+ level: 'error',
546
+ code: 'SELF_FLOW',
547
+ message: `Flow "${f.id}" leaves "${f.source}" and arrives at the same element; a loop needs a gateway or \`loop\`.`,
548
+ where: f.id,
549
+ });
550
+ // Nothing flows INTO a start event or OUT of an end event. Both are structural
551
+ // facts of BPMN, both are reachable through an explicit `.sequenceFlow()` — a
552
+ // `sequence` scope refuses them when it places the node, an explicit scope did not —
553
+ // and the platform reports each as INVALID_CONNECTION_TYPE. The rest of its
554
+ // type-compatibility matrix is NOT ported: these two are the cases an author
555
+ // actually writes by mistake.
556
+ const target = byId.get(f.target);
557
+ if (target !== undefined && isStartElement(target))
558
+ diags.push({
559
+ level: 'error',
560
+ code: 'FLOW_INTO_START',
561
+ message: `Flow "${f.id}" arrives at start event "${f.target}"; a start event has no incoming flow.`,
562
+ where: f.id,
563
+ });
564
+ const source = byId.get(f.source);
565
+ if (source !== undefined && source.kind === 'endEvent')
566
+ diags.push({
567
+ level: 'error',
568
+ code: 'FLOW_OUT_OF_END',
569
+ message: `Flow "${f.id}" leaves end event "${f.source}"; an end event ends its path.`,
570
+ where: f.id,
571
+ });
572
+ if (expressionHasAssignment(f.condition))
573
+ diags.push({
574
+ level: 'warning',
575
+ code: 'ASSIGNMENT_NOT_ALLOWED',
576
+ message: `Condition on flow "${f.id}" contains an assignment operator; it must only evaluate. ` +
577
+ `\`=\` assigns and \`==\` compares.`,
578
+ where: f.id,
579
+ });
233
580
  }
234
581
  const outgoing = new Map();
235
582
  const incomingCount = new Map();
@@ -259,11 +606,55 @@ inEventSubProcess = false) {
259
606
  for (const o of outs)
260
607
  if (o.id !== n.default && o.condition === undefined)
261
608
  diags.push({ level, code: 'MISSING_CONDITION_EXPRESSION', message: `Outgoing flow "${o.id}" of gateway "${n.id}" has no condition.`, where: o.id });
609
+ // Every branch conditioned and no default: when no condition holds the token
610
+ // has nowhere to go, and the instance stalls at the gateway. A WARNING, not an
611
+ // error: the platform has no rule for it (`uip maestro bpmn validate` says Valid),
612
+ // and the runtime is what discovers it — as the diagnose evals' "stuck gateway"
613
+ // does. `.choose()` with an `otherwise` arm cannot produce this shape.
614
+ if (n.kind === 'exclusiveGateway' && n.default === undefined && outs.every((o) => o.condition !== undefined))
615
+ diags.push({
616
+ level: 'warning',
617
+ code: 'NO_DEFAULT_FLOW',
618
+ message: `Gateway "${n.id}" has no default flow: when none of its ${outs.length} conditions holds, the instance ` +
619
+ `stalls there. Mark one flow as the default, or add an \`otherwise\` arm.`,
620
+ where: n.id,
621
+ });
262
622
  }
263
623
  }
264
- // A gateway with exactly one in and one out is superfluous.
265
- if (GATEWAY_KINDS.has(n.kind) && ins === 1 && outs.length === 1)
266
- diags.push({ level: 'warning', code: 'SUPERFLUOUS_GATEWAY', message: `Gateway "${n.id}" has one incoming and one outgoing flow; remove it.`, where: n.id });
624
+ // A gateway with exactly one in and one out is superfluous. An ERROR, and scoped to
625
+ // the three kinds the platform's rule covers: `validateSuperfluousGateway` matches
626
+ // ExclusiveGateway / ParallelGateway / InclusiveGateway at severity ERROR, so a
627
+ // warning here told the author something the last rung would refuse — measured as a
628
+ // `validate`-only row in the conformance ledger even though the code existed.
629
+ // An event-based gateway is outside the platform's rule and stays a warning.
630
+ if (GATEWAY_KINDS.has(n.kind) && ins === 1 && outs.length === 1) {
631
+ const platformRule = n.kind !== 'eventBasedGateway';
632
+ diags.push({
633
+ level: platformRule ? 'error' : 'warning',
634
+ code: 'SUPERFLUOUS_GATEWAY',
635
+ message: `Gateway "${n.id}" has one incoming and one outgoing flow, so it decides nothing; remove it and wire ` +
636
+ `its source straight to its target.`,
637
+ where: n.id,
638
+ });
639
+ }
640
+ // The only outgoing flow carries a condition, so a false condition strands the token
641
+ // with nowhere to go. The platform's rule is on ANY node — a task with one conditional
642
+ // outgoing flow trips it too, not just a gateway — minus parallel and event-based
643
+ // gateways, and minus the exclusive/inclusive gateways the superfluous rule above
644
+ // already owns (one in, one out), so the two rules do not both fire on one element.
645
+ if (n.kind !== 'parallelGateway' && n.kind !== 'eventBasedGateway' && outs.length === 1) {
646
+ const ownedBySuperfluous = (n.kind === 'exclusiveGateway' || n.kind === 'inclusiveGateway') && ins === 1;
647
+ const only = outs[0];
648
+ const defaultFlowId = 'default' in n ? n.default : undefined;
649
+ if (!ownedBySuperfluous && only.condition !== undefined && only.id !== defaultFlowId)
650
+ diags.push({
651
+ level: 'error',
652
+ code: 'SINGLE_CONDITIONAL_OUTGOING_FLOW',
653
+ message: `The only outgoing flow of "${n.id}" ("${only.id}") carries a condition, so the instance strands there ` +
654
+ `whenever it is false. Drop the condition, mark the flow as the default, or add the other branch.`,
655
+ where: only.id,
656
+ });
657
+ }
267
658
  // An activity or event with >1 incoming flow is an implicit join. A WARNING,
268
659
  // not an error: the platform's FAKE_JOIN rule only matches the abstract types
269
660
  // `bpmn:Activity`/`bpmn:Event`, which never appear on real exported BPMN, so
@@ -276,6 +667,42 @@ inEventSubProcess = false) {
276
667
  message: `"${n.id}" has ${ins} incoming flows; the platform accepts this, but joining with a gateway is clearer.`,
277
668
  where: n.id,
278
669
  });
670
+ // The mirror of FAKE_JOIN: an activity or event with more than one outgoing
671
+ // flow and no condition on any of them is an implicit PARALLEL SPLIT — every
672
+ // branch runs. A warning for the same reason: the platform accepts it.
673
+ //
674
+ // Worth saying because of how easily the nesting sugar produces one by accident.
675
+ // An explicit `.sequenceFlow()` off an element the builder has ALREADY implied a
676
+ // flow out of adds a second edge rather than replacing the first, so
677
+ // `.task('a').endEvent('done').sequenceFlow('a', 'failed')` fans out where the
678
+ // author meant to redirect. Writing the explicit flow BEFORE the next element
679
+ // takes the path off the books instead (see `sequenceFlow`), so the two orders
680
+ // no longer differ silently — this is what reports the one that cannot be caught
681
+ // at the call site.
682
+ const unconditioned = outs.filter((f) => f.condition === undefined);
683
+ if (!GATEWAY_KINDS.has(n.kind) && n.kind !== 'boundaryEvent' && unconditioned.length > 1)
684
+ diags.push({
685
+ level: 'warning',
686
+ code: 'IMPLICIT_SPLIT',
687
+ message: `"${n.id}" has ${unconditioned.length} outgoing flows and no condition on any of them ` +
688
+ `(${unconditioned.map((f) => `"${f.target}"`).join(', ')}), so every one of them runs. ` +
689
+ `If that is meant, split with .fork(); if one was meant to replace another, write the ` +
690
+ `.sequenceFlow() before the element that follows.`,
691
+ where: n.id,
692
+ });
693
+ // An element nothing leaves, that is not an end. The token arriving there has
694
+ // nowhere to go, and the instance sits at that element for ever. A WARNING: the
695
+ // platform has no rule for it (`uip maestro bpmn validate` says Valid), and a
696
+ // process under construction legitimately has loose ends. It is here because
697
+ // `bpmn check --graph` showed one in a shipped example that every other gate had
698
+ // passed — an activity whose only exit was through its error handler.
699
+ if (outs.length === 0 && n.kind !== 'endEvent' && !(n.kind === 'subProcess' && n.triggeredByEvent === true))
700
+ diags.push({
701
+ level: 'warning',
702
+ code: 'DEAD_END',
703
+ message: `"${n.id}" has no outgoing flow, so a token reaching it stops there. End the path with an end event, or wire it on.`,
704
+ where: n.id,
705
+ });
279
706
  // Boundary events must attach to an activity in the same scope.
280
707
  if (n.kind === 'boundaryEvent') {
281
708
  const target = nodes.find((x) => x.id === n.attachedTo);
@@ -301,8 +728,39 @@ inEventSubProcess = false) {
301
728
  }
302
729
  if (n.kind !== 'subProcess' && 'event' in n && n.event?.type === 'timer') {
303
730
  const t = n.event.timer;
731
+ const blank = [t.duration, t.date, t.cycle].find(isBlankTimerValue) !== undefined;
304
732
  if (t.duration === undefined && t.date === undefined && t.cycle === undefined)
305
733
  diags.push({ level: 'error', code: 'TIMER_MISSING_FIELD', message: `Timer on "${n.id}" needs a duration, date, or cycle.`, where: n.id });
734
+ else if (blank)
735
+ // The field is present and empty, which the platform reports as
736
+ // TIMER_MISSING_REQUIRED_FIELD. Same diagnosis, one rung earlier.
737
+ diags.push({
738
+ level: 'error',
739
+ code: 'TIMER_MISSING_FIELD',
740
+ message: `Timer on "${n.id}" declares a field with an empty value; give it a duration, date, or cycle.`,
741
+ where: n.id,
742
+ });
743
+ // An expression or a binding reference is resolved at run time, so its shape is
744
+ // not ours to judge — the platform skips `=`/`@` values for the same reason.
745
+ const d = t.duration;
746
+ if (d !== undefined && d.trim() !== '' && !d.startsWith('=') && !d.startsWith('@')) {
747
+ if (!isIsoDuration(d))
748
+ diags.push({
749
+ level: 'error',
750
+ code: 'TIMER_DURATION_INVALID',
751
+ message: `Timer duration "${d}" on "${n.id}" is not an ISO-8601 duration, e.g. "PT30S" or "P1DT12H".`,
752
+ where: n.id,
753
+ });
754
+ else if (!isRuntimeDuration(d))
755
+ diags.push({
756
+ level: 'warning',
757
+ code: 'TIMER_DURATION_NOT_RUNTIME_PARSEABLE',
758
+ message: `Timer duration "${d}" on "${n.id}" is valid ISO-8601 but the runtime parses days/hours/minutes/seconds ` +
759
+ `only, so a week, month or year designator is accepted by validate and then fails at run time. ` +
760
+ `Write it in days, e.g. "P7D" for a week.`,
761
+ where: n.id,
762
+ });
763
+ }
306
764
  }
307
765
  // Retry / loop options on any activity.
308
766
  checkActivityFields(n, diags);
@@ -320,7 +778,7 @@ inEventSubProcess = false) {
320
778
  const contextView = { ...n.context };
321
779
  for (const row of n.contextRows ?? [])
322
780
  contextView[row.name] = row.value ?? row.body ?? '';
323
- for (const field of bindingRequiredFields(n.type)) {
781
+ for (const field of bindingRequiredFields(n.type, n.spec)) {
324
782
  const value = contextView[field.name];
325
783
  if (typeof value === 'string' && value.startsWith('=bindings.'))
326
784
  continue;
@@ -333,6 +791,59 @@ inEventSubProcess = false) {
333
791
  where: n.id,
334
792
  });
335
793
  }
794
+ // A human task's own timer, which the platform bounds to 15 minutes … 90 days.
795
+ // Note the units and the type: this is a NUMBER OF MINUTES on an `actions` node,
796
+ // not an ISO duration and not a boundary timer, and an `=`/`@` value is a runtime
797
+ // expression the rule skips. Warning, as the platform reports it.
798
+ if (n.type === 'actions') {
799
+ const raw = contextView['Timer'];
800
+ const str = raw === undefined || raw === null ? '' : String(raw);
801
+ const minutes = Number(str);
802
+ if (str !== '' && !str.startsWith('=') && !str.startsWith('@') && !Number.isNaN(minutes)) {
803
+ if (minutes < MIN_TASK_TIMER_MINUTES || minutes > MAX_TASK_TIMER_MINUTES)
804
+ diags.push({
805
+ level: 'warning',
806
+ code: 'TASK_TIMER_OUT_OF_RANGE',
807
+ message: `"${n.id}" sets Timer to ${str}, outside the 15 minutes to 90 days the platform accepts. ` +
808
+ `The value is a NUMBER OF MINUTES — "${MIN_TASK_TIMER_MINUTES}" is the shortest, ` +
809
+ `"${MAX_TASK_TIMER_MINUTES}" the longest — not an ISO duration.`,
810
+ where: n.id,
811
+ });
812
+ }
813
+ }
814
+ // A service task of a type that starts or enqueues something, with nothing
815
+ // naming WHAT. The platform reports `MISSING_RESOURCE` at WARNING, so it does
816
+ // not gate — and a warning at the last rung is the weakest signal there is. It
817
+ // is a warning here too: the platform accepts the process, so an error would be
818
+ // the SDK refusing what deploys.
819
+ if (SERVICE_TYPES_REQUIRING_RESOURCE.has(n.type)) {
820
+ const quickHitl = n.type === 'Actions.HITL' &&
821
+ (contextView['hitlType'] === 'quick' ||
822
+ (contextView['hitlType'] !== 'custom' && Object.keys(contextView).some((k) => HITL_QUICK_SCHEMA_CONTEXT.has(k))));
823
+ // `appId` is a deliberate, narrow LOOSENING of the platform's list, and the one
824
+ // place this rule does not mirror it. The canvas rule reads only
825
+ // `releaseKey`/`name`/`queueName`, so every `Actions.HITL` node — including
826
+ // every one `.humanTask()` itself produces, which writes the app as `appId` —
827
+ // trips it. Warning on a shape the SDK's own wrapper emits, about a field that
828
+ // is not how an Action app is addressed, is noise the author cannot act on.
829
+ // Safe in this direction: the platform reports MISSING_RESOURCE at WARNING, so
830
+ // staying quiet cannot turn an accepted artifact into a rejected one. The
831
+ // reverse — reporting more than the platform — is what this file forbids.
832
+ const resourceFields = n.type === 'Actions.HITL' ? ['releaseKey', 'name', 'queueName', 'appId'] : ['releaseKey', 'name', 'queueName'];
833
+ const bound = resourceFields.some((f) => {
834
+ const v = contextView[f];
835
+ return v !== undefined && v !== null && String(v) !== '';
836
+ });
837
+ if (!quickHitl && !bound)
838
+ diags.push({
839
+ level: 'warning',
840
+ code: 'MISSING_RESOURCE',
841
+ message: `"${n.id}" (${n.type}) names no resource, so nothing says which process or queue it acts on. ` +
842
+ `Set "releaseKey", or "name"/"queueName" — normally as a binding reference, e.g. ` +
843
+ `\`name: '=bindings.MyProcess'\`.`,
844
+ where: n.id,
845
+ });
846
+ }
336
847
  // Context reaches a node two ways — the `context` record, or the verbatim
337
848
  // `contextRows` an importer uses when a row carries detail the record cannot
338
849
  // hold (its own type, a `target`, or the absence of a value). Both are context,
@@ -344,7 +855,7 @@ inEventSubProcess = false) {
344
855
  // emits, so whether this fired depended on a detail of the source document that
345
856
  // has nothing to do with whether the field is set.
346
857
  const contextNames = contextView;
347
- for (const field of missingRequiredContext(n.type, contextNames)) {
858
+ for (const field of missingRequiredContext(n.type, contextNames, n.spec)) {
348
859
  diags.push({
349
860
  level: 'error',
350
861
  code: 'EMPTY_REQUIRED_FIELD',
@@ -354,7 +865,12 @@ inEventSubProcess = false) {
354
865
  }
355
866
  // The same rule over the PAYLOAD fields — the platform's RequiredFieldsRule
356
867
  // draws on both, so an unset required input is the same EMPTY_REQUIRED_FIELD.
357
- for (const field of missingRequiredInputs(n.type, n.inputs, contextNames)) {
868
+ // The payload reaches a node the same two ways — `inputs`, or the verbatim
869
+ // `inputRows` — and both satisfy a required field.
870
+ const inputView = { ...n.inputs };
871
+ for (const row of n.inputRows ?? [])
872
+ inputView[row.name] = row.value ?? row.body ?? '';
873
+ for (const field of missingRequiredInputs(n.type, inputView, contextNames, n.spec)) {
358
874
  diags.push({
359
875
  level: 'error',
360
876
  code: 'EMPTY_REQUIRED_FIELD',
@@ -370,7 +886,7 @@ inEventSubProcess = false) {
370
886
  code: 'OUTPUT_ROWS_FIXED',
371
887
  message: `"${n.id}" (${n.type}) permits only its declared output row, so the extra row(s) ` +
372
888
  `${Object.keys(n.outputs ?? {}).map((k) => `"${k}"`).join(', ')} will be refused ` +
373
- `("output name must be \\"${registryType(n.type).output?.name}\\""). Use outputVar and read the ` +
889
+ `("output name must be \\"${registryType(n.type, n.spec).output?.name}\\""). Use outputVar and read the ` +
374
890
  `typed row instead.`,
375
891
  where: n.id,
376
892
  });
@@ -379,13 +895,13 @@ inEventSubProcess = false) {
379
895
  // not error: the node is as correct as the registry allows, so refusing it
380
896
  // would block work the platform, not the author, has to fix. Saying nothing
381
897
  // is worse — the artifact validates here and is rejected by the CLI.
382
- const unsatisfiable = unsatisfiableRequiredInputs(n.type);
898
+ const unsatisfiable = unsatisfiableRequiredInputs(n.type, n.spec);
383
899
  if (unsatisfiable.length) {
384
900
  diags.push({
385
901
  level: 'warning',
386
902
  code: 'REQUIRED_INPUT_UNSATISFIABLE',
387
903
  message: `"${n.id}" (${n.type}) declares required input(s) ${unsatisfiable.map((f) => `"${f}"`).join(', ')}, ` +
388
- `but its "${registryType(n.type).inputPattern}" pattern merges every input into one row, so the ` +
904
+ `but its "${registryType(n.type, n.spec).inputPattern}" pattern merges every input into one row, so the ` +
389
905
  `platform's required-field rule cannot see them. \`uip maestro bpmn validate\` will report ` +
390
906
  `EMPTY_REQUIRED_FIELD whatever this node carries — a platform defect, not an authoring error. ` +
391
907
  `See docs/BPMN_COVERAGE_DESIGN.md D13.`,
@@ -417,7 +933,7 @@ function isStartElement(n) {
417
933
  if (n.kind !== 'typed')
418
934
  return false;
419
935
  try {
420
- return registryType(n.type).element === 'bpmn:StartEvent';
936
+ return registryType(n.type, n.spec).element === 'bpmn:StartEvent';
421
937
  }
422
938
  catch {
423
939
  return false;