camunda-cli 0.3.1 → 0.4.2

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/PANDUAN.md ADDED
@@ -0,0 +1,341 @@
1
+ # Panduan camunda-cli
2
+
3
+ Alat baris perintah untuk Camunda 7 self-hosted. Dibuat untuk dua pekerjaan yang paling
4
+ memakan waktu saat mengembangkan BPMN: **memahami model yang sudah ter-deploy**, dan
5
+ **mencari tahu kenapa sebuah instance macet**.
6
+
7
+ Dokumen ini disusun per kasus nyata, bukan per perintah. Untuk daftar opsi lengkap tiap
8
+ perintah, jalankan `camunda <perintah> --help`.
9
+
10
+ ---
11
+
12
+ ## Pasang dan masuk
13
+
14
+ ```bash
15
+ npm install -g camunda-cli
16
+ camunda login https://camunda.contoh.com
17
+ ```
18
+
19
+ URL boleh tanpa `/engine-rest`, akan ditambahkan otomatis. Username dan password ditanyakan
20
+ lewat prompt (jangan ditulis di baris perintah supaya tidak masuk riwayat shell).
21
+
22
+ Kredensial disimpan di `~/.config/camunda-cli/config.json` dengan permission `0600`. Camunda 7
23
+ memakai HTTP Basic Auth di setiap request dan tidak punya token sesi, jadi yang disimpan
24
+ memang password, dan hanya dikirim ke engine yang kamu konfigurasi.
25
+
26
+ Cek sambungan kapan saja:
27
+
28
+ ```bash
29
+ camunda whoami
30
+ ```
31
+
32
+ **Publish tidak memperbarui yang sudah terpasang.** Setelah versi baru rilis, jalankan lagi
33
+ `npm install -g camunda-cli`, lalu pastikan dengan `camunda --version`.
34
+
35
+ ---
36
+
37
+ ## Kasus 1: memeriksa model sebelum di-deploy
38
+
39
+ Ini kebiasaan yang paling menghemat waktu. Kondisi gateway, form field, dan wiring addon
40
+ hanya ada di dalam XML BPMN, tidak ada endpoint REST-nya, jadi tanpa alat ini satu-satunya
41
+ cara mengetahuinya adalah membaca XML mentah.
42
+
43
+ ```bash
44
+ camunda lint ./proses-saya.bpmn # file lokal, tanpa engine, tanpa login
45
+ camunda lint Process_PengajuanCuti # versi yang sudah ter-deploy
46
+ ```
47
+
48
+ Severity dipisah dengan sengaja:
49
+
50
+ - **ERROR** hanya untuk yang **terbukti rusak dari modelnya sendiri**. `camunda deploy`
51
+ menolak model yang punya error.
52
+ - **WARNING** untuk risiko yang butuh penilaianmu, karena modelnya sendiri tidak cukup untuk
53
+ membuktikan salah atau benar.
54
+ - **INFO** untuk catatan, disembunyikan kecuali pakai `--all`.
55
+
56
+ Kalibrasi ini diuji ke 190 model produksi nyata: nol error, nol deploy terblokir, sementara
57
+ model yang memang dibuat rusak tetap menghasilkan dua error.
58
+
59
+ ### Yang ditangkap sebagai ERROR
60
+
61
+ | Aturan | Artinya |
62
+ |---|---|
63
+ | `uncovered-value` | Gateway bercabang `> N` dan `< N`, jadi nilai tepat `N` tidak ke mana-mana. Engine melempar `ENGINE-02004` dan instance berhenti. |
64
+ | `default-flow-with-condition` | Flow yang ditandai default tapi masih punya kondisi. Engine menolak model ini saat deploy dengan `ENGINE-09005`. |
65
+ | `variable-name-mismatch` | Form menulis satu variabel yang tidak dibaca siapa pun, sementara kondisi di hilirnya membaca variabel yang tidak ditulis siapa pun. Menyelesaikan elemen itu tidak akan pernah memenuhi kondisinya. |
66
+ | `dangling-flow`, `dangling-boundary`, `no-start-event` | Menunjuk elemen yang tidak ada. |
67
+
68
+ ### Yang ditangkap sebagai WARNING
69
+
70
+ `no-default-flow` (semua cabang berkondisi tanpa fallback), `unwritten-variable` (membaca
71
+ `${x}` yang tidak ditulis siapa pun di model ini), `initiator-expression`, `no-op-service-task`
72
+ (service task tanpa implementasi), `addon-without-config`, `unreachable`, `dead-end`,
73
+ `ambiguous-branch`.
74
+
75
+ ### Melihat isi model
76
+
77
+ ```bash
78
+ camunda inspect Process_PengajuanCuti
79
+ camunda inspect ./proses-saya.bpmn
80
+ ```
81
+
82
+ Menampilkan semua elemen beserta penanda async, assignee, addon yang dipanggil, jumlah form
83
+ field, dan **berapa instance yang sedang berada di elemen itu**; lalu seluruh sequence flow
84
+ dengan kondisinya; lalu form field per elemen; lalu daftar integrasi.
85
+
86
+ ---
87
+
88
+ ## Kasus 2: menguji proses dari awal sampai selesai
89
+
90
+ ```bash
91
+ camunda start Process_PengajuanCuti --var initiator=budi@contoh.com -b UJI-1
92
+ ```
93
+
94
+ Yang muncul:
95
+
96
+ ```
97
+ Started Process_PengajuanCuti v3 as instance 3441986
98
+ business key UJI-1
99
+
100
+ Now waiting at:
101
+ 3442065 Review Leave Request budi@contoh.com
102
+ camunda complete 3442065 --var catatan=<value>
103
+ ```
104
+
105
+ `start` **tidak berhenti di "berhasil dikirim"**. HTTP 200 dari engine cuma berarti perintahnya
106
+ diterima; apa pun yang ditandai async baru berjalan setelah respons. Jadi `start` menunggu
107
+ sebentar lalu melaporkan salah satu dari tiga: gagal, selesai, atau sedang menunggu di task
108
+ mana beserta perintah persis untuk menyelesaikannya.
109
+
110
+ Lanjutkan dengan menyalin perintah yang disarankan:
111
+
112
+ ```bash
113
+ camunda complete 3442065 --var catatan=ok
114
+ ```
115
+
116
+ ```
117
+ Task 3442065 completed.
118
+ Instance 3441986 finished in 3.6s.
119
+ ```
120
+
121
+ Kalau proses masih berjalan, `complete` menampilkan task berikutnya. Jadi menggiring proses
122
+ tiga langkah cukup tiga perintah, tanpa perlu mencari-cari sendiri.
123
+
124
+ ### Menemukan task lewat business key
125
+
126
+ ```bash
127
+ camunda tasks -b UJI-1
128
+ ```
129
+
130
+ Business key itu pegangan yang kamu tentukan sendiri saat `start`, jadi bisa dipakai langsung
131
+ tanpa perlu tahu instance id.
132
+
133
+ ### Tipe variabel itu penting
134
+
135
+ ```bash
136
+ --var jumlah=300 # String, dan "300" > 200 dibandingkan sebagai teks
137
+ --var jumlah=300:Integer # angka sungguhan
138
+ --var aktif=true:Boolean
139
+ --var data:='{"a":1}' # JSON
140
+ ```
141
+
142
+ Kalau gateway membandingkan secara numerik, salah tipe membuat kondisinya berperilaku aneh
143
+ tanpa error.
144
+
145
+ ### Membersihkan setelah menguji
146
+
147
+ ```bash
148
+ camunda cancel --key Process_PengajuanCuti # semua instance proses itu
149
+ camunda cancel 3441986 # satu instance
150
+ camunda cancel --key Process_X -y -r "bersih-bersih" # tanpa konfirmasi
151
+ ```
152
+
153
+ Menguji meninggalkan banyak instance menggantung. Tanpa `-y` akan diminta konfirmasi.
154
+
155
+ ---
156
+
157
+ ## Kasus 3: instance macet atau gagal
158
+
159
+ Satu perintah untuk semuanya:
160
+
161
+ ```bash
162
+ camunda diagnose 3441986
163
+ ```
164
+
165
+ Ini membaca lebih banyak daripada sekadar `/incident`, karena beberapa bentuk kegagalan tidak
166
+ terlihat di sana:
167
+
168
+ - Langkah yang gagal **di dalam transaksi pemanggilnya** tidak meninggalkan incident maupun
169
+ job sama sekali; satu-satunya jejak ada di historic job log, atau tidak ada sama sekali.
170
+ - Incident menunjuk activity tempat job menempel, yang **sering bukan** activity yang error.
171
+ `diagnose` membedakan keduanya secara eksplisit.
172
+ - Incident yang sudah teratasi hilang dari `/incident`.
173
+
174
+ Contoh keluaran:
175
+
176
+ ```
177
+ Stopped at
178
+ Activity_CekSisaKuota transition Check Remaining Quota
179
+
180
+ 1 problem(s)
181
+
182
+ open incident at 2026-08-13 08:19:33
183
+ failing element: Activity_TinjauPengajuan
184
+ job attached to: Activity_CekSisaKuota (the async marker sits here, the error came from
185
+ the element above)
186
+ Unknown property used in expression: ${initiator}. Cause: Cannot resolve identifier 'initiator'
187
+
188
+ The variable "initiator" is injected by AlurKerja when a process is started through its own
189
+ API. Starting the same process straight through the Camunda REST API skips that...
190
+ ```
191
+
192
+ Exit code-nya **1 kalau ada yang sedang rusak, 0 kalau sehat**, jadi bisa dipakai di skrip.
193
+
194
+ Instance yang pernah gagal lalu pulih dilaporkan **sehat**, dengan riwayat kegagalannya
195
+ ditampilkan terpisah sebagai konteks, bukan sebagai vonis.
196
+
197
+ ### Error addon yang bersarang
198
+
199
+ Kegagalan addon datang sebagai pesan REST yang membungkus exception Java yang membungkus
200
+ `body: {json}` yang field `output`-nya berisi JSON lagi. Kalimat yang benar-benar berguna ada
201
+ di lapisan paling dalam, jadi itulah yang ditampilkan paling atas:
202
+
203
+ ```
204
+ sales_name kosong. Pastikan form Submit-Weekly-Report benar-benar mengisi variabel ini
205
+ sebelum service task ini berjalan.
206
+
207
+ integration response:
208
+ { "details": "script exited with code 1", ... }
209
+
210
+ script output:
211
+ { "status": "error", "message": "sales_name kosong...", ... }
212
+ ```
213
+
214
+ Ini berlaku baik di `diagnose` maupun saat `complete` gagal.
215
+
216
+ ### Perintah pendukung
217
+
218
+ ```bash
219
+ camunda trace 3441986 # setiap langkah yang dilalui, urut sesuai eksekusi engine
220
+ camunda vars 3441986 # variabel saat ini
221
+ camunda vars 3441986 -H # setiap perubahan variabel beserta waktunya
222
+ camunda jobs -i 3441986 -f # job yang gagal
223
+ camunda stacktrace <jobId> # stack trace, frame internal engine disaring
224
+ camunda incidents -k Process_X # incident yang terbuka
225
+ camunda incidents -k Process_X -H # termasuk yang sudah teratasi
226
+ camunda stats Process_X # instance menumpuk di elemen mana
227
+ ```
228
+
229
+ `trace` diurutkan memakai urutan eksekusi milik engine, bukan timestamp. Gateway dan event di
230
+ sekitarnya sering berbagi milidetik yang sama, dan mengurutkan pakai waktu membuat urutannya
231
+ acak.
232
+
233
+ ---
234
+
235
+ ## Kasus 4: memperbaiki instance yang gagal tanpa mengulang dari awal
236
+
237
+ Ini alur sehari-hari saat mengembangkan addon: script-nya salah, instance gagal, script
238
+ diperbaiki, lalu ingin melanjutkan instance yang sudah jalan.
239
+
240
+ ```bash
241
+ camunda jobs -i 3441986 --failed # cari job yang gagal
242
+ camunda set-var 3441986 initiator=budi@contoh.com # perbaiki datanya
243
+ camunda retry 3441988 --now # jalankan ulang job itu sekarang
244
+ camunda diagnose 3441986 # pastikan benar-benar pulih
245
+ ```
246
+
247
+ `retry` mengembalikan jatah percobaan job supaya dijalankan lagi. Tanpa `--now`, job runner
248
+ yang akan mengambilnya beberapa saat kemudian.
249
+
250
+ Kalau yang salah adalah **kodenya** (script addon, konfigurasi listener), jangan retry job di
251
+ instance yang sama untuk menguji perbaikannya: data yang terlanjur dihasilkan run sebelumnya
252
+ sudah tersimpan di variabel instance itu dan tidak akan dibuat ulang. Mulai instance baru.
253
+ Retry di tempat hanya masuk akal untuk yang sifatnya sementara (jaringan, service luar yang
254
+ sedang mati) atau kalau yang diperbaiki memang datanya.
255
+
256
+ ---
257
+
258
+ ## Kasus 5: mesin dengan banyak tenant
259
+
260
+ Di engine bersama, satu process key yang sama ada di banyak tenant. Endpoint bawaan Camunda
261
+ menjawab *"no matching process definition ... and no tenant-id"*, yang terbaca seolah prosesnya
262
+ tidak ada padahal ada.
263
+
264
+ ```bash
265
+ camunda tenants -s Telco # cari id tenant dari namanya
266
+ camunda definitions -k Contract -t <tenantId> -l
267
+ camunda inspect Process_X -t <tenantId>
268
+ ```
269
+
270
+ Kalau sebuah key ambigu, perintahnya tidak menebak, melainkan menampilkan kandidatnya:
271
+
272
+ ```
273
+ "V2-Contract-Lifecycle-Management" exists in 3 tenants. Narrow it with --tenant <id>, or
274
+ pass the full definition id:
275
+ V2-Contract-...:103:2794600 tenant=ef93ab58-... version=103
276
+ ...
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Kasus 6: deploy
282
+
283
+ ```bash
284
+ camunda deploy ./proses.bpmn -t <tenantId> -n "nama deployment"
285
+ ```
286
+
287
+ `deploy` menjalankan pemeriksaan yang sama dengan `lint` dan **menolak model yang punya error**.
288
+ Pakai `--skip-lint` untuk memaksa.
289
+
290
+ Kalau isinya identik dengan yang sudah ter-deploy, Camunda tidak membuat versi baru dan
291
+ perintahnya memberi tahu hal itu, bukan diam-diam sukses.
292
+
293
+ ```bash
294
+ camunda deployments # deployment terbaru
295
+ camunda undeploy <deploymentId> # hapus (pakai --cascade untuk ikut instance & history)
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Dipakai dari skrip atau agent AI
301
+
302
+ Setiap perintah menerima `--json` untuk mengeluarkan payload API mentah:
303
+
304
+ ```bash
305
+ camunda tasks -b UJI-1 --json
306
+ camunda lint Process_X --json
307
+ camunda diagnose 3441986 --json
308
+ ```
309
+
310
+ Keluaran biasa juga sudah aman dibaca mesin: tidak ada karakter garis kotak, dan warna hanya
311
+ muncul kalau stdout benar-benar terminal. Kolomnya dirapikan dengan spasi biasa.
312
+
313
+ Exit code: `0` berhasil, `1` gagal atau ada temuan error.
314
+
315
+ ---
316
+
317
+ ## Hal-hal yang menghemat waktu
318
+
319
+ **401 yang muncul acak.** Engine di belakang load balancer kadang menolak kredensial yang
320
+ benar saat satu replika sedang tidak sehat: pernah teramati 5 kali gagal lalu 5 kali berhasil
321
+ berturut-turut, kredensial sama, jeda setengah detik. Request diulang otomatis supaya ini
322
+ tidak terbaca sebagai password salah. Error 4xx yang membawa pesan Camunda asli tidak pernah
323
+ diulang, karena itu jawaban sungguhan.
324
+
325
+ **`${initiator}` kosong kalau start dari CLI.** AlurKerja mengisi variabel itu saat proses
326
+ dimulai lewat API-nya sendiri. Start langsung ke Camunda melewatkannya, jadi elemen yang
327
+ memakai `${initiator}` (biasanya assignee) gagal begitu tercapai. Tambahkan
328
+ `--var initiator=<userId>` saat menguji dari CLI.
329
+
330
+ **Form micro-frontend menulis variabel yang tidak terlihat di model.** Field bertipe
331
+ `EXTERNAL_MICRO_FRONTEND_FORM` namanya cuma titik pasang; MFE-nya bisa menulis variabel apa
332
+ pun tanpa dideklarasikan di BPMN. Jadi `unwritten-variable` di proses seperti itu wajar dan
333
+ belum tentu bug.
334
+
335
+ ---
336
+
337
+ ## Yang sengaja tidak dicakup
338
+
339
+ Evaluasi DMN, operasi batch, migrasi dan modifikasi instance, serta manajemen authorization.
340
+ Ini keputusan sadar, bukan kelalaian: cakupan perintah di sini mengikuti apa yang benar-benar
341
+ berulang saat mengembangkan dan men-debug proses, bukan seluruh 300-an endpoint REST Camunda.
package/README.md CHANGED
@@ -19,6 +19,8 @@ ERROR variable-name-mismatch flow Flow_0jpsuir (retry)
19
19
  expression throws "Cannot resolve identifier 'input_huruf'" the moment it is evaluated.
