@bpmnkit/core 0.1.2 → 0.2.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 (42) hide show
  1. package/README.md +30 -1
  2. package/dist/bpmn/bpmn-builder.d.ts +209 -3
  3. package/dist/bpmn/bpmn-builder.js +456 -16
  4. package/dist/bpmn/bpmn-model.d.ts +110 -0
  5. package/dist/bpmn/bpmn-parser.js +1413 -528
  6. package/dist/bpmn/bpmn-serializer.js +101 -19
  7. package/dist/bpmn/compact.d.ts +17 -2
  8. package/dist/bpmn/compact.js +3 -3
  9. package/dist/bpmn/full-operations.d.ts +89 -0
  10. package/dist/bpmn/full-operations.js +478 -0
  11. package/dist/bpmn/index.d.ts +19 -0
  12. package/dist/bpmn/index.js +21 -0
  13. package/dist/bpmn/optimize/feel.js +2 -2
  14. package/dist/bpmn/optimize/patterns.js +23 -16
  15. package/dist/bpmn/optimize/tasks.js +30 -7
  16. package/dist/bpmn/optimize/utils.js +2 -4
  17. package/dist/bpmn/optimize/variable-flow.js +58 -67
  18. package/dist/bpmn/semantic-hash.d.ts +93 -0
  19. package/dist/bpmn/semantic-hash.js +155 -0
  20. package/dist/bpmn/sha256.d.ts +17 -0
  21. package/dist/bpmn/sha256.js +95 -0
  22. package/dist/bpmn/zeebe-extensions.d.ts +56 -0
  23. package/dist/bpmn/zeebe-extensions.js +79 -0
  24. package/dist/bpmn/zeebe-placement.d.ts +12 -0
  25. package/dist/bpmn/zeebe-placement.js +140 -0
  26. package/dist/errors.d.ts +40 -1
  27. package/dist/errors.js +41 -0
  28. package/dist/index.d.ts +10 -4
  29. package/dist/index.js +7 -3
  30. package/dist/layout/semantic/graph.d.ts +9 -1
  31. package/dist/layout/semantic/graph.js +42 -17
  32. package/dist/layout/semantic/route.js +102 -42
  33. package/dist/node/index.d.ts +10 -0
  34. package/dist/node/index.js +9 -0
  35. package/dist/node/write.d.ts +81 -0
  36. package/dist/node/write.js +167 -0
  37. package/dist/types/id-generator.js +11 -3
  38. package/dist/xml/index.d.ts +3 -1
  39. package/dist/xml/index.js +2 -1
  40. package/dist/xml/xml-parser.d.ts +32 -0
  41. package/dist/xml/xml-parser.js +394 -143
  42. package/package.json +8 -1
