@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.
- package/README.md +2 -0
- package/dist/bpmn/compact.d.ts +5 -0
- package/dist/bpmn/compact.js +45 -6
- package/dist/bpmn/operations.d.ts +82 -0
- package/dist/bpmn/operations.js +152 -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 +5 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
|
@@ -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
|