20
20
  ```
21
21
 
22
+ A task-by-task guide in Indonesian is in [PANDUAN.md](PANDUAN.md).
23
+
22
24
  ## Install
23
25
 
24
26
  ```bash
@@ -72,21 +74,29 @@ camunda lint ./order-process.bpmn # no engine and no login needed
72
74
  camunda lint order-process # or the deployed version
73
75
  ```
74
76
 
75
- Every rule exists because that failure was reproduced against a real engine first:
77
+ Every rule exists because that failure was reproduced against a real engine first.
78
+
79
+ **Errors** are reserved for defects that are provable from the model alone, because `deploy`
80
+ refuses to push a model that has one:
76
81
 
77
82
  | Rule | What it catches |
78
83
  |---|---|
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`.
84
+ | `uncovered-value` | `> N` and `< N` branches leaving `== N` with nowhere to go (`ENGINE-02004`) |
85
+ | `default-flow-with-condition` | A default flow that also carries a condition, which the engine rejects outright (`ENGINE-09005`) |
86
+ | `variable-name-mismatch` | A form writing one variable nothing reads, feeding a condition reading one nothing writes |
87
+ | `dangling-flow`, `dangling-boundary`, `no-start-event` | References to elements that do not exist |
88
+
89
+ **Warnings** are risks that need a human to judge, since the model cannot prove them either
90
+ way: `no-default-flow`, `unwritten-variable`, `initiator-expression`, `no-op-service-task`,
91
+ `addon-without-config`, `unreachable`, `dead-end`, `ambiguous-branch`.
92
+
93
+ Run over 190 production models, the checks raised zero errors and did not block a single
94
+ deploy, while still flagging both defects in a model built to contain them. That balance is
95
+ deliberate: a static check an agent cannot trust is worse than none, because acting on a
96
+ confident wrong answer breaks a process that was working.
97
+
98
+ `deploy` runs the same checks and refuses to push a model with an error, unless you pass
99
+ `--skip-lint`.
90
100
 
