camunda-cli 0.2.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.
@@ -0,0 +1,112 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
3
+ import { Client } from '../client.js';
4
+ import { saveConfig, clearConfig, requireConfig } from '../config.js';
5
+ import * as out from '../output.js';
6
+
7
+ const CTRL_C = '\x03';
8
+ const BACKSPACE = /[\x7f\x08]/;
9
+
10
+ // readline echoes what you type; this mutes it for the password prompt only.
11
+ function askHidden(question) {
12
+ stdout.write(question);
13
+ let buffer = '';
14
+ return new Promise((resolve) => {
15
+ const cleanup = () => {
16
+ stdin.setRawMode?.(false);
17
+ stdin.pause();
18
+ stdin.removeListener('data', onData);
19
+ };
20
+ const onData = (chunk) => {
21
+ const char = chunk.toString();
22
+ if (char === '\n' || char === '\r') {
23
+ cleanup();
24
+ stdout.write('\n');
25
+ resolve(buffer);
26
+ return;
27
+ }
28
+ if (char === CTRL_C) {
29
+ cleanup();
30
+ stdout.write('\n');
31
+ process.exit(1);
32
+ }
33
+ if (BACKSPACE.test(char)) {
34
+ buffer = buffer.slice(0, -1);
35
+ return;
36
+ }
37
+ buffer += char;
38
+ };
39
+ stdin.setRawMode?.(true);
40
+ stdin.resume();
41
+ stdin.on('data', onData);
42
+ });
43
+ }
44
+
45
+ export async function loginCommand(url, options) {
46
+ let baseUrl = url;
47
+ // A frequent mistake is pointing at the webapp root rather than the REST root.
48
+ if (!/\/engine-rest\/?$/.test(baseUrl) && !options.noSuffix) {
49
+ baseUrl = baseUrl.replace(/\/+$/, '') + '/engine-rest';
50
+ out.note(`Using ${baseUrl} (pass --no-suffix to use the URL exactly as given).`);
51
+ }
52
+
53
+ const rl = createInterface({ input: stdin, output: stdout });
54
+ try {
55
+ const username = options.username || (await rl.question('Username: '));
56
+ rl.close();
57
+ const password = options.password || (await askHidden('Password: '));
58
+
59
+ const client = new Client({ baseUrl, username, password });
60
+ // /engine answers without auth on some setups, so prove the credential on an
61
+ // endpoint that actually requires it.
62
+ const [engines, count] = await Promise.all([client.engines(), client.processDefinitionCount()]);
63
+
64
+ saveConfig({ baseUrl, username, password, engineName: engines[0]?.name ?? 'default' });
65
+ out.line(`Logged in to ${baseUrl} as ${username}.`);
66
+ out.note(`Engine "${engines[0]?.name ?? 'default'}", ${count.count} process definitions deployed.`);
67
+ } catch (err) {
68
+ rl.close();
69
+ if (err.status === 401) {
70
+ throw new Error(`Login failed: the engine rejected these credentials (HTTP 401).`);
71
+ }
72
+ throw new Error(`Login failed: ${err.message}`);
73
+ }
74
+ }
75
+
76
+ export function logoutCommand() {
77
+ clearConfig();
78
+ out.note('Local session cleared.');
79
+ }
80
+
81
+ export async function whoamiCommand() {
82
+ const config = requireConfig();
83
+ const client = new Client(config);
84
+ const [engines, defs, insts, incidents] = await Promise.all([
85
+ client.engines(),
86
+ client.processDefinitionCount(),
87
+ client.get('/process-instance/count'),
88
+ client.get('/incident/count'),
89
+ ]);
90
+
91
+ if (out.isJsonMode()) {
92
+ return out.json({
93
+ baseUrl: config.baseUrl,
94
+ username: config.username,
95
+ engines: engines.map((e) => e.name),
96
+ processDefinitions: defs.count,
97
+ runningInstances: insts.count,
98
+ openIncidents: incidents.count,
99
+ });
100
+ }
101
+
102
+ out.kv(
103
+ [
104
+ ['user', config.username],
105
+ ['engine', `${config.baseUrl} (${engines.map((e) => e.name).join(', ')})`],
106
+ ['definitions', defs.count],
107
+ ['running', insts.count],
108
+ ['incidents', incidents.count > 0 ? `${incidents.count} open` : '0'],
109
+ ],
110
+ ''
111
+ );
112
+ }
@@ -0,0 +1,129 @@
1
+ import { Client, parseVariableFlags } from '../client.js';
2
+ import { requireConfig } from '../config.js';
3
+ import { readProcess } from '../bpmn.js';
4
+ import { unwrapError, explain } from '../errors.js';
5
+ import * as out from '../output.js';
6
+
7
+ export async function tasksCommand(options) {
8
+ const client = new Client(requireConfig());
9
+ const query = { maxResults: options.limit ?? 50 };
10
+ if (options.assignee) query.assignee = options.assignee;
11
+ if (options.instance) query.processInstanceId = options.instance;
12
+ if (options.key) query.processDefinitionKey = options.key;
13
+ if (options.tenant) query.tenantIdIn = options.tenant;
14
+ if (options.unassigned) query.unassigned = true;
15
+
16
+ const tasks = await client.tasks(query);
17
+ if (out.isJsonMode()) return out.json(tasks);
18
+ if (tasks.length === 0) return out.note('No open tasks match.');
19
+
20
+ out.table(
21
+ ['ID', 'NAME', 'ASSIGNEE', 'CREATED', 'INSTANCE'],
22
+ tasks.map((t) => [t.id, out.truncate(t.name, 34), t.assignee ?? 'unassigned', out.formatDateTime(t.created), t.processInstanceId])
23
+ );
24
+ out.note(`\n${tasks.length} open task(s)`);
25
+ }
26
+
27
+ // Shows what completing this task actually requires. The form fields come from the
28
+ // deployed model, because AlurKerja keeps its form definition in an extension attribute
29
+ // that the engine's own form endpoints do not read.
30
+ export async function taskCommand(id) {
31
+ const client = new Client(requireConfig());
32
+ const task = await client.task(id);
33
+
34
+ const [variables, definition] = await Promise.all([
35
+ client.taskVariables(id).catch(() => ({})),
36
+ client.processDefinitionXml(task.processDefinitionId).catch(() => null),
37
+ ]);
38
+
39
+ let fields = [];
40
+ if (definition) {
41
+ try {
42
+ const model = readProcess(definition.bpmn20Xml);
43
+ fields = model.nodes.find((n) => n.id === task.taskDefinitionKey)?.formFields ?? [];
44
+ } catch {
45
+ /* an unparsable model should not stop the task from being shown */
46
+ }
47
+ }
48
+
49
+ if (out.isJsonMode()) return out.json({ task, variables, formFields: fields });
50
+
51
+ out.heading(`Task ${task.id}: ${task.name}`);
52
+ out.kv([
53
+ ['element', task.taskDefinitionKey],
54
+ ['assignee', task.assignee ?? 'unassigned'],
55
+ ['instance', task.processInstanceId],
56
+ ['definition', task.processDefinitionId],
57
+ ['created', out.formatDateTime(task.created)],
58
+ ['due', out.formatDateTime(task.due)],
59
+ ['tenant', task.tenantId ?? '-'],
60
+ ]);
61
+
62
+ if (fields.length > 0) {
63
+ out.line('\nForm fields');
64
+ out.table(
65
+ ['NAME', 'TYPE', 'REQUIRED', 'LABEL'],
66
+ fields.map((f) => [f.name, f.type, f.required ? 'yes' : '', f.disabled ? `${f.label} (read-only)` : f.label])
67
+ );
68
+ const writable = fields.filter((f) => !f.disabled);
69
+ if (writable.length > 0) {
70
+ out.note(`\ncamunda complete ${id} ${writable.map((f) => `--var ${f.name}=<value>`).join(' ')}`);
71
+ }
72
+ } else {
73
+ out.note('\nNo form fields declared on this element.');
74
+ }
75
+
76
+ const names = Object.keys(variables);
77
+ if (names.length > 0) {
78
+ out.line('\nVisible variables');
79
+ out.table(null, names.map((n) => [` ${n}`, variables[n].type, out.truncate(JSON.stringify(variables[n].value), 60)]));
80
+ }
81
+ }
82
+
83
+ export async function completeCommand(id, options) {
84
+ const client = new Client(requireConfig());
85
+ const variables = parseVariableFlags(options.var);
86
+
87
+ try {
88
+ await client.completeTask(id, variables);
89
+ } catch (err) {
90
+ // Anything the task triggers synchronously fails here rather than becoming an
91
+ // incident, so this response is the only place the cause is ever recorded.
92
+ const unwrapped = unwrapError(err.body?.message || err.message);
93
+ out.problem(`Could not complete task ${id}.`);
94
+ out.line(`\n${unwrapped.message}`);
95
+ for (const layer of unwrapped.layers) {
96
+ const text = typeof layer.value === 'string' ? layer.value : JSON.stringify(layer.value, null, 2);
97
+ out.note(`\n${layer.label}:`);
98
+ for (const l of text.split('\n')) out.note(` ${l}`);
99
+ }
100
+ const hint = explain(err.body?.message || err.message);
101
+ if (hint) {
102
+ out.line('');
103
+ for (const l of out.wrap(hint, 92)) out.line(l);
104
+ }
105
+ out.note('\nThe task is untouched; the whole completion rolled back.');
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+
110
+ out.line(`Task ${id} completed.`);
111
+
112
+ if (!options.noWait) {
113
+ await new Promise((r) => setTimeout(r, options.wait ?? 1000));
114
+ const task = await client.task(id).catch(() => null);
115
+ void task;
116
+ }
117
+ }
118
+
119
+ export async function claimCommand(id, options) {
120
+ const client = new Client(requireConfig());
121
+ if (options.unclaim) {
122
+ await client.unclaimTask(id);
123
+ return out.line(`Task ${id} unassigned.`);
124
+ }
125
+ const config = requireConfig();
126
+ const user = options.user || config.username;
127
+ await client.setTaskAssignee(id, user);
128
+ out.line(`Task ${id} assigned to ${user}.`);
129
+ }
package/src/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.config', 'camunda-cli');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ export function loadConfig() {
9
+ if (!existsSync(CONFIG_FILE)) return null;
10
+ try {
11
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function saveConfig(config) {
18
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
19
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
20
+ }
21
+
22
+ export function clearConfig() {
23
+ if (existsSync(CONFIG_FILE)) writeFileSync(CONFIG_FILE, '{}');
24
+ }
25
+
26
+ export function requireConfig() {
27
+ const config = loadConfig();
28
+ if (!config || !config.baseUrl || !config.username) {
29
+ console.error('Not logged in. Run: camunda login <engine-rest-url>');
30
+ process.exit(1);
31
+ }
32
+ return config;
33
+ }
package/src/errors.js ADDED
@@ -0,0 +1,138 @@
1
+ // Camunda surfaces failures as one long string with the real cause buried several
2
+ // layers deep. Observed against a live AlurKerja deployment, an addon script failure
3
+ // arrives as: a REST message, containing a Java exception, containing `body: {json}`,
4
+ // whose `output` field is itself a JSON *string* holding the message that actually
5
+ // tells you what went wrong. This module peels that apart and, where the error is a
6
+ // well-known Camunda one, says what usually causes it.
7
+
8
+ const NESTED_BODY = /body:\s*(\{[\s\S]*)$/;
9
+
10
+ // Pulls out every layer we can parse. Returns { raw, layers[], message } where
11
+ // `message` is the deepest human-readable sentence found.
12
+ export function unwrapError(raw) {
13
+ const text = typeof raw === 'string' ? raw : raw?.message || String(raw);
14
+ const layers = [];
15
+ let deepest = text;
16
+
17
+ const bodyMatch = text.match(NESTED_BODY);
18
+ if (bodyMatch) {
19
+ const parsed = tryParse(bodyMatch[1]);
20
+ if (parsed) {
21
+ layers.push({ label: 'integration response', value: parsed });
22
+ if (typeof parsed.output === 'string') {
23
+ const inner = tryParse(parsed.output);
24
+ if (inner) {
25
+ layers.push({ label: 'script output', value: inner });
26
+ if (inner.message) deepest = inner.message;
27
+ } else if (parsed.output.trim()) {
28
+ layers.push({ label: 'script output', value: parsed.output.trim() });
29
+ deepest = parsed.output.trim();
30
+ }
31
+ } else if (parsed.message) {
32
+ deepest = parsed.message;
33
+ } else if (parsed.error) {
34
+ deepest = parsed.error;
35
+ }
36
+ }
37
+ }
38
+
39
+ return { raw: text, layers, message: deepest };
40
+ }
41
+
42
+ function tryParse(s) {
43
+ try {
44
+ const v = JSON.parse(s);
45
+ return v && typeof v === 'object' ? v : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Known Camunda failure signatures, each with the cause we actually observed in
52
+ // practice rather than a restatement of the message.
53
+ const HINTS = [
54
+ {
55
+ match: /Cannot resolve identifier 'initiator'/,
56
+ hint: () =>
57
+ `The variable "initiator" is injected by AlurKerja when a process is started through its own API. ` +
58
+ `Starting the same process straight through the Camunda REST API skips that, so any element using ` +
59
+ `\${initiator} (usually a task assignee) fails as soon as it is reached. Start it with ` +
60
+ `--var initiator=<userId> to stand in for the real caller.`,
61
+ },
62
+ {
63
+ match: /Unknown property used in expression: \$\{([^}]*)\}.*Cannot resolve identifier '([^']+)'/,
64
+ hint: (m) =>
65
+ `Nothing has ever set the variable "${m[2]}" on this instance, and the expression reads it directly, ` +
66
+ `so the engine throws instead of treating it as null. Either set it before this point, or rewrite the ` +
67
+ `expression as \${execution.getVariable('${m[2]}')} which yields null instead of throwing. ` +
68
+ `Check for a name mismatch first: a form field named slightly differently is the usual cause.`,
69
+ },
70
+ {
71
+ match: /ENGINE-02004 No outgoing sequence flow for the element with id '([^']+)'/,
72
+ hint: (m) =>
73
+ `Gateway "${m[1]}" evaluated every outgoing flow condition to false and has no default flow, ` +
74
+ `so the token has nowhere to go. Either make the conditions exhaustive (a > and a < leave the ` +
75
+ `equal case uncovered) or mark one flow as the gateway's default.`,
76
+ },
77
+ {
78
+ match: /ENGINE-02001 .*more than one outgoing sequence flow/,
79
+ hint: () =>
80
+ `Several conditions on an exclusive gateway were true at once. Exclusive gateways take exactly one ` +
81
+ `path, so tighten the conditions until they are mutually exclusive.`,
82
+ },
83
+ {
84
+ match: /Alurkerja Integration failed/,
85
+ hint: () =>
86
+ `The addon behind this service task returned an error. The script's own message is shown above under ` +
87
+ `"script output"; the service task itself is fine, the failure is inside the addon action.`,
88
+ },
89
+ {
90
+ match: /ENGINE-13030|correlat\w+ .*ambiguous|more than one .*subscription/i,
91
+ hint: () =>
92
+ `A message matched more than one waiting subscription. This is usually several versions of the ` +
93
+ `receiving process still deployed and active at once, not a modelling mistake in the sender.`,
94
+ },
95
+ {
96
+ match: /OptimisticLockingException/,
97
+ hint: () =>
98
+ `Two transactions touched the same row at once. Camunda normally retries this by itself; if it keeps ` +
99
+ `surfacing, something outside the engine is writing to the same instance concurrently.`,
100
+ },
101
+ {
102
+ match: /no processes deployed with key '([^']+)'|No matching process definition with key: ([^\s]+) and no tenant-id/,
103
+ hint: (m) =>
104
+ `The key exists under a tenant, not at the root. Pass --tenant <tenantId>; "camunda definitions -k ${m[1] || m[2]}" ` +
105
+ `lists which tenant holds it.`,
106
+ },
107
+ ];
108
+
109
+ export function explain(message) {
110
+ for (const { match, hint } of HINTS) {
111
+ const m = String(message || '').match(match);
112
+ if (m) return hint(m);
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // Camunda stacktraces are ~90 lines of engine internals. The frames worth reading are
118
+ // the exception header and anything that is not org.camunda.bpm.engine.impl.*.
119
+ export function condenseStacktrace(trace, { keep = 12 } = {}) {
120
+ const lines = String(trace || '').split('\n');
121
+ const header = [];
122
+ const interesting = [];
123
+ for (const l of lines) {
124
+ const t = l.trim();
125
+ if (!t) continue;
126
+ if (!t.startsWith('at ')) {
127
+ header.push(t);
128
+ continue;
129
+ }
130
+ if (/^at (org\.camunda\.bpm\.engine\.impl|java\.|jakarta\.|javax\.|sun\.|jdk\.)/.test(t)) continue;
131
+ interesting.push(t);
132
+ }
133
+ return {
134
+ header,
135
+ frames: interesting.slice(0, keep),
136
+ omitted: lines.filter((l) => l.trim().startsWith('at ')).length - Math.min(interesting.length, keep),
137
+ };
138
+ }
package/src/lint.js ADDED
@@ -0,0 +1,244 @@
1
+ // Static checks over a deployed BPMN model.
2
+ //
3
+ // Every rule here exists because the failure it describes was reproduced against a real
4
+ // engine first, not because it seemed plausible. The messages name the specific element
5
+ // and say what to change, since the output is mostly read by tooling that will act on it.
6
+
7
+ import { collectExpressions, readVariables, collectWrittenVariables } from './bpmn.js';
8
+
9
+ const GATEWAYS = new Set(['exclusiveGateway', 'inclusiveGateway', 'complexGateway']);
10
+ const START_TYPES = new Set(['startEvent']);
11
+ const END_TYPES = new Set(['endEvent']);
12
+
13
+ function levenshtein(a, b) {
14
+ const m = a.length;
15
+ const n = b.length;
16
+ if (Math.abs(m - n) > 4) return 99;
17
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
18
+ for (let j = 0; j <= n; j++) d[0][j] = j;
19
+ for (let i = 1; i <= m; i++) {
20
+ for (let j = 1; j <= n; j++) {
21
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
22
+ }
23
+ }
24
+ return d[m][n];
25
+ }
26
+
27
+ function similarNames(target, candidates) {
28
+ const prefix = target.includes('_') ? target.slice(0, target.indexOf('_') + 1) : null;
29
+ return candidates.filter((c) => {
30
+ if (c === target) return false;
31
+ if (prefix && c.startsWith(prefix)) return true;
32
+ return levenshtein(c.toLowerCase(), target.toLowerCase()) <= 3;
33
+ });
34
+ }
35
+
36
+ // Reads `${x > 300}` style comparisons so a gateway's branches can be checked for gaps.
37
+ function parseComparison(expression) {
38
+ const m = String(expression || '').match(
39
+ /\$\{\s*([A-Za-z_$][\w$]*)\s*(==|!=|>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*\}/
40
+ );
41
+ if (!m) return null;
42
+ return { variable: m[1], op: m[2], value: Number(m[3]) };
43
+ }
44
+
45
+ export function lintProcess(model, { engineVariables = [] } = {}) {
46
+ const findings = [];
47
+ const add = (severity, rule, element, message) => findings.push({ severity, rule, element, message });
48
+
49
+ const byId = new Map(model.nodes.map((n) => [n.id, n]));
50
+ const outgoing = new Map();
51
+ const incoming = new Map();
52
+ for (const f of model.flows) {
53
+ if (!outgoing.has(f.source)) outgoing.set(f.source, []);
54
+ if (!incoming.has(f.target)) incoming.set(f.target, []);
55
+ outgoing.get(f.source).push(f);
56
+ incoming.get(f.target).push(f);
57
+ }
58
+
59
+ // --- dangling references -------------------------------------------------
60
+ for (const f of model.flows) {
61
+ if (!byId.has(f.source)) add('error', 'dangling-flow', f.id, `Sequence flow starts at "${f.source}", which is not an element in this process.`);
62
+ if (!byId.has(f.target)) add('error', 'dangling-flow', f.id, `Sequence flow ends at "${f.target}", which is not an element in this process.`);
63
+ }
64
+ for (const n of model.nodes) {
65
+ if (n.attachedTo && !byId.has(n.attachedTo)) {
66
+ add('error', 'dangling-boundary', n.id, `Boundary event is attached to "${n.attachedTo}", which does not exist.`);
67
+ }
68
+ }
69
+
70
+ // --- reachability --------------------------------------------------------
71
+ const starts = model.nodes.filter((n) => START_TYPES.has(n.type) && !n.scope);
72
+ if (starts.length === 0) add('error', 'no-start-event', model.id, 'The process has no start event, so nothing can ever begin it.');
73
+
74
+ const reachable = new Set();
75
+ const queue = starts.map((s) => s.id);
76
+ while (queue.length) {
77
+ const id = queue.shift();
78
+ if (reachable.has(id)) continue;
79
+ reachable.add(id);
80
+ for (const f of outgoing.get(id) || []) queue.push(f.target);
81
+ }
82
+ for (const n of model.nodes) {
83
+ if (n.scope || n.attachedTo || START_TYPES.has(n.type)) continue;
84
+ if (!reachable.has(n.id)) {
85
+ add('warning', 'unreachable', n.id, `"${n.name || n.id}" cannot be reached from any start event.`);
86
+ }
87
+ }
88
+ for (const n of model.nodes) {
89
+ if (n.scope || n.attachedTo) continue;
90
+ if (!END_TYPES.has(n.type) && (outgoing.get(n.id) || []).length === 0) {
91
+ add('warning', 'dead-end', n.id, `"${n.name || n.id}" has no outgoing sequence flow, so the token stops there without reaching an end event.`);
92
+ }
93
+ }
94
+
95
+ // --- gateway branching ---------------------------------------------------
96
+ for (const n of model.nodes) {
97
+ if (!GATEWAYS.has(n.type)) continue;
98
+ const outs = outgoing.get(n.id) || [];
99
+ if (outs.length < 2) continue;
100
+
101
+ const conditional = outs.filter((f) => f.condition);
102
+ const unconditional = outs.filter((f) => !f.condition && f.id !== n.defaultFlow);
103
+
104
+ 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.`
112
+ );
113
+
114
+ // Numeric gap: a > N and a < N leave a == N with nowhere to go.
115
+ const comparisons = conditional.map((f) => ({ flow: f, cmp: parseComparison(f.condition) })).filter((c) => c.cmp);
116
+ const byVar = new Map();
117
+ for (const c of comparisons) {
118
+ if (!byVar.has(c.cmp.variable)) byVar.set(c.cmp.variable, []);
119
+ byVar.get(c.cmp.variable).push(c.cmp);
120
+ }
121
+ for (const [variable, cmps] of byVar) {
122
+ if (cmps.length !== conditional.length) continue;
123
+ const gt = cmps.find((c) => c.op === '>');
124
+ const lt = cmps.find((c) => c.op === '<');
125
+ if (gt && lt && gt.value === lt.value && !cmps.some((c) => ['==', '>=', '<='].includes(c.op))) {
126
+ add(
127
+ 'error',
128
+ 'uncovered-value',
129
+ n.id,
130
+ `"${n.name || n.id}" branches on ${variable} > ${gt.value} and ${variable} < ${lt.value}, ` +
131
+ `so ${variable} == ${gt.value} matches neither branch and the instance will fail there.`
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ if (n.type === 'exclusiveGateway' && unconditional.length > 1) {
138
+ add(
139
+ 'warning',
140
+ 'ambiguous-branch',
141
+ n.id,
142
+ `"${n.name || n.id}" has ${unconditional.length} outgoing flows with no condition. An exclusive gateway ` +
143
+ `takes the first one it finds, which makes the path taken depend on document order rather than intent.`
144
+ );
145
+ }
146
+ }
147
+
148
+ // --- variables read but never written ------------------------------------
149
+ const written = collectWrittenVariables(model);
150
+ const knownNames = new Set([...written.keys(), ...engineVariables]);
151
+ const formFieldNames = [...written.keys()];
152
+
153
+ for (const { where, expression, kind } of collectExpressions(model)) {
154
+ const { direct } = readVariables(expression);
155
+ for (const v of direct) {
156
+ if (knownNames.has(v)) continue;
157
+
158
+ if (v === 'initiator') {
159
+ add(
160
+ 'warning',
161
+ 'initiator-expression',
162
+ where,
163
+ `Reads \${initiator}. AlurKerja sets that variable when a process is started through its own API, ` +
164
+ `but starting the same process straight through the Camunda REST API does not, and the step fails ` +
165
+ `asynchronously with "Cannot resolve identifier 'initiator'". Pass --var initiator=<user> when starting from the CLI.`
166
+ );
167
+ continue;
168
+ }
169
+
170
+ const near = similarNames(v, formFieldNames);
171
+ if (near.length > 0) {
172
+ add(
173
+ 'error',
174
+ 'variable-name-mismatch',
175
+ where,
176
+ `Reads "${v}", which nothing in this process writes, while a form here writes ${near.map((s) => `"${s}"`).join(', ')}. ` +
177
+ `These names look related, so this is most likely a typo: the expression throws ` +
178
+ `"Cannot resolve identifier '${v}'" the moment it is evaluated.`
179
+ );
180
+ } else {
181
+ add(
182
+ 'warning',
183
+ 'unwritten-variable',
184
+ where,
185
+ `Reads "${v}" directly and nothing in this process writes it. If it is not supplied at start time the ` +
186
+ `expression throws rather than treating it as null. Use \${execution.getVariable('${v}')} if absent is a valid state.`
187
+ );
188
+ }
189
+ }
190
+ }
191
+
192
+ // --- integration wiring --------------------------------------------------
193
+ for (const n of model.nodes) {
194
+ const addonListeners = n.listeners.filter((l) => l.addon);
195
+ for (const l of addonListeners) {
196
+ if (!l.addon.config) {
197
+ add(
198
+ 'warning',
199
+ 'addon-without-config',
200
+ n.id,
201
+ `Calls addon "${l.addon.addon}" action "${l.addon.action}" without a config id, so the addon runs with no ` +
202
+ `Integration Config bound. That is only correct if the addon holds its settings internally.`
203
+ );
204
+ }
205
+ }
206
+ if (n.type === 'serviceTask' && addonListeners.length > 0 && !n.async) {
207
+ add(
208
+ 'info',
209
+ 'sync-integration',
210
+ n.id,
211
+ `Calls addon "${addonListeners[0].addon.addon}" with no async marker, so the call runs inside the caller's ` +
212
+ `transaction. A failure here rolls back and surfaces in the HTTP response only: no incident and no job ` +
213
+ `record is left behind, so "camunda incidents" will show nothing afterwards.`
214
+ );
215
+ }
216
+ if (n.type === 'serviceTask' && addonListeners.length === 0 && !n.class && !n.topic) {
217
+ const expr = (n.expression || '').trim();
218
+ if (!expr || expr === '${true}' || expr === 'true') {
219
+ add(
220
+ 'warning',
221
+ 'no-op-service-task',
222
+ n.id,
223
+ `"${n.name || n.id}" is a service task with no implementation behind it (expression ${expr || 'empty'} and no ` +
224
+ `listener), so it does nothing at runtime beyond passing the token along.`
225
+ );
226
+ }
227
+ }
228
+ }
229
+
230
+ // --- user tasks ----------------------------------------------------------
231
+ for (const n of model.nodes) {
232
+ if (n.type !== 'userTask') continue;
233
+ if (!n.assignee && !n.candidateGroups) {
234
+ add('info', 'unassigned-user-task', n.id, `"${n.name || n.id}" has neither an assignee nor candidate groups, so it lands in nobody's list until it is claimed.`);
235
+ }
236
+ if (n.formFields.length === 0) {
237
+ add('info', 'no-form', n.id, `"${n.name || n.id}" has no form fields, so completing it submits nothing.`);
238
+ }
239
+ }
240
+
241
+ const order = { error: 0, warning: 1, info: 2 };
242
+ findings.sort((a, b) => order[a.severity] - order[b.severity]);
243
+ return findings;
244
+ }