@@ -0,0 +1,478 @@
1
+ import { buildFlowElement, makeEventDef, } from "./compact.js";
2
+ import { bpmnElementName, ensureZeebeExtension, isZeebePlacementAllowed, } from "./zeebe-extensions.js";
3
+ /** Thrown by {@link applyBpmnOperations} in strict mode. */
4
+ export class OperationError extends Error {
5
+ problems;
6
+ constructor(problems) {
7
+ super(`${problems.length} operation(s) could not be applied: ${problems
8
+ .map((problem) => `[${problem.index}] ${problem.reason}`)
9
+ .join("; ")}`);
10
+ this.name = "OperationError";
11
+ this.problems = problems;
12
+ }
13
+ }
14
+ function containersOf(definitions) {
15
+ const containers = [];
16
+ const visit = (container) => {
17
+ containers.push(container);
18
+ for (const element of container.flowElements) {
19
+ if ("flowElements" in element && "sequenceFlows" in element) {
20
+ visit(element);
21
+ }
22
+ }
23
+ };
24
+ for (const process of definitions.processes)
25
+ visit(process);
26
+ return containers;
27
+ }
28
+ function findElement(definitions, id) {
29
+ for (const container of containersOf(definitions)) {
30
+ const element = container.flowElements.find((candidate) => candidate.id === id);
31
+ if (element)
32
+ return { container, element };
33
+ }
34
+ return undefined;
35
+ }
36
+ function findFlow(definitions, id) {
37
+ for (const container of containersOf(definitions)) {
38
+ const flow = container.sequenceFlows.find((candidate) => candidate.id === id);
39
+ if (flow)
40
+ return { container, flow };
41
+ }
42
+ return undefined;
43
+ }
44
+ /**
45
+ * The container an operation should act in: a named process or sub-process, or
46
+ * the first process when none is named.
47
+ */
48
+ function resolveContainer(definitions, parentId) {
49
+ if (parentId === undefined)
50
+ return definitions.processes[0];
51
+ const process = definitions.processes.find((candidate) => candidate.id === parentId);
52
+ if (process)
53
+ return process;
54
+ const found = findElement(definitions, parentId);
55
+ if (!found)
56
+ return undefined;
57
+ const element = found.element;
58
+ return "flowElements" in element && "sequenceFlows" in element
59
+ ? element
60
+ : undefined;
61
+ }
62
+ function nextFlowId(definitions) {
63
+ const taken = new Set(containersOf(definitions).flatMap((container) => container.sequenceFlows.map((f) => f.id)));
64
+ let index = taken.size + 1;
65
+ while (taken.has(`flow_${index}`))
66
+ index++;
67
+ return `flow_${index}`;
68
+ }
69
+ /**
70
+ * Finds an extension element by name, creating it if it is not there yet.
71
+ *
72
+ * Zeebe extensions go through {@link ensureZeebeExtension}, which refuses a
73
+ * placement the schema forbids — writing `zeebe:calledDecision` onto a service
74
+ * task produces a file Camunda rejects at deploy time, and an operation that
75
+ * asks for it is a mistake worth reporting here rather than there.
76
+ */
77
+ function ensureExtension(element, name) {
78
+ if (name.startsWith("zeebe:"))
79
+ return ensureZeebeExtension(element, name);
80
+ const existing = element.extensionElements.find((candidate) => candidate.name === name);
81
+ if (existing)
82
+ return existing;
83
+ const created = { name, attributes: {}, children: [] };
84
+ element.extensionElements.push(created);
85
+ return created;
86
+ }
87
+ /** The Zeebe extension each patch field writes to, for the placement check. */
88
+ const PATCH_EXTENSIONS = [
89
+ ["jobType", "zeebe:taskDefinition"],
90
+ ["formId", "zeebe:formDefinition"],
91
+ ["calledProcess", "zeebe:calledElement"],
92
+ ["decisionId", "zeebe:calledDecision"],
93
+ ["taskHeaders", "zeebe:taskHeaders"],
94
+ ];
95
+ /**
96
+ * Reports a patch field that would write an extension the Zeebe schema does not
97
+ * allow on this element — `decisionId` on a service task, say.
98
+ *
99
+ * Checked before anything is written so the operation stays atomic: it is
100
+ * reported like any other failed operation rather than thrown, which is what
101
+ * lets a non-strict caller apply the rest of the batch and show the user what
102
+ * was rejected.
103
+ */
104
+ function misplacedExtensions(element, patch) {
105
+ const owner = bpmnElementName(element);
106
+ for (const [field, extension] of PATCH_EXTENSIONS) {
107
+ if (patch[field] === undefined)
108
+ continue;
109
+ if (isZeebePlacementAllowed(owner, extension))
110
+ continue;
111
+ return `${String(field)} writes <${extension}>, which the Zeebe schema does not allow on <${owner}>`;
112
+ }
113
+ return undefined;
114
+ }
115
+ /**
116
+ * Sets one attribute on one extension element.
117
+ *
118
+ * Deliberately not a wholesale replacement: `zeebe:taskDefinition` also carries
119
+ * `retries`, `zeebe:calledElement` carries `propagateAllChildVariables`, and the
120
+ * compact form models none of them. Rebuilding the element from the compact
121
+ * fields would drop them, which is the loss this module exists to avoid.
122
+ */
123
+ function setExtensionAttribute(element, extensionName, attribute, value) {
124
+ ensureExtension(element, extensionName).attributes[attribute] = value;
125
+ }
126
+ /** Replaces the `zeebe:taskHeaders` children, which are a map and move together. */
127
+ function setTaskHeaders(element, headers) {
128
+ const extension = ensureExtension(element, "zeebe:taskHeaders");
129
+ extension.children = Object.entries(headers).map(([key, value]) => ({
130
+ name: "zeebe:header",
131
+ attributes: { key, value },
132
+ children: [],
133
+ }));
134
+ }
135
+ /**
136
+ * Points a service task's result at a variable, keeping the rest of its
137
+ * `zeebe:ioMapping`. On a business rule task the result variable lives on
138
+ * `zeebe:calledDecision` instead.
139
+ */
140
+ function setResultVariable(element, resultVariable) {
141
+ const calledDecision = element.extensionElements.find((e) => e.name === "zeebe:calledDecision");
142
+ if (calledDecision) {
143
+ calledDecision.attributes.resultVariable = resultVariable;
144
+ return;
145
+ }
146
+ const mapping = ensureExtension(element, "zeebe:ioMapping");
147
+ const outputs = mapping.children.filter((child) => child.name === "zeebe:output");
148
+ // `compactify` reads the result variable from the single output of an
149
+ // ioMapping, whatever its source, so this has to write back to that same
150
+ // one. Matching on a particular source instead appends a second output and
151
+ // the mapping grows on every edit.
152
+ const single = outputs.length === 1 ? outputs[0] : undefined;
153
+ if (single) {
154
+ single.attributes.target = resultVariable;
155
+ return;
156
+ }
157
+ // No output to update, or several with no way to tell which is primary:
158
+ // add one wired to the connector response, the shape `expand` produces.
159
+ mapping.children.push({
160
+ name: "zeebe:output",
161
+ attributes: { source: "= response", target: resultVariable },
162
+ children: [],
163
+ });
164
+ }
165
+ /**
166
+ * Applies the compact fields a patch names onto an existing element.
167
+ *
168
+ * Only the named fields move, and each moves the smallest thing it can: one
169
+ * attribute rather than one extension element, so everything the compact form
170
+ * does not model stays exactly as it was.
171
+ */
172
+ function patchElement(element, patch) {
173
+ if (patch.type !== undefined && patch.type !== element.type) {
174
+ return `changing type (${element.type} → ${patch.type}) needs a delete and an insert, so that what the new element should carry is explicit`;
175
+ }
176
+ const misplaced = misplacedExtensions(element, patch);
177
+ if (misplaced !== undefined)
178
+ return misplaced;
179
+ if (patch.name !== undefined)
180
+ element.name = patch.name;
181
+ if (patch.attachedTo !== undefined && "attachedToRef" in element) {
182
+ element.attachedToRef = patch.attachedTo;
183
+ }
184
+ if (patch.interrupting !== undefined && "cancelActivity" in element) {
185
+ element.cancelActivity = patch.interrupting;
186
+ }
187
+ if (patch.jobType !== undefined) {
188
+ setExtensionAttribute(element, "zeebe:taskDefinition", "type", patch.jobType);
189
+ }
190
+ if (patch.formId !== undefined) {
191
+ setExtensionAttribute(element, "zeebe:formDefinition", "formId", patch.formId);
192
+ }
193
+ if (patch.calledProcess !== undefined) {
194
+ setExtensionAttribute(element, "zeebe:calledElement", "processId", patch.calledProcess);
195
+ }
196
+ if (patch.decisionId !== undefined) {
197
+ setExtensionAttribute(element, "zeebe:calledDecision", "decisionId", patch.decisionId);
198
+ }
199
+ if (patch.resultVariable !== undefined)
200
+ setResultVariable(element, patch.resultVariable);
201
+ if (patch.taskHeaders !== undefined)
202
+ setTaskHeaders(element, patch.taskHeaders);
203
+ if (patch.eventType !== undefined && "eventDefinitions" in element) {
204
+ const current = element.eventDefinitions[0]?.type;
205
+ if (current !== patch.eventType) {
206
+ const replacement = makeEventDef(patch.eventType);
207
+ element.eventDefinitions = replacement ? [replacement] : [];
208
+ }
209
+ }
210
+ return undefined;
211
+ }
212
+ /**
213
+ * Applies operations to a copy of the model.
214
+ *
215
+ * @param definitions - The model to edit. Never mutated.
216
+ * @param operations - Operations to apply, in order.
217
+ * @param options - `strict` (default `true`) throws instead of returning problems.
218
+ * @returns The edited model, how many operations landed, and what did not.
219
+ * @throws {OperationError} In strict mode, when any operation fails. Nothing is
220
+ * applied — the caller's model and the returned one are both untouched.
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * import { applyBpmnOperations } from "@bpmnkit/core"
225
+ *
226
+ * const { definitions } = applyBpmnOperations(parsed, [
227
+ * { op: "rename", id: "Task_1", name: "Approve invoice" },
228
+ * ])
229
+ * ```
230
+ */
231
+ export function applyBpmnOperations(definitions, operations, options = {}) {
232
+ const draft = structuredClone(definitions);
233
+ const problems = [];
234
+ let applied = 0;
235
+ operations.forEach((operation, index) => {
236
+ const reason = applyOne(draft, operation);
237
+ if (reason === undefined)
238
+ applied++;
239
+ else
240
+ problems.push({ index, operation, reason });
241
+ });
242
+ if ((options.strict ?? true) && problems.length > 0) {
243
+ throw new OperationError(problems);
244
+ }
245
+ return { definitions: draft, applied, problems };
246
+ }
247
+ function applyOne(definitions, operation) {
248
+ switch (operation.op) {
249
+ case "rename": {
250
+ const found = findElement(definitions, operation.id);
251
+ if (!found)
252
+ return `no element with id "${operation.id}"`;
253
+ found.element.name = operation.name;
254
+ return undefined;
255
+ }
256
+ case "update": {
257
+ const found = findElement(definitions, operation.id);
258
+ if (!found)
259
+ return `no element with id "${operation.id}"`;
260
+ return patchElement(found.element, operation.patch);
261
+ }
262
+ case "delete": {
263
+ const found = findElement(definitions, operation.id);
264
+ if (!found)
265
+ return `no element with id "${operation.id}"`;
266
+ found.container.flowElements = found.container.flowElements.filter((element) => element.id !== operation.id);
267
+ // A flow to or from a removed element would dangle.
268
+ found.container.sequenceFlows = found.container.sequenceFlows.filter((flow) => flow.sourceRef !== operation.id && flow.targetRef !== operation.id);
269
+ recomputeRefs(found.container);
270
+ return undefined;
271
+ }
272
+ case "insert": {
273
+ const container = resolveContainer(definitions, operation.parent);
274
+ if (!container) {
275
+ return operation.parent === undefined
276
+ ? "the document has no process to insert into"
277
+ : `no container with id "${operation.parent}"`;
278
+ }
279
+ if (findElement(definitions, operation.element.id)) {
280
+ return `id "${operation.element.id}" is already taken`;
281
+ }
282
+ const element = buildFlowElement(operation.element, [], []);
283
+ const anchor = operation.after ?? operation.before;
284
+ if (anchor !== undefined) {
285
+ const at = container.flowElements.findIndex((candidate) => candidate.id === anchor);
286
+ if (at === -1)
287
+ return `no element with id "${anchor}" to insert next to`;
288
+ container.flowElements.splice(operation.after !== undefined ? at + 1 : at, 0, element);
289
+ }
290
+ else {
291
+ container.flowElements.push(element);
292
+ }
293
+ return undefined;
294
+ }
295
+ case "add_flow": {
296
+ const container = resolveContainer(definitions, operation.parent);
297
+ if (!container) {
298
+ return operation.parent === undefined
299
+ ? "the document has no process to add a flow to"
300
+ : `no container with id "${operation.parent}"`;
301
+ }
302
+ if (!findElement(definitions, operation.from))
303
+ return `no element with id "${operation.from}"`;
304
+ if (!findElement(definitions, operation.to))
305
+ return `no element with id "${operation.to}"`;
306
+ const id = operation.id ?? nextFlowId(definitions);
307
+ if (findFlow(definitions, id))
308
+ return `flow id "${id}" is already taken`;
309
+ const flow = {
310
+ id,
311
+ sourceRef: operation.from,
312
+ targetRef: operation.to,
313
+ extensionElements: [],
314
+ unknownAttributes: {},
315
+ };
316
+ if (operation.name)
317
+ flow.name = operation.name;
318
+ if (operation.condition) {
319
+ flow.conditionExpression = { text: operation.condition, attributes: {} };
320
+ }
321
+ container.sequenceFlows.push(flow);
322
+ recomputeRefs(container);
323
+ return undefined;
324
+ }
325
+ case "delete_flow": {
326
+ const found = findFlow(definitions, operation.id);
327
+ if (!found)
328
+ return `no sequence flow with id "${operation.id}"`;
329
+ found.container.sequenceFlows = found.container.sequenceFlows.filter((flow) => flow.id !== operation.id);
330
+ recomputeRefs(found.container);
331
+ return undefined;
332
+ }
333
+ case "redirect_flow": {
334
+ const found = findFlow(definitions, operation.id);
335
+ if (!found)
336
+ return `no sequence flow with id "${operation.id}"`;
337
+ if (operation.from !== undefined) {
338
+ if (!findElement(definitions, operation.from)) {
339
+ return `no element with id "${operation.from}"`;
340
+ }
341
+ found.flow.sourceRef = operation.from;
342
+ }
343
+ if (operation.to !== undefined) {
344
+ if (!findElement(definitions, operation.to))
345
+ return `no element with id "${operation.to}"`;
346
+ found.flow.targetRef = operation.to;
347
+ }
348
+ recomputeRefs(found.container);
349
+ return undefined;
350
+ }
351
+ }
352
+ }
353
+ /** Rebuilds every element's `incoming`/`outgoing` from the container's flows. */
354
+ function recomputeRefs(container) {
355
+ for (const element of container.flowElements) {
356
+ element.incoming = [];
357
+ element.outgoing = [];
358
+ }
359
+ const byId = new Map(container.flowElements.map((element) => [element.id, element]));
360
+ for (const flow of container.sequenceFlows) {
361
+ byId.get(flow.sourceRef)?.outgoing.push(flow.id);
362
+ byId.get(flow.targetRef)?.incoming.push(flow.id);
363
+ }
364
+ }
365
+ /**
366
+ * Applies a {@link CompactDiagram} onto an existing model as a set of changes,
367
+ * rather than expanding it into a replacement.
368
+ *
369
+ * `expand(compact)` builds a whole new model from about fifteen properties per
370
+ * element, so using it to apply an edit destroys everything the compact form
371
+ * does not carry. This walks the same input as a description of the intended
372
+ * topology and turns it into {@link BpmnOperation}s: elements that already exist
373
+ * are patched in place and keep their extensions, new ones are inserted, and
374
+ * ones the input no longer mentions are removed.
375
+ *
376
+ * Processes are only ever added, never removed — a caller sending one process of
377
+ * a multi-process document means "this is how that process should look", not
378
+ * "delete the others".
379
+ *
380
+ * @param definitions - The model to update. Never mutated.
381
+ * @param compact - The intended topology.
382
+ * @param options - `strict` (default `true`) throws instead of returning problems.
383
+ * @returns The updated model, plus what applied and what did not.
384
+ */
385
+ export function reconcileCompact(definitions, compact, options = {}) {
386
+ const draft = structuredClone(definitions);
387
+ for (const compactProcess of compact.processes) {
388
+ if (!draft.processes.some((process) => process.id === compactProcess.id)) {
389
+ draft.processes.push({
390
+ id: compactProcess.id,
391
+ name: compactProcess.name,
392
+ extensionElements: [],
393
+ flowElements: [],
394
+ sequenceFlows: [],
395
+ textAnnotations: [],
396
+ associations: [],
397
+ groups: [],
398
+ unknownAttributes: {},
399
+ });
400
+ }
401
+ }
402
+ return applyBpmnOperations(draft, operationsForCompact(draft, compact), options);
403
+ }
404
+ /**
405
+ * Derives the operations that would bring `definitions` in line with `compact`.
406
+ *
407
+ * Order matters. Removing an element also removes the flows attached to it, so
408
+ * flows are settled first and any flow the removal would take with it is
409
+ * re-added afterwards rather than redirected onto something that no longer
410
+ * exists.
411
+ *
412
+ * A flow whose endpoints move is redirected, keeping whatever else it carries.
413
+ * One whose name or condition changes is replaced, which does not preserve
414
+ * extensions on the flow itself — rare enough to be worth the simpler rule.
415
+ */
416
+ function operationsForCompact(definitions, compact) {
417
+ const operations = [];
418
+ for (const compactProcess of compact.processes) {
419
+ const process = definitions.processes.find((candidate) => candidate.id === compactProcess.id);
420
+ if (!process)
421
+ continue;
422
+ const wanted = new Map(compactProcess.elements.map((element) => [element.id, element]));
423
+ const wantedFlows = new Map(compactProcess.flows.map((flow) => [flow.id, flow]));
424
+ const present = process.flowElements.map((element) => element.id);
425
+ const removedElements = present.filter((id) => !wanted.has(id));
426
+ const removed = new Set(removedElements);
427
+ const replacedFlows = new Set();
428
+ for (const flow of process.sequenceFlows) {
429
+ const want = wantedFlows.get(flow.id);
430
+ const attachedToRemoved = removed.has(flow.sourceRef) || removed.has(flow.targetRef);
431
+ if (want === undefined || attachedToRemoved) {
432
+ operations.push({ op: "delete_flow", id: flow.id });
433
+ if (want !== undefined)
434
+ replacedFlows.add(flow.id);
435
+ continue;
436
+ }
437
+ const sameLabel = (want.name ?? undefined) === (flow.name ?? undefined);
438
+ const sameCondition = (want.condition ?? undefined) === (flow.conditionExpression?.text ?? undefined);
439
+ if (!sameLabel || !sameCondition) {
440
+ operations.push({ op: "delete_flow", id: flow.id });
441
+ replacedFlows.add(flow.id);
442
+ continue;
443
+ }
444
+ if (want.from !== flow.sourceRef || want.to !== flow.targetRef) {
445
+ operations.push({ op: "redirect_flow", id: flow.id, from: want.from, to: want.to });
446
+ }
447
+ }
448
+ for (const id of removedElements)
449
+ operations.push({ op: "delete", id });
450
+ for (const [id, element] of wanted) {
451
+ if (removed.has(id))
452
+ continue;
453
+ if (present.includes(id)) {
454
+ const { id: _id, children: _children, ...patch } = element;
455
+ operations.push({ op: "update", id, patch });
456
+ }
457
+ else {
458
+ operations.push({ op: "insert", element, parent: process.id });
459
+ }
460
+ }
461
+ const existingFlowIds = new Set(process.sequenceFlows.map((flow) => flow.id));
462
+ for (const [id, flow] of wantedFlows) {
463
+ if (existingFlowIds.has(id) && !replacedFlows.has(id))
464
+ continue;
465
+ operations.push({
466
+ op: "add_flow",
467
+ id,
468
+ parent: process.id,
469
+ from: flow.from,
470
+ to: flow.to,
471
+ name: flow.name,
472
+ condition: flow.condition,
473
+ });
474
+ }
475
+ }
476
+ return operations;
477
+ }
478
+ //# sourceMappingURL=full-operations.js.map
@@ -41,6 +41,25 @@ export declare const Bpmn: {
41
41
  * ```
42
42
  */
43
43
  readonly createProcess: (processId: string) => ProcessBuilder;
44
+ /**
45
+ * Continue an existing model instead of generating a replacement for it.
46
+ *
47
+ * `build()` returns that document with the named process's contents replaced,
48
+ * so other processes, the collaboration, lanes, diagram interchange, root
49
+ * elements and unmodelled content all survive. The input is not mutated.
50
+ *
51
+ * @param definitions - The parsed model to continue.
52
+ * @param processId - Which process to continue, named explicitly.
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * const updated = Bpmn.continueProcess(Bpmn.parse(xml), "order-process")
57
+ * .at("validate")
58
+ * .serviceTask("notify", { name: "Notify", taskType: "notify" })
59
+ * .build()
60
+ * ```
61
+ */
62
+ readonly continueProcess: (definitions: BpmnDefinitions, processId: string) => ProcessBuilder;
44
63
  /**
45
64
  * Create a multi-process BPMN definitions document using the fluent builder API.
46
65
  *
@@ -86,6 +86,27 @@ export const Bpmn = {
86
86
  createProcess(processId) {
87
87
  return new ProcessBuilder(processId);
88
88
  },
89
+ /**
90
+ * Continue an existing model instead of generating a replacement for it.
91
+ *
92
+ * `build()` returns that document with the named process's contents replaced,
93
+ * so other processes, the collaboration, lanes, diagram interchange, root
94
+ * elements and unmodelled content all survive. The input is not mutated.
95
+ *
96
+ * @param definitions - The parsed model to continue.
97
+ * @param processId - Which process to continue, named explicitly.
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const updated = Bpmn.continueProcess(Bpmn.parse(xml), "order-process")
102
+ * .at("validate")
103
+ * .serviceTask("notify", { name: "Notify", taskType: "notify" })
104
+ * .build()
105
+ * ```
106
+ */
107
+ continueProcess(definitions, processId) {
108
+ return ProcessBuilder.from(definitions, processId);
109
+ },
89
110
  /**
90
111
  * Create a multi-process BPMN definitions document using the fluent builder API.
91
112
  *
@@ -58,7 +58,7 @@ function scoreFeelExpression(expr, opts) {
58
58
  // ---------------------------------------------------------------------------
59
59
  export function analyzeFeel(p, opts) {
60
60
  const findings = [];
61
- const { bySource } = buildFlowIndex(p);
61
+ const { byId, bySource } = buildFlowIndex(p);
62
62
  const processId = p.id;
63
63
  // Track FEEL expressions across all sequence flows for duplicate detection
64
64
  const exprToFlowIds = new Map();
@@ -66,7 +66,7 @@ export function analyzeFeel(p, opts) {
66
66
  // Determine if source is an exclusive/inclusive gateway with more than one
67
67
  // outgoing flow — a join (single outgoing flow) makes no decision, so its
68
68
  // one outgoing flow needs neither a condition nor a default marker.
69
- const srcEl = p.flowElements.find((e) => e.id === flow.sourceRef);
69
+ const srcEl = byId.get(flow.sourceRef);
70
70
  const srcIsDecisionGateway = srcEl !== undefined &&
71
71
  (srcEl.type === "exclusiveGateway" || srcEl.type === "inclusiveGateway") &&
72
72
  (bySource.get(srcEl.id)?.length ?? 0) > 1;
@@ -2,19 +2,25 @@ import { buildFlowIndex, readZeebeIoMapping, readZeebeTaskType } from "./utils.j
2
2
  // ---------------------------------------------------------------------------
3
3
  // Helpers
4
4
  // ---------------------------------------------------------------------------
5
- /** Returns true if the element has a boundary event of the given type attached. */
6
- function hasBoundaryOf(elementId, eventType, p) {
5
+ /** hostId event-definition types of the boundary events attached to it. */
6
+ function indexBoundaryTypes(p) {
7
+ const index = new Map();
7
8
  for (const el of p.flowElements) {
8
9
  if (el.type !== "boundaryEvent")
9
10
  continue;
10
- if (el.attachedToRef !== elementId)
11
- continue;
12
- for (const def of el.eventDefinitions) {
13
- if (def.type === eventType)
14
- return true;
11
+ let types = index.get(el.attachedToRef);
12
+ if (!types) {
13
+ types = new Set();
14
+ index.set(el.attachedToRef, types);
15
15
  }
16
+ for (const def of el.eventDefinitions)
17
+ types.add(def.type);
16
18
  }
17
- return false;
19
+ return index;
20
+ }
21
+ /** Returns true if the element has a boundary event of the given type attached. */
22
+ function hasBoundaryOf(elementId, eventType, boundaryTypes) {
23
+ return boundaryTypes.get(elementId)?.has(eventType) ?? false;
18
24
  }
19
25
  /** Returns true if the condition expression text appears to contain only literals (no variable names). */
20
26
  function isLiteralOnlyCondition(text) {
@@ -36,7 +42,8 @@ function isLiteralOnlyCondition(text) {
36
42
  export function analyzePatterns(p) {
37
43
  const findings = [];
38
44
  const processId = p.id;
39
- const { bySource, byTarget } = buildFlowIndex(p);
45
+ const { byId, bySource, byTarget } = buildFlowIndex(p);
46
+ const boundaryTypes = indexBoundaryTypes(p);
40
47
  // ── Rule 1: HTTP/REST service task without error boundary ───────────────
41
48
  for (const el of p.flowElements) {
42
49
  if (el.type !== "serviceTask")
@@ -47,7 +54,7 @@ export function analyzePatterns(p) {
47
54
  jobType === "io.camunda.connector.HttpJson:1";
48
55
  if (!isHttp)
49
56
  continue;
50
- if (!hasBoundaryOf(el.id, "error", p)) {
57
+ if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
51
58
  findings.push({
52
59
  id: "pattern/http-no-error-boundary",
53
60
  category: "pattern",
@@ -82,7 +89,7 @@ export function analyzePatterns(p) {
82
89
  for (const el of p.flowElements) {
83
90
  if (el.type !== "subProcess" && el.type !== "adHocSubProcess" && el.type !== "transaction")
84
91
  continue;
85
- if (!hasBoundaryOf(el.id, "error", p)) {
92
+ if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
86
93
  findings.push({
87
94
  id: "pattern/subprocess-no-error-boundary",
88
95
  category: "pattern",
@@ -98,7 +105,7 @@ export function analyzePatterns(p) {
98
105
  for (const el of p.flowElements) {
99
106
  if (el.type !== "callActivity")
100
107
  continue;
101
- if (!hasBoundaryOf(el.id, "error", p)) {
108
+ if (!hasBoundaryOf(el.id, "error", boundaryTypes)) {
102
109
  findings.push({
103
110
  id: "pattern/call-activity-no-error-boundary",
104
111
  category: "pattern",
@@ -121,7 +128,7 @@ export function analyzePatterns(p) {
121
128
  const branchTargets = [];
122
129
  for (const flow of outflows) {
123
130
  const targets = [];
124
- const branchEl = p.flowElements.find((e) => e.id === flow.targetRef);
131
+ const branchEl = byId.get(flow.targetRef);
125
132
  if (branchEl !== undefined) {
126
133
  const io = readZeebeIoMapping(branchEl.extensionElements);
127
134
  if (io !== null) {
@@ -158,7 +165,7 @@ export function analyzePatterns(p) {
158
165
  for (const el of p.flowElements) {
159
166
  if (el.type !== "userTask")
160
167
  continue;
161
- if (!hasBoundaryOf(el.id, "timer", p)) {
168
+ if (!hasBoundaryOf(el.id, "timer", boundaryTypes)) {
162
169
  findings.push({
163
170
  id: "pattern/user-task-no-timer",
164
171
  category: "pattern",
@@ -200,7 +207,7 @@ export function analyzePatterns(p) {
200
207
  continue;
201
208
  const outflows = bySource.get(el.id) ?? [];
202
209
  for (const flow of outflows) {
203
- const target = p.flowElements.find((e) => e.id === flow.targetRef);
210
+ const target = byId.get(flow.targetRef);
204
211
  if (target !== undefined && target.type === "endEvent") {
205
212
  findings.push({
206
213
  id: "pattern/catch-and-swallow",
@@ -348,7 +355,7 @@ export function analyzePatterns(p) {
348
355
  if (cond === undefined || cond === "")
349
356
  continue;
350
357
  if (isLiteralOnlyCondition(cond)) {
351
- const sourceEl = p.flowElements.find((e) => e.id === flow.sourceRef);
358
+ const sourceEl = byId.get(flow.sourceRef);
352
359
  findings.push({
353
360
  id: "pattern/literal-condition",
354
361
  category: "pattern",