91
101
  **Work out why an instance is stuck.** `diagnose` gathers what is scattered across several
92
102
  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.2')
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()
@@ -189,6 +189,7 @@ withTenant(
189
189
  .description('Open human tasks')
190
190
  .option('-a, --assignee <userId>')
191
191
  .option('-i, --instance <processInstanceId>')
192
+ .option('-b, --business-key <key>', 'tasks of the instance started with this business key')
192
193
  .option('-k, --key <definitionKey>')
193
194
  .option('-u, --unassigned')
194
195
  .option('--limit <n>', 'maximum rows', int)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camunda-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
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",
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "files": [
18
18
  "bin",
19
- "src"
19
+ "src",
20
+ "PANDUAN.md"
20
21
  ],
21
22
  "keywords": [
22
23
  "camunda",
Binary file
@@ -10,6 +10,9 @@ export async function tasksCommand(options) {
10
10
  const query = { maxResults: options.limit ?? 50 };
11
11
  if (options.assignee) query.assignee = options.assignee;
12
12
  if (options.instance) query.processInstanceId = options.instance;
13
+ // The business key is the handle you chose when starting, so it is the natural way back
14
+ // to a task without first looking the instance id up.
15
+ if (options.businessKey) query.processInstanceBusinessKey = options.businessKey;
13
16
  if (options.key) query.processDefinitionKey = options.key;
14
17
  if (options.tenant) query.tenantIdIn = options.tenant;
15
18
  if (options.unassigned) query.unassigned = true;
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