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 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 that leave `== N` with nowhere to go (`ENGINE-02004`) |
80
- | `no-default-flow` | Every branch conditional, no default: any instance where they all fail stops dead |
81
- | `variable-name-mismatch` | A condition reads `foo` while the form writes `foo_2`, so the expression throws |
82
- | `unwritten-variable` | A direct `${x}` read where nothing sets `x`; throws instead of yielding null |
83
- | `initiator-expression` | `${initiator}`, which exists only when started through AlurKerja's API |
84
- | `no-op-service-task` | A service task with no implementation behind it |
85
- | `addon-without-config` | An integration call with no config bound |
86
- | `unreachable`, `dead-end`, `dangling-flow` | Structural mistakes |
87
-
88
- `deploy` runs the same checks and refuses to push a model with blocking issues, unless you
89
- pass `--skip-lint`.
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.3.1')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camunda-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Command-line client for self-hosted Camunda 7: inspect and lint deployed BPMN models, and diagnose why an instance is stuck",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- if (prefix && c.startsWith(prefix)) return true;
32
- return levenshtein(c.toLowerCase(), target.toLowerCase()) <= 3;
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
- // Reads `${x > 300}` style comparisons so a gateway's branches can be checked for gaps.
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
- /\$\{\s*([A-Za-z_$][\w$]*)\s*(==|!=|>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*\}/
97
+ /^\s*\$\{\s*([A-Za-z_$][\w$]*)\s*(==|!=|>=|<=|>|<)\s*('[^']*'|"[^"]*"|-?\d+(?:\.\d+)?|true|false)\s*\}\s*$/
40
98
  );
41
99
  if (!m) return null;
42
- return { variable: m[1], op: m[2], value: Number(m[3]) };
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
- add(
106
- 'error',
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
- // Numeric gap: a > N and a < N leave a == N with nowhere to go.
116
- const comparisons = conditional.map((f) => ({ flow: f, cmp: parseComparison(f.condition) })).filter((c) => c.cmp);
117
- const byVar = new Map();
118
- for (const c of comparisons) {
119
- if (!byVar.has(c.cmp.variable)) byVar.set(c.cmp.variable, []);
120
- byVar.get(c.cmp.variable).push(c.cmp);
121
- }
122
- for (const [variable, cmps] of byVar) {
123
- if (cmps.length !== conditional.length) continue;
124
- const gt = cmps.find((c) => c.op === '>');
125
- const lt = cmps.find((c) => c.op === '<');
126
- if (gt && lt && gt.value === lt.value && !cmps.some((c) => ['==', '>=', '<='].includes(c.op))) {
127
- add(
128
- 'error',
129
- 'uncovered-value',
130
- n.id,
131
- `"${n.name || n.id}" branches on ${variable} > ${gt.value} and ${variable} < ${lt.value}, ` +
132
- `so ${variable} == ${gt.value} matches neither branch and the instance will fail there.`
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
- for (const { where, expression, kind } of collectExpressions(model)) {
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
- if (near.length > 0) {
196
- add(
197
- 'error',
198
- 'variable-name-mismatch',
199
- where,
200
- `Reads "${v}", which nothing in this process writes, while a form here writes ${near.map((s) => `"${s}"`).join(', ')}. ` +
201
- `These names look related, so this is most likely a typo: the expression throws ` +
202
- `"Cannot resolve identifier '${v}'" the moment it is evaluated.`
203
- );
204
- } else {
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