camunda-cli 0.3.1 → 0.4.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.
- package/README.md +20 -12
- package/bin/camunda.js +1 -1
- package/package.json +1 -1
- package/src/lint.js +166 -54
package/README.md
CHANGED
|
@@ -72,21 +72,29 @@ camunda lint ./order-process.bpmn # no engine and no login needed
|
|
|
72
72
|
camunda lint order-process # or the deployed version
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Every rule exists because that failure was reproduced against a real engine first
|
|
75
|
+
Every rule exists because that failure was reproduced against a real engine first.
|
|
76
|
+
|
|
77
|
+
**Errors** are reserved for defects that are provable from the model alone, because `deploy`
|
|
78
|
+
refuses to push a model that has one:
|
|
76
79
|
|
|
77
80
|
| Rule | What it catches |
|
|
78
81
|
|---|---|
|
|
79
|
-
| `uncovered-value` | `> N` and `< N` branches
|
|
80
|
-
| `
|
|
81
|
-
| `variable-name-mismatch` | A
|
|
82
|
-
| `
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
| `uncovered-value` | `> N` and `< N` branches leaving `== N` with nowhere to go (`ENGINE-02004`) |
|
|
83
|
+
| `default-flow-with-condition` | A default flow that also carries a condition, which the engine rejects outright (`ENGINE-09005`) |
|
|
84
|
+
| `variable-name-mismatch` | A form writing one variable nothing reads, feeding a condition reading one nothing writes |
|
|
85
|
+
| `dangling-flow`, `dangling-boundary`, `no-start-event` | References to elements that do not exist |
|
|
86
|
+
|
|
87
|
+
**Warnings** are risks that need a human to judge, since the model cannot prove them either
|
|
88
|
+
way: `no-default-flow`, `unwritten-variable`, `initiator-expression`, `no-op-service-task`,
|
|
89
|
+
`addon-without-config`, `unreachable`, `dead-end`, `ambiguous-branch`.
|
|
90
|
+
|
|
91
|
+
Run over 190 production models, the checks raised zero errors and did not block a single
|
|
92
|
+
deploy, while still flagging both defects in a model built to contain them. That balance is
|
|
93
|
+
deliberate: a static check an agent cannot trust is worse than none, because acting on a
|
|
94
|
+
confident wrong answer breaks a process that was working.
|
|
95
|
+
|
|
96
|
+
`deploy` runs the same checks and refuses to push a model with an error, unless you pass
|
|
97
|
+
`--skip-lint`.
|
|
90
98
|
|
|
91
99
|
**Work out why an instance is stuck.** `diagnose` gathers what is scattered across several
|
|
92
100
|
endpoints and unpacks it:
|
package/bin/camunda.js
CHANGED
|
@@ -24,7 +24,7 @@ program
|
|
|
24
24
|
'Start with "camunda inspect <key>" to read a deployed model, and\n' +
|
|
25
25
|
'"camunda diagnose <instanceId>" when an instance misbehaves.'
|
|
26
26
|
)
|
|
27
|
-
.version('0.
|
|
27
|
+
.version('0.4.0')
|
|
28
28
|
.option('--json', 'print the raw API payload instead of a formatted view')
|
|
29
29
|
.option('--no-color', 'never emit colour, even on a terminal')
|
|
30
30
|
.showHelpAfterError()
|
package/package.json
CHANGED
package/src/lint.js
CHANGED
|
@@ -24,22 +24,104 @@ function levenshtein(a, b) {
|
|
|
24
24
|
return d[m][n];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Deliberately narrow. An earlier version also treated a shared prefix as evidence, which
|
|
28
|
+
// made every model using a naming convention look broken: in a process where a form writes
|
|
29
|
+
// gr_number, gr_date and gr_amount, reading an unrelated gr_recorded was reported as a typo
|
|
30
|
+
// of gr_number. A near-identical spelling is the only similarity worth mentioning, and even
|
|
31
|
+
// that is offered as a candidate rather than a conclusion.
|
|
27
32
|
function similarNames(target, candidates) {
|
|
28
|
-
const prefix = target.includes('_') ? target.slice(0, target.indexOf('_') + 1) : null;
|
|
29
33
|
return candidates.filter((c) => {
|
|
30
34
|
if (c === target) return false;
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
const a = c.toLowerCase().replace(/[_-]/g, '');
|
|
36
|
+
const b = target.toLowerCase().replace(/[_-]/g, '');
|
|
37
|
+
if (a === b) return true;
|
|
38
|
+
if (Math.min(a.length, b.length) < 4) return false;
|
|
39
|
+
return levenshtein(a, b) <= 2;
|
|
33
40
|
});
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
//
|
|
43
|
+
// Evidence that a form and the expression reading it were never lined up, independent of
|
|
44
|
+
// how the two names are spelled: an element whose form writes exactly one variable that
|
|
45
|
+
// nothing anywhere reads, immediately followed by a flow reading a variable nothing writes.
|
|
46
|
+
// Both halves are dead on their own, and they sit either side of the same element.
|
|
47
|
+
// Field types whose name is a mount point rather than a variable. An embedded micro
|
|
48
|
+
// frontend writes whatever it likes and the model has no way to declare that, so a form
|
|
49
|
+
// containing one tells you nothing about which variables the element sets. Reasoning about
|
|
50
|
+
// unwritten variables around these produced a confident, wrong error on a process that had
|
|
51
|
+
// been running correctly for months.
|
|
52
|
+
const OPAQUE_FIELD_TYPES = new Set([
|
|
53
|
+
'EXTERNAL_MICRO_FRONTEND_FORM',
|
|
54
|
+
'HTML_CUSTOM_FORM',
|
|
55
|
+
'VARIABLE_RENDERER',
|
|
56
|
+
'EXPRESSION_INPUT',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
function findStrandedFormField(model, node, byId, outgoing, readEverywhere, written) {
|
|
60
|
+
if (node.formFields.some((f) => OPAQUE_FIELD_TYPES.has(f.type))) return null;
|
|
61
|
+
const writable = node.formFields.filter((f) => !f.disabled && f.name);
|
|
62
|
+
if (writable.length !== 1) return null;
|
|
63
|
+
const field = writable[0].name;
|
|
64
|
+
if (readEverywhere.has(field)) return null;
|
|
65
|
+
|
|
66
|
+
// The conditions that act on a user task's input usually sit on the flows out of the
|
|
67
|
+
// gateway after it rather than on the task's own flow, so pass through gateways. Nothing
|
|
68
|
+
// else is followed: once a real activity intervenes, the value could have come from there.
|
|
69
|
+
const seen = new Set([node.id]);
|
|
70
|
+
const frontier = [node.id];
|
|
71
|
+
for (let depth = 0; depth < 3 && frontier.length; depth++) {
|
|
72
|
+
const next = [];
|
|
73
|
+
for (const id of frontier) {
|
|
74
|
+
for (const flow of outgoing.get(id) ?? []) {
|
|
75
|
+
if (flow.condition) {
|
|
76
|
+
const { direct } = readVariables(flow.condition);
|
|
77
|
+
const orphan = direct.find((v) => !written.has(v) && v !== 'initiator');
|
|
78
|
+
if (orphan) return { field, orphan, flow };
|
|
79
|
+
}
|
|
80
|
+
const target = byId.get(flow.target);
|
|
81
|
+
if (target && GATEWAYS.has(target.type) && !seen.has(target.id)) {
|
|
82
|
+
seen.add(target.id);
|
|
83
|
+
next.push(target.id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
frontier.length = 0;
|
|
88
|
+
frontier.push(...next);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reads `${x > 300}` and `${x == 'draft'}` style comparisons so a gateway's branches can be
|
|
94
|
+
// checked for gaps. Values stay as written: a numeric gap is only meaningful between numbers.
|
|
37
95
|
function parseComparison(expression) {
|
|
38
96
|
const m = String(expression || '').match(
|
|
39
|
-
|
|
97
|
+
/^\s*\$\{\s*([A-Za-z_$][\w$]*)\s*(==|!=|>=|<=|>|<)\s*('[^']*'|"[^"]*"|-?\d+(?:\.\d+)?|true|false)\s*\}\s*$/
|
|
40
98
|
);
|
|
41
99
|
if (!m) return null;
|
|
42
|
-
|
|
100
|
+
const raw = m[3];
|
|
101
|
+
const numeric = /^-?\d/.test(raw);
|
|
102
|
+
return {
|
|
103
|
+
variable: m[1],
|
|
104
|
+
op: m[2],
|
|
105
|
+
value: numeric ? Number(raw) : raw.replace(/^['"]|['"]$/g, ''),
|
|
106
|
+
numeric,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Two branches that between them cover every value need no default flow. Recognising the
|
|
111
|
+
// common complementary pairs keeps the check off models that are already exhaustive, which
|
|
112
|
+
// would otherwise be a third of them.
|
|
113
|
+
const COMPLEMENTARY = [
|
|
114
|
+
['==', '!='],
|
|
115
|
+
['>', '<='],
|
|
116
|
+
['<', '>='],
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function coversEverything(comparisons) {
|
|
120
|
+
if (comparisons.length !== 2) return false;
|
|
121
|
+
const [a, b] = comparisons;
|
|
122
|
+
if (a.variable !== b.variable) return false;
|
|
123
|
+
if (a.numeric !== b.numeric || String(a.value) !== String(b.value)) return false;
|
|
124
|
+
return COMPLEMENTARY.some(([x, y]) => (a.op === x && b.op === y) || (a.op === y && b.op === x));
|
|
43
125
|
}
|
|
44
126
|
|
|
45
127
|
export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
@@ -71,6 +153,17 @@ export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
|
71
153
|
const starts = model.nodes.filter((n) => START_TYPES.has(n.type) && !n.scope);
|
|
72
154
|
if (starts.length === 0) add('error', 'no-start-event', model.id, 'The process has no start event, so nothing can ever begin it.');
|
|
73
155
|
|
|
156
|
+
// A boundary event has no incoming sequence flow: it fires because the activity it is
|
|
157
|
+
// attached to is running. So whatever hangs off a boundary event is reachable exactly
|
|
158
|
+
// when its host activity is, and walking sequence flows alone would report every error
|
|
159
|
+
// and timeout handler in the model as unreachable.
|
|
160
|
+
const boundaryByHost = new Map();
|
|
161
|
+
for (const n of model.nodes) {
|
|
162
|
+
if (!n.attachedTo) continue;
|
|
163
|
+
if (!boundaryByHost.has(n.attachedTo)) boundaryByHost.set(n.attachedTo, []);
|
|
164
|
+
boundaryByHost.get(n.attachedTo).push(n.id);
|
|
165
|
+
}
|
|
166
|
+
|
|
74
167
|
const reachable = new Set();
|
|
75
168
|
const queue = starts.map((s) => s.id);
|
|
76
169
|
while (queue.length) {
|
|
@@ -78,6 +171,7 @@ export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
|
78
171
|
if (reachable.has(id)) continue;
|
|
79
172
|
reachable.add(id);
|
|
80
173
|
for (const f of outgoing.get(id) || []) queue.push(f.target);
|
|
174
|
+
for (const b of boundaryByHost.get(id) || []) queue.push(b);
|
|
81
175
|
}
|
|
82
176
|
for (const n of model.nodes) {
|
|
83
177
|
if (n.scope || n.attachedTo || START_TYPES.has(n.type)) continue;
|
|
@@ -102,36 +196,36 @@ export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
|
102
196
|
const unconditional = outs.filter((f) => !f.condition && f.id !== n.defaultFlow);
|
|
103
197
|
|
|
104
198
|
if (conditional.length === outs.length && !n.defaultFlow) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
'no-default-flow',
|
|
108
|
-
n.id,
|
|
109
|
-
`Every outgoing flow of "${n.name || n.id}" has a condition and there is no default flow. ` +
|
|
110
|
-
`If they all evaluate false at runtime the engine raises ENGINE-02004 and the instance stops. ` +
|
|
111
|
-
`Mark one flow as the default and remove its condition: a default flow carrying a condition is ` +
|
|
112
|
-
`rejected at deploy time with ENGINE-09005.`
|
|
113
|
-
);
|
|
199
|
+
const comparisons = conditional.map((f) => parseComparison(f.condition)).filter(Boolean);
|
|
200
|
+
const exhaustive = comparisons.length === conditional.length && coversEverything(comparisons);
|
|
114
201
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
202
|
+
// A provable gap: `> N` and `< N` between them never match `== N`. This one is
|
|
203
|
+
// certain, so it is an error even though the general case below is not.
|
|
204
|
+
const gt = comparisons.find((c) => c.op === '>' && c.numeric);
|
|
205
|
+
const lt = comparisons.find((c) => c.op === '<' && c.numeric);
|
|
206
|
+
const sameVar = gt && lt && gt.variable === lt.variable && gt.value === lt.value;
|
|
207
|
+
|
|
208
|
+
if (sameVar && comparisons.length === conditional.length) {
|
|
209
|
+
add(
|
|
210
|
+
'error',
|
|
211
|
+
'uncovered-value',
|
|
212
|
+
n.id,
|
|
213
|
+
`"${n.name || n.id}" branches on ${gt.variable} > ${gt.value} and ${lt.variable} < ${lt.value}, ` +
|
|
214
|
+
`so ${gt.variable} == ${gt.value} matches neither branch. The engine raises ENGINE-02004 and the ` +
|
|
215
|
+
`instance stops there.`
|
|
216
|
+
);
|
|
217
|
+
} else if (!exhaustive) {
|
|
218
|
+
// Not provably broken: the conditions may well cover every case in a way that
|
|
219
|
+
// cannot be read off the expressions. Worth flagging, not worth blocking a deploy.
|
|
220
|
+
add(
|
|
221
|
+
'warning',
|
|
222
|
+
'no-default-flow',
|
|
223
|
+
n.id,
|
|
224
|
+
`Every outgoing flow of "${n.name || n.id}" has a condition and there is no default flow. If a case ever ` +
|
|
225
|
+
`arises where they all evaluate false, the engine raises ENGINE-02004 and the instance stops. Marking ` +
|
|
226
|
+
`one flow as the default removes that risk; a default flow must not carry a condition itself, or the ` +
|
|
227
|
+
`model is rejected at deploy time with ENGINE-09005.`
|
|
228
|
+
);
|
|
135
229
|
}
|
|
136
230
|
}
|
|
137
231
|
|
|
@@ -174,7 +268,33 @@ export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
|
174
268
|
const knownNames = new Set([...written.keys(), ...engineVariables]);
|
|
175
269
|
const formFieldNames = [...written.keys()];
|
|
176
270
|
|
|
177
|
-
|
|
271
|
+
const hasOpaqueForms = model.nodes.some((n) => n.formFields.some((f) => OPAQUE_FIELD_TYPES.has(f.type)));
|
|
272
|
+
const expressions = collectExpressions(model);
|
|
273
|
+
const readEverywhere = new Set();
|
|
274
|
+
for (const e of expressions) {
|
|
275
|
+
const { direct, safe } = readVariables(e.expression);
|
|
276
|
+
for (const v of [...direct, ...safe]) readEverywhere.add(v);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Structural mismatch first: a form field nothing reads sitting directly upstream of a
|
|
280
|
+
// condition reading something nothing writes. This holds whatever the two are called,
|
|
281
|
+
// so it is the only case reported as an error.
|
|
282
|
+
for (const n of model.nodes) {
|
|
283
|
+
if (n.formFields.length === 0) continue;
|
|
284
|
+
const stranded = findStrandedFormField(model, n, byId, outgoing, readEverywhere, written);
|
|
285
|
+
if (!stranded) continue;
|
|
286
|
+
add(
|
|
287
|
+
'error',
|
|
288
|
+
'variable-name-mismatch',
|
|
289
|
+
n.id,
|
|
290
|
+
`The form on "${n.name || n.id}" writes only "${stranded.field}", which nothing in this process reads, ` +
|
|
291
|
+
`while the flow leaving it ("${stranded.flow.name || stranded.flow.id}") reads "${stranded.orphan}", which ` +
|
|
292
|
+
`nothing writes. Completing this element therefore cannot satisfy the condition, and evaluating it throws ` +
|
|
293
|
+
`"Cannot resolve identifier '${stranded.orphan}'". One of the two names needs to change.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const { where, expression, kind } of expressions) {
|
|
178
298
|
const { direct } = readVariables(expression);
|
|
179
299
|
for (const v of direct) {
|
|
180
300
|
if (knownNames.has(v)) continue;
|
|
@@ -192,24 +312,16 @@ export function lintProcess(model, { engineVariables = [] } = {}) {
|
|
|
192
312
|
}
|
|
193
313
|
|
|
194
314
|
const near = similarNames(v, formFieldNames);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
`
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
add(
|
|
206
|
-
'warning',
|
|
207
|
-
'unwritten-variable',
|
|
208
|
-
where,
|
|
209
|
-
`Reads "${v}" directly and nothing in this process writes it. If it is not supplied at start time the ` +
|
|
210
|
-
`expression throws rather than treating it as null. Use \${execution.getVariable('${v}')} if absent is a valid state.`
|
|
211
|
-
);
|
|
212
|
-
}
|
|
315
|
+
add(
|
|
316
|
+
'warning',
|
|
317
|
+
'unwritten-variable',
|
|
318
|
+
where,
|
|
319
|
+
`Reads "${v}" directly and nothing in this process writes it. That is fine if it always arrives with the ` +
|
|
320
|
+
`start payload; otherwise the expression throws "Cannot resolve identifier '${v}'" rather than treating ` +
|
|
321
|
+
`it as null, and \${execution.getVariable('${v}')} would yield null instead.` +
|
|
322
|
+
(near.length > 0 ? ` A form here writes ${near.map((s) => `"${s}"`).join(', ')}, which is spelled almost the same.` : '') +
|
|
323
|
+
(hasOpaqueForms ? ` This process embeds an external form, which can set variables the model does not declare, so "${v}" may well be one of those.` : '')
|
|
324
|
+
);
|
|
213
325
|
}
|
|
214
326
|
}
|
|
215
327
|
|