@bpmnkit/core 0.0.14 → 0.0.16

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.
@@ -0,0 +1,365 @@
1
+ import { buildFlowIndex, readZeebeIoMapping, readZeebeTaskType } from "./utils.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Helpers
4
+ // ---------------------------------------------------------------------------
5
+ /** Returns true if the element has a boundary event of the given type attached. */
6
+ function hasBoundaryOf(elementId, eventType, p) {
7
+ for (const el of p.flowElements) {
8
+ if (el.type !== "boundaryEvent")
9
+ continue;
10
+ if (el.attachedToRef !== elementId)
11
+ continue;
12
+ for (const def of el.eventDefinitions) {
13
+ if (def.type === eventType)
14
+ return true;
15
+ }
16
+ }
17
+ return false;
18
+ }
19
+ /** Returns true if the condition expression text appears to contain only literals (no variable names). */
20
+ function isLiteralOnlyCondition(text) {
21
+ // Strip leading "=" (FEEL unary test prefix)
22
+ const expr = text.replace(/^\s*=\s*/, "").trim();
23
+ // Patterns that are clearly literals: numbers, quoted strings, true/false/null
24
+ const literalPattern = /^(?:"[^"]*"|'[^']*'|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)$/i;
25
+ if (literalPattern.test(expr))
26
+ return true;
27
+ // List of literals: [1, 2, 3] or ["a", "b"]
28
+ const listPattern = /^\[(?:\s*(?:"[^"]*"|-?\d+(?:\.\d+)?|true|false|null)\s*,?\s*)*\]$/i;
29
+ if (listPattern.test(expr))
30
+ return true;
31
+ return false;
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Pattern rules
35
+ // ---------------------------------------------------------------------------
36
+ export function analyzePatterns(p) {
37
+ const findings = [];
38
+ const processId = p.id;
39
+ const { bySource, byTarget } = buildFlowIndex(p);
40
+ // ── Rule 1: HTTP/REST service task without error boundary ───────────────
41
+ for (const el of p.flowElements) {
42
+ if (el.type !== "serviceTask")
43
+ continue;
44
+ const jobType = readZeebeTaskType(el.extensionElements) ?? "";
45
+ const isHttp = jobType.toLowerCase().includes("http") ||
46
+ jobType.toLowerCase().includes("rest") ||
47
+ jobType === "io.camunda.connector.HttpJson:1";
48
+ if (!isHttp)
49
+ continue;
50
+ if (!hasBoundaryOf(el.id, "error", p)) {
51
+ findings.push({
52
+ id: "pattern/http-no-error-boundary",
53
+ category: "pattern",
54
+ severity: "error",
55
+ message: `Service task "${el.name ?? el.id}" calls an HTTP connector but has no error boundary event.`,
56
+ suggestion: "Add an error boundary event to handle network failures (timeouts, non-2xx responses).",
57
+ processId,
58
+ elementIds: [el.id],
59
+ });
60
+ }
61
+ }
62
+ // ── Rule 2: Exclusive gateway without default flow ──────────────────────
63
+ for (const el of p.flowElements) {
64
+ if (el.type !== "exclusiveGateway")
65
+ continue;
66
+ const outflows = bySource.get(el.id) ?? [];
67
+ if (outflows.length <= 1)
68
+ continue; // covered by single-outgoing rule
69
+ if (el.default === undefined) {
70
+ findings.push({
71
+ id: "pattern/gateway-no-default-flow",
72
+ category: "pattern",
73
+ severity: "error",
74
+ message: `Exclusive gateway "${el.name ?? el.id}" has no default sequence flow.`,
75
+ suggestion: "Add a default flow to ensure the process does not get stuck when no condition matches.",
76
+ processId,
77
+ elementIds: [el.id],
78
+ });
79
+ }
80
+ }
81
+ // ── Rule 3: Sub-process without error boundary ──────────────────────────
82
+ for (const el of p.flowElements) {
83
+ if (el.type !== "subProcess" && el.type !== "adHocSubProcess" && el.type !== "transaction")
84
+ continue;
85
+ if (!hasBoundaryOf(el.id, "error", p)) {
86
+ findings.push({
87
+ id: "pattern/subprocess-no-error-boundary",
88
+ category: "pattern",
89
+ severity: "error",
90
+ message: `Sub-process "${el.name ?? el.id}" has no error boundary event.`,
91
+ suggestion: "Add an error boundary event to catch unhandled errors thrown inside the sub-process.",
92
+ processId,
93
+ elementIds: [el.id],
94
+ });
95
+ }
96
+ }
97
+ // ── Rule 4: Call activity with no error propagation ─────────────────────
98
+ for (const el of p.flowElements) {
99
+ if (el.type !== "callActivity")
100
+ continue;
101
+ if (!hasBoundaryOf(el.id, "error", p)) {
102
+ findings.push({
103
+ id: "pattern/call-activity-no-error-boundary",
104
+ category: "pattern",
105
+ severity: "error",
106
+ message: `Call activity "${el.name ?? el.id}" has no error boundary event.`,
107
+ suggestion: "Add an error boundary event to handle errors propagated from the called process.",
108
+ processId,
109
+ elementIds: [el.id],
110
+ });
111
+ }
112
+ }
113
+ // ── Rule 5: Parallel branches writing the same variable ─────────────────
114
+ for (const el of p.flowElements) {
115
+ if (el.type !== "parallelGateway")
116
+ continue;
117
+ const outflows = bySource.get(el.id) ?? [];
118
+ if (outflows.length < 2)
119
+ continue;
120
+ // Collect output variable targets per branch (BFS one level deep)
121
+ const branchTargets = [];
122
+ for (const flow of outflows) {
123
+ const targets = [];
124
+ const branchEl = p.flowElements.find((e) => e.id === flow.targetRef);
125
+ if (branchEl !== undefined) {
126
+ const io = readZeebeIoMapping(branchEl.extensionElements);
127
+ if (io !== null) {
128
+ for (const out of io.outputs) {
129
+ if (out.target.trim() !== "")
130
+ targets.push(out.target.trim());
131
+ }
132
+ }
133
+ }
134
+ branchTargets.push(targets);
135
+ }
136
+ // Find variables written by more than one branch
137
+ const seen = new Map(); // varName -> branch count
138
+ for (const targets of branchTargets) {
139
+ const unique = new Set(targets);
140
+ for (const t of unique) {
141
+ seen.set(t, (seen.get(t) ?? 0) + 1);
142
+ }
143
+ }
144
+ const conflicts = [...seen.entries()].filter(([, count]) => count > 1).map(([v]) => v);
145
+ if (conflicts.length > 0) {
146
+ findings.push({
147
+ id: "pattern/parallel-variable-conflict",
148
+ category: "pattern",
149
+ severity: "error",
150
+ message: `Parallel branches from gateway "${el.name ?? el.id}" both write to: ${conflicts.join(", ")}.`,
151
+ suggestion: "Last writer wins — result is non-deterministic. Use distinct variable names per branch.",
152
+ processId,
153
+ elementIds: [el.id],
154
+ });
155
+ }
156
+ }
157
+ // ── Rule 6: User task without timer boundary ────────────────────────────
158
+ for (const el of p.flowElements) {
159
+ if (el.type !== "userTask")
160
+ continue;
161
+ if (!hasBoundaryOf(el.id, "timer", p)) {
162
+ findings.push({
163
+ id: "pattern/user-task-no-timer",
164
+ category: "pattern",
165
+ severity: "warning",
166
+ message: `User task "${el.name ?? el.id}" has no timer boundary event.`,
167
+ suggestion: "Add a timer boundary to enforce an SLA and prevent tasks from waiting indefinitely.",
168
+ processId,
169
+ elementIds: [el.id],
170
+ });
171
+ }
172
+ }
173
+ // ── Rule 7: Service task output mapping with no result variable ──────────
174
+ for (const el of p.flowElements) {
175
+ if (el.type !== "serviceTask")
176
+ continue;
177
+ const jobType = readZeebeTaskType(el.extensionElements);
178
+ if (jobType === null)
179
+ continue; // not a worker task
180
+ const io = readZeebeIoMapping(el.extensionElements);
181
+ const hasOutputs = io !== null && io.outputs.length > 0;
182
+ if (!hasOutputs) {
183
+ findings.push({
184
+ id: "pattern/service-task-no-output",
185
+ category: "pattern",
186
+ severity: "warning",
187
+ message: `Service task "${el.name ?? el.id}" has no output variable mapping.`,
188
+ suggestion: "Map the job result to process variables so downstream tasks can consume it.",
189
+ processId,
190
+ elementIds: [el.id],
191
+ });
192
+ }
193
+ }
194
+ // ── Rule 8: Error boundary leading directly to end event (catch-and-swallow) ─
195
+ for (const el of p.flowElements) {
196
+ if (el.type !== "boundaryEvent")
197
+ continue;
198
+ const hasError = el.eventDefinitions.some((d) => d.type === "error");
199
+ if (!hasError)
200
+ continue;
201
+ const outflows = bySource.get(el.id) ?? [];
202
+ for (const flow of outflows) {
203
+ const target = p.flowElements.find((e) => e.id === flow.targetRef);
204
+ if (target !== undefined && target.type === "endEvent") {
205
+ findings.push({
206
+ id: "pattern/catch-and-swallow",
207
+ category: "pattern",
208
+ severity: "warning",
209
+ message: `Error boundary on "${el.attachedToRef}" leads directly to an end event — error is silently consumed.`,
210
+ suggestion: "Add error logging, compensation, or re-throw the error rather than swallowing it silently.",
211
+ processId,
212
+ elementIds: [el.id],
213
+ });
214
+ break;
215
+ }
216
+ }
217
+ }
218
+ // ── Rule 9: Exclusive gateway with only one outgoing flow ───────────────
219
+ for (const el of p.flowElements) {
220
+ if (el.type !== "exclusiveGateway")
221
+ continue;
222
+ const outflows = bySource.get(el.id) ?? [];
223
+ if (outflows.length === 1) {
224
+ findings.push({
225
+ id: "pattern/gateway-single-outgoing",
226
+ category: "pattern",
227
+ severity: "warning",
228
+ message: `Exclusive gateway "${el.name ?? el.id}" has only one outgoing flow and is a pass-through.`,
229
+ suggestion: "Remove this gateway and connect its source directly to its target.",
230
+ processId,
231
+ elementIds: [el.id],
232
+ });
233
+ }
234
+ }
235
+ // ── Rule 10: Undocumented process start variables ───────────────────────
236
+ for (const el of p.flowElements) {
237
+ if (el.type !== "startEvent")
238
+ continue;
239
+ if (el.eventDefinitions.length > 0)
240
+ continue; // message/timer start — skip
241
+ const inflows = byTarget.get(el.id) ?? [];
242
+ if (inflows.length > 0)
243
+ continue; // not a true start
244
+ const hasDoc = el.documentation !== undefined && el.documentation.trim() !== "";
245
+ if (!hasDoc) {
246
+ findings.push({
247
+ id: "pattern/start-no-documentation",
248
+ category: "pattern",
249
+ severity: "warning",
250
+ message: `Start event "${el.name ?? el.id}" has no documentation describing expected input variables.`,
251
+ suggestion: "Add documentation listing the process input variables so callers know the expected contract.",
252
+ processId,
253
+ elementIds: [el.id],
254
+ });
255
+ }
256
+ }
257
+ // ── Rule 11: Timer boundary with duration 0 ──────────────────────────────
258
+ for (const el of p.flowElements) {
259
+ if (el.type !== "boundaryEvent")
260
+ continue;
261
+ for (const def of el.eventDefinitions) {
262
+ if (def.type !== "timer")
263
+ continue;
264
+ const dur = def.timeDuration?.trim() ?? "";
265
+ const isZero = dur === "PT0S" ||
266
+ dur === "P0D" ||
267
+ dur === "PT0M" ||
268
+ dur === "PT0H" ||
269
+ dur === "0" ||
270
+ dur === "P0";
271
+ if (isZero) {
272
+ findings.push({
273
+ id: "pattern/timer-duration-zero",
274
+ category: "pattern",
275
+ severity: "error",
276
+ message: `Timer boundary on "${el.attachedToRef}" has a duration of zero — it will fire immediately.`,
277
+ suggestion: "Set a meaningful duration (e.g. PT1H for 1 hour) to avoid instant firing.",
278
+ processId,
279
+ elementIds: [el.id],
280
+ });
281
+ }
282
+ }
283
+ }
284
+ // ── Rule 12: Boundary event with no outgoing flow ────────────────────────
285
+ for (const el of p.flowElements) {
286
+ if (el.type !== "boundaryEvent")
287
+ continue;
288
+ const outflows = bySource.get(el.id) ?? [];
289
+ if (outflows.length === 0) {
290
+ findings.push({
291
+ id: "pattern/boundary-no-outgoing",
292
+ category: "pattern",
293
+ severity: "error",
294
+ message: `Boundary event on "${el.attachedToRef}" has no outgoing sequence flow.`,
295
+ suggestion: "Connect the boundary event to a handler task or end event.",
296
+ processId,
297
+ elementIds: [el.id],
298
+ });
299
+ }
300
+ }
301
+ // ── Rule 13: Empty text annotation ──────────────────────────────────────
302
+ for (const ann of p.textAnnotations) {
303
+ const text = ann.text?.trim() ?? "";
304
+ if (text === "") {
305
+ findings.push({
306
+ id: "pattern/empty-annotation",
307
+ category: "pattern",
308
+ severity: "info",
309
+ message: `Text annotation "${ann.id}" is empty.`,
310
+ suggestion: "Fill in the annotation or remove it to keep the diagram clean.",
311
+ processId,
312
+ elementIds: [ann.id],
313
+ });
314
+ }
315
+ }
316
+ // ── Rule 14: Duplicate job type across multiple service tasks ────────────
317
+ const jobTypeCounts = new Map(); // jobType -> [elementId]
318
+ for (const el of p.flowElements) {
319
+ if (el.type !== "serviceTask")
320
+ continue;
321
+ const jobType = readZeebeTaskType(el.extensionElements);
322
+ if (jobType === null || jobType.trim() === "")
323
+ continue;
324
+ const ids = jobTypeCounts.get(jobType) ?? [];
325
+ ids.push(el.id);
326
+ jobTypeCounts.set(jobType, ids);
327
+ }
328
+ for (const [jobType, ids] of jobTypeCounts) {
329
+ if (ids.length < 2)
330
+ continue;
331
+ findings.push({
332
+ id: "pattern/duplicate-job-type",
333
+ category: "pattern",
334
+ severity: "info",
335
+ message: `Job type "${jobType}" is used by ${ids.length} service tasks.`,
336
+ suggestion: "Verify this is intentional — the same worker will handle all these tasks. Consider distinct job types if behaviors differ.",
337
+ processId,
338
+ elementIds: ids,
339
+ });
340
+ }
341
+ // ── Rule 15: FEEL condition using only literal values ────────────────────
342
+ const checkedFlows = new Set();
343
+ for (const flow of p.sequenceFlows) {
344
+ if (checkedFlows.has(flow.id))
345
+ continue;
346
+ checkedFlows.add(flow.id);
347
+ const cond = flow.conditionExpression?.text?.trim();
348
+ if (cond === undefined || cond === "")
349
+ continue;
350
+ if (isLiteralOnlyCondition(cond)) {
351
+ const sourceEl = p.flowElements.find((e) => e.id === flow.sourceRef);
352
+ findings.push({
353
+ id: "pattern/literal-condition",
354
+ category: "pattern",
355
+ severity: "info",
356
+ message: `Sequence flow "${flow.name ?? flow.id}" has a condition that only references literal values: \`${cond}\``,
357
+ suggestion: "A literal condition never changes at runtime. Replace with a variable reference or remove the condition.",
358
+ processId,
359
+ elementIds: [flow.id, ...(sourceEl !== undefined ? [sourceEl.id] : [])],
360
+ });
361
+ }
362
+ }
363
+ return findings;
364
+ }
365
+ //# sourceMappingURL=patterns.js.map
@@ -1,6 +1,6 @@
1
1
  import type { BpmnDefinitions } from "../bpmn-model.js";
2
2
  export type OptimizationSeverity = "info" | "warning" | "error";
3
- export type OptimizationCategory = "feel" | "flow" | "naming" | "task-reuse" | "extract";
3
+ export type OptimizationCategory = "feel" | "flow" | "naming" | "task-reuse" | "extract" | "pattern" | "data-flow";
4
4
  export interface ApplyFixResult {
5
5
  description: string;
6
6
  /** New BpmnDefinitions generated by the fix (e.g. extracted reusable sub-process). */
@@ -16,6 +16,10 @@ export interface OptimizationFinding {
16
16
  elementIds: string[];
17
17
  /** Mutates `defs` in-place. Returns what changed + any generated file. */
18
18
  applyFix?: (defs: BpmnDefinitions) => ApplyFixResult;
19
+ /** Data-flow: variable names produced (written) by the associated element. */
20
+ produces?: string[];
21
+ /** Data-flow: variable names consumed (read) by the associated element. */
22
+ consumes?: string[];
19
23
  }
20
24
  export interface OptimizationReport {
21
25
  findings: OptimizationFinding[];
@@ -0,0 +1,6 @@
1
+ import type { BpmnProcess } from "../bpmn-model.js";
2
+ import type { OptimizationFinding } from "./types.js";
3
+ /** Extract variable names referenced in a FEEL expression string. */
4
+ export declare function extractFeelIdentifiers(expression: string): string[];
5
+ export declare function analyzeVariableFlow(p: BpmnProcess): OptimizationFinding[];
6
+ //# sourceMappingURL=variable-flow.d.ts.map