@bpmnkit/core 0.0.14 → 0.0.15
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.
- package/README.md +2 -0
- package/dist/bpmn/optimize/index.js +26 -5
- package/dist/bpmn/optimize/patterns.d.ts +4 -0
- package/dist/bpmn/optimize/patterns.js +365 -0
- package/dist/bpmn/optimize/types.d.ts +5 -1
- package/dist/bpmn/optimize/variable-flow.d.ts +6 -0
- package/dist/bpmn/optimize/variable-flow.js +431 -0
- package/dist/bpmn/story.d.ts +10 -0
- package/dist/bpmn/story.js +336 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -186,6 +186,8 @@ const outXml = Bpmn.export(restored)
|
|
|
186
186
|
| [`@bpmnkit/cli-sdk`](https://www.npmjs.com/package/@bpmnkit/cli-sdk) | Plugin authoring SDK for the casen CLI |
|
|
187
187
|
| [`@bpmnkit/create-casen-plugin`](https://www.npmjs.com/package/@bpmnkit/create-casen-plugin) | Scaffold a new casen CLI plugin in seconds |
|
|
188
188
|
| [`@bpmnkit/casen-report`](https://www.npmjs.com/package/@bpmnkit/casen-report) | HTML reports from Camunda 8 incident and SLA data |
|
|
189
|
+
| [`@bpmnkit/casen-worker-http`](https://www.npmjs.com/package/@bpmnkit/casen-worker-http) | Example HTTP worker plugin — completes jobs with live JSONPlaceholder API data |
|
|
190
|
+
| [`@bpmnkit/casen-worker-ai`](https://www.npmjs.com/package/@bpmnkit/casen-worker-ai) | AI task worker — classify, summarize, extract, and decide using Claude |
|
|
189
191
|
|
|
190
192
|
## License
|
|
191
193
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { analyzeFeel } from "./feel.js";
|
|
2
2
|
import { analyzeFlow } from "./flow.js";
|
|
3
3
|
import { analyzeNaming } from "./naming.js";
|
|
4
|
+
import { analyzePatterns } from "./patterns.js";
|
|
4
5
|
import { analyzeTasks } from "./tasks.js";
|
|
5
|
-
|
|
6
|
+
import { analyzeVariableFlow } from "./variable-flow.js";
|
|
7
|
+
const ALL_CATEGORIES = [
|
|
8
|
+
"feel",
|
|
9
|
+
"flow",
|
|
10
|
+
"naming",
|
|
11
|
+
"task-reuse",
|
|
12
|
+
"extract",
|
|
13
|
+
"pattern",
|
|
14
|
+
"data-flow",
|
|
15
|
+
];
|
|
6
16
|
function resolveOptions(opts) {
|
|
7
17
|
return {
|
|
8
18
|
feelLengthThreshold: opts?.feelLengthThreshold ?? 80,
|
|
@@ -30,11 +40,22 @@ export function optimize(defs, options) {
|
|
|
30
40
|
if (resolved.categories.includes("task-reuse")) {
|
|
31
41
|
findings.push(...analyzeTasks(process, resolved));
|
|
32
42
|
}
|
|
43
|
+
if (resolved.categories.includes("pattern")) {
|
|
44
|
+
findings.push(...analyzePatterns(process));
|
|
45
|
+
}
|
|
46
|
+
if (resolved.categories.includes("data-flow")) {
|
|
47
|
+
findings.push(...analyzeVariableFlow(process));
|
|
48
|
+
}
|
|
33
49
|
}
|
|
34
|
-
const byCategory = Object.fromEntries([
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
const byCategory = Object.fromEntries([
|
|
51
|
+
"feel",
|
|
52
|
+
"flow",
|
|
53
|
+
"naming",
|
|
54
|
+
"task-reuse",
|
|
55
|
+
"extract",
|
|
56
|
+
"pattern",
|
|
57
|
+
"data-flow",
|
|
58
|
+
].map((c) => [c, findings.filter((f) => f.category === c).length]));
|
|
38
59
|
const bySeverity = Object.fromEntries(["info", "warning", "error"].map((s) => [
|
|
39
60
|
s,
|
|
40
61
|
findings.filter((f) => f.severity === s).length,
|
|
@@ -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
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { parseExpression } from "@bpmnkit/feel";
|
|
2
|
+
import { buildFlowIndex, readZeebeIoMapping, readZeebeTaskType } from "./utils.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// FEEL built-in names (excluded from variable references)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const FEEL_BUILTINS = new Set([
|
|
7
|
+
"string",
|
|
8
|
+
"string length",
|
|
9
|
+
"substring",
|
|
10
|
+
"substring before",
|
|
11
|
+
"substring after",
|
|
12
|
+
"upper case",
|
|
13
|
+
"lower case",
|
|
14
|
+
"contains",
|
|
15
|
+
"starts with",
|
|
16
|
+
"ends with",
|
|
17
|
+
"matches",
|
|
18
|
+
"replace",
|
|
19
|
+
"split",
|
|
20
|
+
"string join",
|
|
21
|
+
"number",
|
|
22
|
+
"decimal",
|
|
23
|
+
"floor",
|
|
24
|
+
"ceiling",
|
|
25
|
+
"round half up",
|
|
26
|
+
"round half down",
|
|
27
|
+
"round up",
|
|
28
|
+
"round down",
|
|
29
|
+
"abs",
|
|
30
|
+
"modulo",
|
|
31
|
+
"sqrt",
|
|
32
|
+
"log",
|
|
33
|
+
"exp",
|
|
34
|
+
"odd",
|
|
35
|
+
"even",
|
|
36
|
+
"random number",
|
|
37
|
+
"count",
|
|
38
|
+
"list contains",
|
|
39
|
+
"append",
|
|
40
|
+
"concatenate",
|
|
41
|
+
"insert before",
|
|
42
|
+
"remove",
|
|
43
|
+
"reverse",
|
|
44
|
+
"index of",
|
|
45
|
+
"union",
|
|
46
|
+
"distinct values",
|
|
47
|
+
"duplicate values",
|
|
48
|
+
"flatten",
|
|
49
|
+
"product",
|
|
50
|
+
"sum",
|
|
51
|
+
"mean",
|
|
52
|
+
"all",
|
|
53
|
+
"any",
|
|
54
|
+
"sublist",
|
|
55
|
+
"min",
|
|
56
|
+
"max",
|
|
57
|
+
"median",
|
|
58
|
+
"mode",
|
|
59
|
+
"sort",
|
|
60
|
+
"string join",
|
|
61
|
+
"date",
|
|
62
|
+
"time",
|
|
63
|
+
"date and time",
|
|
64
|
+
"duration",
|
|
65
|
+
"years and months duration",
|
|
66
|
+
"now",
|
|
67
|
+
"today",
|
|
68
|
+
"day of week",
|
|
69
|
+
"day of year",
|
|
70
|
+
"week of year",
|
|
71
|
+
"month of year",
|
|
72
|
+
"last day of month",
|
|
73
|
+
"is",
|
|
74
|
+
"is defined",
|
|
75
|
+
"not",
|
|
76
|
+
"true",
|
|
77
|
+
"false",
|
|
78
|
+
"null",
|
|
79
|
+
"and",
|
|
80
|
+
"or",
|
|
81
|
+
"instance of",
|
|
82
|
+
"get value",
|
|
83
|
+
"get entries",
|
|
84
|
+
"put",
|
|
85
|
+
"put all",
|
|
86
|
+
"context",
|
|
87
|
+
"context merge",
|
|
88
|
+
"context put",
|
|
89
|
+
"context get entries",
|
|
90
|
+
"context get value",
|
|
91
|
+
]);
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// FEEL AST identifier extractor
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
function collectNames(node, out) {
|
|
96
|
+
switch (node.kind) {
|
|
97
|
+
case "name":
|
|
98
|
+
if (!FEEL_BUILTINS.has(node.name))
|
|
99
|
+
out.add(node.name);
|
|
100
|
+
break;
|
|
101
|
+
case "path":
|
|
102
|
+
// a.b.c — only the root (base) is a variable reference
|
|
103
|
+
collectNames(node.base, out);
|
|
104
|
+
break;
|
|
105
|
+
case "binary":
|
|
106
|
+
collectNames(node.left, out);
|
|
107
|
+
collectNames(node.right, out);
|
|
108
|
+
break;
|
|
109
|
+
case "unary-minus":
|
|
110
|
+
collectNames(node.operand, out);
|
|
111
|
+
break;
|
|
112
|
+
case "list":
|
|
113
|
+
for (const item of node.items)
|
|
114
|
+
collectNames(item, out);
|
|
115
|
+
break;
|
|
116
|
+
case "context":
|
|
117
|
+
for (const entry of node.entries)
|
|
118
|
+
collectNames(entry.value, out);
|
|
119
|
+
break;
|
|
120
|
+
case "range":
|
|
121
|
+
collectNames(node.low, out);
|
|
122
|
+
collectNames(node.high, out);
|
|
123
|
+
break;
|
|
124
|
+
case "filter":
|
|
125
|
+
collectNames(node.base, out);
|
|
126
|
+
collectNames(node.condition, out);
|
|
127
|
+
break;
|
|
128
|
+
case "call":
|
|
129
|
+
for (const arg of node.args)
|
|
130
|
+
collectNames(arg, out);
|
|
131
|
+
break;
|
|
132
|
+
case "call-named":
|
|
133
|
+
for (const arg of node.args)
|
|
134
|
+
collectNames(arg.value, out);
|
|
135
|
+
break;
|
|
136
|
+
case "if":
|
|
137
|
+
collectNames(node.condition, out);
|
|
138
|
+
collectNames(node.then, out);
|
|
139
|
+
collectNames(node.else, out);
|
|
140
|
+
break;
|
|
141
|
+
case "for":
|
|
142
|
+
for (const b of node.bindings)
|
|
143
|
+
collectNames(b.domain, out);
|
|
144
|
+
collectNames(node.body, out);
|
|
145
|
+
break;
|
|
146
|
+
case "some":
|
|
147
|
+
case "every":
|
|
148
|
+
for (const b of node.bindings)
|
|
149
|
+
collectNames(b.domain, out);
|
|
150
|
+
collectNames(node.satisfies, out);
|
|
151
|
+
break;
|
|
152
|
+
case "between":
|
|
153
|
+
collectNames(node.value, out);
|
|
154
|
+
collectNames(node.low, out);
|
|
155
|
+
collectNames(node.high, out);
|
|
156
|
+
break;
|
|
157
|
+
case "in-test":
|
|
158
|
+
collectNames(node.value, out);
|
|
159
|
+
collectNames(node.test, out);
|
|
160
|
+
break;
|
|
161
|
+
case "function-def":
|
|
162
|
+
collectNames(node.body, out);
|
|
163
|
+
break;
|
|
164
|
+
case "unary-test-list":
|
|
165
|
+
for (const t of node.tests)
|
|
166
|
+
collectNames(t, out);
|
|
167
|
+
break;
|
|
168
|
+
case "unary-not":
|
|
169
|
+
for (const t of node.tests)
|
|
170
|
+
collectNames(t, out);
|
|
171
|
+
break;
|
|
172
|
+
// Leaf nodes: number, string, boolean, null, temporal, any-input, instance-of
|
|
173
|
+
default:
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Extract variable names referenced in a FEEL expression string. */
|
|
178
|
+
export function extractFeelIdentifiers(expression) {
|
|
179
|
+
const trimmed = expression.trim();
|
|
180
|
+
if (trimmed === "")
|
|
181
|
+
return [];
|
|
182
|
+
// Strip leading "=" unary-test prefix if present
|
|
183
|
+
const expr = trimmed.startsWith("=") ? trimmed.slice(1).trim() : trimmed;
|
|
184
|
+
const result = parseExpression(expr);
|
|
185
|
+
if (result.ast === null)
|
|
186
|
+
return [];
|
|
187
|
+
const names = new Set();
|
|
188
|
+
collectNames(result.ast, names);
|
|
189
|
+
return [...names];
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Levenshtein distance (for typo suggestions)
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
function levenshtein(a, b) {
|
|
195
|
+
const m = a.length;
|
|
196
|
+
const n = b.length;
|
|
197
|
+
// Flat row buffers — avoids noUncheckedIndexedAccess issues with 2D arrays
|
|
198
|
+
let prev = new Int32Array(n + 1);
|
|
199
|
+
let curr = new Int32Array(n + 1);
|
|
200
|
+
for (let j = 0; j <= n; j++)
|
|
201
|
+
prev[j] = j;
|
|
202
|
+
for (let i = 1; i <= m; i++) {
|
|
203
|
+
curr[0] = i;
|
|
204
|
+
for (let j = 1; j <= n; j++) {
|
|
205
|
+
curr[j] =
|
|
206
|
+
a[i - 1] === b[j - 1]
|
|
207
|
+
? prev[j - 1]
|
|
208
|
+
: 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
|
|
209
|
+
}
|
|
210
|
+
;
|
|
211
|
+
[prev, curr] = [curr, prev];
|
|
212
|
+
}
|
|
213
|
+
return prev[n];
|
|
214
|
+
}
|
|
215
|
+
function findClosest(name, candidates) {
|
|
216
|
+
let best = null;
|
|
217
|
+
let bestDist = 3; // only suggest if distance ≤ 2
|
|
218
|
+
for (const c of candidates) {
|
|
219
|
+
if (c === name)
|
|
220
|
+
continue;
|
|
221
|
+
const d = levenshtein(name, c);
|
|
222
|
+
if (d < bestDist) {
|
|
223
|
+
bestDist = d;
|
|
224
|
+
best = c;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return best;
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Extension readers for result variables
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
function readResultVariable(ext) {
|
|
233
|
+
for (const el of ext) {
|
|
234
|
+
if (el.name === "zeebe:calledDecision" && el.attributes.resultVariable) {
|
|
235
|
+
return el.attributes.resultVariable;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Main analysis
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
export function analyzeVariableFlow(p) {
|
|
244
|
+
const findings = [];
|
|
245
|
+
const processId = p.id;
|
|
246
|
+
// Maps: variable name → list of element IDs that produce / consume it
|
|
247
|
+
const producedBy = new Map();
|
|
248
|
+
const consumedBy = new Map();
|
|
249
|
+
// Per-element role tracking
|
|
250
|
+
const elementProduces = new Map();
|
|
251
|
+
const elementConsumes = new Map();
|
|
252
|
+
function addProducer(varName, elementId) {
|
|
253
|
+
if (varName.trim() === "")
|
|
254
|
+
return;
|
|
255
|
+
const existing = producedBy.get(varName) ?? [];
|
|
256
|
+
if (!existing.includes(elementId))
|
|
257
|
+
existing.push(elementId);
|
|
258
|
+
producedBy.set(varName, existing);
|
|
259
|
+
const elList = elementProduces.get(elementId) ?? [];
|
|
260
|
+
if (!elList.includes(varName))
|
|
261
|
+
elList.push(varName);
|
|
262
|
+
elementProduces.set(elementId, elList);
|
|
263
|
+
}
|
|
264
|
+
function addConsumer(varName, elementId) {
|
|
265
|
+
if (varName.trim() === "")
|
|
266
|
+
return;
|
|
267
|
+
const existing = consumedBy.get(varName) ?? [];
|
|
268
|
+
if (!existing.includes(elementId))
|
|
269
|
+
existing.push(elementId);
|
|
270
|
+
consumedBy.set(varName, existing);
|
|
271
|
+
const elList = elementConsumes.get(elementId) ?? [];
|
|
272
|
+
if (!elList.includes(varName))
|
|
273
|
+
elList.push(varName);
|
|
274
|
+
elementConsumes.set(elementId, elList);
|
|
275
|
+
}
|
|
276
|
+
// ── Scan flow elements ───────────────────────────────────────────────────
|
|
277
|
+
for (const el of p.flowElements) {
|
|
278
|
+
const io = readZeebeIoMapping(el.extensionElements);
|
|
279
|
+
if (io !== null) {
|
|
280
|
+
// IO mapping inputs: the *target* variable is what gets written into this task's local scope
|
|
281
|
+
// IO mapping outputs: the *target* variable is what gets written back into the process scope
|
|
282
|
+
for (const inp of io.inputs) {
|
|
283
|
+
if (inp.target.trim() !== "")
|
|
284
|
+
addProducer(inp.target.trim(), el.id);
|
|
285
|
+
// The source expression may consume variables from the process scope
|
|
286
|
+
for (const name of extractFeelIdentifiers(inp.source)) {
|
|
287
|
+
addConsumer(name, el.id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const out of io.outputs) {
|
|
291
|
+
if (out.target.trim() !== "")
|
|
292
|
+
addProducer(out.target.trim(), el.id);
|
|
293
|
+
// The source expression may consume local variables
|
|
294
|
+
for (const name of extractFeelIdentifiers(out.source)) {
|
|
295
|
+
addConsumer(name, el.id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Result variable (business rule tasks via zeebe:calledDecision)
|
|
300
|
+
const resultVar = readResultVariable(el.extensionElements);
|
|
301
|
+
if (resultVar !== null) {
|
|
302
|
+
addProducer(resultVar, el.id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ── Scan sequence flow conditions ────────────────────────────────────────
|
|
306
|
+
for (const flow of p.sequenceFlows) {
|
|
307
|
+
const cond = flow.conditionExpression?.text?.trim();
|
|
308
|
+
if (cond === undefined || cond === "")
|
|
309
|
+
continue;
|
|
310
|
+
for (const name of extractFeelIdentifiers(cond)) {
|
|
311
|
+
// Conditions on flows consume variables from the process scope
|
|
312
|
+
// Associate with the source element (gateway)
|
|
313
|
+
addConsumer(name, flow.sourceRef);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// ── Compute findings ─────────────────────────────────────────────────────
|
|
317
|
+
const allProducedNames = [...producedBy.keys()];
|
|
318
|
+
// Finding: variable consumed but never produced anywhere
|
|
319
|
+
const checkedConsumed = new Set();
|
|
320
|
+
for (const [varName, elementIds] of consumedBy) {
|
|
321
|
+
if (checkedConsumed.has(varName))
|
|
322
|
+
continue;
|
|
323
|
+
checkedConsumed.add(varName);
|
|
324
|
+
if (producedBy.has(varName))
|
|
325
|
+
continue;
|
|
326
|
+
const closest = findClosest(varName, allProducedNames);
|
|
327
|
+
const suggestion = closest !== null
|
|
328
|
+
? `"${varName}" is never set. Did you mean "${closest}"?`
|
|
329
|
+
: `"${varName}" is never set on any path through this process.`;
|
|
330
|
+
findings.push({
|
|
331
|
+
id: `data-flow/undefined-variable:${varName}`,
|
|
332
|
+
category: "data-flow",
|
|
333
|
+
severity: "warning",
|
|
334
|
+
message: `Variable "${varName}" is referenced but never set in this process.`,
|
|
335
|
+
suggestion,
|
|
336
|
+
processId,
|
|
337
|
+
elementIds,
|
|
338
|
+
consumes: [varName],
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// Finding: variable produced but never consumed anywhere
|
|
342
|
+
const checkedProduced = new Set();
|
|
343
|
+
for (const [varName, elementIds] of producedBy) {
|
|
344
|
+
if (checkedProduced.has(varName))
|
|
345
|
+
continue;
|
|
346
|
+
checkedProduced.add(varName);
|
|
347
|
+
if (consumedBy.has(varName))
|
|
348
|
+
continue;
|
|
349
|
+
findings.push({
|
|
350
|
+
id: `data-flow/dead-output:${varName}`,
|
|
351
|
+
category: "data-flow",
|
|
352
|
+
severity: "info",
|
|
353
|
+
message: `Variable "${varName}" is set but never read by any downstream element.`,
|
|
354
|
+
suggestion: `Remove the output mapping for "${varName}" or add a task that consumes it.`,
|
|
355
|
+
processId,
|
|
356
|
+
elementIds,
|
|
357
|
+
produces: [varName],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// ── Attach per-element role findings (for the overlay plugin) ────────────
|
|
361
|
+
const roleElements = new Set([...elementProduces.keys(), ...elementConsumes.keys()]);
|
|
362
|
+
for (const elementId of roleElements) {
|
|
363
|
+
const produces = elementProduces.get(elementId) ?? [];
|
|
364
|
+
const consumes = elementConsumes.get(elementId) ?? [];
|
|
365
|
+
if (produces.length === 0 && consumes.length === 0)
|
|
366
|
+
continue;
|
|
367
|
+
findings.push({
|
|
368
|
+
id: `data-flow/role:${elementId}`,
|
|
369
|
+
category: "data-flow",
|
|
370
|
+
severity: "info",
|
|
371
|
+
message: `Element "${elementId}" ${produces.length > 0 ? `produces: ${produces.join(", ")}` : ""}${produces.length > 0 && consumes.length > 0 ? "; " : ""}${consumes.length > 0 ? `consumes: ${consumes.join(", ")}` : ""}.`,
|
|
372
|
+
suggestion: "",
|
|
373
|
+
processId,
|
|
374
|
+
elementIds: [elementId],
|
|
375
|
+
produces: produces.length > 0 ? produces : undefined,
|
|
376
|
+
consumes: consumes.length > 0 ? consumes : undefined,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// ── Per-edge scope findings (variables available at each sequence flow) ──
|
|
380
|
+
// Build reverse adjacency: targetId → set of source IDs
|
|
381
|
+
const reverseAdj = new Map();
|
|
382
|
+
for (const flow of p.sequenceFlows) {
|
|
383
|
+
const set = reverseAdj.get(flow.targetRef) ?? new Set();
|
|
384
|
+
set.add(flow.sourceRef);
|
|
385
|
+
reverseAdj.set(flow.targetRef, set);
|
|
386
|
+
}
|
|
387
|
+
// Collect all transitive predecessors of an element (inclusive of start)
|
|
388
|
+
function allPredecessors(elementId) {
|
|
389
|
+
const visited = new Set();
|
|
390
|
+
const queue = [elementId];
|
|
391
|
+
while (queue.length > 0) {
|
|
392
|
+
const current = queue.shift();
|
|
393
|
+
if (current === undefined)
|
|
394
|
+
break;
|
|
395
|
+
const preds = reverseAdj.get(current);
|
|
396
|
+
if (preds === undefined)
|
|
397
|
+
continue;
|
|
398
|
+
for (const pred of preds) {
|
|
399
|
+
if (!visited.has(pred)) {
|
|
400
|
+
visited.add(pred);
|
|
401
|
+
queue.push(pred);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return visited;
|
|
406
|
+
}
|
|
407
|
+
for (const flow of p.sequenceFlows) {
|
|
408
|
+
const predIds = allPredecessors(flow.sourceRef);
|
|
409
|
+
predIds.add(flow.sourceRef);
|
|
410
|
+
const inScope = new Set();
|
|
411
|
+
for (const predId of predIds) {
|
|
412
|
+
for (const v of elementProduces.get(predId) ?? [])
|
|
413
|
+
inScope.add(v);
|
|
414
|
+
}
|
|
415
|
+
if (inScope.size === 0)
|
|
416
|
+
continue;
|
|
417
|
+
const scopeVars = [...inScope].sort();
|
|
418
|
+
findings.push({
|
|
419
|
+
id: `data-flow/edge-scope:${flow.id}`,
|
|
420
|
+
category: "data-flow",
|
|
421
|
+
severity: "info",
|
|
422
|
+
message: `Variables in scope at flow "${flow.id}": ${scopeVars.join(", ")}.`,
|
|
423
|
+
suggestion: "",
|
|
424
|
+
processId,
|
|
425
|
+
elementIds: [flow.id],
|
|
426
|
+
produces: scopeVars,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return findings;
|
|
430
|
+
}
|
|
431
|
+
//# sourceMappingURL=variable-flow.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BpmnDefinitions } from "./bpmn-model.js";
|
|
2
|
+
export interface StoryRenderOptions {
|
|
3
|
+
/** If true, wrap in a complete HTML document with embedded CSS. Default false (returns fragment). */
|
|
4
|
+
standalone?: boolean;
|
|
5
|
+
/** Color theme. Default "light". */
|
|
6
|
+
theme?: "dark" | "light";
|
|
7
|
+
}
|
|
8
|
+
/** Render a BPMN process as a story-mode HTML string (no DOM required). */
|
|
9
|
+
export declare function renderStoryHtml(defs: BpmnDefinitions, options?: StoryRenderOptions): string;
|
|
10
|
+
//# sourceMappingURL=story.d.ts.map
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
2
|
+
function escapeHtml(s) {
|
|
3
|
+
return s
|
|
4
|
+
.replace(/&/g, "&")
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, """)
|
|
8
|
+
.replace(/'/g, "'");
|
|
9
|
+
}
|
|
10
|
+
function getCardInfo(el, laneName) {
|
|
11
|
+
switch (el.type) {
|
|
12
|
+
case "startEvent":
|
|
13
|
+
return { role: "start", header: "Process starts" };
|
|
14
|
+
case "endEvent":
|
|
15
|
+
return { role: "end", header: "Process ends" };
|
|
16
|
+
case "serviceTask":
|
|
17
|
+
return { role: "service", header: "System" };
|
|
18
|
+
case "userTask":
|
|
19
|
+
return { role: "user", header: laneName ?? "User" };
|
|
20
|
+
case "businessRuleTask":
|
|
21
|
+
return { role: "service", header: "Decision table" };
|
|
22
|
+
case "scriptTask":
|
|
23
|
+
return { role: "service", header: "Script" };
|
|
24
|
+
case "exclusiveGateway":
|
|
25
|
+
case "inclusiveGateway":
|
|
26
|
+
return { role: "gateway", header: "Decision" };
|
|
27
|
+
case "parallelGateway":
|
|
28
|
+
return { role: "parallel", header: "Parallel" };
|
|
29
|
+
case "callActivity":
|
|
30
|
+
return { role: "subprocess", header: "Sub-process" };
|
|
31
|
+
case "subProcess":
|
|
32
|
+
case "eventSubProcess":
|
|
33
|
+
case "transaction":
|
|
34
|
+
return { role: "subprocess", header: "Sub-process" };
|
|
35
|
+
case "intermediateCatchEvent":
|
|
36
|
+
case "intermediateThrowEvent":
|
|
37
|
+
case "boundaryEvent":
|
|
38
|
+
return { role: "event", header: "Event" };
|
|
39
|
+
default: {
|
|
40
|
+
const t = el.type;
|
|
41
|
+
const header = t.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
42
|
+
return { role: "task", header };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ── Topological sort (Kahn's algorithm) ─────────────────────────────────────
|
|
47
|
+
function topoSort(elements, flows) {
|
|
48
|
+
const idToEl = new Map();
|
|
49
|
+
for (const el of elements)
|
|
50
|
+
idToEl.set(el.id, el);
|
|
51
|
+
// in-degree and adjacency
|
|
52
|
+
const inDegree = new Map();
|
|
53
|
+
const successors = new Map();
|
|
54
|
+
for (const el of elements) {
|
|
55
|
+
inDegree.set(el.id, 0);
|
|
56
|
+
successors.set(el.id, []);
|
|
57
|
+
}
|
|
58
|
+
for (const flow of flows) {
|
|
59
|
+
if (!idToEl.has(flow.sourceRef) || !idToEl.has(flow.targetRef))
|
|
60
|
+
continue;
|
|
61
|
+
successors.get(flow.sourceRef)?.push(flow.targetRef);
|
|
62
|
+
inDegree.set(flow.targetRef, (inDegree.get(flow.targetRef) ?? 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
const queue = [];
|
|
65
|
+
for (const [id, deg] of inDegree) {
|
|
66
|
+
if (deg === 0)
|
|
67
|
+
queue.push(id);
|
|
68
|
+
}
|
|
69
|
+
const result = [];
|
|
70
|
+
const visited = new Set();
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const id = queue.shift();
|
|
73
|
+
if (id === undefined)
|
|
74
|
+
break;
|
|
75
|
+
if (visited.has(id))
|
|
76
|
+
continue;
|
|
77
|
+
visited.add(id);
|
|
78
|
+
const el = idToEl.get(id);
|
|
79
|
+
if (el)
|
|
80
|
+
result.push(el);
|
|
81
|
+
for (const next of successors.get(id) ?? []) {
|
|
82
|
+
const deg = (inDegree.get(next) ?? 1) - 1;
|
|
83
|
+
inDegree.set(next, deg);
|
|
84
|
+
if (deg === 0)
|
|
85
|
+
queue.push(next);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Handle cycles: append any unvisited elements in original order
|
|
89
|
+
for (const el of elements) {
|
|
90
|
+
if (!visited.has(el.id))
|
|
91
|
+
result.push(el);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
// ── Lane mapping ─────────────────────────────────────────────────────────────
|
|
96
|
+
function buildLaneMap(process) {
|
|
97
|
+
const map = new Map();
|
|
98
|
+
if (!process.laneSet)
|
|
99
|
+
return map;
|
|
100
|
+
for (const lane of process.laneSet.lanes) {
|
|
101
|
+
const name = lane.name ?? lane.id;
|
|
102
|
+
for (const ref of lane.flowNodeRefs) {
|
|
103
|
+
map.set(ref, name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
// ── Outgoing conditions ───────────────────────────────────────────────────────
|
|
109
|
+
function getOutgoingConditions(el, flows) {
|
|
110
|
+
const outgoing = new Set(el.outgoing);
|
|
111
|
+
const result = [];
|
|
112
|
+
for (const flow of flows) {
|
|
113
|
+
if (!outgoing.has(flow.id))
|
|
114
|
+
continue;
|
|
115
|
+
if (flow.conditionExpression) {
|
|
116
|
+
result.push({
|
|
117
|
+
label: flow.name ?? flow.targetRef,
|
|
118
|
+
condition: flow.conditionExpression.text,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
// ── Card HTML ─────────────────────────────────────────────────────────────────
|
|
125
|
+
function renderCard(el, flows, laneName) {
|
|
126
|
+
const { role, header } = getCardInfo(el, laneName);
|
|
127
|
+
const name = "name" in el ? (el.name ?? "") : "";
|
|
128
|
+
const conditions = role === "gateway" ? getOutgoingConditions(el, flows) : [];
|
|
129
|
+
let conditionsHtml = "";
|
|
130
|
+
if (conditions.length > 0) {
|
|
131
|
+
const items = conditions
|
|
132
|
+
.map((c) => `<div class="bks-condition"><span class="bks-condition-label">${escapeHtml(c.label)}</span><span class="bks-condition-expr">${escapeHtml(c.condition)}</span></div>`)
|
|
133
|
+
.join("");
|
|
134
|
+
conditionsHtml = `<div class="bks-conditions">${items}</div>`;
|
|
135
|
+
}
|
|
136
|
+
return `<div class="bks-card bks-card--${role}" data-bpmnkit-id="${escapeHtml(el.id)}"><div class="bks-card-header">${escapeHtml(header)}</div><div class="bks-card-body">${escapeHtml(name)}</div>${conditionsHtml}</div>`;
|
|
137
|
+
}
|
|
138
|
+
// ── Lane HTML ─────────────────────────────────────────────────────────────────
|
|
139
|
+
function renderLane(laneName, elements, flows, laneMap) {
|
|
140
|
+
if (elements.length === 0)
|
|
141
|
+
return "";
|
|
142
|
+
const cards = elements
|
|
143
|
+
.map((el, i) => {
|
|
144
|
+
const card = renderCard(el, flows, laneName === "_default" ? undefined : laneName);
|
|
145
|
+
const arrow = i < elements.length - 1 ? '<div class="bks-arrow">→</div>' : "";
|
|
146
|
+
return card + arrow;
|
|
147
|
+
})
|
|
148
|
+
.join("");
|
|
149
|
+
const laneHeader = laneName !== "_default" ? `<div class="bks-lane-header">${escapeHtml(laneName)}</div>` : "";
|
|
150
|
+
return `<div class="bks-lane">${laneHeader}<div class="bks-lane-cards">${cards}</div></div>`;
|
|
151
|
+
}
|
|
152
|
+
// ── Standalone CSS ────────────────────────────────────────────────────────────
|
|
153
|
+
function buildStandaloneCss(theme) {
|
|
154
|
+
const isDark = theme === "dark";
|
|
155
|
+
const vars = isDark
|
|
156
|
+
? `
|
|
157
|
+
--bks-bg: #0d0d16;
|
|
158
|
+
--bks-surface: #161626;
|
|
159
|
+
--bks-border: #2a2a42;
|
|
160
|
+
--bks-fg: #cdd6f4;
|
|
161
|
+
--bks-fg-muted: #8888a8;
|
|
162
|
+
--bks-accent: #6b9df7;
|
|
163
|
+
--bks-success: #22c55e;
|
|
164
|
+
--bks-danger: #f87171;
|
|
165
|
+
--bks-warn: #f59e0b;
|
|
166
|
+
--bks-teal: #2dd4bf;
|
|
167
|
+
--bks-purple: #a78bfa;`
|
|
168
|
+
: `
|
|
169
|
+
--bks-bg: #f4f4f8;
|
|
170
|
+
--bks-surface: #ffffff;
|
|
171
|
+
--bks-border: #d0d0e8;
|
|
172
|
+
--bks-fg: #1a1a2e;
|
|
173
|
+
--bks-fg-muted: #6666a0;
|
|
174
|
+
--bks-accent: #1a56db;
|
|
175
|
+
--bks-success: #16a34a;
|
|
176
|
+
--bks-danger: #dc2626;
|
|
177
|
+
--bks-warn: #d97706;
|
|
178
|
+
--bks-teal: #0d9488;
|
|
179
|
+
--bks-purple: #7c3aed;`;
|
|
180
|
+
return `
|
|
181
|
+
:root {${vars}
|
|
182
|
+
}
|
|
183
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
184
|
+
body {
|
|
185
|
+
background: var(--bks-bg);
|
|
186
|
+
color: var(--bks-fg);
|
|
187
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
188
|
+
font-size: 14px;
|
|
189
|
+
line-height: 1.5;
|
|
190
|
+
padding: 24px;
|
|
191
|
+
}
|
|
192
|
+
.bks-process-title {
|
|
193
|
+
font-size: 20px;
|
|
194
|
+
font-weight: 700;
|
|
195
|
+
margin-bottom: 20px;
|
|
196
|
+
color: var(--bks-fg);
|
|
197
|
+
}
|
|
198
|
+
.bks-lane {
|
|
199
|
+
margin-bottom: 16px;
|
|
200
|
+
}
|
|
201
|
+
.bks-lane-header {
|
|
202
|
+
font-size: 11px;
|
|
203
|
+
font-weight: 700;
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
letter-spacing: 0.06em;
|
|
206
|
+
color: var(--bks-fg-muted);
|
|
207
|
+
padding: 4px 0 8px;
|
|
208
|
+
border-bottom: 1px solid var(--bks-border);
|
|
209
|
+
margin-bottom: 10px;
|
|
210
|
+
}
|
|
211
|
+
.bks-lane-cards {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-wrap: wrap;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: 4px;
|
|
216
|
+
}
|
|
217
|
+
.bks-card {
|
|
218
|
+
background: var(--bks-surface);
|
|
219
|
+
border: 1px solid var(--bks-border);
|
|
220
|
+
border-radius: 8px;
|
|
221
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
222
|
+
padding: 10px 14px;
|
|
223
|
+
min-width: 120px;
|
|
224
|
+
max-width: 200px;
|
|
225
|
+
}
|
|
226
|
+
.bks-card-header {
|
|
227
|
+
font-size: 10px;
|
|
228
|
+
font-weight: 700;
|
|
229
|
+
text-transform: uppercase;
|
|
230
|
+
letter-spacing: 0.05em;
|
|
231
|
+
color: var(--bks-fg-muted);
|
|
232
|
+
margin-bottom: 4px;
|
|
233
|
+
}
|
|
234
|
+
.bks-card-body {
|
|
235
|
+
font-size: 13px;
|
|
236
|
+
font-weight: 500;
|
|
237
|
+
color: var(--bks-fg);
|
|
238
|
+
word-break: break-word;
|
|
239
|
+
}
|
|
240
|
+
.bks-card--start { border-left: 3px solid var(--bks-success); }
|
|
241
|
+
.bks-card--end { border-left: 3px solid var(--bks-fg-muted); }
|
|
242
|
+
.bks-card--service { border-left: 3px solid var(--bks-accent); }
|
|
243
|
+
.bks-card--user { border-left: 3px solid var(--bks-teal); }
|
|
244
|
+
.bks-card--gateway { border-left: 3px solid var(--bks-warn); }
|
|
245
|
+
.bks-card--parallel { border-left: 3px solid var(--bks-fg-muted); }
|
|
246
|
+
.bks-card--subprocess { border-left: 3px solid var(--bks-purple); }
|
|
247
|
+
.bks-card--event { border-left: 3px solid var(--bks-accent); }
|
|
248
|
+
.bks-card--task { border-left: 3px solid var(--bks-border); }
|
|
249
|
+
.bks-conditions {
|
|
250
|
+
margin-top: 6px;
|
|
251
|
+
display: flex;
|
|
252
|
+
flex-direction: column;
|
|
253
|
+
gap: 3px;
|
|
254
|
+
}
|
|
255
|
+
.bks-condition {
|
|
256
|
+
font-size: 11px;
|
|
257
|
+
display: flex;
|
|
258
|
+
gap: 4px;
|
|
259
|
+
flex-wrap: wrap;
|
|
260
|
+
}
|
|
261
|
+
.bks-condition-label {
|
|
262
|
+
font-weight: 600;
|
|
263
|
+
color: var(--bks-fg);
|
|
264
|
+
}
|
|
265
|
+
.bks-condition-expr {
|
|
266
|
+
color: var(--bks-fg-muted);
|
|
267
|
+
font-family: ui-monospace, monospace;
|
|
268
|
+
}
|
|
269
|
+
.bks-arrow {
|
|
270
|
+
color: var(--bks-fg-muted);
|
|
271
|
+
font-size: 18px;
|
|
272
|
+
padding: 0 2px;
|
|
273
|
+
flex-shrink: 0;
|
|
274
|
+
}
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
// ── Main renderer ─────────────────────────────────────────────────────────────
|
|
278
|
+
/** Render a BPMN process as a story-mode HTML string (no DOM required). */
|
|
279
|
+
export function renderStoryHtml(defs, options) {
|
|
280
|
+
const standalone = options?.standalone ?? false;
|
|
281
|
+
const theme = options?.theme ?? "light";
|
|
282
|
+
const process = defs.processes[0];
|
|
283
|
+
if (!process)
|
|
284
|
+
return standalone ? wrapDocument("", "", theme) : "";
|
|
285
|
+
const laneMap = buildLaneMap(process);
|
|
286
|
+
const sorted = topoSort(process.flowElements, process.sequenceFlows);
|
|
287
|
+
// Group by lane
|
|
288
|
+
const laneNames = new Set();
|
|
289
|
+
const laneElements = new Map();
|
|
290
|
+
if (process.laneSet && process.laneSet.lanes.length > 0) {
|
|
291
|
+
// Collect unique lane names in alpha order
|
|
292
|
+
const sortedLaneNames = process.laneSet.lanes
|
|
293
|
+
.map((l) => l.name ?? l.id)
|
|
294
|
+
.sort((a, b) => a.localeCompare(b));
|
|
295
|
+
for (const n of sortedLaneNames) {
|
|
296
|
+
laneNames.add(n);
|
|
297
|
+
laneElements.set(n, []);
|
|
298
|
+
}
|
|
299
|
+
// Place each element in its lane
|
|
300
|
+
for (const el of sorted) {
|
|
301
|
+
const lane = laneMap.get(el.id) ?? sortedLaneNames[0];
|
|
302
|
+
if (lane !== undefined) {
|
|
303
|
+
laneElements.get(lane)?.push(el);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
// No lane set — single default lane
|
|
309
|
+
laneNames.add("_default");
|
|
310
|
+
laneElements.set("_default", sorted);
|
|
311
|
+
}
|
|
312
|
+
let body = "";
|
|
313
|
+
for (const laneName of laneNames) {
|
|
314
|
+
const els = laneElements.get(laneName) ?? [];
|
|
315
|
+
body += renderLane(laneName, els, process.sequenceFlows, laneMap);
|
|
316
|
+
}
|
|
317
|
+
const processTitle = process.name ?? process.id;
|
|
318
|
+
const fragment = `<div class="bks-process-title">${escapeHtml(processTitle)}</div>${body}`;
|
|
319
|
+
if (standalone) {
|
|
320
|
+
return wrapDocument(fragment, buildStandaloneCss(theme), theme);
|
|
321
|
+
}
|
|
322
|
+
return fragment;
|
|
323
|
+
}
|
|
324
|
+
function wrapDocument(body, css, theme) {
|
|
325
|
+
return `<!DOCTYPE html>
|
|
326
|
+
<html lang="en" data-theme="${theme}">
|
|
327
|
+
<head>
|
|
328
|
+
<meta charset="UTF-8">
|
|
329
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
330
|
+
<title>BPMN Story View</title>
|
|
331
|
+
<style>${css}</style>
|
|
332
|
+
</head>
|
|
333
|
+
<body>${body}</body>
|
|
334
|
+
</html>`;
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=story.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export { parseXml, serializeXml } from "./xml/index.js";
|
|
|
24
24
|
export { readDiColor, writeDiColor, BIOC_NS, COLOR_NS } from "./bpmn/di-color.js";
|
|
25
25
|
export type { DiColor } from "./bpmn/di-color.js";
|
|
26
26
|
export { optimize } from "./bpmn/optimize/index.js";
|
|
27
|
+
export { renderStoryHtml } from "./bpmn/story.js";
|
|
28
|
+
export type { StoryRenderOptions } from "./bpmn/story.js";
|
|
29
|
+
export { analyzeVariableFlow, extractFeelIdentifiers } from "./bpmn/optimize/variable-flow.js";
|
|
27
30
|
export type { OptimizationReport, OptimizationFinding, OptimizationSeverity, OptimizationCategory, ApplyFixResult, OptimizeOptions, } from "./bpmn/optimize/types.js";
|
|
28
31
|
export { layoutProcess, layoutFlowNodes } from "./layout/index.js";
|
|
29
32
|
export { benchmarkLayout, compareLayouts, formatBenchmarkResult, generateAutoLayout, parseReferenceLayout, } from "./layout/index.js";
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ export { generateId, resetIdCounter } from "./types/id-generator.js";
|
|
|
11
11
|
export { parseXml, serializeXml } from "./xml/index.js";
|
|
12
12
|
export { readDiColor, writeDiColor, BIOC_NS, COLOR_NS } from "./bpmn/di-color.js";
|
|
13
13
|
export { optimize } from "./bpmn/optimize/index.js";
|
|
14
|
+
export { renderStoryHtml } from "./bpmn/story.js";
|
|
15
|
+
export { analyzeVariableFlow, extractFeelIdentifiers } from "./bpmn/optimize/variable-flow.js";
|
|
14
16
|
export { layoutProcess, layoutFlowNodes } from "./layout/index.js";
|
|
15
17
|
export { benchmarkLayout, compareLayouts, formatBenchmarkResult, generateAutoLayout, parseReferenceLayout, } from "./layout/index.js";
|
|
16
18
|
export { ELEMENT_SIZES, GRID_CELL_HEIGHT } from "./layout/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bpmnkit/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"dist/**/*.js",
|
|
17
17
|
"dist/**/*.d.ts"
|
|
18
18
|
],
|
|
19
|
-
"dependencies": {
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@bpmnkit/feel": "0.0.13"
|
|
21
|
+
},
|
|
20
22
|
"description": "TypeScript-first BPMN 2.0 SDK — parse, build, layout, and optimize diagrams",
|
|
21
23
|
"keywords": [
|
|
22
24
|
"bpmn",
|