@transitrix/cli 1.0.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/cli.js +3184 -0
- package/dist/export-compliance.js +1076 -0
- package/dist/repo-validate.js +207 -0
- package/package.json +48 -0
- package/schemas/bpmn-dsl.schema.json +136 -0
- package/schemas/cervinrc.schema.json +23 -0
- package/schemas/transitrixrc.schema.json +23 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire__ } from 'node:module'; const require = __createRequire__(import.meta.url);
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// ../../src/package-version.ts
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
function cervinPackageVersion() {
|
|
18
|
+
try {
|
|
19
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
20
|
+
const j = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
21
|
+
return typeof j.version === "string" ? j.version : "0.0.0";
|
|
22
|
+
} catch {
|
|
23
|
+
return "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
var init_package_version = __esm({
|
|
27
|
+
"../../src/package-version.ts"() {
|
|
28
|
+
"use strict";
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ../../src/emitter.ts
|
|
33
|
+
import { create } from "xmlbuilder2";
|
|
34
|
+
function collaborationId(processId) {
|
|
35
|
+
return `Collaboration_${processId}`;
|
|
36
|
+
}
|
|
37
|
+
function participantBpmnId(poolId) {
|
|
38
|
+
return `Participant_${poolId}`;
|
|
39
|
+
}
|
|
40
|
+
function appendFlowNode(parent, el, defaultFlowMap) {
|
|
41
|
+
const attrs = { id: el.id };
|
|
42
|
+
if (el.name) attrs.name = el.name;
|
|
43
|
+
if (defaultFlowMap?.has(el.id)) {
|
|
44
|
+
attrs.defaultFlowRef = defaultFlowMap.get(el.id);
|
|
45
|
+
}
|
|
46
|
+
parent.ele(el.type, attrs);
|
|
47
|
+
}
|
|
48
|
+
function emitBpmnXml(layout) {
|
|
49
|
+
const { process: process3 } = layout;
|
|
50
|
+
const collabId = collaborationId(process3.id);
|
|
51
|
+
const definitions = create({ version: "1.0", encoding: "UTF-8" }).ele("definitions", {
|
|
52
|
+
xmlns: BPMN_MODEL,
|
|
53
|
+
"xmlns:bpmndi": BPMN_DI,
|
|
54
|
+
"xmlns:dc": DC,
|
|
55
|
+
"xmlns:di": DI,
|
|
56
|
+
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
|
57
|
+
id: "Definitions_1",
|
|
58
|
+
targetNamespace: "http://bpmn.io/schema/bpmn",
|
|
59
|
+
exporter: "cervin",
|
|
60
|
+
exporterVersion: cervinPackageVersion()
|
|
61
|
+
});
|
|
62
|
+
const collaboration = definitions.ele("collaboration", { id: collabId });
|
|
63
|
+
collaboration.ele("participant", {
|
|
64
|
+
id: participantBpmnId(process3.poolId),
|
|
65
|
+
name: process3.poolName,
|
|
66
|
+
processRef: process3.id
|
|
67
|
+
});
|
|
68
|
+
const proc = definitions.ele("process", {
|
|
69
|
+
id: process3.id,
|
|
70
|
+
name: process3.name,
|
|
71
|
+
isExecutable: "false"
|
|
72
|
+
});
|
|
73
|
+
const laneSet = proc.ele("laneSet", { id: `LaneSet_${process3.id}` });
|
|
74
|
+
for (const lane of process3.lanes) {
|
|
75
|
+
const laneEl = laneSet.ele("lane", { id: lane.id, name: lane.name });
|
|
76
|
+
for (const el of lane.elements) {
|
|
77
|
+
laneEl.ele("flowNodeRef").txt(el.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const defaultFlowMap = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const f of layout.flows) {
|
|
82
|
+
if (f.default) {
|
|
83
|
+
defaultFlowMap.set(f.from, f.id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const sortedElements = [...process3.lanes.flatMap((l) => l.elements)].sort(
|
|
87
|
+
(a, b) => a.id.localeCompare(b.id)
|
|
88
|
+
);
|
|
89
|
+
for (const el of sortedElements) {
|
|
90
|
+
appendFlowNode(proc, el, defaultFlowMap);
|
|
91
|
+
}
|
|
92
|
+
const sortedFlows = [...layout.flows].sort((a, b) => a.id.localeCompare(b.id));
|
|
93
|
+
for (const f of sortedFlows) {
|
|
94
|
+
const attrs = { id: f.id, sourceRef: f.from, targetRef: f.to };
|
|
95
|
+
if (f.name) attrs.name = f.name;
|
|
96
|
+
const seq = proc.ele("sequenceFlow", attrs);
|
|
97
|
+
if (f.condition) {
|
|
98
|
+
seq.ele("conditionExpression", {
|
|
99
|
+
"xsi:type": "tFormalExpression"
|
|
100
|
+
}).txt(f.condition);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const diagram = definitions.ele("bpmndi:BPMNDiagram", { id: "BPMNDiagram_1" });
|
|
104
|
+
const plane = diagram.ele("bpmndi:BPMNPlane", {
|
|
105
|
+
id: "BPMNPlane_1",
|
|
106
|
+
bpmnElement: collabId
|
|
107
|
+
});
|
|
108
|
+
const pb = layout.poolBounds;
|
|
109
|
+
plane.ele("bpmndi:BPMNShape", {
|
|
110
|
+
id: `Shape_${participantBpmnId(process3.poolId)}`,
|
|
111
|
+
bpmnElement: participantBpmnId(process3.poolId),
|
|
112
|
+
isHorizontal: "true"
|
|
113
|
+
}).ele("dc:Bounds", {
|
|
114
|
+
x: String(pb.x),
|
|
115
|
+
y: String(pb.y),
|
|
116
|
+
width: String(pb.width),
|
|
117
|
+
height: String(pb.height)
|
|
118
|
+
});
|
|
119
|
+
for (const lane of process3.lanes) {
|
|
120
|
+
const lb = layout.laneBounds.get(lane.id);
|
|
121
|
+
if (!lb) throw new Error(`Missing layout bounds for lane ${lane.id}`);
|
|
122
|
+
plane.ele("bpmndi:BPMNShape", {
|
|
123
|
+
id: `Shape_Lane_${lane.id}`,
|
|
124
|
+
bpmnElement: lane.id,
|
|
125
|
+
isHorizontal: "true"
|
|
126
|
+
}).ele("dc:Bounds", {
|
|
127
|
+
x: String(lb.x),
|
|
128
|
+
y: String(lb.y),
|
|
129
|
+
width: String(lb.width),
|
|
130
|
+
height: String(lb.height)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
for (const el of sortedElements) {
|
|
134
|
+
const b = layout.elements.get(el.id);
|
|
135
|
+
if (!b) throw new Error(`Missing layout bounds for flow node ${el.id}`);
|
|
136
|
+
const shapeAttrs = {
|
|
137
|
+
id: `Shape_${el.id}`,
|
|
138
|
+
bpmnElement: el.id
|
|
139
|
+
};
|
|
140
|
+
if (el.type === "exclusiveGateway" || el.type === "parallelGateway") {
|
|
141
|
+
shapeAttrs.isMarkerVisible = "true";
|
|
142
|
+
}
|
|
143
|
+
const shape = plane.ele("bpmndi:BPMNShape", shapeAttrs);
|
|
144
|
+
shape.ele("dc:Bounds", {
|
|
145
|
+
x: String(b.x),
|
|
146
|
+
y: String(b.y),
|
|
147
|
+
width: String(b.width),
|
|
148
|
+
height: String(b.height)
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const f of sortedFlows) {
|
|
152
|
+
const edge = plane.ele("bpmndi:BPMNEdge", {
|
|
153
|
+
id: `Edge_${f.id}`,
|
|
154
|
+
bpmnElement: f.id
|
|
155
|
+
});
|
|
156
|
+
const wps = f.waypoints.length >= 2 ? f.waypoints : defaultWaypoints(layout, f.from, f.to);
|
|
157
|
+
for (const p of wps) {
|
|
158
|
+
edge.ele("di:waypoint", { x: String(Math.round(p.x)), y: String(Math.round(p.y)) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return definitions.end({ prettyPrint: true });
|
|
162
|
+
}
|
|
163
|
+
function defaultWaypoints(layout, from, to) {
|
|
164
|
+
const a = layout.elements.get(from);
|
|
165
|
+
const b = layout.elements.get(to);
|
|
166
|
+
if (!a || !b) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Layout invariant violated: missing bounds for flow "${from}" \u2192 "${to}" (${!a ? from : to} not found)`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
{ x: a.x + a.width / 2, y: a.y + a.height / 2 },
|
|
173
|
+
{ x: b.x + b.width / 2, y: b.y + b.height / 2 }
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
var BPMN_MODEL, BPMN_DI, DC, DI;
|
|
177
|
+
var init_emitter = __esm({
|
|
178
|
+
"../../src/emitter.ts"() {
|
|
179
|
+
"use strict";
|
|
180
|
+
init_package_version();
|
|
181
|
+
BPMN_MODEL = "http://www.omg.org/spec/BPMN/20100524/MODEL";
|
|
182
|
+
BPMN_DI = "http://www.omg.org/spec/BPMN/20100524/DI";
|
|
183
|
+
DC = "http://www.omg.org/spec/DD/20100524/DC";
|
|
184
|
+
DI = "http://www.omg.org/spec/DD/20100524/DI";
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ../../src/ir.ts
|
|
189
|
+
var GATEWAY_TYPES;
|
|
190
|
+
var init_ir = __esm({
|
|
191
|
+
"../../src/ir.ts"() {
|
|
192
|
+
"use strict";
|
|
193
|
+
GATEWAY_TYPES = /* @__PURE__ */ new Set(["exclusiveGateway", "parallelGateway"]);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ../../src/layout-options.ts
|
|
198
|
+
function clamp(n) {
|
|
199
|
+
return Math.min(BOUNDS.max, Math.max(BOUNDS.min, n));
|
|
200
|
+
}
|
|
201
|
+
function mergeLayoutDiagramOptions(partial) {
|
|
202
|
+
const d = DEFAULT_LAYOUT_DIAGRAM_OPTIONS;
|
|
203
|
+
if (!partial) return { ...d };
|
|
204
|
+
const pick = (k) => {
|
|
205
|
+
const v = partial[k];
|
|
206
|
+
if (v === void 0) return d[k];
|
|
207
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return d[k];
|
|
208
|
+
return clamp(v);
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
poolPad: pick("poolPad"),
|
|
212
|
+
poolOriginX: pick("poolOriginX"),
|
|
213
|
+
poolOriginY: pick("poolOriginY"),
|
|
214
|
+
participantLabelBand: pick("participantLabelBand"),
|
|
215
|
+
laneLabelWidth: pick("laneLabelWidth"),
|
|
216
|
+
laneVerticalGap: pick("laneVerticalGap"),
|
|
217
|
+
laneContentRightPad: pick("laneContentRightPad"),
|
|
218
|
+
elkNodeSpacing: pick("elkNodeSpacing"),
|
|
219
|
+
elkLayerSpacing: pick("elkLayerSpacing"),
|
|
220
|
+
elkDiagramPadding: pick("elkDiagramPadding")
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function asFiniteNumber(value) {
|
|
224
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
225
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
226
|
+
const n = Number(value);
|
|
227
|
+
if (Number.isFinite(n)) return n;
|
|
228
|
+
}
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
function parseLayoutDiagramOptionsFromJson(value) {
|
|
232
|
+
if (!value || typeof value !== "object") return {};
|
|
233
|
+
const o = value;
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const k of LAYOUT_KEYS) {
|
|
236
|
+
const n = asFiniteNumber(o[k]);
|
|
237
|
+
if (n !== void 0) out[k] = n;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
var DEFAULT_LAYOUT_DIAGRAM_OPTIONS, BOUNDS, LAYOUT_KEYS;
|
|
242
|
+
var init_layout_options = __esm({
|
|
243
|
+
"../../src/layout-options.ts"() {
|
|
244
|
+
"use strict";
|
|
245
|
+
DEFAULT_LAYOUT_DIAGRAM_OPTIONS = {
|
|
246
|
+
poolPad: 40,
|
|
247
|
+
poolOriginX: 12,
|
|
248
|
+
poolOriginY: 12,
|
|
249
|
+
participantLabelBand: 48,
|
|
250
|
+
laneLabelWidth: 72,
|
|
251
|
+
laneVerticalGap: 40,
|
|
252
|
+
laneContentRightPad: 40,
|
|
253
|
+
elkNodeSpacing: 52,
|
|
254
|
+
elkLayerSpacing: 88,
|
|
255
|
+
elkDiagramPadding: 44
|
|
256
|
+
};
|
|
257
|
+
BOUNDS = { min: 0, max: 800 };
|
|
258
|
+
LAYOUT_KEYS = [
|
|
259
|
+
"poolPad",
|
|
260
|
+
"poolOriginX",
|
|
261
|
+
"poolOriginY",
|
|
262
|
+
"participantLabelBand",
|
|
263
|
+
"laneLabelWidth",
|
|
264
|
+
"laneVerticalGap",
|
|
265
|
+
"laneContentRightPad",
|
|
266
|
+
"elkNodeSpacing",
|
|
267
|
+
"elkLayerSpacing",
|
|
268
|
+
"elkDiagramPadding"
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ../../src/schema-path.ts
|
|
274
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
275
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
276
|
+
function dslSchemaPath(fromImportMetaUrl) {
|
|
277
|
+
const here = dirname2(fileURLToPath2(fromImportMetaUrl));
|
|
278
|
+
return join2(here, "..", "schemas", "bpmn-dsl.schema.json");
|
|
279
|
+
}
|
|
280
|
+
var init_schema_path = __esm({
|
|
281
|
+
"../../src/schema-path.ts"() {
|
|
282
|
+
"use strict";
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ../../src/parser.ts
|
|
287
|
+
import { createRequire } from "node:module";
|
|
288
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
289
|
+
import yaml from "js-yaml";
|
|
290
|
+
function formatAjvErrors() {
|
|
291
|
+
const e = validateRaw.errors;
|
|
292
|
+
if (!e?.length) return [];
|
|
293
|
+
return e.map(
|
|
294
|
+
(err) => `${err.instancePath || "/"} ${err.message ?? err.keyword}`.trim()
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
function validateDocument(data) {
|
|
298
|
+
if (!validateRaw(data)) {
|
|
299
|
+
const err = new Error("DSL validation failed");
|
|
300
|
+
err.errors = formatAjvErrors();
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function collectElements(doc) {
|
|
305
|
+
const p = doc.process;
|
|
306
|
+
if (p.pools.length !== 1) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
p.pools.length === 0 ? "At least one pool is required." : `Multiple pools are not supported (found ${p.pools.length}). Use a single pool per process.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const pool = p.pools[0];
|
|
312
|
+
const seen = /* @__PURE__ */ new Set();
|
|
313
|
+
const lanes = pool.lanes.map((lane) => ({
|
|
314
|
+
id: lane.id,
|
|
315
|
+
name: lane.name,
|
|
316
|
+
elements: lane.elements.map((el) => {
|
|
317
|
+
if (seen.has(el.id)) {
|
|
318
|
+
throw new Error(`Duplicate element id: ${el.id}`);
|
|
319
|
+
}
|
|
320
|
+
seen.add(el.id);
|
|
321
|
+
return {
|
|
322
|
+
id: el.id,
|
|
323
|
+
type: el.type,
|
|
324
|
+
name: el.name,
|
|
325
|
+
poolId: pool.id,
|
|
326
|
+
laneId: lane.id
|
|
327
|
+
};
|
|
328
|
+
})
|
|
329
|
+
}));
|
|
330
|
+
if (seen.has(pool.id)) {
|
|
331
|
+
throw new Error(`Pool id must differ from element ids: ${pool.id}`);
|
|
332
|
+
}
|
|
333
|
+
for (const lane of pool.lanes) {
|
|
334
|
+
if (seen.has(lane.id)) {
|
|
335
|
+
throw new Error(`Lane id must differ from element ids: ${lane.id}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const explicitFlowIds = new Set(p.flows.filter((f) => f.id != null).map((f) => f.id));
|
|
339
|
+
let autoIdx = 1;
|
|
340
|
+
const flows = p.flows.map((f) => {
|
|
341
|
+
let id;
|
|
342
|
+
if (f.id != null) {
|
|
343
|
+
id = f.id;
|
|
344
|
+
} else {
|
|
345
|
+
while (explicitFlowIds.has(`Flow_${autoIdx}`)) autoIdx++;
|
|
346
|
+
id = `Flow_${autoIdx++}`;
|
|
347
|
+
}
|
|
348
|
+
return { id, from: f.from, to: f.to, condition: f.condition, default: f.default, name: f.name };
|
|
349
|
+
});
|
|
350
|
+
const seenFlowIds = /* @__PURE__ */ new Set();
|
|
351
|
+
for (const f of flows) {
|
|
352
|
+
if (seenFlowIds.has(f.id)) {
|
|
353
|
+
throw new Error(`Duplicate flow id: ${f.id}`);
|
|
354
|
+
}
|
|
355
|
+
seenFlowIds.add(f.id);
|
|
356
|
+
}
|
|
357
|
+
for (const f of flows) {
|
|
358
|
+
if (f.from === f.to) {
|
|
359
|
+
throw new Error(`Self-loop flow is not supported: element "${f.from}" references itself`);
|
|
360
|
+
}
|
|
361
|
+
if (!seen.has(f.from)) {
|
|
362
|
+
throw new Error(`Flow references unknown element (from): ${f.from}`);
|
|
363
|
+
}
|
|
364
|
+
if (!seen.has(f.to)) {
|
|
365
|
+
throw new Error(`Flow references unknown element (to): ${f.to}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
id: p.id,
|
|
370
|
+
name: p.name,
|
|
371
|
+
poolId: pool.id,
|
|
372
|
+
poolName: pool.name,
|
|
373
|
+
lanes,
|
|
374
|
+
flows
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function parseYamlToIr(yamlText) {
|
|
378
|
+
const data = yaml.load(yamlText);
|
|
379
|
+
validateDocument(data);
|
|
380
|
+
return collectElements(data);
|
|
381
|
+
}
|
|
382
|
+
function elkNodeSize(kind) {
|
|
383
|
+
switch (kind) {
|
|
384
|
+
case "startEvent":
|
|
385
|
+
case "endEvent":
|
|
386
|
+
return { x: 0, y: 0, width: 36, height: 36 };
|
|
387
|
+
case "exclusiveGateway":
|
|
388
|
+
case "parallelGateway":
|
|
389
|
+
return { x: 0, y: 0, width: 50, height: 50 };
|
|
390
|
+
default:
|
|
391
|
+
return { x: 0, y: 0, width: 100, height: 80 };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
var SCHEMA, require2, Ajv, addFormats, ajv, validateRaw;
|
|
395
|
+
var init_parser = __esm({
|
|
396
|
+
"../../src/parser.ts"() {
|
|
397
|
+
"use strict";
|
|
398
|
+
init_schema_path();
|
|
399
|
+
SCHEMA = JSON.parse(readFileSync2(dslSchemaPath(import.meta.url), "utf8"));
|
|
400
|
+
require2 = createRequire(import.meta.url);
|
|
401
|
+
Ajv = require2("ajv");
|
|
402
|
+
addFormats = require2("ajv-formats");
|
|
403
|
+
ajv = new Ajv({ allErrors: true, strict: false });
|
|
404
|
+
addFormats(ajv);
|
|
405
|
+
validateRaw = ajv.compile(SCHEMA);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ../../src/layout.ts
|
|
410
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
411
|
+
function portPoint(b, port) {
|
|
412
|
+
const cx = b.x + b.width / 2;
|
|
413
|
+
const cy = b.y + b.height / 2;
|
|
414
|
+
switch (port) {
|
|
415
|
+
case "left":
|
|
416
|
+
return { x: b.x, y: cy };
|
|
417
|
+
case "right":
|
|
418
|
+
return { x: b.x + b.width, y: cy };
|
|
419
|
+
case "top":
|
|
420
|
+
return { x: cx, y: b.y };
|
|
421
|
+
case "bottom":
|
|
422
|
+
return { x: cx, y: b.y + b.height };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function laneElkLayoutOptions(o) {
|
|
426
|
+
const p = o.elkDiagramPadding;
|
|
427
|
+
return {
|
|
428
|
+
"elk.algorithm": "layered",
|
|
429
|
+
"elk.direction": "RIGHT",
|
|
430
|
+
"elk.spacing.nodeNode": String(o.elkNodeSpacing),
|
|
431
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(o.elkLayerSpacing),
|
|
432
|
+
"elk.padding": `[${p},${p},${p},${p}]`,
|
|
433
|
+
"elk.edgeRouting": "ORTHOGONAL"
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function elementIdSet(ir) {
|
|
437
|
+
const s = /* @__PURE__ */ new Set();
|
|
438
|
+
for (const lane of ir.lanes) {
|
|
439
|
+
for (const el of lane.elements) s.add(el.id);
|
|
440
|
+
}
|
|
441
|
+
return s;
|
|
442
|
+
}
|
|
443
|
+
function collectElementLaneMap(ir) {
|
|
444
|
+
const m = /* @__PURE__ */ new Map();
|
|
445
|
+
for (const lane of ir.lanes) {
|
|
446
|
+
for (const el of lane.elements) m.set(el.id, lane.id);
|
|
447
|
+
}
|
|
448
|
+
return m;
|
|
449
|
+
}
|
|
450
|
+
function collectGraphicBounds(elkOut, ids) {
|
|
451
|
+
const out = /* @__PURE__ */ new Map();
|
|
452
|
+
function visit(n, ox, oy) {
|
|
453
|
+
const x = ox + (n.x ?? 0);
|
|
454
|
+
const y = oy + (n.y ?? 0);
|
|
455
|
+
const w = n.width ?? 0;
|
|
456
|
+
const h = n.height ?? 0;
|
|
457
|
+
const hasChildren = Boolean(n.children?.length);
|
|
458
|
+
if (ids.has(n.id) && w > 0 && h > 0 && !hasChildren) {
|
|
459
|
+
out.set(n.id, { x, y, width: w, height: h });
|
|
460
|
+
}
|
|
461
|
+
if (hasChildren) {
|
|
462
|
+
for (const c of n.children ?? []) visit(c, x, y);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
visit(elkOut, 0, 0);
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
function boundsEnvelope(map) {
|
|
469
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
470
|
+
for (const b of map.values()) {
|
|
471
|
+
minX = Math.min(minX, b.x);
|
|
472
|
+
minY = Math.min(minY, b.y);
|
|
473
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
474
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
475
|
+
}
|
|
476
|
+
if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 0, height: 0 };
|
|
477
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
478
|
+
}
|
|
479
|
+
function dedupePoints(pts) {
|
|
480
|
+
const res = [];
|
|
481
|
+
for (const p of pts) {
|
|
482
|
+
const last = res[res.length - 1];
|
|
483
|
+
if (last && last.x === p.x && last.y === p.y) continue;
|
|
484
|
+
res.push(p);
|
|
485
|
+
}
|
|
486
|
+
return res;
|
|
487
|
+
}
|
|
488
|
+
function diagonalFallbackRects(a, b) {
|
|
489
|
+
return [
|
|
490
|
+
{ x: a.x + a.width / 2, y: a.y + a.height / 2 },
|
|
491
|
+
{ x: b.x + b.width / 2, y: b.y + b.height / 2 }
|
|
492
|
+
];
|
|
493
|
+
}
|
|
494
|
+
function routeSameLane(fromB, toB, exitYOffset = 0, exitPort = "right", isSourceGateway = false) {
|
|
495
|
+
const backward = toB.x + toB.width < fromB.x + CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
496
|
+
if (backward) {
|
|
497
|
+
const arcX = Math.min(fromB.x, toB.x) - BACKWARD_LOOP_CLEARANCE_PX;
|
|
498
|
+
return dedupePoints([
|
|
499
|
+
{ x: fromB.x, y: fromB.y + fromB.height / 2 },
|
|
500
|
+
{ x: arcX, y: fromB.y + fromB.height / 2 },
|
|
501
|
+
{ x: arcX, y: toB.y + toB.height / 2 },
|
|
502
|
+
{ x: toB.x, y: toB.y + toB.height / 2 }
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
if (exitPort !== "right") {
|
|
506
|
+
const ep = portPoint(fromB, exitPort);
|
|
507
|
+
const ip = portPoint(toB, "left");
|
|
508
|
+
if (exitPort === "top" || exitPort === "bottom") {
|
|
509
|
+
return dedupePoints([ep, { x: ep.x, y: ip.y }, ip]);
|
|
510
|
+
}
|
|
511
|
+
const midX2 = (ep.x + ip.x) / 2;
|
|
512
|
+
return dedupePoints([ep, { x: midX2, y: ep.y }, { x: midX2, y: ip.y }, ip]);
|
|
513
|
+
}
|
|
514
|
+
const ex = fromB.x + fromB.width;
|
|
515
|
+
const ey = fromB.y + fromB.height / 2 + exitYOffset;
|
|
516
|
+
const ix = toB.x;
|
|
517
|
+
const iy = toB.y + toB.height / 2;
|
|
518
|
+
if (exitYOffset === 0 && Math.abs(ey - iy) < 1) return [{ x: ex, y: ey }, { x: ix, y: iy }];
|
|
519
|
+
if (exitYOffset === 0 && Math.abs(ey - iy) < GATEWAY_BRANCH_CLEARANCE_PX) {
|
|
520
|
+
const approachX = ix - GATEWAY_BRANCH_CLEARANCE_PX;
|
|
521
|
+
return dedupePoints([{ x: ex, y: ey }, { x: approachX, y: ey }, { x: approachX, y: iy }, { x: ix, y: iy }]);
|
|
522
|
+
}
|
|
523
|
+
const midX = (ex + ix) / 2;
|
|
524
|
+
return dedupePoints([{ x: ex, y: ey }, { x: midX, y: ey }, { x: midX, y: iy }, { x: ix, y: iy }]);
|
|
525
|
+
}
|
|
526
|
+
function routeCrossLane(fromB, toB, fromLaneBound, toLaneBound, exitPort = "right", laneVerticalGap = 40) {
|
|
527
|
+
const targetBelow = toB.y >= fromB.y + fromB.height - CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
528
|
+
const targetAbove = toB.y + toB.height <= fromB.y + CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
529
|
+
const targetClearlyLeft = toB.x + toB.width / 2 < fromB.x - fromB.width / 2;
|
|
530
|
+
if ((targetBelow || targetAbove) && !targetClearlyLeft) {
|
|
531
|
+
const ep = portPoint(fromB, exitPort === "bottom" || exitPort === "top" ? exitPort : "right");
|
|
532
|
+
const ix2 = toB.x;
|
|
533
|
+
const iy2 = toB.y + toB.height / 2;
|
|
534
|
+
if (exitPort === "bottom" || exitPort === "top") {
|
|
535
|
+
if (!fromLaneBound) return diagonalFallbackRects(fromB, toB);
|
|
536
|
+
const chanY = targetBelow ? fromLaneBound.y + fromLaneBound.height + laneVerticalGap / 2 : fromLaneBound.y - laneVerticalGap / 2;
|
|
537
|
+
const approachX2 = ix2 - GATEWAY_BRANCH_CLEARANCE_PX;
|
|
538
|
+
return dedupePoints([
|
|
539
|
+
ep,
|
|
540
|
+
{ x: ep.x, y: chanY },
|
|
541
|
+
// vertical to inter-lane gap
|
|
542
|
+
{ x: approachX2, y: chanY },
|
|
543
|
+
// horizontal along gap to approach column
|
|
544
|
+
{ x: approachX2, y: iy2 },
|
|
545
|
+
// vertical to target centre Y
|
|
546
|
+
{ x: ix2, y: iy2 }
|
|
547
|
+
// horizontal into target left vertex
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
const approachX = ix2 - GATEWAY_BRANCH_CLEARANCE_PX;
|
|
551
|
+
return dedupePoints([
|
|
552
|
+
ep,
|
|
553
|
+
{ x: approachX, y: ep.y },
|
|
554
|
+
{ x: approachX, y: iy2 },
|
|
555
|
+
{ x: ix2, y: iy2 }
|
|
556
|
+
]);
|
|
557
|
+
}
|
|
558
|
+
if (targetBelow || targetAbove) {
|
|
559
|
+
const arcX = Math.min(fromB.x, toB.x) - BACKWARD_LOOP_CLEARANCE_PX;
|
|
560
|
+
return dedupePoints([
|
|
561
|
+
{ x: fromB.x, y: fromB.y + fromB.height / 2 },
|
|
562
|
+
{ x: arcX, y: fromB.y + fromB.height / 2 },
|
|
563
|
+
{ x: arcX, y: toB.y + toB.height / 2 },
|
|
564
|
+
{ x: toB.x, y: toB.y + toB.height / 2 }
|
|
565
|
+
]);
|
|
566
|
+
}
|
|
567
|
+
const ex = fromB.x + fromB.width;
|
|
568
|
+
const ey = fromB.y + fromB.height / 2;
|
|
569
|
+
const ix = toB.x;
|
|
570
|
+
const iy = toB.y + toB.height / 2;
|
|
571
|
+
const midX = (ex + ix) / 2;
|
|
572
|
+
return dedupePoints([{ x: ex, y: ey }, { x: midX, y: ey }, { x: midX, y: iy }, { x: ix, y: iy }]);
|
|
573
|
+
}
|
|
574
|
+
function buildGlobalGraph(ir, elkOpts) {
|
|
575
|
+
return {
|
|
576
|
+
id: "__global",
|
|
577
|
+
layoutOptions: elkOpts,
|
|
578
|
+
children: ir.lanes.flatMap(
|
|
579
|
+
(lane) => lane.elements.map((el) => {
|
|
580
|
+
const { width, height } = elkNodeSize(el.type);
|
|
581
|
+
return { id: el.id, width, height };
|
|
582
|
+
})
|
|
583
|
+
),
|
|
584
|
+
edges: ir.flows.map((f) => ({ id: f.id, sources: [f.from], targets: [f.to] }))
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function buildLaneGraph(laneId, els, internal, elkOpts) {
|
|
588
|
+
return {
|
|
589
|
+
id: `laneRoot_${laneId}`,
|
|
590
|
+
layoutOptions: elkOpts,
|
|
591
|
+
children: els.map((el) => {
|
|
592
|
+
const { width, height } = elkNodeSize(el.type);
|
|
593
|
+
return { id: el.id, width, height };
|
|
594
|
+
}),
|
|
595
|
+
edges: internal.map((f) => ({ id: f.id, sources: [f.from], targets: [f.to] }))
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async function layoutProcess(ir, layoutOpts) {
|
|
599
|
+
const o = mergeLayoutDiagramOptions(layoutOpts ?? {});
|
|
600
|
+
const elkOpts = laneElkLayoutOptions(o);
|
|
601
|
+
const ids = elementIdSet(ir);
|
|
602
|
+
const elementLane = collectElementLaneMap(ir);
|
|
603
|
+
const globalElkOut = await elk.layout(buildGlobalGraph(ir, elkOpts));
|
|
604
|
+
const globalBounds = collectGraphicBounds(globalElkOut, ids);
|
|
605
|
+
const globalMinX = Math.min(...[...globalBounds.values()].map((b) => b.x));
|
|
606
|
+
const contentW = Math.max(
|
|
607
|
+
...[...globalBounds.values()].map((b) => b.x - globalMinX + b.width),
|
|
608
|
+
100
|
|
609
|
+
);
|
|
610
|
+
const laneData = await Promise.all(
|
|
611
|
+
ir.lanes.map(async (lane) => {
|
|
612
|
+
const internalFlows = ir.flows.filter(
|
|
613
|
+
(f) => elementLane.get(f.from) === lane.id && elementLane.get(f.to) === lane.id
|
|
614
|
+
);
|
|
615
|
+
const graph = buildLaneGraph(lane.id, lane.elements, internalFlows, elkOpts);
|
|
616
|
+
const elkOut = await elk.layout(graph);
|
|
617
|
+
const graphic = collectGraphicBounds(elkOut, ids);
|
|
618
|
+
const env = boundsEnvelope(graphic);
|
|
619
|
+
const localY = new Map(
|
|
620
|
+
[...graphic.entries()].map(([id, b]) => [id, b.y - env.y])
|
|
621
|
+
);
|
|
622
|
+
return { laneId: lane.id, height: elkOut.height ?? env.height, localY, envY: env.y };
|
|
623
|
+
})
|
|
624
|
+
);
|
|
625
|
+
const laneOriginX = o.poolPad + o.participantLabelBand;
|
|
626
|
+
const laneLocalItems = /* @__PURE__ */ new Map();
|
|
627
|
+
for (const ld of laneData) {
|
|
628
|
+
const laneDef = ir.lanes.find((l) => l.id === ld.laneId);
|
|
629
|
+
if (!laneDef) throw new Error(`Layout invariant violated: lane "${ld.laneId}" not found in IR`);
|
|
630
|
+
const items = [];
|
|
631
|
+
for (const el of laneDef.elements) {
|
|
632
|
+
const gb = globalBounds.get(el.id);
|
|
633
|
+
if (!gb) continue;
|
|
634
|
+
items.push({
|
|
635
|
+
id: el.id,
|
|
636
|
+
x: laneOriginX + o.laneLabelWidth + (gb.x - globalMinX),
|
|
637
|
+
// Ensure at least elkDiagramPadding of top margin so backward arc clearance
|
|
638
|
+
// (BACKWARD_LOOP_CLEARANCE_PX = 32) always stays inside the lane boundary.
|
|
639
|
+
localY: Math.max((ld.localY.get(el.id) ?? 0) + ld.envY, o.elkDiagramPadding),
|
|
640
|
+
width: gb.width,
|
|
641
|
+
height: gb.height
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
laneLocalItems.set(ld.laneId, items);
|
|
645
|
+
}
|
|
646
|
+
for (const items of laneLocalItems.values()) {
|
|
647
|
+
const byColumn = /* @__PURE__ */ new Map();
|
|
648
|
+
for (const item of items) {
|
|
649
|
+
const col = Math.round(item.x);
|
|
650
|
+
const arr = byColumn.get(col) ?? [];
|
|
651
|
+
arr.push(item);
|
|
652
|
+
byColumn.set(col, arr);
|
|
653
|
+
}
|
|
654
|
+
for (const col of byColumn.values()) {
|
|
655
|
+
if (col.length <= 1) continue;
|
|
656
|
+
col.sort((a, b) => a.localY - b.localY);
|
|
657
|
+
for (let i = 1; i < col.length; i++) {
|
|
658
|
+
const prev = col[i - 1];
|
|
659
|
+
const curr = col[i];
|
|
660
|
+
const minY = prev.localY + prev.height + o.elkNodeSpacing;
|
|
661
|
+
if (curr.localY < minY) curr.localY = minY;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
let yCursor = o.poolPad;
|
|
666
|
+
const elements = /* @__PURE__ */ new Map();
|
|
667
|
+
const laneBounds = /* @__PURE__ */ new Map();
|
|
668
|
+
for (const ld of laneData) {
|
|
669
|
+
const items = laneLocalItems.get(ld.laneId) ?? [];
|
|
670
|
+
for (const item of items) {
|
|
671
|
+
elements.set(item.id, {
|
|
672
|
+
x: item.x,
|
|
673
|
+
y: yCursor + item.localY,
|
|
674
|
+
width: item.width,
|
|
675
|
+
height: item.height
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const contentBottom = items.length > 0 ? Math.max(...items.map((item) => item.localY + item.height)) : 0;
|
|
679
|
+
const laneHeight = Math.max(ld.height, contentBottom + ld.envY);
|
|
680
|
+
laneBounds.set(ld.laneId, {
|
|
681
|
+
x: laneOriginX,
|
|
682
|
+
y: yCursor,
|
|
683
|
+
width: o.laneLabelWidth + contentW + o.laneContentRightPad,
|
|
684
|
+
height: laneHeight
|
|
685
|
+
});
|
|
686
|
+
yCursor += laneHeight + o.laneVerticalGap;
|
|
687
|
+
}
|
|
688
|
+
const innerBottom = yCursor - o.laneVerticalGap;
|
|
689
|
+
const participantW = o.poolPad * 2 + o.participantLabelBand + o.laneLabelWidth + contentW + o.laneContentRightPad;
|
|
690
|
+
const participantH = innerBottom + o.poolPad;
|
|
691
|
+
const poolBounds = {
|
|
692
|
+
x: o.poolOriginX,
|
|
693
|
+
y: o.poolOriginY,
|
|
694
|
+
width: participantW,
|
|
695
|
+
height: participantH
|
|
696
|
+
};
|
|
697
|
+
for (const lb of laneBounds.values()) {
|
|
698
|
+
lb.width = participantW - 2 * o.poolPad;
|
|
699
|
+
}
|
|
700
|
+
for (const [laneId, items] of laneLocalItems) {
|
|
701
|
+
const lb = laneBounds.get(laneId);
|
|
702
|
+
if (!lb) continue;
|
|
703
|
+
const axisY = lb.y + lb.height / 2;
|
|
704
|
+
const byColumn = /* @__PURE__ */ new Map();
|
|
705
|
+
for (const item of items) {
|
|
706
|
+
const col = Math.round(item.x);
|
|
707
|
+
const arr = byColumn.get(col) ?? [];
|
|
708
|
+
arr.push(item);
|
|
709
|
+
byColumn.set(col, arr);
|
|
710
|
+
}
|
|
711
|
+
for (const col of byColumn.values()) {
|
|
712
|
+
if (col.length !== 1) continue;
|
|
713
|
+
const item = col[0];
|
|
714
|
+
const el = elements.get(item.id);
|
|
715
|
+
if (!el) continue;
|
|
716
|
+
let snapMinY = lb.y + BACKWARD_LOOP_CLEARANCE_PX;
|
|
717
|
+
let snapMaxY = lb.y + lb.height - item.height - BACKWARD_LOOP_CLEARANCE_PX;
|
|
718
|
+
if (snapMinY > snapMaxY) {
|
|
719
|
+
snapMinY = lb.y + 4;
|
|
720
|
+
snapMaxY = lb.y + lb.height - item.height - 4;
|
|
721
|
+
}
|
|
722
|
+
const snappedY = Math.max(snapMinY, Math.min(snapMaxY, axisY - item.height / 2));
|
|
723
|
+
elements.set(item.id, { ...el, y: snappedY });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const elementTypeMap = /* @__PURE__ */ new Map();
|
|
727
|
+
for (const lane of ir.lanes) {
|
|
728
|
+
for (const el of lane.elements) elementTypeMap.set(el.id, el.type);
|
|
729
|
+
}
|
|
730
|
+
const flowExitPort = /* @__PURE__ */ new Map();
|
|
731
|
+
const fwdSameLaneBySource = /* @__PURE__ */ new Map();
|
|
732
|
+
for (const f of ir.flows) {
|
|
733
|
+
const fb = elements.get(f.from);
|
|
734
|
+
const tb = elements.get(f.to);
|
|
735
|
+
if (!fb || !tb) continue;
|
|
736
|
+
if (elementLane.get(f.from) !== elementLane.get(f.to)) continue;
|
|
737
|
+
if (!GATEWAY_TYPES.has(elementTypeMap.get(f.from) ?? "")) continue;
|
|
738
|
+
const backward = tb.x + tb.width < fb.x + CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
739
|
+
if (backward) continue;
|
|
740
|
+
const arr = fwdSameLaneBySource.get(f.from) ?? [];
|
|
741
|
+
arr.push({ id: f.id, toB: tb });
|
|
742
|
+
fwdSameLaneBySource.set(f.from, arr);
|
|
743
|
+
}
|
|
744
|
+
for (const f of ir.flows) {
|
|
745
|
+
const fb = elements.get(f.from);
|
|
746
|
+
const tb = elements.get(f.to);
|
|
747
|
+
if (!fb || !tb) continue;
|
|
748
|
+
if (elementLane.get(f.from) === elementLane.get(f.to)) continue;
|
|
749
|
+
if (!GATEWAY_TYPES.has(elementTypeMap.get(f.from) ?? "")) continue;
|
|
750
|
+
const targetClearlyLeft = tb.x + tb.width / 2 < fb.x - fb.width / 2;
|
|
751
|
+
if (targetClearlyLeft) continue;
|
|
752
|
+
const targetBelow = tb.y >= fb.y + fb.height - CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
753
|
+
const targetAbove = tb.y + tb.height <= fb.y + CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
754
|
+
if (targetBelow) flowExitPort.set(f.id, "bottom");
|
|
755
|
+
else if (targetAbove) flowExitPort.set(f.id, "top");
|
|
756
|
+
}
|
|
757
|
+
const flowExitYOffset = /* @__PURE__ */ new Map();
|
|
758
|
+
const sameLaneFwdByNonGwSource = /* @__PURE__ */ new Map();
|
|
759
|
+
for (const f of ir.flows) {
|
|
760
|
+
const fb = elements.get(f.from);
|
|
761
|
+
const tb = elements.get(f.to);
|
|
762
|
+
if (!fb || !tb) continue;
|
|
763
|
+
if (elementLane.get(f.from) !== elementLane.get(f.to)) continue;
|
|
764
|
+
if (GATEWAY_TYPES.has(elementTypeMap.get(f.from) ?? "")) continue;
|
|
765
|
+
const backward = tb.x + tb.width < fb.x + CROSS_LANE_EDGE_OVERLAP_EPSILON_PX;
|
|
766
|
+
if (backward) continue;
|
|
767
|
+
const arr = sameLaneFwdByNonGwSource.get(f.from) ?? [];
|
|
768
|
+
arr.push({ id: f.id, targetX: tb.x });
|
|
769
|
+
sameLaneFwdByNonGwSource.set(f.from, arr);
|
|
770
|
+
}
|
|
771
|
+
for (const flows2 of sameLaneFwdByNonGwSource.values()) {
|
|
772
|
+
if (flows2.length <= 1) continue;
|
|
773
|
+
flows2.sort((a, b) => a.targetX - b.targetX);
|
|
774
|
+
const totalSpread = (flows2.length - 1) * MULTI_EXIT_OFFSET_STEP_PX;
|
|
775
|
+
flows2.forEach((f, i) => {
|
|
776
|
+
flowExitYOffset.set(f.id, -totalSpread / 2 + i * MULTI_EXIT_OFFSET_STEP_PX);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const GATEWAY_VERTEX_THRESHOLD_PX = 10;
|
|
780
|
+
for (const [sourceId, flows2] of fwdSameLaneBySource) {
|
|
781
|
+
if (flows2.length <= 1) continue;
|
|
782
|
+
const gwB = elements.get(sourceId);
|
|
783
|
+
const gwCY = gwB.y + gwB.height / 2;
|
|
784
|
+
const above = flows2.filter((f) => f.toB.y + f.toB.height / 2 < gwCY - GATEWAY_VERTEX_THRESHOLD_PX).sort((a, b) => a.toB.y + a.toB.height / 2 - (b.toB.y + b.toB.height / 2));
|
|
785
|
+
const below = flows2.filter((f) => f.toB.y + f.toB.height / 2 > gwCY + GATEWAY_VERTEX_THRESHOLD_PX).sort((a, b) => b.toB.y + b.toB.height / 2 - (a.toB.y + a.toB.height / 2));
|
|
786
|
+
if (above.length > 0) flowExitPort.set(above[0].id, "top");
|
|
787
|
+
if (below.length > 0) flowExitPort.set(below[0].id, "bottom");
|
|
788
|
+
const rightFlows = [
|
|
789
|
+
...above.slice(1),
|
|
790
|
+
...flows2.filter(
|
|
791
|
+
(f) => Math.abs(f.toB.y + f.toB.height / 2 - gwCY) <= GATEWAY_VERTEX_THRESHOLD_PX
|
|
792
|
+
),
|
|
793
|
+
...below.slice(1)
|
|
794
|
+
].sort((a, b) => a.toB.y - b.toB.y);
|
|
795
|
+
if (rightFlows.length > 1) {
|
|
796
|
+
const total = (rightFlows.length - 1) * MULTI_EXIT_OFFSET_STEP_PX;
|
|
797
|
+
rightFlows.forEach((f, i) => {
|
|
798
|
+
flowExitYOffset.set(f.id, -total / 2 + i * MULTI_EXIT_OFFSET_STEP_PX);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const flows = ir.flows.map((f) => {
|
|
803
|
+
const fb = elements.get(f.from);
|
|
804
|
+
const tb = elements.get(f.to);
|
|
805
|
+
let wps = [];
|
|
806
|
+
if (fb && tb) {
|
|
807
|
+
const fromL = elementLane.get(f.from);
|
|
808
|
+
const toL = elementLane.get(f.to);
|
|
809
|
+
const exitOffset = flowExitYOffset.get(f.id) ?? 0;
|
|
810
|
+
const exitPort = flowExitPort.get(f.id) ?? "right";
|
|
811
|
+
const fromLaneBound = fromL ? laneBounds.get(fromL) : void 0;
|
|
812
|
+
const toLaneBound = toL ? laneBounds.get(toL) : void 0;
|
|
813
|
+
const isSourceGateway = GATEWAY_TYPES.has(elementTypeMap.get(f.from) ?? "");
|
|
814
|
+
wps = fromL === toL ? routeSameLane(fb, tb, exitOffset, exitPort, isSourceGateway) : routeCrossLane(fb, tb, fromLaneBound, toLaneBound, exitPort, o.laneVerticalGap);
|
|
815
|
+
if (wps.length < 2) wps = diagonalFallbackRects(fb, tb);
|
|
816
|
+
}
|
|
817
|
+
return { ...f, waypoints: wps };
|
|
818
|
+
});
|
|
819
|
+
flows.sort((a, b) => a.id.localeCompare(b.id));
|
|
820
|
+
return { process: ir, elements, laneBounds, poolBounds, flows };
|
|
821
|
+
}
|
|
822
|
+
var require3, ELKCtor, elk, CROSS_LANE_EDGE_OVERLAP_EPSILON_PX, BACKWARD_LOOP_CLEARANCE_PX, MULTI_EXIT_OFFSET_STEP_PX, GATEWAY_BRANCH_CLEARANCE_PX;
|
|
823
|
+
var init_layout = __esm({
|
|
824
|
+
"../../src/layout.ts"() {
|
|
825
|
+
"use strict";
|
|
826
|
+
init_ir();
|
|
827
|
+
init_layout_options();
|
|
828
|
+
init_parser();
|
|
829
|
+
require3 = createRequire2(import.meta.url);
|
|
830
|
+
ELKCtor = require3("elkjs/lib/elk.bundled.js");
|
|
831
|
+
elk = new ELKCtor();
|
|
832
|
+
CROSS_LANE_EDGE_OVERLAP_EPSILON_PX = 4;
|
|
833
|
+
BACKWARD_LOOP_CLEARANCE_PX = 32;
|
|
834
|
+
MULTI_EXIT_OFFSET_STEP_PX = 8;
|
|
835
|
+
GATEWAY_BRANCH_CLEARANCE_PX = 20;
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// ../../src/validator.ts
|
|
840
|
+
function validateProcess(ir, config) {
|
|
841
|
+
return validator.validate(ir, config);
|
|
842
|
+
}
|
|
843
|
+
var ValidatorRegistry, validator, rule_SE_001_StartEventExists, rule_SE_003_NoIncomingToStart, rule_SE_004_OneOutgoingFromStart, rule_EE_001_EndEventExists, rule_EE_003_NoOutgoingFromEnd, rule_EE_004_IncomingToEnd, rule_ACT_001_TaskConnectivity, rule_CONN_001_ElementReachability, rule_CONN_002_GraphConnectivity, rule_SF_005_ConditionSourceRestriction, rule_SF_006_DefaultFlowSourceRestriction, rule_SF_007_DefaultAndConditionExclusive, rule_GW_XOR_001_SingleInSingleOutForbidden, rule_GW_XOR_002_SplitConstraints, rule_GW_AND_004_NoConditionsOnParallelSplit, rule_AP_FloatingElements, rule_AP_MissingDefaultOnConditionalXor, rule_AP_ImplicitJoin, IMPERATIVE_VERBS, rule_AP_GatewayAsTask, rule_SF_DUP_NoDuplicateFlows, rule_SF_01_ValidFlowEndpoints;
|
|
844
|
+
var init_validator = __esm({
|
|
845
|
+
"../../src/validator.ts"() {
|
|
846
|
+
"use strict";
|
|
847
|
+
ValidatorRegistry = class {
|
|
848
|
+
rules = /* @__PURE__ */ new Map();
|
|
849
|
+
/**
|
|
850
|
+
* Register a new validation rule.
|
|
851
|
+
* @param rule The validation rule to register.
|
|
852
|
+
*/
|
|
853
|
+
register(rule) {
|
|
854
|
+
this.rules.set(rule.ruleId, rule);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Deregister a validation rule by ID.
|
|
858
|
+
* @param ruleId ID of the rule to remove.
|
|
859
|
+
*/
|
|
860
|
+
deregister(ruleId) {
|
|
861
|
+
this.rules.delete(ruleId);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Get all registered rule IDs.
|
|
865
|
+
*/
|
|
866
|
+
getRuleIds() {
|
|
867
|
+
return Array.from(this.rules.keys());
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Get the rules map (for config validation and introspection).
|
|
871
|
+
* Used by RD-097 config loader to enforce severity constraints.
|
|
872
|
+
*/
|
|
873
|
+
getRules() {
|
|
874
|
+
return this.rules;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Validate a ProcessIr against all registered rules.
|
|
878
|
+
* @param ir The ProcessIr to validate.
|
|
879
|
+
* @param config Optional validator configuration (e.g., filter rules).
|
|
880
|
+
* @returns ValidationReport with findings and summary.
|
|
881
|
+
*/
|
|
882
|
+
validate(ir, config) {
|
|
883
|
+
const findings = [];
|
|
884
|
+
let rulesToRun = Array.from(this.rules.values());
|
|
885
|
+
if (config?.enabledRules) {
|
|
886
|
+
rulesToRun = rulesToRun.filter(
|
|
887
|
+
(rule) => config.enabledRules.has(rule.ruleId)
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
for (const rule of rulesToRun) {
|
|
891
|
+
findings.push(...rule.validate(ir));
|
|
892
|
+
}
|
|
893
|
+
const severityOrder = {
|
|
894
|
+
error: 0,
|
|
895
|
+
warning: 1,
|
|
896
|
+
info: 2
|
|
897
|
+
};
|
|
898
|
+
findings.sort(
|
|
899
|
+
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
|
|
900
|
+
);
|
|
901
|
+
return {
|
|
902
|
+
isValid: !findings.some((f) => f.severity === "error"),
|
|
903
|
+
findings,
|
|
904
|
+
summary: {
|
|
905
|
+
errorCount: findings.filter((f) => f.severity === "error").length,
|
|
906
|
+
warningCount: findings.filter((f) => f.severity === "warning").length,
|
|
907
|
+
infoCount: findings.filter((f) => f.severity === "info").length
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
validator = new ValidatorRegistry();
|
|
913
|
+
rule_SE_001_StartEventExists = {
|
|
914
|
+
ruleId: "SE-001",
|
|
915
|
+
severity: "error",
|
|
916
|
+
description: "Process must have at least one start event",
|
|
917
|
+
validate(ir) {
|
|
918
|
+
const startEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "startEvent");
|
|
919
|
+
if (startEvents.length === 0) {
|
|
920
|
+
return [
|
|
921
|
+
{
|
|
922
|
+
ruleId: "SE-001",
|
|
923
|
+
severity: "error",
|
|
924
|
+
message: "Process must have at least one start event",
|
|
925
|
+
hint: "Add a start event to begin process flow",
|
|
926
|
+
docUrl: "docs/validation.md#se-001"
|
|
927
|
+
}
|
|
928
|
+
];
|
|
929
|
+
}
|
|
930
|
+
return [];
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
rule_SE_003_NoIncomingToStart = {
|
|
934
|
+
ruleId: "SE-003",
|
|
935
|
+
severity: "error",
|
|
936
|
+
description: "Start events must not have incoming flows",
|
|
937
|
+
validate(ir) {
|
|
938
|
+
const startEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "startEvent");
|
|
939
|
+
const findings = [];
|
|
940
|
+
for (const start of startEvents) {
|
|
941
|
+
const hasIncoming = ir.flows.some((flow) => flow.to === start.id);
|
|
942
|
+
if (hasIncoming) {
|
|
943
|
+
findings.push({
|
|
944
|
+
ruleId: "SE-003",
|
|
945
|
+
severity: "error",
|
|
946
|
+
elementId: start.id,
|
|
947
|
+
message: `Start event "${start.name || start.id}" must not have incoming flows`,
|
|
948
|
+
hint: "Remove flows targeting this start event; it is an entry point only",
|
|
949
|
+
docUrl: "docs/validation.md#se-003"
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return findings;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
rule_SE_004_OneOutgoingFromStart = {
|
|
957
|
+
ruleId: "SE-004",
|
|
958
|
+
severity: "error",
|
|
959
|
+
description: "Start events must have exactly one outgoing flow",
|
|
960
|
+
validate(ir) {
|
|
961
|
+
const startEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "startEvent");
|
|
962
|
+
const findings = [];
|
|
963
|
+
for (const start of startEvents) {
|
|
964
|
+
const outgoingCount = ir.flows.filter((flow) => flow.from === start.id).length;
|
|
965
|
+
if (outgoingCount !== 1) {
|
|
966
|
+
const message = outgoingCount === 0 ? `Start event "${start.name || start.id}" must have an outgoing flow` : `Start event "${start.name || start.id}" must have exactly one outgoing flow (found ${outgoingCount})`;
|
|
967
|
+
const hint = outgoingCount === 0 ? "Add a flow from this start event to the first process activity" : `Remove ${outgoingCount - 1} extra flow(s) from this start event; use a gateway if multiple paths are needed`;
|
|
968
|
+
findings.push({
|
|
969
|
+
ruleId: "SE-004",
|
|
970
|
+
severity: "error",
|
|
971
|
+
elementId: start.id,
|
|
972
|
+
message,
|
|
973
|
+
hint,
|
|
974
|
+
docUrl: "docs/validation.md#se-004"
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return findings;
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
validator.register(rule_SE_001_StartEventExists);
|
|
982
|
+
validator.register(rule_SE_003_NoIncomingToStart);
|
|
983
|
+
validator.register(rule_SE_004_OneOutgoingFromStart);
|
|
984
|
+
rule_EE_001_EndEventExists = {
|
|
985
|
+
ruleId: "EE-001",
|
|
986
|
+
severity: "error",
|
|
987
|
+
description: "Process must have at least one end event",
|
|
988
|
+
validate(ir) {
|
|
989
|
+
const endEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "endEvent");
|
|
990
|
+
if (endEvents.length === 0) {
|
|
991
|
+
return [
|
|
992
|
+
{
|
|
993
|
+
ruleId: "EE-001",
|
|
994
|
+
severity: "error",
|
|
995
|
+
message: "Process must have at least one end event",
|
|
996
|
+
hint: "Add an end event to define process termination",
|
|
997
|
+
docUrl: "docs/validation.md#ee-001"
|
|
998
|
+
}
|
|
999
|
+
];
|
|
1000
|
+
}
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
rule_EE_003_NoOutgoingFromEnd = {
|
|
1005
|
+
ruleId: "EE-003",
|
|
1006
|
+
severity: "error",
|
|
1007
|
+
description: "End events must not have outgoing flows",
|
|
1008
|
+
validate(ir) {
|
|
1009
|
+
const endEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "endEvent");
|
|
1010
|
+
const findings = [];
|
|
1011
|
+
for (const end of endEvents) {
|
|
1012
|
+
const hasOutgoing = ir.flows.some((flow) => flow.from === end.id);
|
|
1013
|
+
if (hasOutgoing) {
|
|
1014
|
+
findings.push({
|
|
1015
|
+
ruleId: "EE-003",
|
|
1016
|
+
severity: "error",
|
|
1017
|
+
elementId: end.id,
|
|
1018
|
+
message: `End event "${end.name || end.id}" must not have outgoing flows`,
|
|
1019
|
+
hint: "Remove flows originating from this end event",
|
|
1020
|
+
docUrl: "docs/validation.md#ee-003"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return findings;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
rule_EE_004_IncomingToEnd = {
|
|
1028
|
+
ruleId: "EE-004",
|
|
1029
|
+
severity: "error",
|
|
1030
|
+
description: "End events must have at least one incoming flow",
|
|
1031
|
+
validate(ir) {
|
|
1032
|
+
const endEvents = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "endEvent");
|
|
1033
|
+
const findings = [];
|
|
1034
|
+
for (const end of endEvents) {
|
|
1035
|
+
const incomingCount = ir.flows.filter((flow) => flow.to === end.id).length;
|
|
1036
|
+
if (incomingCount === 0) {
|
|
1037
|
+
findings.push({
|
|
1038
|
+
ruleId: "EE-004",
|
|
1039
|
+
severity: "error",
|
|
1040
|
+
elementId: end.id,
|
|
1041
|
+
message: `End event "${end.name || end.id}" must have at least one incoming flow`,
|
|
1042
|
+
hint: "Connect this end event to the final process activity",
|
|
1043
|
+
docUrl: "docs/validation.md#ee-004"
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return findings;
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
validator.register(rule_EE_001_EndEventExists);
|
|
1051
|
+
validator.register(rule_EE_003_NoOutgoingFromEnd);
|
|
1052
|
+
validator.register(rule_EE_004_IncomingToEnd);
|
|
1053
|
+
rule_ACT_001_TaskConnectivity = {
|
|
1054
|
+
ruleId: "ACT-001",
|
|
1055
|
+
severity: "error",
|
|
1056
|
+
description: "Tasks must have at least one incoming and one outgoing flow",
|
|
1057
|
+
validate(ir) {
|
|
1058
|
+
const tasks = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "task");
|
|
1059
|
+
const totalElements = ir.lanes.reduce((sum, lane) => sum + lane.elements.length, 0);
|
|
1060
|
+
const isSoleElement = totalElements === 1;
|
|
1061
|
+
const findings = [];
|
|
1062
|
+
for (const task of tasks) {
|
|
1063
|
+
if (isSoleElement) {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const incoming = ir.flows.filter((flow) => flow.to === task.id).length;
|
|
1067
|
+
const outgoing = ir.flows.filter((flow) => flow.from === task.id).length;
|
|
1068
|
+
if (incoming === 0 || outgoing === 0) {
|
|
1069
|
+
const issues = [];
|
|
1070
|
+
if (incoming === 0) issues.push("no incoming");
|
|
1071
|
+
if (outgoing === 0) issues.push("no outgoing");
|
|
1072
|
+
findings.push({
|
|
1073
|
+
ruleId: "ACT-001",
|
|
1074
|
+
severity: "error",
|
|
1075
|
+
elementId: task.id,
|
|
1076
|
+
message: `Task "${task.name || task.id}" must have incoming and outgoing flows (${issues.join(", ")})`,
|
|
1077
|
+
hint: incoming === 0 && outgoing === 0 ? "Connect this task to both a predecessor and successor activity" : incoming === 0 ? "Connect a flow from the previous activity to this task" : "Connect a flow from this task to the next activity",
|
|
1078
|
+
docUrl: "docs/validation.md#act-001"
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return findings;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
validator.register(rule_ACT_001_TaskConnectivity);
|
|
1086
|
+
rule_CONN_001_ElementReachability = {
|
|
1087
|
+
ruleId: "CONN-001",
|
|
1088
|
+
severity: "error",
|
|
1089
|
+
description: "All elements must be reachable from a start event and reach an end event",
|
|
1090
|
+
validate(ir) {
|
|
1091
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1092
|
+
const startEvents = allElements.filter((el) => el.type === "startEvent");
|
|
1093
|
+
const endEvents = allElements.filter((el) => el.type === "endEvent");
|
|
1094
|
+
if (startEvents.length === 0 || endEvents.length === 0) {
|
|
1095
|
+
return [];
|
|
1096
|
+
}
|
|
1097
|
+
const forward = /* @__PURE__ */ new Map();
|
|
1098
|
+
const backward = /* @__PURE__ */ new Map();
|
|
1099
|
+
for (const el of allElements) {
|
|
1100
|
+
forward.set(el.id, []);
|
|
1101
|
+
backward.set(el.id, []);
|
|
1102
|
+
}
|
|
1103
|
+
for (const flow of ir.flows) {
|
|
1104
|
+
const outgoing = forward.get(flow.from) ?? [];
|
|
1105
|
+
outgoing.push(flow.to);
|
|
1106
|
+
forward.set(flow.from, outgoing);
|
|
1107
|
+
const incoming = backward.get(flow.to) ?? [];
|
|
1108
|
+
incoming.push(flow.from);
|
|
1109
|
+
backward.set(flow.to, incoming);
|
|
1110
|
+
}
|
|
1111
|
+
const reachableFromStart = /* @__PURE__ */ new Set();
|
|
1112
|
+
for (const start of startEvents) {
|
|
1113
|
+
const stack = [start.id];
|
|
1114
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1115
|
+
while (stack.length > 0) {
|
|
1116
|
+
const node = stack.pop();
|
|
1117
|
+
if (visited.has(node)) continue;
|
|
1118
|
+
visited.add(node);
|
|
1119
|
+
reachableFromStart.add(node);
|
|
1120
|
+
const outgoing = forward.get(node) ?? [];
|
|
1121
|
+
for (const next of outgoing) {
|
|
1122
|
+
if (!visited.has(next)) {
|
|
1123
|
+
stack.push(next);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const reachesEnd = /* @__PURE__ */ new Set();
|
|
1129
|
+
for (const end of endEvents) {
|
|
1130
|
+
const stack = [end.id];
|
|
1131
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1132
|
+
while (stack.length > 0) {
|
|
1133
|
+
const node = stack.pop();
|
|
1134
|
+
if (visited.has(node)) continue;
|
|
1135
|
+
visited.add(node);
|
|
1136
|
+
reachesEnd.add(node);
|
|
1137
|
+
const incoming = backward.get(node) ?? [];
|
|
1138
|
+
for (const prev of incoming) {
|
|
1139
|
+
if (!visited.has(prev)) {
|
|
1140
|
+
stack.push(prev);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const findings = [];
|
|
1146
|
+
for (const el of allElements) {
|
|
1147
|
+
if (el.type === "startEvent" || el.type === "endEvent") {
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
const fromStart = reachableFromStart.has(el.id);
|
|
1151
|
+
const toEnd = reachesEnd.has(el.id);
|
|
1152
|
+
if (!fromStart || !toEnd) {
|
|
1153
|
+
const issues = [];
|
|
1154
|
+
if (!fromStart) issues.push("not reachable from start");
|
|
1155
|
+
if (!toEnd) issues.push("does not reach end");
|
|
1156
|
+
findings.push({
|
|
1157
|
+
ruleId: "CONN-001",
|
|
1158
|
+
severity: "error",
|
|
1159
|
+
elementId: el.id,
|
|
1160
|
+
message: `Element "${el.name || el.id}" must be reachable from start and reach end (${issues.join(", ")})`,
|
|
1161
|
+
hint: !fromStart && !toEnd ? "Connect this element to the main process flow" : !fromStart ? "Add a flow from a preceding element to this element" : "Add a flow from this element to a succeeding element",
|
|
1162
|
+
docUrl: "docs/validation.md#conn-001"
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return findings;
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
rule_CONN_002_GraphConnectivity = {
|
|
1170
|
+
ruleId: "CONN-002",
|
|
1171
|
+
severity: "error",
|
|
1172
|
+
description: "Process graph must be weakly connected (no isolated subgraphs)",
|
|
1173
|
+
validate(ir) {
|
|
1174
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1175
|
+
if (allElements.length <= 1) return [];
|
|
1176
|
+
const neighbors = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const el of allElements) {
|
|
1178
|
+
neighbors.set(el.id, /* @__PURE__ */ new Set());
|
|
1179
|
+
}
|
|
1180
|
+
for (const flow of ir.flows) {
|
|
1181
|
+
neighbors.get(flow.from)?.add(flow.to);
|
|
1182
|
+
neighbors.get(flow.to)?.add(flow.from);
|
|
1183
|
+
}
|
|
1184
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1185
|
+
const stack = [allElements[0].id];
|
|
1186
|
+
while (stack.length > 0) {
|
|
1187
|
+
const node = stack.pop();
|
|
1188
|
+
if (visited.has(node)) continue;
|
|
1189
|
+
visited.add(node);
|
|
1190
|
+
const adjacent = neighbors.get(node) ?? /* @__PURE__ */ new Set();
|
|
1191
|
+
for (const next of adjacent) {
|
|
1192
|
+
if (!visited.has(next)) {
|
|
1193
|
+
stack.push(next);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (visited.size === allElements.length) {
|
|
1198
|
+
return [];
|
|
1199
|
+
}
|
|
1200
|
+
const isolated = allElements.filter((el) => !visited.has(el.id));
|
|
1201
|
+
return isolated.map((el) => ({
|
|
1202
|
+
ruleId: "CONN-002",
|
|
1203
|
+
severity: "error",
|
|
1204
|
+
elementId: el.id,
|
|
1205
|
+
message: `Element "${el.name || el.id}" is in an isolated subgraph`,
|
|
1206
|
+
hint: "Connect this element to the main process flow",
|
|
1207
|
+
docUrl: "docs/validation.md#conn-002"
|
|
1208
|
+
}));
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
validator.register(rule_CONN_001_ElementReachability);
|
|
1212
|
+
validator.register(rule_CONN_002_GraphConnectivity);
|
|
1213
|
+
rule_SF_005_ConditionSourceRestriction = {
|
|
1214
|
+
ruleId: "SF-005",
|
|
1215
|
+
severity: "error",
|
|
1216
|
+
description: "Condition expressions only allowed on flows from Activity or XOR gateway",
|
|
1217
|
+
validate(ir) {
|
|
1218
|
+
const findings = [];
|
|
1219
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1220
|
+
const elementMap = new Map(allElements.map((el) => [el.id, el]));
|
|
1221
|
+
for (const flow of ir.flows) {
|
|
1222
|
+
if (flow.condition) {
|
|
1223
|
+
const sourceEl = elementMap.get(flow.from);
|
|
1224
|
+
if (sourceEl && !["task", "userTask", "serviceTask", "exclusiveGateway"].includes(sourceEl.type)) {
|
|
1225
|
+
findings.push({
|
|
1226
|
+
ruleId: "SF-005",
|
|
1227
|
+
severity: "error",
|
|
1228
|
+
elementId: flow.id,
|
|
1229
|
+
message: `Flow "${flow.id}" with condition cannot originate from "${sourceEl.type}"`,
|
|
1230
|
+
hint: "Condition expressions are only allowed on flows from Activities (task, userTask, serviceTask) or XOR gateways",
|
|
1231
|
+
docUrl: "docs/validation.md#sf-005"
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return findings;
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
rule_SF_006_DefaultFlowSourceRestriction = {
|
|
1240
|
+
ruleId: "SF-006",
|
|
1241
|
+
severity: "error",
|
|
1242
|
+
description: "Default flow marker only allowed on flows from Activity or XOR gateway",
|
|
1243
|
+
validate(ir) {
|
|
1244
|
+
const findings = [];
|
|
1245
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1246
|
+
const elementMap = new Map(allElements.map((el) => [el.id, el]));
|
|
1247
|
+
for (const flow of ir.flows) {
|
|
1248
|
+
if (flow.default) {
|
|
1249
|
+
const sourceEl = elementMap.get(flow.from);
|
|
1250
|
+
if (sourceEl && !["task", "userTask", "serviceTask", "exclusiveGateway"].includes(sourceEl.type)) {
|
|
1251
|
+
findings.push({
|
|
1252
|
+
ruleId: "SF-006",
|
|
1253
|
+
severity: "error",
|
|
1254
|
+
elementId: flow.id,
|
|
1255
|
+
message: `Flow "${flow.id}" marked as default cannot originate from "${sourceEl.type}"`,
|
|
1256
|
+
hint: "Default flow marker is only allowed on flows from Activities (task, userTask, serviceTask) or XOR gateways",
|
|
1257
|
+
docUrl: "docs/validation.md#sf-006"
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return findings;
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
rule_SF_007_DefaultAndConditionExclusive = {
|
|
1266
|
+
ruleId: "SF-007",
|
|
1267
|
+
severity: "error",
|
|
1268
|
+
description: "Flow cannot have both default marker and condition expression",
|
|
1269
|
+
validate(ir) {
|
|
1270
|
+
const findings = [];
|
|
1271
|
+
for (const flow of ir.flows) {
|
|
1272
|
+
if (flow.default && flow.condition) {
|
|
1273
|
+
findings.push({
|
|
1274
|
+
ruleId: "SF-007",
|
|
1275
|
+
severity: "error",
|
|
1276
|
+
elementId: flow.id,
|
|
1277
|
+
message: `Flow "${flow.id}" cannot have both default marker and condition expression`,
|
|
1278
|
+
hint: "A flow must be either the default route (default: true) or have a condition, not both",
|
|
1279
|
+
docUrl: "docs/validation.md#sf-007"
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return findings;
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
validator.register(rule_SF_005_ConditionSourceRestriction);
|
|
1287
|
+
validator.register(rule_SF_006_DefaultFlowSourceRestriction);
|
|
1288
|
+
validator.register(rule_SF_007_DefaultAndConditionExclusive);
|
|
1289
|
+
rule_GW_XOR_001_SingleInSingleOutForbidden = {
|
|
1290
|
+
ruleId: "GW-XOR-01",
|
|
1291
|
+
severity: "error",
|
|
1292
|
+
description: "XOR gateway cannot have single incoming and single outgoing flow",
|
|
1293
|
+
validate(ir) {
|
|
1294
|
+
const findings = [];
|
|
1295
|
+
const xorGateways = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "exclusiveGateway");
|
|
1296
|
+
for (const gateway of xorGateways) {
|
|
1297
|
+
const incoming = ir.flows.filter((f) => f.to === gateway.id).length;
|
|
1298
|
+
const outgoing = ir.flows.filter((f) => f.from === gateway.id).length;
|
|
1299
|
+
if (incoming === 1 && outgoing === 1) {
|
|
1300
|
+
findings.push({
|
|
1301
|
+
ruleId: "GW-XOR-01",
|
|
1302
|
+
severity: "error",
|
|
1303
|
+
elementId: gateway.id,
|
|
1304
|
+
message: `XOR gateway "${gateway.name || gateway.id}" has single incoming and single outgoing flow`,
|
|
1305
|
+
hint: "Use a direct flow instead of a single-in single-out gateway; XOR is for routing decisions",
|
|
1306
|
+
docUrl: "docs/validation.md#gw-xor-01"
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return findings;
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
rule_GW_XOR_002_SplitConstraints = {
|
|
1314
|
+
ruleId: "GW-XOR-02",
|
|
1315
|
+
severity: "error",
|
|
1316
|
+
description: "XOR split: at most one default flow; all others must have condition",
|
|
1317
|
+
validate(ir) {
|
|
1318
|
+
const findings = [];
|
|
1319
|
+
const xorGateways = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "exclusiveGateway");
|
|
1320
|
+
for (const gateway of xorGateways) {
|
|
1321
|
+
const outgoing = ir.flows.filter((f) => f.from === gateway.id);
|
|
1322
|
+
if (outgoing.length <= 1) continue;
|
|
1323
|
+
const defaultFlows = outgoing.filter((f) => f.default);
|
|
1324
|
+
const conditional = outgoing.filter((f) => f.condition);
|
|
1325
|
+
if (defaultFlows.length > 1) {
|
|
1326
|
+
findings.push({
|
|
1327
|
+
ruleId: "GW-XOR-02",
|
|
1328
|
+
severity: "error",
|
|
1329
|
+
elementId: gateway.id,
|
|
1330
|
+
message: `XOR split "${gateway.name || gateway.id}" has ${defaultFlows.length} default flows (max 1)`,
|
|
1331
|
+
hint: "Mark at most one outgoing flow as default; others must have explicit conditions",
|
|
1332
|
+
docUrl: "docs/validation.md#gw-xor-02"
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
const unconditional = outgoing.filter((f) => !f.default && !f.condition);
|
|
1336
|
+
if (unconditional.length > 0) {
|
|
1337
|
+
findings.push({
|
|
1338
|
+
ruleId: "GW-XOR-02",
|
|
1339
|
+
severity: "error",
|
|
1340
|
+
elementId: gateway.id,
|
|
1341
|
+
message: `XOR split "${gateway.name || gateway.id}" has ${unconditional.length} flow(s) without condition or default`,
|
|
1342
|
+
hint: "All outgoing flows must either have a condition or be marked as default (but max 1 default)",
|
|
1343
|
+
docUrl: "docs/validation.md#gw-xor-02"
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return findings;
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
rule_GW_AND_004_NoConditionsOnParallelSplit = {
|
|
1351
|
+
ruleId: "GW-AND-04",
|
|
1352
|
+
severity: "error",
|
|
1353
|
+
description: "Parallel gateway split outgoing flows must not have conditions",
|
|
1354
|
+
validate(ir) {
|
|
1355
|
+
const findings = [];
|
|
1356
|
+
const andGateways = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "parallelGateway");
|
|
1357
|
+
for (const gateway of andGateways) {
|
|
1358
|
+
const outgoing = ir.flows.filter((f) => f.from === gateway.id);
|
|
1359
|
+
const withCondition = outgoing.filter((f) => f.condition);
|
|
1360
|
+
if (withCondition.length > 0) {
|
|
1361
|
+
findings.push({
|
|
1362
|
+
ruleId: "GW-AND-04",
|
|
1363
|
+
severity: "error",
|
|
1364
|
+
elementId: gateway.id,
|
|
1365
|
+
message: `Parallel gateway "${gateway.name || gateway.id}" has ${withCondition.length} outgoing flow(s) with condition`,
|
|
1366
|
+
hint: "Parallel gateways route all tokens to all branches; conditions are not evaluated",
|
|
1367
|
+
docUrl: "docs/validation.md#gw-and-04"
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return findings;
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
validator.register(rule_GW_XOR_001_SingleInSingleOutForbidden);
|
|
1375
|
+
validator.register(rule_GW_XOR_002_SplitConstraints);
|
|
1376
|
+
validator.register(rule_GW_AND_004_NoConditionsOnParallelSplit);
|
|
1377
|
+
rule_AP_FloatingElements = {
|
|
1378
|
+
ruleId: "AP-FLOAT",
|
|
1379
|
+
severity: "warning",
|
|
1380
|
+
description: "Element has no incoming or outgoing flows (floating)",
|
|
1381
|
+
validate(ir) {
|
|
1382
|
+
const findings = [];
|
|
1383
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1384
|
+
for (const el of allElements) {
|
|
1385
|
+
const incoming = ir.flows.filter((f) => f.to === el.id).length;
|
|
1386
|
+
const outgoing = ir.flows.filter((f) => f.from === el.id).length;
|
|
1387
|
+
if (incoming === 0 && outgoing === 0) {
|
|
1388
|
+
findings.push({
|
|
1389
|
+
ruleId: "AP-FLOAT",
|
|
1390
|
+
severity: "warning",
|
|
1391
|
+
elementId: el.id,
|
|
1392
|
+
message: `Element "${el.name || el.id}" has no incoming or outgoing flows`,
|
|
1393
|
+
hint: "Connect this element to the process flow, or remove it if it is unused",
|
|
1394
|
+
docUrl: "docs/validation.md#ap-float"
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return findings;
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
rule_AP_MissingDefaultOnConditionalXor = {
|
|
1402
|
+
ruleId: "AP-NO-DEFAULT",
|
|
1403
|
+
severity: "warning",
|
|
1404
|
+
description: "XOR split has all conditional flows but no default (risk of deadlock)",
|
|
1405
|
+
validate(ir) {
|
|
1406
|
+
const findings = [];
|
|
1407
|
+
const xorGateways = ir.lanes.flatMap((lane) => lane.elements).filter((el) => el.type === "exclusiveGateway");
|
|
1408
|
+
for (const gateway of xorGateways) {
|
|
1409
|
+
const outgoing = ir.flows.filter((f) => f.from === gateway.id);
|
|
1410
|
+
if (outgoing.length <= 1) continue;
|
|
1411
|
+
const defaultFlows = outgoing.filter((f) => f.default);
|
|
1412
|
+
const allConditional = outgoing.every((f) => f.condition);
|
|
1413
|
+
if (defaultFlows.length === 0 && allConditional) {
|
|
1414
|
+
findings.push({
|
|
1415
|
+
ruleId: "AP-NO-DEFAULT",
|
|
1416
|
+
severity: "warning",
|
|
1417
|
+
elementId: gateway.id,
|
|
1418
|
+
message: `XOR split "${gateway.name || gateway.id}" has all conditional flows but no default`,
|
|
1419
|
+
hint: "If all conditions are false, the token will be trapped; mark one flow as default or add a catch-all condition",
|
|
1420
|
+
docUrl: "docs/validation.md#ap-no-default"
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return findings;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
rule_AP_ImplicitJoin = {
|
|
1428
|
+
ruleId: "AP-IMPLICIT-JOIN",
|
|
1429
|
+
severity: "warning",
|
|
1430
|
+
description: "Task has multiple incoming flows without a joining gateway",
|
|
1431
|
+
validate(ir) {
|
|
1432
|
+
const findings = [];
|
|
1433
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1434
|
+
const tasks = allElements.filter((el) => ["task", "userTask", "serviceTask"].includes(el.type));
|
|
1435
|
+
for (const task of tasks) {
|
|
1436
|
+
const incoming = ir.flows.filter((f) => f.to === task.id);
|
|
1437
|
+
if (incoming.length > 1) {
|
|
1438
|
+
findings.push({
|
|
1439
|
+
ruleId: "AP-IMPLICIT-JOIN",
|
|
1440
|
+
severity: "warning",
|
|
1441
|
+
elementId: task.id,
|
|
1442
|
+
message: `Task "${task.name || task.id}" has ${incoming.length} incoming flows (implicit join)`,
|
|
1443
|
+
hint: "Each incoming token independently activates the task; use an AND join gateway if synchronization is intended",
|
|
1444
|
+
docUrl: "docs/validation.md#ap-implicit-join"
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return findings;
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
IMPERATIVE_VERBS = [
|
|
1452
|
+
"accept",
|
|
1453
|
+
"approve",
|
|
1454
|
+
"assign",
|
|
1455
|
+
"authorize",
|
|
1456
|
+
"calculate",
|
|
1457
|
+
"cancel",
|
|
1458
|
+
"check",
|
|
1459
|
+
"classify",
|
|
1460
|
+
"confirm",
|
|
1461
|
+
"convert",
|
|
1462
|
+
"create",
|
|
1463
|
+
"decline",
|
|
1464
|
+
"delete",
|
|
1465
|
+
"derive",
|
|
1466
|
+
"determine",
|
|
1467
|
+
"distribute",
|
|
1468
|
+
"document",
|
|
1469
|
+
"evaluate",
|
|
1470
|
+
"execute",
|
|
1471
|
+
"extract",
|
|
1472
|
+
"generate",
|
|
1473
|
+
"identify",
|
|
1474
|
+
"implement",
|
|
1475
|
+
"invoice",
|
|
1476
|
+
"judge",
|
|
1477
|
+
"log",
|
|
1478
|
+
"manage",
|
|
1479
|
+
"notify",
|
|
1480
|
+
"organize",
|
|
1481
|
+
"pay",
|
|
1482
|
+
"perform",
|
|
1483
|
+
"prepare",
|
|
1484
|
+
"process",
|
|
1485
|
+
"produce",
|
|
1486
|
+
"propose",
|
|
1487
|
+
"publish",
|
|
1488
|
+
"read",
|
|
1489
|
+
"receive",
|
|
1490
|
+
"reconcile",
|
|
1491
|
+
"record",
|
|
1492
|
+
"reduce",
|
|
1493
|
+
"register",
|
|
1494
|
+
"reject",
|
|
1495
|
+
"release",
|
|
1496
|
+
"remove",
|
|
1497
|
+
"report",
|
|
1498
|
+
"request",
|
|
1499
|
+
"resolve",
|
|
1500
|
+
"review",
|
|
1501
|
+
"revise",
|
|
1502
|
+
"schedule",
|
|
1503
|
+
"send",
|
|
1504
|
+
"sign",
|
|
1505
|
+
"store",
|
|
1506
|
+
"submit",
|
|
1507
|
+
"summarize",
|
|
1508
|
+
"test",
|
|
1509
|
+
"track",
|
|
1510
|
+
"transfer",
|
|
1511
|
+
"transform",
|
|
1512
|
+
"validate",
|
|
1513
|
+
"verify",
|
|
1514
|
+
"write"
|
|
1515
|
+
];
|
|
1516
|
+
rule_AP_GatewayAsTask = {
|
|
1517
|
+
ruleId: "AP-GW-AS-TASK",
|
|
1518
|
+
severity: "warning",
|
|
1519
|
+
description: "Gateway name suggests it might be a task (heuristic: starts with imperative verb)",
|
|
1520
|
+
offByDefault: true,
|
|
1521
|
+
validate(ir) {
|
|
1522
|
+
const findings = [];
|
|
1523
|
+
const allElements = ir.lanes.flatMap((lane) => lane.elements);
|
|
1524
|
+
const gateways = allElements.filter(
|
|
1525
|
+
(el) => ["exclusiveGateway", "parallelGateway", "inclusiveGateway", "eventBasedGateway"].includes(el.type)
|
|
1526
|
+
);
|
|
1527
|
+
for (const gateway of gateways) {
|
|
1528
|
+
if (!gateway.name) continue;
|
|
1529
|
+
const lowerName = gateway.name.toLowerCase();
|
|
1530
|
+
const startsWithVerb = IMPERATIVE_VERBS.some((verb) => lowerName.startsWith(verb));
|
|
1531
|
+
if (startsWithVerb) {
|
|
1532
|
+
findings.push({
|
|
1533
|
+
ruleId: "AP-GW-AS-TASK",
|
|
1534
|
+
severity: "warning",
|
|
1535
|
+
elementId: gateway.id,
|
|
1536
|
+
message: `Gateway "${gateway.name}" has a name starting with an imperative verb; ensure this is a routing construct, not a task`,
|
|
1537
|
+
hint: "Gateways are for routing logic only. If this element performs work, use a task instead.",
|
|
1538
|
+
docUrl: "docs/validation.md#ap-gw-as-task"
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return findings;
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
validator.register(rule_AP_FloatingElements);
|
|
1546
|
+
validator.register(rule_AP_MissingDefaultOnConditionalXor);
|
|
1547
|
+
validator.register(rule_AP_ImplicitJoin);
|
|
1548
|
+
validator.register(rule_AP_GatewayAsTask);
|
|
1549
|
+
rule_SF_DUP_NoDuplicateFlows = {
|
|
1550
|
+
ruleId: "SF-DUP",
|
|
1551
|
+
severity: "error",
|
|
1552
|
+
description: "No duplicate sequence flows between same source and target",
|
|
1553
|
+
validate(ir) {
|
|
1554
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1555
|
+
const findings = [];
|
|
1556
|
+
for (const flow of ir.flows) {
|
|
1557
|
+
const key = `${flow.from}\u2192${flow.to}`;
|
|
1558
|
+
if (seen.has(key)) {
|
|
1559
|
+
findings.push({
|
|
1560
|
+
ruleId: "SF-DUP",
|
|
1561
|
+
severity: "error",
|
|
1562
|
+
elementId: flow.id,
|
|
1563
|
+
message: `Duplicate flow from "${flow.from}" to "${flow.to}" (first: ${seen.get(key)})`,
|
|
1564
|
+
hint: "Remove one of the duplicate flows; only one flow can connect the same source-target pair",
|
|
1565
|
+
docUrl: "docs/validation.md#sf-dup"
|
|
1566
|
+
});
|
|
1567
|
+
} else {
|
|
1568
|
+
seen.set(key, flow.id);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return findings;
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
rule_SF_01_ValidFlowEndpoints = {
|
|
1575
|
+
ruleId: "SF-001",
|
|
1576
|
+
severity: "error",
|
|
1577
|
+
description: "Flow endpoints must reference existing elements",
|
|
1578
|
+
validate(ir) {
|
|
1579
|
+
const elementIds = new Set(ir.lanes.flatMap((l) => l.elements.map((e) => e.id)));
|
|
1580
|
+
const findings = [];
|
|
1581
|
+
for (const flow of ir.flows) {
|
|
1582
|
+
if (!elementIds.has(flow.from)) {
|
|
1583
|
+
findings.push({
|
|
1584
|
+
ruleId: "SF-001",
|
|
1585
|
+
severity: "error",
|
|
1586
|
+
elementId: flow.id,
|
|
1587
|
+
message: `Flow source element "${flow.from}" does not exist`,
|
|
1588
|
+
hint: "Verify the source element ID is correct and exists in the process",
|
|
1589
|
+
docUrl: "docs/validation.md#sf-01"
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
if (!elementIds.has(flow.to)) {
|
|
1593
|
+
findings.push({
|
|
1594
|
+
ruleId: "SF-001",
|
|
1595
|
+
severity: "error",
|
|
1596
|
+
elementId: flow.id,
|
|
1597
|
+
message: `Flow target element "${flow.to}" does not exist`,
|
|
1598
|
+
hint: "Verify the target element ID is correct and exists in the process",
|
|
1599
|
+
docUrl: "docs/validation.md#sf-01"
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return findings;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
validator.register(rule_SF_DUP_NoDuplicateFlows);
|
|
1607
|
+
validator.register(rule_SF_01_ValidFlowEndpoints);
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// ../../src/cervinrc.ts
|
|
1612
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
1613
|
+
import { join as join3 } from "node:path";
|
|
1614
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
1615
|
+
function formatAjvErrors2() {
|
|
1616
|
+
const e = validateConfig.errors;
|
|
1617
|
+
if (!e?.length) return [];
|
|
1618
|
+
return e.map(
|
|
1619
|
+
(err) => `${err.instancePath || "/"} ${err.message ?? err.keyword}`.trim()
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
function validateCervinrcDocument(data) {
|
|
1623
|
+
if (!validateConfig(data)) {
|
|
1624
|
+
const err = new Error("Config validation failed");
|
|
1625
|
+
err.errors = formatAjvErrors2();
|
|
1626
|
+
throw err;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
function noteCervinrcDeprecation() {
|
|
1630
|
+
if (cervinrcNoticeShown) return;
|
|
1631
|
+
cervinrcNoticeShown = true;
|
|
1632
|
+
console.warn(CERVINRC_DEPRECATION_NOTICE);
|
|
1633
|
+
}
|
|
1634
|
+
function loadRcFile(rcPath, fileLabel) {
|
|
1635
|
+
try {
|
|
1636
|
+
const content = readFileSync3(rcPath, "utf8");
|
|
1637
|
+
const parsed = JSON.parse(content);
|
|
1638
|
+
validateCervinrcDocument(parsed);
|
|
1639
|
+
return parsed;
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
if (e instanceof SyntaxError) {
|
|
1642
|
+
const err2 = new Error(`Invalid JSON in ${fileLabel}: ${e.message}`);
|
|
1643
|
+
throw err2;
|
|
1644
|
+
}
|
|
1645
|
+
if (e.errors) {
|
|
1646
|
+
throw e;
|
|
1647
|
+
}
|
|
1648
|
+
const err = e;
|
|
1649
|
+
throw new Error(`Failed to load ${fileLabel}: ${err.message}`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
function loadTransitrixrc(startPath = process.cwd()) {
|
|
1653
|
+
const transitrixPath = join3(startPath, TRANSITRIXRC_FILE);
|
|
1654
|
+
if (existsSync(transitrixPath)) {
|
|
1655
|
+
return loadRcFile(transitrixPath, TRANSITRIXRC_FILE);
|
|
1656
|
+
}
|
|
1657
|
+
const cervinPath = join3(startPath, CERVINRC_FILE);
|
|
1658
|
+
if (existsSync(cervinPath)) {
|
|
1659
|
+
noteCervinrcDeprecation();
|
|
1660
|
+
return loadRcFile(cervinPath, CERVINRC_FILE);
|
|
1661
|
+
}
|
|
1662
|
+
return {};
|
|
1663
|
+
}
|
|
1664
|
+
function assertNoCriticalRuleDowngrade(registeredRules, config) {
|
|
1665
|
+
if (!config.rules) return;
|
|
1666
|
+
for (const [ruleId, override] of Object.entries(config.rules)) {
|
|
1667
|
+
const rule = registeredRules.get(ruleId);
|
|
1668
|
+
if (rule && rule.severity === "error" && override === "off") {
|
|
1669
|
+
const err = new Error(
|
|
1670
|
+
`Config error: rule "${ruleId}" is an error-severity rule and cannot be downgraded to "${override}"`
|
|
1671
|
+
);
|
|
1672
|
+
err.errors = [
|
|
1673
|
+
`Rule "${ruleId}" has severity "error" and is a BPMN conformance gate; it cannot be disabled or demoted.`,
|
|
1674
|
+
"Only warning-severity rules can be overridden."
|
|
1675
|
+
];
|
|
1676
|
+
throw err;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function mergeConfigWithDefaults(registeredRules, config) {
|
|
1681
|
+
const enabledRules = /* @__PURE__ */ new Set();
|
|
1682
|
+
for (const [ruleId, rule] of registeredRules.entries()) {
|
|
1683
|
+
if (!rule.offByDefault) {
|
|
1684
|
+
enabledRules.add(ruleId);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (config.rules) {
|
|
1688
|
+
for (const [ruleId, override] of Object.entries(config.rules)) {
|
|
1689
|
+
if (override === "off") {
|
|
1690
|
+
enabledRules.delete(ruleId);
|
|
1691
|
+
} else if (override === "warn") {
|
|
1692
|
+
enabledRules.add(ruleId);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
return enabledRules;
|
|
1697
|
+
}
|
|
1698
|
+
var require4, Ajv2, addFormats2, CERVINRC_SCHEMA, ajv2, validateConfig, TRANSITRIXRC_FILE, CERVINRC_FILE, CERVINRC_DEPRECATION_NOTICE, cervinrcNoticeShown;
|
|
1699
|
+
var init_cervinrc = __esm({
|
|
1700
|
+
"../../src/cervinrc.ts"() {
|
|
1701
|
+
"use strict";
|
|
1702
|
+
require4 = createRequire3(import.meta.url);
|
|
1703
|
+
Ajv2 = require4("ajv");
|
|
1704
|
+
addFormats2 = require4("ajv-formats");
|
|
1705
|
+
CERVINRC_SCHEMA = {
|
|
1706
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1707
|
+
type: "object",
|
|
1708
|
+
properties: {
|
|
1709
|
+
rules: {
|
|
1710
|
+
type: "object",
|
|
1711
|
+
patternProperties: {
|
|
1712
|
+
"^[A-Z]+-[0-9-]+$": {
|
|
1713
|
+
type: "string",
|
|
1714
|
+
enum: ["off", "warn"]
|
|
1715
|
+
}
|
|
1716
|
+
},
|
|
1717
|
+
additionalProperties: false
|
|
1718
|
+
}
|
|
1719
|
+
},
|
|
1720
|
+
additionalProperties: false,
|
|
1721
|
+
required: []
|
|
1722
|
+
};
|
|
1723
|
+
ajv2 = new Ajv2({ allErrors: true, strict: false });
|
|
1724
|
+
addFormats2(ajv2);
|
|
1725
|
+
validateConfig = ajv2.compile(CERVINRC_SCHEMA);
|
|
1726
|
+
TRANSITRIXRC_FILE = ".transitrixrc";
|
|
1727
|
+
CERVINRC_FILE = ".cervinrc";
|
|
1728
|
+
CERVINRC_DEPRECATION_NOTICE = ".cervinrc is deprecated and will be removed in 2.0.0 \u2014 rename it to .transitrixrc.";
|
|
1729
|
+
cervinrcNoticeShown = false;
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
// ../../src/compiler.ts
|
|
1734
|
+
import { BpmnModdle } from "bpmn-moddle";
|
|
1735
|
+
async function compileTransitrixYamlWithLayout(yamlText, options) {
|
|
1736
|
+
const ir = parseYamlToIr(yamlText);
|
|
1737
|
+
const rcConfig = loadTransitrixrc();
|
|
1738
|
+
assertNoCriticalRuleDowngrade(validator.getRules(), rcConfig);
|
|
1739
|
+
const enabledRules = mergeConfigWithDefaults(validator.getRules(), rcConfig);
|
|
1740
|
+
const validatorConfig = { enabledRules };
|
|
1741
|
+
const validation = validateProcess(ir, validatorConfig);
|
|
1742
|
+
const layout = await layoutProcess(ir, options?.layout);
|
|
1743
|
+
const xml = emitBpmnXml(layout);
|
|
1744
|
+
const moddle = new BpmnModdle();
|
|
1745
|
+
const { warnings: bpmnWarnings } = await moddle.fromXML(xml, "bpmn:Definitions");
|
|
1746
|
+
if (bpmnWarnings && bpmnWarnings.length > 0) {
|
|
1747
|
+
const bpmnFindings = bpmnWarnings.map((warning) => ({
|
|
1748
|
+
ruleId: "RD-111-BPMN-MODDLE",
|
|
1749
|
+
severity: "error",
|
|
1750
|
+
message: `BPMN 2.0 conformance: ${warning.message ?? String(warning)}`,
|
|
1751
|
+
hint: "This is a Transitrix Studio compiler bug; please report it",
|
|
1752
|
+
docUrl: "docs/validation.md#bpmn-moddle-validation"
|
|
1753
|
+
}));
|
|
1754
|
+
validation.findings.push(...bpmnFindings);
|
|
1755
|
+
validation.summary.errorCount += bpmnFindings.length;
|
|
1756
|
+
validation.isValid = false;
|
|
1757
|
+
}
|
|
1758
|
+
return { ir, layout, xml, validation };
|
|
1759
|
+
}
|
|
1760
|
+
var init_compiler = __esm({
|
|
1761
|
+
"../../src/compiler.ts"() {
|
|
1762
|
+
"use strict";
|
|
1763
|
+
init_emitter();
|
|
1764
|
+
init_layout();
|
|
1765
|
+
init_parser();
|
|
1766
|
+
init_validator();
|
|
1767
|
+
init_cervinrc();
|
|
1768
|
+
init_layout_options();
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
// ../../src/metrics.ts
|
|
1773
|
+
function computeLayoutMetrics(layout, scoreConfig) {
|
|
1774
|
+
const crossings = countCrossings(layout);
|
|
1775
|
+
const bends = countBends(layout);
|
|
1776
|
+
const edgeLength = computeEdgeLength(layout);
|
|
1777
|
+
const waypointDensity = computeWaypointDensity(layout);
|
|
1778
|
+
const spineDeviation = computeSpineDeviation(layout);
|
|
1779
|
+
const emptyArea = computeEmptyArea(layout);
|
|
1780
|
+
const portViolations = countPortViolations(layout);
|
|
1781
|
+
const portUniqueness = computePortUniqueness(layout);
|
|
1782
|
+
const laneAxisAlignment = computeLaneAxisAlignment(layout);
|
|
1783
|
+
const layoutScore = scoreConfig ? computeLayoutScore({
|
|
1784
|
+
crossings,
|
|
1785
|
+
spineDeviation,
|
|
1786
|
+
portViolations,
|
|
1787
|
+
emptyArea,
|
|
1788
|
+
laneAxisAlignment,
|
|
1789
|
+
...scoreConfig
|
|
1790
|
+
}) : 0;
|
|
1791
|
+
return {
|
|
1792
|
+
crossings,
|
|
1793
|
+
bends,
|
|
1794
|
+
edgeLength,
|
|
1795
|
+
waypointDensity,
|
|
1796
|
+
spineDeviation,
|
|
1797
|
+
emptyArea,
|
|
1798
|
+
portViolations,
|
|
1799
|
+
portUniqueness,
|
|
1800
|
+
laneAxisAlignment,
|
|
1801
|
+
layoutScore
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
function countCrossings(layout) {
|
|
1805
|
+
const flows = layout.flows;
|
|
1806
|
+
if (!flows || flows.length < 2) return 0;
|
|
1807
|
+
let count = 0;
|
|
1808
|
+
for (let i = 0; i < flows.length; i++) {
|
|
1809
|
+
for (let j = i + 1; j < flows.length; j++) {
|
|
1810
|
+
if (pathsIntersect(flows[i].waypoints, flows[j].waypoints)) {
|
|
1811
|
+
count++;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return count;
|
|
1816
|
+
}
|
|
1817
|
+
function pathsIntersect(path1, path2) {
|
|
1818
|
+
if (!path1 || !path2 || path1.length < 2 || path2.length < 2) return false;
|
|
1819
|
+
for (let i = 0; i < path1.length - 1; i++) {
|
|
1820
|
+
for (let j = 0; j < path2.length - 1; j++) {
|
|
1821
|
+
const seg1 = [path1[i], path1[i + 1]];
|
|
1822
|
+
const seg2 = [path2[j], path2[j + 1]];
|
|
1823
|
+
if (orthogonalSegmentsIntersect(seg1, seg2)) {
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return false;
|
|
1829
|
+
}
|
|
1830
|
+
function orthogonalSegmentsIntersect(seg1, seg2) {
|
|
1831
|
+
const [p1, p2] = seg1;
|
|
1832
|
+
const [p3, p4] = seg2;
|
|
1833
|
+
const seg1Vertical = Math.abs(p1.x - p2.x) < 0.01;
|
|
1834
|
+
const seg1Horizontal = Math.abs(p1.y - p2.y) < 0.01;
|
|
1835
|
+
const seg2Vertical = Math.abs(p3.x - p4.x) < 0.01;
|
|
1836
|
+
const seg2Horizontal = Math.abs(p3.y - p4.y) < 0.01;
|
|
1837
|
+
if (seg1Vertical && seg2Horizontal) {
|
|
1838
|
+
const x = p1.x;
|
|
1839
|
+
const y = p3.y;
|
|
1840
|
+
const xInRange = x > Math.min(p3.x, p4.x) && x < Math.max(p3.x, p4.x);
|
|
1841
|
+
const yInRange = y > Math.min(p1.y, p2.y) && y < Math.max(p1.y, p2.y);
|
|
1842
|
+
return xInRange && yInRange;
|
|
1843
|
+
}
|
|
1844
|
+
if (seg1Horizontal && seg2Vertical) {
|
|
1845
|
+
const x = p3.x;
|
|
1846
|
+
const y = p1.y;
|
|
1847
|
+
const xInRange = x > Math.min(p1.x, p2.x) && x < Math.max(p1.x, p2.x);
|
|
1848
|
+
const yInRange = y > Math.min(p3.y, p4.y) && y < Math.max(p3.y, p4.y);
|
|
1849
|
+
return xInRange && yInRange;
|
|
1850
|
+
}
|
|
1851
|
+
return false;
|
|
1852
|
+
}
|
|
1853
|
+
function countBends(layout) {
|
|
1854
|
+
const flows = layout.flows;
|
|
1855
|
+
if (!flows) return 0;
|
|
1856
|
+
let totalBends = 0;
|
|
1857
|
+
for (const flow of flows) {
|
|
1858
|
+
totalBends += computeFlowBends(flow.waypoints);
|
|
1859
|
+
}
|
|
1860
|
+
return totalBends;
|
|
1861
|
+
}
|
|
1862
|
+
function computeFlowBends(waypoints) {
|
|
1863
|
+
if (!waypoints || waypoints.length < 3) return 0;
|
|
1864
|
+
let bends = 0;
|
|
1865
|
+
for (let i = 1; i < waypoints.length - 1; i++) {
|
|
1866
|
+
const prev = waypoints[i - 1];
|
|
1867
|
+
const curr = waypoints[i];
|
|
1868
|
+
const next = waypoints[i + 1];
|
|
1869
|
+
const prevDir = { x: curr.x - prev.x, y: curr.y - prev.y };
|
|
1870
|
+
const nextDir = { x: next.x - curr.x, y: next.y - curr.y };
|
|
1871
|
+
const prevHorizontal = Math.abs(prevDir.y) < 0.01;
|
|
1872
|
+
const nextVertical = Math.abs(nextDir.x) < 0.01;
|
|
1873
|
+
const prevVertical = Math.abs(prevDir.x) < 0.01;
|
|
1874
|
+
const nextHorizontal = Math.abs(nextDir.y) < 0.01;
|
|
1875
|
+
if (prevHorizontal && nextVertical || prevVertical && nextHorizontal) {
|
|
1876
|
+
bends++;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return bends;
|
|
1880
|
+
}
|
|
1881
|
+
function computeEdgeLength(layout) {
|
|
1882
|
+
const flows = layout.flows;
|
|
1883
|
+
if (!flows) return 0;
|
|
1884
|
+
let totalLength = 0;
|
|
1885
|
+
for (const flow of flows) {
|
|
1886
|
+
totalLength += computeFlowLength(flow.waypoints);
|
|
1887
|
+
}
|
|
1888
|
+
return totalLength;
|
|
1889
|
+
}
|
|
1890
|
+
function computeFlowLength(waypoints) {
|
|
1891
|
+
if (!waypoints || waypoints.length < 2) return 0;
|
|
1892
|
+
let length = 0;
|
|
1893
|
+
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
1894
|
+
const p1 = waypoints[i];
|
|
1895
|
+
const p2 = waypoints[i + 1];
|
|
1896
|
+
length += Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
|
|
1897
|
+
}
|
|
1898
|
+
return length;
|
|
1899
|
+
}
|
|
1900
|
+
function computeWaypointDensity(layout) {
|
|
1901
|
+
const flows = layout.flows;
|
|
1902
|
+
if (!flows || flows.length === 0) return 0;
|
|
1903
|
+
const totalWaypoints = flows.reduce((sum, f) => sum + (f.waypoints?.length || 0), 0);
|
|
1904
|
+
return totalWaypoints / flows.length;
|
|
1905
|
+
}
|
|
1906
|
+
function computeSpineDeviation(layout) {
|
|
1907
|
+
const lanes = layout.process?.lanes || [];
|
|
1908
|
+
if (lanes.length === 0) return 0;
|
|
1909
|
+
const laneDeviations = [];
|
|
1910
|
+
for (const lane of lanes) {
|
|
1911
|
+
const laneBounds = layout.laneBounds.get(lane.id);
|
|
1912
|
+
if (!laneBounds) continue;
|
|
1913
|
+
const axisY = laneBounds.y + laneBounds.height / 2;
|
|
1914
|
+
const elements = lane.elements || [];
|
|
1915
|
+
if (elements.length === 0) continue;
|
|
1916
|
+
const maxDev = Math.max(
|
|
1917
|
+
...elements.map((el) => {
|
|
1918
|
+
const elBounds = layout.elements.get(el.id);
|
|
1919
|
+
if (!elBounds) return 0;
|
|
1920
|
+
return Math.abs(elBounds.y + elBounds.height / 2 - axisY);
|
|
1921
|
+
})
|
|
1922
|
+
);
|
|
1923
|
+
laneDeviations.push(maxDev);
|
|
1924
|
+
}
|
|
1925
|
+
return laneDeviations.length > 0 ? median(laneDeviations) : 0;
|
|
1926
|
+
}
|
|
1927
|
+
function computeEmptyArea(layout) {
|
|
1928
|
+
const lanes = layout.process?.lanes || [];
|
|
1929
|
+
if (lanes.length === 0) return 0;
|
|
1930
|
+
const ratios = [];
|
|
1931
|
+
for (const lane of lanes) {
|
|
1932
|
+
const elements = lane.elements || [];
|
|
1933
|
+
if (elements.length <= 1) continue;
|
|
1934
|
+
const laneBounds = layout.laneBounds.get(lane.id);
|
|
1935
|
+
if (!laneBounds) continue;
|
|
1936
|
+
let minX = Infinity, maxX = -Infinity;
|
|
1937
|
+
let minY = Infinity, maxY = -Infinity;
|
|
1938
|
+
for (const el of elements) {
|
|
1939
|
+
const elBounds = layout.elements.get(el.id);
|
|
1940
|
+
if (!elBounds) continue;
|
|
1941
|
+
minX = Math.min(minX, elBounds.x);
|
|
1942
|
+
maxX = Math.max(maxX, elBounds.x + elBounds.width);
|
|
1943
|
+
minY = Math.min(minY, elBounds.y);
|
|
1944
|
+
maxY = Math.max(maxY, elBounds.y + elBounds.height);
|
|
1945
|
+
}
|
|
1946
|
+
if (minX === Infinity) continue;
|
|
1947
|
+
const laneBoxArea = (maxX - minX) * (maxY - minY);
|
|
1948
|
+
const occupiedArea = elements.reduce((sum, el) => {
|
|
1949
|
+
const elBounds = layout.elements.get(el.id);
|
|
1950
|
+
return sum + (elBounds ? elBounds.width * elBounds.height : 0);
|
|
1951
|
+
}, 0);
|
|
1952
|
+
const emptyArea = laneBoxArea - occupiedArea;
|
|
1953
|
+
const ratio = emptyArea / laneBoxArea;
|
|
1954
|
+
ratios.push(ratio);
|
|
1955
|
+
}
|
|
1956
|
+
return ratios.length > 0 ? median(ratios) : 0;
|
|
1957
|
+
}
|
|
1958
|
+
function countPortViolations(layout) {
|
|
1959
|
+
const flows = layout.flows;
|
|
1960
|
+
if (!flows) return 0;
|
|
1961
|
+
const elementMap = /* @__PURE__ */ new Map();
|
|
1962
|
+
for (const lane of layout.process?.lanes ?? []) {
|
|
1963
|
+
for (const el of lane.elements) {
|
|
1964
|
+
elementMap.set(el.id, el);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
let violations = 0;
|
|
1968
|
+
for (const flow of flows) {
|
|
1969
|
+
const fromEl = elementMap.get(flow.from);
|
|
1970
|
+
const toEl = elementMap.get(flow.to);
|
|
1971
|
+
if (!fromEl || !toEl) continue;
|
|
1972
|
+
const isSameLane = fromEl.laneId === toEl.laneId;
|
|
1973
|
+
const fromBounds = layout.elements.get(fromEl.id);
|
|
1974
|
+
const toBounds = layout.elements.get(toEl.id);
|
|
1975
|
+
if (!fromBounds || !toBounds) continue;
|
|
1976
|
+
const exitPort = determinePort(flow.waypoints, fromBounds, "exit");
|
|
1977
|
+
const entryPort = determinePort(flow.waypoints, toBounds, "entry");
|
|
1978
|
+
if (isSameLane) {
|
|
1979
|
+
const isSourceGateway = GATEWAY_TYPES.has(fromEl.type);
|
|
1980
|
+
const validExit = isSourceGateway ? ["LEFT", "RIGHT", "TOP", "BOTTOM"].includes(exitPort) : ["LEFT", "RIGHT"].includes(exitPort);
|
|
1981
|
+
if (!validExit || !["LEFT", "RIGHT"].includes(entryPort)) {
|
|
1982
|
+
violations++;
|
|
1983
|
+
}
|
|
1984
|
+
} else {
|
|
1985
|
+
const sourceLaneIdx = layout.process?.lanes.findIndex((l) => l.id === fromEl.laneId);
|
|
1986
|
+
const targetLaneIdx = layout.process?.lanes.findIndex((l) => l.id === toEl.laneId);
|
|
1987
|
+
if (sourceLaneIdx === void 0 || targetLaneIdx === void 0) continue;
|
|
1988
|
+
if (sourceLaneIdx < targetLaneIdx) {
|
|
1989
|
+
if (!["RIGHT", "BOTTOM"].includes(exitPort) || !["RIGHT", "BOTTOM"].includes(entryPort)) {
|
|
1990
|
+
violations++;
|
|
1991
|
+
}
|
|
1992
|
+
} else if (sourceLaneIdx > targetLaneIdx) {
|
|
1993
|
+
if (!["RIGHT", "TOP"].includes(exitPort) || !["RIGHT", "TOP"].includes(entryPort)) {
|
|
1994
|
+
violations++;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
return violations;
|
|
2000
|
+
}
|
|
2001
|
+
function determinePort(waypoints, elementBounds, type) {
|
|
2002
|
+
if (!waypoints || waypoints.length < 2) return "CENTER";
|
|
2003
|
+
const centerX = elementBounds.x + elementBounds.width / 2;
|
|
2004
|
+
const centerY = elementBounds.y + elementBounds.height / 2;
|
|
2005
|
+
if (type === "exit") {
|
|
2006
|
+
const p1 = waypoints[0];
|
|
2007
|
+
const p2 = waypoints[1];
|
|
2008
|
+
if (Math.abs(p1.x - p2.x) < 0.01) {
|
|
2009
|
+
return p2.y > p1.y ? "BOTTOM" : "TOP";
|
|
2010
|
+
}
|
|
2011
|
+
if (Math.abs(p1.y - p2.y) < 0.01) return p2.x > p1.x ? "RIGHT" : "LEFT";
|
|
2012
|
+
} else {
|
|
2013
|
+
const pn = waypoints[waypoints.length - 1];
|
|
2014
|
+
const pn1 = waypoints[waypoints.length - 2];
|
|
2015
|
+
if (Math.abs(pn.x - pn1.x) < 0.01) {
|
|
2016
|
+
return pn.y > pn1.y ? "BOTTOM" : "TOP";
|
|
2017
|
+
}
|
|
2018
|
+
if (Math.abs(pn.y - pn1.y) < 0.01) return pn.x > pn1.x ? "RIGHT" : "LEFT";
|
|
2019
|
+
}
|
|
2020
|
+
return "CENTER";
|
|
2021
|
+
}
|
|
2022
|
+
function computePortUniqueness(layout) {
|
|
2023
|
+
const gateways = layout.process?.lanes.flatMap((l) => l.elements).filter((el) => GATEWAY_TYPES.has(el.type)) ?? [];
|
|
2024
|
+
if (gateways.length === 0) return 1;
|
|
2025
|
+
let sumUniqueness = 0;
|
|
2026
|
+
let countGateways = 0;
|
|
2027
|
+
for (const gw of gateways) {
|
|
2028
|
+
const outflows = layout.flows.filter((f) => f.from === gw.id);
|
|
2029
|
+
if (outflows.length < 2) continue;
|
|
2030
|
+
const portsUsed = /* @__PURE__ */ new Set();
|
|
2031
|
+
const gwBounds = layout.elements.get(gw.id);
|
|
2032
|
+
if (!gwBounds) continue;
|
|
2033
|
+
for (const flow of outflows) {
|
|
2034
|
+
const port = determinePort(flow.waypoints, gwBounds, "exit");
|
|
2035
|
+
portsUsed.add(port);
|
|
2036
|
+
}
|
|
2037
|
+
const uniqueness = portsUsed.size / outflows.length;
|
|
2038
|
+
sumUniqueness += uniqueness;
|
|
2039
|
+
countGateways++;
|
|
2040
|
+
}
|
|
2041
|
+
return countGateways > 0 ? sumUniqueness / countGateways : 1;
|
|
2042
|
+
}
|
|
2043
|
+
function computeLaneAxisAlignment(layout) {
|
|
2044
|
+
const lanes = layout.process?.lanes || [];
|
|
2045
|
+
if (lanes.length === 0) return 1;
|
|
2046
|
+
let totalOnAxis = 0;
|
|
2047
|
+
let totalSingleColumn = 0;
|
|
2048
|
+
for (const lane of lanes) {
|
|
2049
|
+
const elements = lane.elements || [];
|
|
2050
|
+
if (elements.length === 0) continue;
|
|
2051
|
+
const laneBounds = layout.laneBounds.get(lane.id);
|
|
2052
|
+
if (!laneBounds) continue;
|
|
2053
|
+
const columnGroups = /* @__PURE__ */ new Map();
|
|
2054
|
+
for (const el of elements) {
|
|
2055
|
+
const elBounds = layout.elements.get(el.id);
|
|
2056
|
+
if (!elBounds) continue;
|
|
2057
|
+
const roundedX = Math.round((elBounds.x + elBounds.width / 2) / 10) * 10;
|
|
2058
|
+
if (!columnGroups.has(roundedX)) {
|
|
2059
|
+
columnGroups.set(roundedX, []);
|
|
2060
|
+
}
|
|
2061
|
+
columnGroups.get(roundedX).push(el);
|
|
2062
|
+
}
|
|
2063
|
+
const axisY = laneBounds.y + laneBounds.height / 2;
|
|
2064
|
+
for (const [, group] of columnGroups) {
|
|
2065
|
+
if (group.length === 1) {
|
|
2066
|
+
totalSingleColumn++;
|
|
2067
|
+
const el = group[0];
|
|
2068
|
+
const elBounds = layout.elements.get(el.id);
|
|
2069
|
+
if (elBounds && Math.abs(elBounds.y + elBounds.height / 2 - axisY) <= 4) {
|
|
2070
|
+
totalOnAxis++;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
return totalSingleColumn > 0 ? totalOnAxis / totalSingleColumn : 1;
|
|
2076
|
+
}
|
|
2077
|
+
function computeLayoutScore(input) {
|
|
2078
|
+
const crossingsNorm = Math.min(input.crossings / Math.max(input.crossingsBaseline, 1), 1);
|
|
2079
|
+
const spineDevNorm = Math.min(input.spineDeviation / Math.max(input.spineDeviationBaseline, 0.1), 1);
|
|
2080
|
+
const portViolNorm = Math.min(input.portViolations / Math.max(input.portViolationsBaseline, 1), 1);
|
|
2081
|
+
const emptyAreaNorm = input.emptyArea;
|
|
2082
|
+
return 1e3 * (1 - crossingsNorm) * (1 - spineDevNorm) * (1 - portViolNorm) * (1 - emptyAreaNorm) * input.laneAxisAlignment;
|
|
2083
|
+
}
|
|
2084
|
+
function median(values) {
|
|
2085
|
+
if (values.length === 0) return 0;
|
|
2086
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2087
|
+
const mid = Math.floor(sorted.length / 2);
|
|
2088
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
2089
|
+
}
|
|
2090
|
+
var init_metrics = __esm({
|
|
2091
|
+
"../../src/metrics.ts"() {
|
|
2092
|
+
"use strict";
|
|
2093
|
+
init_ir();
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
// ../../src/http-body-limit.ts
|
|
2098
|
+
function readHttpBodyLimited(req, maxBytes = MAX_COMPILE_BODY_BYTES) {
|
|
2099
|
+
return new Promise((resolve2, reject) => {
|
|
2100
|
+
const chunks = [];
|
|
2101
|
+
let total = 0;
|
|
2102
|
+
let settled = false;
|
|
2103
|
+
const bail = (err) => {
|
|
2104
|
+
if (settled) return;
|
|
2105
|
+
settled = true;
|
|
2106
|
+
if (typeof req.destroy === "function") {
|
|
2107
|
+
try {
|
|
2108
|
+
req.destroy(err);
|
|
2109
|
+
} catch {
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
reject(err);
|
|
2113
|
+
};
|
|
2114
|
+
req.on("data", (c) => {
|
|
2115
|
+
if (settled) return;
|
|
2116
|
+
const buf = typeof c === "string" ? Buffer.from(c, "utf8") : c;
|
|
2117
|
+
total += buf.length;
|
|
2118
|
+
if (total > maxBytes) {
|
|
2119
|
+
bail(new PayloadTooLargeError(maxBytes));
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
chunks.push(buf);
|
|
2123
|
+
});
|
|
2124
|
+
req.on("end", () => {
|
|
2125
|
+
if (settled) return;
|
|
2126
|
+
settled = true;
|
|
2127
|
+
resolve2(Buffer.concat(chunks));
|
|
2128
|
+
});
|
|
2129
|
+
req.on("error", (e) => bail(e instanceof Error ? e : new Error(String(e))));
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
var MAX_COMPILE_BODY_BYTES, PayloadTooLargeError;
|
|
2133
|
+
var init_http_body_limit = __esm({
|
|
2134
|
+
"../../src/http-body-limit.ts"() {
|
|
2135
|
+
"use strict";
|
|
2136
|
+
MAX_COMPILE_BODY_BYTES = 1048576;
|
|
2137
|
+
PayloadTooLargeError = class extends Error {
|
|
2138
|
+
constructor(maxBytes = MAX_COMPILE_BODY_BYTES) {
|
|
2139
|
+
super(`Request body exceeds limit (${maxBytes} bytes)`);
|
|
2140
|
+
this.name = "PayloadTooLargeError";
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
// ../../src/serve-ui.ts
|
|
2147
|
+
var serve_ui_exports = {};
|
|
2148
|
+
__export(serve_ui_exports, {
|
|
2149
|
+
cliServeArgv: () => cliServeArgv,
|
|
2150
|
+
handleCompile: () => handleCompile,
|
|
2151
|
+
isInsideRoot: () => isInsideRoot,
|
|
2152
|
+
runUiServer: () => runUiServer
|
|
2153
|
+
});
|
|
2154
|
+
import { createReadStream, existsSync as existsSync3 } from "node:fs";
|
|
2155
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2156
|
+
import { createServer } from "node:http";
|
|
2157
|
+
import process2 from "node:process";
|
|
2158
|
+
import { basename, dirname as dirname3, extname, resolve as pathResolve, sep as pathSep } from "node:path";
|
|
2159
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2160
|
+
function mimeType(file) {
|
|
2161
|
+
switch (extname(file).toLowerCase()) {
|
|
2162
|
+
case ".html":
|
|
2163
|
+
return "text/html; charset=utf-8";
|
|
2164
|
+
case ".js":
|
|
2165
|
+
return "text/javascript; charset=utf-8";
|
|
2166
|
+
case ".css":
|
|
2167
|
+
return "text/css; charset=utf-8";
|
|
2168
|
+
case ".svg":
|
|
2169
|
+
return "image/svg+xml";
|
|
2170
|
+
case ".woff":
|
|
2171
|
+
return "font/woff";
|
|
2172
|
+
case ".woff2":
|
|
2173
|
+
return "font/woff2";
|
|
2174
|
+
case ".ttf":
|
|
2175
|
+
return "font/ttf";
|
|
2176
|
+
case ".json":
|
|
2177
|
+
return "application/json";
|
|
2178
|
+
default:
|
|
2179
|
+
return "application/octet-stream";
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function resolveUiDist() {
|
|
2183
|
+
const dir = pathResolve(__dirname, "..", "ui", "dist");
|
|
2184
|
+
if (existsSync3(pathResolve(dir, "index.html"))) {
|
|
2185
|
+
return dir;
|
|
2186
|
+
}
|
|
2187
|
+
throw new Error(
|
|
2188
|
+
`UI build not found (${pathResolve(dir, "index.html")}). Run: npm run ui:build`
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
function isInsideRoot(staticRoot, candidateAbs) {
|
|
2192
|
+
const root = pathResolve(staticRoot);
|
|
2193
|
+
const candidate = pathResolve(candidateAbs);
|
|
2194
|
+
return candidate === root || candidate.startsWith(root + pathSep);
|
|
2195
|
+
}
|
|
2196
|
+
async function serveFile(res, filePath) {
|
|
2197
|
+
const st = await stat(filePath);
|
|
2198
|
+
res.writeHead(200, {
|
|
2199
|
+
"Content-Type": mimeType(filePath),
|
|
2200
|
+
"Content-Length": String(st.size),
|
|
2201
|
+
"Cache-Control": "no-cache"
|
|
2202
|
+
});
|
|
2203
|
+
const stream = createReadStream(filePath);
|
|
2204
|
+
stream.on("error", (err) => {
|
|
2205
|
+
console.error("serveFile: read stream error", err);
|
|
2206
|
+
res.destroy(err);
|
|
2207
|
+
});
|
|
2208
|
+
stream.pipe(res);
|
|
2209
|
+
}
|
|
2210
|
+
async function handleCompile(req, res) {
|
|
2211
|
+
if (req.method !== "POST") {
|
|
2212
|
+
res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2213
|
+
res.end("405 Method Not Allowed");
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
try {
|
|
2217
|
+
const raw = await readHttpBodyLimited(req);
|
|
2218
|
+
const ctype = (req.headers["content-type"] ?? "").toLowerCase();
|
|
2219
|
+
let yaml2;
|
|
2220
|
+
let layout;
|
|
2221
|
+
if (ctype.includes("application/json")) {
|
|
2222
|
+
let body;
|
|
2223
|
+
try {
|
|
2224
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
2225
|
+
} catch {
|
|
2226
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
2227
|
+
res.end(JSON.stringify({ message: "Invalid JSON request body", details: [] }));
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const rec = body;
|
|
2231
|
+
if (typeof rec.yaml !== "string") {
|
|
2232
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
2233
|
+
res.end(
|
|
2234
|
+
JSON.stringify({
|
|
2235
|
+
message: 'Expected JSON body shaped like { "yaml": "<source>" [, "layout": { ... }] }',
|
|
2236
|
+
details: []
|
|
2237
|
+
})
|
|
2238
|
+
);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
yaml2 = rec.yaml;
|
|
2242
|
+
const parsedLayout = parseLayoutDiagramOptionsFromJson(rec.layout);
|
|
2243
|
+
if (Object.keys(parsedLayout).length > 0) {
|
|
2244
|
+
layout = parsedLayout;
|
|
2245
|
+
}
|
|
2246
|
+
} else {
|
|
2247
|
+
yaml2 = raw.toString("utf8");
|
|
2248
|
+
}
|
|
2249
|
+
const result = await compileTransitrixYamlWithLayout(yaml2, layout ? { layout } : void 0);
|
|
2250
|
+
const metrics = computeLayoutMetrics(result.layout);
|
|
2251
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
2252
|
+
res.end(
|
|
2253
|
+
JSON.stringify({ xml: result.xml, metrics, validation: result.validation }, null, 2)
|
|
2254
|
+
);
|
|
2255
|
+
} catch (e) {
|
|
2256
|
+
if (e instanceof PayloadTooLargeError) {
|
|
2257
|
+
res.writeHead(413, { "Content-Type": "application/json; charset=utf-8" });
|
|
2258
|
+
res.end(JSON.stringify({ message: e.message, details: [] }));
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const err = e;
|
|
2262
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
2263
|
+
res.end(
|
|
2264
|
+
JSON.stringify({
|
|
2265
|
+
message: err.message ?? "Compilation failed",
|
|
2266
|
+
details: err.errors ?? []
|
|
2267
|
+
})
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
async function runUiServer(opts) {
|
|
2272
|
+
const staticRoot = resolveUiDist();
|
|
2273
|
+
const server = createServer(async (req, res) => {
|
|
2274
|
+
try {
|
|
2275
|
+
let pathOnly;
|
|
2276
|
+
try {
|
|
2277
|
+
pathOnly = decodeURIComponent(req.url?.split("?")[0] ?? "/");
|
|
2278
|
+
} catch {
|
|
2279
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2280
|
+
res.end("400 Bad Request");
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (pathOnly === "/api/compile") {
|
|
2284
|
+
await handleCompile(req, res);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
const relReq = pathOnly === "/" ? "index.html" : pathOnly.replace(/^\/+/, "");
|
|
2288
|
+
const filePath = pathResolve(staticRoot, relReq);
|
|
2289
|
+
if (!isInsideRoot(staticRoot, filePath)) {
|
|
2290
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2291
|
+
res.end("403 Forbidden");
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
try {
|
|
2295
|
+
const st = await stat(filePath);
|
|
2296
|
+
if (st.isFile()) {
|
|
2297
|
+
await serveFile(res, filePath);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
} catch {
|
|
2301
|
+
}
|
|
2302
|
+
const indexHtml = pathResolve(staticRoot, "index.html");
|
|
2303
|
+
if (existsSync3(indexHtml)) {
|
|
2304
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
2305
|
+
res.end(await readFile(indexHtml, "utf8"));
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2309
|
+
res.end("404 Not Found");
|
|
2310
|
+
} catch (e) {
|
|
2311
|
+
console.error("transitrix serve: unhandled error", e);
|
|
2312
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2313
|
+
res.end("Internal server error");
|
|
2314
|
+
}
|
|
2315
|
+
});
|
|
2316
|
+
await new Promise((resolve2, reject) => {
|
|
2317
|
+
server.listen(opts.port, opts.host ?? "127.0.0.1", () => resolve2());
|
|
2318
|
+
server.on("error", reject);
|
|
2319
|
+
});
|
|
2320
|
+
const host = opts.host ?? "127.0.0.1";
|
|
2321
|
+
const url = `http://${host}:${opts.port}/`;
|
|
2322
|
+
const rootName = basename(staticRoot);
|
|
2323
|
+
console.error(`Transitrix Studio UI \u2192 ${url} (static root: "${rootName}")`);
|
|
2324
|
+
}
|
|
2325
|
+
async function cliServeArgv(argv) {
|
|
2326
|
+
let port = 8765;
|
|
2327
|
+
let host = "127.0.0.1";
|
|
2328
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2329
|
+
const a = argv[i];
|
|
2330
|
+
if (a === "--port" || a === "-p") {
|
|
2331
|
+
const v = argv[++i];
|
|
2332
|
+
if (v) {
|
|
2333
|
+
const parsed = Number.parseInt(v, 10);
|
|
2334
|
+
if (!Number.isFinite(parsed)) {
|
|
2335
|
+
console.error(`transitrix serve: --port requires a numeric value, got: ${v}`);
|
|
2336
|
+
process2.exitCode = 1;
|
|
2337
|
+
process2.exit(1);
|
|
2338
|
+
}
|
|
2339
|
+
port = parsed;
|
|
2340
|
+
}
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
if (a.startsWith("--port=")) {
|
|
2344
|
+
const v = a.slice("--port=".length);
|
|
2345
|
+
const parsed = Number.parseInt(v, 10);
|
|
2346
|
+
if (!Number.isFinite(parsed)) {
|
|
2347
|
+
console.error(`transitrix serve: --port requires a numeric value, got: ${v}`);
|
|
2348
|
+
process2.exitCode = 1;
|
|
2349
|
+
process2.exit(1);
|
|
2350
|
+
}
|
|
2351
|
+
port = parsed;
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
if (a === "--host" || a === "-H") {
|
|
2355
|
+
const v = argv[++i];
|
|
2356
|
+
if (v) host = v;
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (a === "--help" || a === "-h") {
|
|
2360
|
+
console.error(`usage: transitrix serve [--port 8765] [--host 127.0.0.1]
|
|
2361
|
+
|
|
2362
|
+
Local web UI: YAML on the left, BPMN preview on the right.
|
|
2363
|
+
Compiles through the server (same pipeline as the CLI / VS Code extension for BPMN).
|
|
2364
|
+
|
|
2365
|
+
Before first run:
|
|
2366
|
+
npm run build && npm run ui:build
|
|
2367
|
+
`);
|
|
2368
|
+
process2.exitCode = 0;
|
|
2369
|
+
process2.exit(0);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
2373
|
+
console.error("transitrix serve: invalid --port");
|
|
2374
|
+
process2.exitCode = 1;
|
|
2375
|
+
process2.exit(1);
|
|
2376
|
+
}
|
|
2377
|
+
await runUiServer({ port, host });
|
|
2378
|
+
}
|
|
2379
|
+
var __dirname, thisScript;
|
|
2380
|
+
var init_serve_ui = __esm({
|
|
2381
|
+
"../../src/serve-ui.ts"() {
|
|
2382
|
+
"use strict";
|
|
2383
|
+
init_compiler();
|
|
2384
|
+
init_metrics();
|
|
2385
|
+
init_http_body_limit();
|
|
2386
|
+
init_layout_options();
|
|
2387
|
+
__dirname = dirname3(fileURLToPath3(import.meta.url));
|
|
2388
|
+
thisScript = pathResolve(fileURLToPath3(import.meta.url));
|
|
2389
|
+
if (process2.argv[1] && pathResolve(process2.argv[1]) === thisScript) {
|
|
2390
|
+
void cliServeArgv(process2.argv.slice(2)).catch((e) => {
|
|
2391
|
+
console.error(String(e.message ?? e));
|
|
2392
|
+
process2.exit(1);
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
// ../../src/cli.ts
|
|
2399
|
+
import { writeFileSync as writeFileSync2 } from "node:fs";
|
|
2400
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
2401
|
+
|
|
2402
|
+
// ../../src/cli-parse.ts
|
|
2403
|
+
var DEFAULT_CERVIN_FILE_EXTENSIONS = [".cervin.yaml", ".bpmn.transitrix.yaml"];
|
|
2404
|
+
function normalizeExt(s) {
|
|
2405
|
+
const t = s.trim();
|
|
2406
|
+
if (!t) return t;
|
|
2407
|
+
return t.startsWith(".") ? t : `.${t}`;
|
|
2408
|
+
}
|
|
2409
|
+
function parseCliFileArgv(argv) {
|
|
2410
|
+
const positional = [];
|
|
2411
|
+
const extList = [];
|
|
2412
|
+
let wantsHelp = false;
|
|
2413
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2414
|
+
const a = argv[i];
|
|
2415
|
+
if (a === "--help" || a === "-h") {
|
|
2416
|
+
wantsHelp = true;
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
if (a === "--ext") {
|
|
2420
|
+
const raw = argv[++i];
|
|
2421
|
+
if (!raw) {
|
|
2422
|
+
return { ok: false, error: "--ext_requires_value" };
|
|
2423
|
+
}
|
|
2424
|
+
raw.split(",").map((x) => normalizeExt(x)).filter(Boolean).forEach((x) => extList.push(x));
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
if (a.startsWith("--ext=")) {
|
|
2428
|
+
const raw = a.slice("--ext=".length);
|
|
2429
|
+
raw.split(",").map((x) => normalizeExt(x)).filter(Boolean).forEach((x) => extList.push(x));
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
positional.push(a);
|
|
2433
|
+
}
|
|
2434
|
+
return { ok: true, positional, extList, wantsHelp };
|
|
2435
|
+
}
|
|
2436
|
+
function parseValidateArgv(argv) {
|
|
2437
|
+
let scope = "file";
|
|
2438
|
+
let root;
|
|
2439
|
+
const rest = [];
|
|
2440
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2441
|
+
const a = argv[i];
|
|
2442
|
+
if (a === "--scope") {
|
|
2443
|
+
const v = argv[++i];
|
|
2444
|
+
if (v === void 0) return { ok: false, error: "--scope_requires_value" };
|
|
2445
|
+
if (v !== "file" && v !== "repo") return { ok: false, error: "bad_scope" };
|
|
2446
|
+
scope = v;
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
if (a.startsWith("--scope=")) {
|
|
2450
|
+
const v = a.slice("--scope=".length);
|
|
2451
|
+
if (v !== "file" && v !== "repo") return { ok: false, error: "bad_scope" };
|
|
2452
|
+
scope = v;
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
if (a === "--root") {
|
|
2456
|
+
const v = argv[++i];
|
|
2457
|
+
if (v === void 0) return { ok: false, error: "--root_requires_value" };
|
|
2458
|
+
root = v;
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
if (a.startsWith("--root=")) {
|
|
2462
|
+
root = a.slice("--root=".length);
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
rest.push(a);
|
|
2466
|
+
}
|
|
2467
|
+
const parsed = parseCliFileArgv(rest);
|
|
2468
|
+
if (!parsed.ok) return { ok: false, error: "--ext_requires_value", scope };
|
|
2469
|
+
return {
|
|
2470
|
+
ok: true,
|
|
2471
|
+
scope,
|
|
2472
|
+
root,
|
|
2473
|
+
positional: parsed.positional,
|
|
2474
|
+
extList: parsed.extList,
|
|
2475
|
+
wantsHelp: parsed.wantsHelp
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
function inputMatchesExtension(filePath, exts) {
|
|
2479
|
+
const lowered = filePath.replace(/\\/g, "/").toLowerCase();
|
|
2480
|
+
return exts.some((e) => lowered.endsWith(e.toLowerCase()));
|
|
2481
|
+
}
|
|
2482
|
+
var CERVIN_DEPRECATION_NOTICE = "cervin: the `cervin` command is deprecated and will be removed in 2.0.0 \u2014 use `transitrix` instead.";
|
|
2483
|
+
function invokedAsCervin(argv1) {
|
|
2484
|
+
if (!argv1) return false;
|
|
2485
|
+
const base = argv1.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
2486
|
+
const stem = base.replace(/\.[^.]+$/, "").toLowerCase();
|
|
2487
|
+
return stem === "cervin";
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// ../../src/cli.ts
|
|
2491
|
+
init_compiler();
|
|
2492
|
+
|
|
2493
|
+
// ../../src/migrate.ts
|
|
2494
|
+
import { spawnSync } from "node:child_process";
|
|
2495
|
+
import { cpSync, existsSync as existsSync2, mkdtempSync, readdirSync, readFileSync as readFileSync4, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2496
|
+
import { join as join4, relative, resolve } from "node:path";
|
|
2497
|
+
import { tmpdir } from "node:os";
|
|
2498
|
+
function parseMigrateArgv(argv) {
|
|
2499
|
+
let from;
|
|
2500
|
+
let to;
|
|
2501
|
+
let dryRun = false;
|
|
2502
|
+
let recipesDir = "../methodology/migrations";
|
|
2503
|
+
let targetDir = process.cwd();
|
|
2504
|
+
let wantsHelp = false;
|
|
2505
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2506
|
+
const a = argv[i];
|
|
2507
|
+
if (a === "--help" || a === "-h") {
|
|
2508
|
+
wantsHelp = true;
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2511
|
+
if (a === "--dry-run") {
|
|
2512
|
+
dryRun = true;
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
if (a === "--from") {
|
|
2516
|
+
from = argv[++i];
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
if (a.startsWith("--from=")) {
|
|
2520
|
+
from = a.slice("--from=".length);
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
if (a === "--to") {
|
|
2524
|
+
to = argv[++i];
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
if (a.startsWith("--to=")) {
|
|
2528
|
+
to = a.slice("--to=".length);
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
if (a === "--recipes") {
|
|
2532
|
+
recipesDir = argv[++i];
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
if (a.startsWith("--recipes=")) {
|
|
2536
|
+
recipesDir = a.slice("--recipes=".length);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (!a.startsWith("-")) {
|
|
2540
|
+
targetDir = a;
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
return { from, to, dryRun, recipesDir, targetDir, wantsHelp };
|
|
2545
|
+
}
|
|
2546
|
+
function scanRecipes(recipesDir) {
|
|
2547
|
+
const absDir = resolve(recipesDir);
|
|
2548
|
+
if (!existsSync2(absDir)) return [];
|
|
2549
|
+
const versionPat = /^\d+\.\d+$/;
|
|
2550
|
+
return readdirSync(absDir).filter((ent) => {
|
|
2551
|
+
const parts = ent.split("-to-");
|
|
2552
|
+
return parts.length === 2 && versionPat.test(parts[0]) && versionPat.test(parts[1]);
|
|
2553
|
+
}).map((ent) => {
|
|
2554
|
+
const [from, to] = ent.split("-to-");
|
|
2555
|
+
return { from, to, dir: join4(absDir, ent) };
|
|
2556
|
+
}).filter((r) => existsSync2(join4(r.dir, "codemod.mjs")));
|
|
2557
|
+
}
|
|
2558
|
+
function resolveChain(from, to, recipes) {
|
|
2559
|
+
if (from === to) return [];
|
|
2560
|
+
const graph = /* @__PURE__ */ new Map();
|
|
2561
|
+
for (const r of recipes) {
|
|
2562
|
+
if (!graph.has(r.from)) graph.set(r.from, []);
|
|
2563
|
+
graph.get(r.from).push(r);
|
|
2564
|
+
}
|
|
2565
|
+
const queue = [{ node: from, path: [] }];
|
|
2566
|
+
const visited = /* @__PURE__ */ new Set([from]);
|
|
2567
|
+
while (queue.length) {
|
|
2568
|
+
const item = queue.shift();
|
|
2569
|
+
for (const step of graph.get(item.node) ?? []) {
|
|
2570
|
+
const newPath = [...item.path, step];
|
|
2571
|
+
if (step.to === to) return newPath;
|
|
2572
|
+
if (!visited.has(step.to)) {
|
|
2573
|
+
visited.add(step.to);
|
|
2574
|
+
queue.push({ node: step.to, path: newPath });
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
return null;
|
|
2579
|
+
}
|
|
2580
|
+
function findFurthestReachable(from, recipes) {
|
|
2581
|
+
const forward = /* @__PURE__ */ new Map();
|
|
2582
|
+
for (const r of recipes) forward.set(r.from, r.to);
|
|
2583
|
+
let current = from;
|
|
2584
|
+
const visited = /* @__PURE__ */ new Set([from]);
|
|
2585
|
+
while (forward.has(current)) {
|
|
2586
|
+
const next = forward.get(current);
|
|
2587
|
+
if (visited.has(next)) break;
|
|
2588
|
+
visited.add(next);
|
|
2589
|
+
current = next;
|
|
2590
|
+
}
|
|
2591
|
+
return current === from ? void 0 : current;
|
|
2592
|
+
}
|
|
2593
|
+
function toMajorMinor(v) {
|
|
2594
|
+
return v.split(".").slice(0, 2).join(".");
|
|
2595
|
+
}
|
|
2596
|
+
function toFullSemver(v) {
|
|
2597
|
+
const parts = v.split(".");
|
|
2598
|
+
while (parts.length < 3) parts.push("0");
|
|
2599
|
+
return parts.slice(0, 3).join(".");
|
|
2600
|
+
}
|
|
2601
|
+
var TRANSITRIX_YAML = "transitrix.yaml";
|
|
2602
|
+
function readMethodologyVersion(dir) {
|
|
2603
|
+
const yamlPath = join4(resolve(dir), TRANSITRIX_YAML);
|
|
2604
|
+
if (!existsSync2(yamlPath)) return void 0;
|
|
2605
|
+
const content = readFileSync4(yamlPath, "utf8");
|
|
2606
|
+
const m = content.match(/^methodology_version\s*:\s*["']?([^\s"'\n]+)["']?/m);
|
|
2607
|
+
return m ? m[1] : void 0;
|
|
2608
|
+
}
|
|
2609
|
+
function updateMethodologyVersion(dir, version) {
|
|
2610
|
+
const yamlPath = join4(resolve(dir), TRANSITRIX_YAML);
|
|
2611
|
+
if (!existsSync2(yamlPath)) {
|
|
2612
|
+
writeFileSync(yamlPath, `methodology_version: ${version}
|
|
2613
|
+
`);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const content = readFileSync4(yamlPath, "utf8");
|
|
2617
|
+
if (/^methodology_version\s*:/m.test(content)) {
|
|
2618
|
+
const updated = content.replace(
|
|
2619
|
+
/^(methodology_version\s*:\s*)["']?[^\s"'\n]+["']?/m,
|
|
2620
|
+
`$1${version}`
|
|
2621
|
+
);
|
|
2622
|
+
writeFileSync(yamlPath, updated);
|
|
2623
|
+
} else {
|
|
2624
|
+
writeFileSync(yamlPath, `${content.trimEnd()}
|
|
2625
|
+
methodology_version: ${version}
|
|
2626
|
+
`);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
function walkFiles(dir, base = dir) {
|
|
2630
|
+
const result = [];
|
|
2631
|
+
let entries;
|
|
2632
|
+
try {
|
|
2633
|
+
entries = readdirSync(dir);
|
|
2634
|
+
} catch {
|
|
2635
|
+
return result;
|
|
2636
|
+
}
|
|
2637
|
+
for (const ent of entries) {
|
|
2638
|
+
const full = join4(dir, ent);
|
|
2639
|
+
let st;
|
|
2640
|
+
try {
|
|
2641
|
+
st = statSync(full);
|
|
2642
|
+
} catch {
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
if (st.isDirectory()) result.push(...walkFiles(full, base));
|
|
2646
|
+
else result.push(relative(base, full).replace(/\\/g, "/"));
|
|
2647
|
+
}
|
|
2648
|
+
return result;
|
|
2649
|
+
}
|
|
2650
|
+
function printDiff(relPath, before, after) {
|
|
2651
|
+
if (before === after) return;
|
|
2652
|
+
console.log(`--- a/${relPath}`);
|
|
2653
|
+
console.log(`+++ b/${relPath}`);
|
|
2654
|
+
const bLines = before ? before.split("\n") : [];
|
|
2655
|
+
const aLines = after ? after.split("\n") : [];
|
|
2656
|
+
const maxLen = Math.max(bLines.length, aLines.length);
|
|
2657
|
+
let lastHunk = -1;
|
|
2658
|
+
for (let i = 0; i < maxLen; i++) {
|
|
2659
|
+
const bl = i < bLines.length ? bLines[i] : null;
|
|
2660
|
+
const al = i < aLines.length ? aLines[i] : null;
|
|
2661
|
+
if (bl !== al) {
|
|
2662
|
+
if (lastHunk !== i - 1) console.log(`@@ -${i + 1},0 +${i + 1},0 @@`);
|
|
2663
|
+
if (bl !== null) console.log(`-${bl}`);
|
|
2664
|
+
if (al !== null) console.log(`+${al}`);
|
|
2665
|
+
lastHunk = i;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
console.log("");
|
|
2669
|
+
}
|
|
2670
|
+
function showTreeDiff(originalDir, modifiedDir) {
|
|
2671
|
+
const origFiles = new Set(walkFiles(originalDir));
|
|
2672
|
+
const modFiles = new Set(walkFiles(modifiedDir));
|
|
2673
|
+
const allFiles = /* @__PURE__ */ new Set([...origFiles, ...modFiles]);
|
|
2674
|
+
const changed = [];
|
|
2675
|
+
for (const f of allFiles) {
|
|
2676
|
+
const origContent = origFiles.has(f) ? readFileSync4(join4(originalDir, f), "utf8") : "";
|
|
2677
|
+
const modContent = modFiles.has(f) ? readFileSync4(join4(modifiedDir, f), "utf8") : "";
|
|
2678
|
+
if (origContent !== modContent) changed.push(f);
|
|
2679
|
+
}
|
|
2680
|
+
if (changed.length === 0) {
|
|
2681
|
+
console.log(" No files would change.");
|
|
2682
|
+
return false;
|
|
2683
|
+
}
|
|
2684
|
+
console.log(` ${changed.length} file(s) would change:
|
|
2685
|
+
`);
|
|
2686
|
+
for (const f of changed) {
|
|
2687
|
+
const origContent = origFiles.has(f) ? readFileSync4(join4(originalDir, f), "utf8") : "";
|
|
2688
|
+
const modContent = modFiles.has(f) ? readFileSync4(join4(modifiedDir, f), "utf8") : "";
|
|
2689
|
+
printDiff(f, origContent, modContent);
|
|
2690
|
+
}
|
|
2691
|
+
return true;
|
|
2692
|
+
}
|
|
2693
|
+
function runScript(scriptPath, args) {
|
|
2694
|
+
const r = spawnSync(process.execPath, [scriptPath, ...args], {
|
|
2695
|
+
encoding: "utf8",
|
|
2696
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2697
|
+
});
|
|
2698
|
+
return { exitCode: r.status ?? 1, output: (r.stdout ?? "") + (r.stderr ?? "") };
|
|
2699
|
+
}
|
|
2700
|
+
function runDryRun(chain, absTarget, toVersion) {
|
|
2701
|
+
const tmpDir = mkdtempSync(join4(tmpdir(), "tx-migrate-"));
|
|
2702
|
+
try {
|
|
2703
|
+
cpSync(absTarget, tmpDir, { recursive: true });
|
|
2704
|
+
let overallExit = 0;
|
|
2705
|
+
for (let i = 0; i < chain.length; i++) {
|
|
2706
|
+
const step = chain[i];
|
|
2707
|
+
console.log(`Step ${i + 1}/${chain.length}: ${step.from} \u2192 ${step.to}`);
|
|
2708
|
+
const { exitCode, output } = runScript(join4(step.dir, "codemod.mjs"), [tmpDir]);
|
|
2709
|
+
process.stdout.write(output);
|
|
2710
|
+
if (exitCode !== 0) {
|
|
2711
|
+
console.error(` codemod exited ${exitCode} \u2014 would require manual intervention`);
|
|
2712
|
+
overallExit = exitCode;
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
console.log("\nFile changes:");
|
|
2716
|
+
showTreeDiff(absTarget, tmpDir);
|
|
2717
|
+
const currentVersion = readMethodologyVersion(absTarget);
|
|
2718
|
+
console.log(`
|
|
2719
|
+
Would update methodology_version: ${currentVersion ?? "(none)"} \u2192 ${toFullSemver(toVersion)}`);
|
|
2720
|
+
if (overallExit !== 0) {
|
|
2721
|
+
console.error("\nDry-run: some steps would require manual intervention (see above).");
|
|
2722
|
+
process.exit(overallExit);
|
|
2723
|
+
}
|
|
2724
|
+
} finally {
|
|
2725
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
function runApply(chain, absTarget, toVersion) {
|
|
2729
|
+
for (let i = 0; i < chain.length; i++) {
|
|
2730
|
+
const step = chain[i];
|
|
2731
|
+
console.log(`Step ${i + 1}/${chain.length}: ${step.from} \u2192 ${step.to}`);
|
|
2732
|
+
const codemodPath = join4(step.dir, "codemod.mjs");
|
|
2733
|
+
const { exitCode: codemodExit, output: codemodOut } = runScript(codemodPath, [absTarget]);
|
|
2734
|
+
process.stdout.write(codemodOut);
|
|
2735
|
+
if (codemodExit !== 0) {
|
|
2736
|
+
console.error(`
|
|
2737
|
+
transitrix migrate: codemod ${step.from}\u2192${step.to} exited ${codemodExit}.`);
|
|
2738
|
+
console.error(` Manual intervention required (see output above).`);
|
|
2739
|
+
console.error(` Fix the flagged files, then re-run from step ${i + 1}:`);
|
|
2740
|
+
console.error(` transitrix migrate --from ${step.from} --to ${toVersion} [target-dir]`);
|
|
2741
|
+
process.exit(codemodExit);
|
|
2742
|
+
}
|
|
2743
|
+
const validatePath = join4(step.dir, "validate.mjs");
|
|
2744
|
+
if (existsSync2(validatePath)) {
|
|
2745
|
+
const { exitCode: valExit, output: valOut } = runScript(validatePath, [absTarget]);
|
|
2746
|
+
process.stdout.write(valOut);
|
|
2747
|
+
if (valExit !== 0) {
|
|
2748
|
+
console.error(`
|
|
2749
|
+
transitrix migrate: post-step validation failed for ${step.from}\u2192${step.to}.`);
|
|
2750
|
+
console.error(` Fix the issues above, then re-run from step ${i + 1}:`);
|
|
2751
|
+
console.error(` transitrix migrate --from ${step.from} --to ${toVersion} [target-dir]`);
|
|
2752
|
+
process.exit(valExit);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
console.log(` \u2713 step complete
|
|
2756
|
+
`);
|
|
2757
|
+
}
|
|
2758
|
+
const newVersion = toFullSemver(toVersion);
|
|
2759
|
+
updateMethodologyVersion(absTarget, newVersion);
|
|
2760
|
+
console.log(`\u2713 Migration complete: ${newVersion}`);
|
|
2761
|
+
console.log(` Updated methodology_version in ${join4(absTarget, TRANSITRIX_YAML)}`);
|
|
2762
|
+
console.log(` Review and commit when ready.`);
|
|
2763
|
+
}
|
|
2764
|
+
async function handleMigrateCommand(argv) {
|
|
2765
|
+
const { from: rawFrom, to: rawTo, dryRun, recipesDir, targetDir, wantsHelp } = parseMigrateArgv(argv);
|
|
2766
|
+
if (wantsHelp) {
|
|
2767
|
+
console.error("usage: transitrix migrate [--from X.Y] [--to X.Y] [--dry-run] [--recipes <dir>] [target-dir]");
|
|
2768
|
+
console.error("");
|
|
2769
|
+
console.error(" Migrates an adopter repository from one methodology version to another");
|
|
2770
|
+
console.error(" by running the ordered recipes from the methodology repo.");
|
|
2771
|
+
console.error("");
|
|
2772
|
+
console.error(" --from X.Y Source version (default: methodology_version in transitrix.yaml)");
|
|
2773
|
+
console.error(" --to X.Y Target version (default: latest available recipe target)");
|
|
2774
|
+
console.error(" --dry-run Preview changes; no files written");
|
|
2775
|
+
console.error(" --recipes <dir> Path to recipes dir (default: ../methodology/migrations)");
|
|
2776
|
+
console.error(" target-dir Adopter repo root (default: current directory)");
|
|
2777
|
+
process.exit(0);
|
|
2778
|
+
}
|
|
2779
|
+
const absTarget = resolve(targetDir);
|
|
2780
|
+
if (!existsSync2(absTarget)) {
|
|
2781
|
+
console.error(`transitrix migrate: target directory does not exist: ${absTarget}`);
|
|
2782
|
+
process.exit(1);
|
|
2783
|
+
}
|
|
2784
|
+
const recipes = scanRecipes(recipesDir);
|
|
2785
|
+
if (recipes.length === 0) {
|
|
2786
|
+
console.error(`transitrix migrate: no recipes found in ${resolve(recipesDir)}`);
|
|
2787
|
+
console.error(` Expected subdirectories named X.Y-to-X.Y containing a codemod.mjs.`);
|
|
2788
|
+
process.exit(1);
|
|
2789
|
+
}
|
|
2790
|
+
const fromVersion = rawFrom ? toMajorMinor(rawFrom) : (() => {
|
|
2791
|
+
const v = readMethodologyVersion(absTarget);
|
|
2792
|
+
if (!v) {
|
|
2793
|
+
console.error(`transitrix migrate: cannot determine source version.`);
|
|
2794
|
+
console.error(` Add methodology_version: X.Y.Z to ${join4(absTarget, TRANSITRIX_YAML)}`);
|
|
2795
|
+
console.error(` or pass --from X.Y`);
|
|
2796
|
+
process.exit(1);
|
|
2797
|
+
}
|
|
2798
|
+
return toMajorMinor(v);
|
|
2799
|
+
})();
|
|
2800
|
+
const toVersion = rawTo ? toMajorMinor(rawTo) : (() => {
|
|
2801
|
+
const v = findFurthestReachable(fromVersion, recipes);
|
|
2802
|
+
if (!v) {
|
|
2803
|
+
console.error(`transitrix migrate: no recipes available from version ${fromVersion}`);
|
|
2804
|
+
console.error(` Available: ${recipes.map((r) => `${r.from}-to-${r.to}`).join(", ")}`);
|
|
2805
|
+
process.exit(1);
|
|
2806
|
+
}
|
|
2807
|
+
return v;
|
|
2808
|
+
})();
|
|
2809
|
+
const chain = resolveChain(fromVersion, toVersion, recipes);
|
|
2810
|
+
if (!chain) {
|
|
2811
|
+
console.error(`transitrix migrate: no migration path from ${fromVersion} to ${toVersion}`);
|
|
2812
|
+
console.error(` Available: ${recipes.map((r) => `${r.from}-to-${r.to}`).join(", ")}`);
|
|
2813
|
+
process.exit(1);
|
|
2814
|
+
}
|
|
2815
|
+
if (chain.length === 0) {
|
|
2816
|
+
console.log(`transitrix migrate: already at ${toVersion} \u2014 nothing to do.`);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
console.log(`transitrix migrate: ${fromVersion} \u2192 ${toVersion} (${chain.length} step${chain.length === 1 ? "" : "s"})`);
|
|
2820
|
+
if (dryRun) console.log(` (dry-run \u2014 no files will be written)
|
|
2821
|
+
`);
|
|
2822
|
+
else console.log("");
|
|
2823
|
+
if (dryRun) {
|
|
2824
|
+
runDryRun(chain, absTarget, toVersion);
|
|
2825
|
+
} else {
|
|
2826
|
+
runApply(chain, absTarget, toVersion);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// ../../src/cli.ts
|
|
2831
|
+
init_metrics();
|
|
2832
|
+
init_parser();
|
|
2833
|
+
init_validator();
|
|
2834
|
+
function printUsage() {
|
|
2835
|
+
console.error(`Transitrix Studio CLI \u2014 usage:
|
|
2836
|
+
transitrix serve [--port 8765] [--host 127.0.0.1]
|
|
2837
|
+
transitrix <input.yaml> <output.bpmn> [--no-metrics] [--no-validate]
|
|
2838
|
+
transitrix [--ext=.cervin.yaml,.bpmn.transitrix.yaml] <input.yaml> <output.bpmn> [--no-metrics] [--no-validate]
|
|
2839
|
+
transitrix metrics <input.yaml> [--json]
|
|
2840
|
+
transitrix metrics [--ext=.cervin.yaml,.bpmn.transitrix.yaml] <input.yaml> [--json]
|
|
2841
|
+
transitrix validate <input.yaml> [--json]
|
|
2842
|
+
transitrix validate [--ext=.cervin.yaml,.bpmn.transitrix.yaml] <input.yaml> [--json]
|
|
2843
|
+
transitrix validate --scope=repo [--root <dir>] [--json]
|
|
2844
|
+
transitrix export-compliance [--format md|pdf] [--scope law:<ID>|product:<ID>|gap] [--output <path>] [--root <dir>]
|
|
2845
|
+
transitrix migrate [--from X.Y] [--to X.Y] [--dry-run] [--recipes <dir>] [target-dir]
|
|
2846
|
+
|
|
2847
|
+
('cervin' is a deprecated alias of 'transitrix'; both run the same CLI.)
|
|
2848
|
+
|
|
2849
|
+
serve \u2014 local web UI (run npm run ui:build once beforehand).
|
|
2850
|
+
<compile> \u2014 YAML \u2192 BPMN 2.0 XML with layout metrics.
|
|
2851
|
+
metrics \u2014 layout quality metrics (with --json for CI).
|
|
2852
|
+
validate \u2014 validation only (no XML output; exit 1 on errors). Default scope
|
|
2853
|
+
is a single file; --scope=repo runs whole-canon checks
|
|
2854
|
+
(referential integrity, atomicity, id uniqueness, policy).
|
|
2855
|
+
export-compliance \u2014 Markdown or PDF report of the compliance views (matrix by
|
|
2856
|
+
default; law:/product:/gap scopes). Scans --root (default cwd) for
|
|
2857
|
+
requirement/assertion/product/codex canon. PDF rendering requires
|
|
2858
|
+
WeasyPrint on PATH (pipx install weasyprint).
|
|
2859
|
+
migrate \u2014 migrate an adopter repo to a newer methodology version by running
|
|
2860
|
+
the ordered recipes from the methodology repo. Reads the current
|
|
2861
|
+
version from transitrix.yaml (or --from X.Y); --dry-run previews
|
|
2862
|
+
without writing; --recipes <dir> overrides the recipe source.
|
|
2863
|
+
|
|
2864
|
+
--no-metrics suppress quality metrics report on compile.
|
|
2865
|
+
--no-validate suppress validation warnings (errors always run).
|
|
2866
|
+
|
|
2867
|
+
Examples:
|
|
2868
|
+
npm run transitrix -- compile input.cervin.yaml output.bpmn
|
|
2869
|
+
npm run transitrix -- serve
|
|
2870
|
+
npm run transitrix -- metrics example.cervin.yaml --json
|
|
2871
|
+
npm run transitrix -- validate example.cervin.yaml
|
|
2872
|
+
npm run transitrix -- validate example.cervin.yaml --json
|
|
2873
|
+
npm run transitrix -- migrate --dry-run
|
|
2874
|
+
npm run transitrix -- migrate --from 0.5 --to 0.6 /path/to/adopter-repo
|
|
2875
|
+
`);
|
|
2876
|
+
}
|
|
2877
|
+
async function handleCompileCommand(argv) {
|
|
2878
|
+
const parsed = parseCliFileArgv(argv);
|
|
2879
|
+
if (!parsed.ok) {
|
|
2880
|
+
console.error("transitrix: --ext requires a comma-separated list of suffixes.");
|
|
2881
|
+
process.exit(1);
|
|
2882
|
+
}
|
|
2883
|
+
const { positional, extList, wantsHelp } = parsed;
|
|
2884
|
+
const exts = extList.length > 0 ? extList : DEFAULT_CERVIN_FILE_EXTENSIONS;
|
|
2885
|
+
if (wantsHelp) {
|
|
2886
|
+
printUsage();
|
|
2887
|
+
process.exit(0);
|
|
2888
|
+
}
|
|
2889
|
+
const [src, dst] = positional;
|
|
2890
|
+
if (!src || !dst) {
|
|
2891
|
+
console.error("transitrix compile: missing input or output file");
|
|
2892
|
+
console.error(`usage: transitrix compile <input.yaml> <output.bpmn>`);
|
|
2893
|
+
process.exit(1);
|
|
2894
|
+
}
|
|
2895
|
+
if (!inputMatchesExtension(src, exts)) {
|
|
2896
|
+
console.error(
|
|
2897
|
+
`transitrix: input file must end with one of: ${exts.join(", ")} (or pass --ext)`
|
|
2898
|
+
);
|
|
2899
|
+
process.exit(1);
|
|
2900
|
+
}
|
|
2901
|
+
const noMetrics = argv.includes("--no-metrics");
|
|
2902
|
+
const noValidateWarnings = argv.includes("--no-validate");
|
|
2903
|
+
try {
|
|
2904
|
+
const yaml2 = await readFile2(src, "utf8");
|
|
2905
|
+
const result = await compileTransitrixYamlWithLayout(yaml2);
|
|
2906
|
+
writeFileSync2(dst, result.xml);
|
|
2907
|
+
printValidationReport(src, result.validation, noValidateWarnings);
|
|
2908
|
+
if (!noMetrics) {
|
|
2909
|
+
const metrics = computeLayoutMetrics(result.layout);
|
|
2910
|
+
printMetricsReport(src, dst, metrics);
|
|
2911
|
+
}
|
|
2912
|
+
if (!result.validation.isValid) {
|
|
2913
|
+
process.exit(1);
|
|
2914
|
+
}
|
|
2915
|
+
} catch (e) {
|
|
2916
|
+
const err = e;
|
|
2917
|
+
console.error(err.message);
|
|
2918
|
+
err.errors?.forEach((line) => console.error(` \u2022 ${line}`));
|
|
2919
|
+
process.exit(1);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
function renderMetricsLines(metrics, showStatusIcons) {
|
|
2923
|
+
const statusIcon = (value) => {
|
|
2924
|
+
return value ? "\u2713" : "\u2717";
|
|
2925
|
+
};
|
|
2926
|
+
const port = showStatusIcons ? statusIcon(metrics.portViolations === 0) + " " : "";
|
|
2927
|
+
const area = showStatusIcons ? statusIcon(metrics.emptyArea <= 0.3) + " " : "";
|
|
2928
|
+
const spine = showStatusIcons ? statusIcon(metrics.spineDeviation <= 4) + " " : "";
|
|
2929
|
+
console.log(` Port violations ${port}${metrics.portViolations}`);
|
|
2930
|
+
console.log(` Empty area ${area}${(metrics.emptyArea * 100).toFixed(1)}%`);
|
|
2931
|
+
console.log(` Spine deviation ${spine}${metrics.spineDeviation.toFixed(1)} px`);
|
|
2932
|
+
console.log(` Bends ${metrics.bends}`);
|
|
2933
|
+
console.log(` Crossings ${metrics.crossings}`);
|
|
2934
|
+
}
|
|
2935
|
+
function printMetricsReport(src, dst, metrics) {
|
|
2936
|
+
console.log();
|
|
2937
|
+
console.log(`\u2713 ${src} \u2192 ${dst}`);
|
|
2938
|
+
console.log();
|
|
2939
|
+
console.log("Layout Quality Metrics:");
|
|
2940
|
+
renderMetricsLines(metrics, true);
|
|
2941
|
+
console.log();
|
|
2942
|
+
}
|
|
2943
|
+
function printValidationReport(src, validation, suppressWarnings) {
|
|
2944
|
+
const { findings, summary } = validation;
|
|
2945
|
+
const visibleFindings = suppressWarnings ? findings.filter((f) => f.severity === "error") : findings;
|
|
2946
|
+
if (visibleFindings.length === 0) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
console.log();
|
|
2950
|
+
console.log(`\u2713 ${src}`);
|
|
2951
|
+
console.log();
|
|
2952
|
+
console.log("Validation:");
|
|
2953
|
+
for (const finding of visibleFindings) {
|
|
2954
|
+
const icon = finding.severity === "error" ? "\u2717" : "\u26A0";
|
|
2955
|
+
const prefix = finding.severity === "error" ? `\x1B[31m${icon} ${finding.ruleId}\x1B[0m` : `\x1B[33m${icon} ${finding.ruleId}\x1B[0m`;
|
|
2956
|
+
console.log(` ${prefix} ${finding.message}`);
|
|
2957
|
+
if (finding.hint) {
|
|
2958
|
+
console.log(` \u2192 ${finding.hint}`);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
const visible = {
|
|
2962
|
+
errorCount: visibleFindings.filter((f) => f.severity === "error").length,
|
|
2963
|
+
warningCount: visibleFindings.filter((f) => f.severity === "warning").length,
|
|
2964
|
+
infoCount: visibleFindings.filter((f) => f.severity === "info").length
|
|
2965
|
+
};
|
|
2966
|
+
console.log();
|
|
2967
|
+
const summaryParts = [];
|
|
2968
|
+
if (visible.errorCount > 0) {
|
|
2969
|
+
summaryParts.push(`\x1B[31m${visible.errorCount} error\x1B[0m${visible.errorCount === 1 ? "" : "s"}`);
|
|
2970
|
+
}
|
|
2971
|
+
if (visible.warningCount > 0 && !suppressWarnings) {
|
|
2972
|
+
summaryParts.push(`\x1B[33m${visible.warningCount} warning\x1B[0m${visible.warningCount === 1 ? "" : "s"}`);
|
|
2973
|
+
}
|
|
2974
|
+
if (summaryParts.length > 0) {
|
|
2975
|
+
console.log(`Validation: ${summaryParts.join(", ")}`);
|
|
2976
|
+
}
|
|
2977
|
+
console.log();
|
|
2978
|
+
}
|
|
2979
|
+
async function handleValidateCommand(argv) {
|
|
2980
|
+
const parsed = parseValidateArgv(argv);
|
|
2981
|
+
if (!parsed.ok) {
|
|
2982
|
+
if (parsed.error === "bad_scope") {
|
|
2983
|
+
console.error('transitrix validate: --scope must be "file" or "repo".');
|
|
2984
|
+
} else if (parsed.error === "--scope_requires_value") {
|
|
2985
|
+
console.error("transitrix validate: --scope requires a value (file|repo).");
|
|
2986
|
+
} else if (parsed.error === "--root_requires_value") {
|
|
2987
|
+
console.error("transitrix validate: --root requires a directory path.");
|
|
2988
|
+
} else {
|
|
2989
|
+
console.error("transitrix: --ext requires a comma-separated list of suffixes.");
|
|
2990
|
+
}
|
|
2991
|
+
process.exit(1);
|
|
2992
|
+
}
|
|
2993
|
+
const { scope, root, positional, extList, wantsHelp } = parsed;
|
|
2994
|
+
const exts = extList.length > 0 ? extList : DEFAULT_CERVIN_FILE_EXTENSIONS;
|
|
2995
|
+
const useJson = argv.includes("--json");
|
|
2996
|
+
if (wantsHelp) {
|
|
2997
|
+
console.error(`usage: transitrix validate <input.yaml> [--json] (file scope, default)`);
|
|
2998
|
+
console.error(` transitrix validate --scope=repo [--root <dir>] [--json]`);
|
|
2999
|
+
console.error("");
|
|
3000
|
+
console.error("file scope \u2014 single-file structural/semantic validation (default).");
|
|
3001
|
+
console.error("repo scope \u2014 whole-canon checks (referential integrity, atomicity,");
|
|
3002
|
+
console.error(" id uniqueness, policy) over <root> (default: cwd).");
|
|
3003
|
+
console.error("Exits with code 1 if any findings.");
|
|
3004
|
+
process.exit(0);
|
|
3005
|
+
}
|
|
3006
|
+
if (scope === "repo") {
|
|
3007
|
+
const repoRoot = root ?? process.cwd();
|
|
3008
|
+
const repoModule = "./repo-validate.js";
|
|
3009
|
+
const { runRepoValidate, reportRepoFindings } = await import(repoModule);
|
|
3010
|
+
const findings = runRepoValidate(repoRoot);
|
|
3011
|
+
reportRepoFindings(repoRoot, findings, useJson);
|
|
3012
|
+
if (findings.length > 0) {
|
|
3013
|
+
process.exit(1);
|
|
3014
|
+
}
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const [src] = positional;
|
|
3018
|
+
if (!src) {
|
|
3019
|
+
console.error("transitrix validate: missing input file");
|
|
3020
|
+
console.error(`usage: transitrix validate <input.yaml> [--json]`);
|
|
3021
|
+
process.exit(1);
|
|
3022
|
+
}
|
|
3023
|
+
if (!inputMatchesExtension(src, exts)) {
|
|
3024
|
+
console.error(
|
|
3025
|
+
`transitrix: input file must end with one of: ${exts.join(", ")} (or pass --ext)`
|
|
3026
|
+
);
|
|
3027
|
+
process.exit(1);
|
|
3028
|
+
}
|
|
3029
|
+
let yaml2;
|
|
3030
|
+
try {
|
|
3031
|
+
yaml2 = await readFile2(src, "utf8");
|
|
3032
|
+
} catch (e) {
|
|
3033
|
+
const err = e;
|
|
3034
|
+
if (useJson) {
|
|
3035
|
+
console.log(JSON.stringify({ valid: false, message: err.message }, null, 2));
|
|
3036
|
+
} else {
|
|
3037
|
+
console.error(`\u2717 ${src}`);
|
|
3038
|
+
console.error();
|
|
3039
|
+
console.error(`Read error: ${err.message}`);
|
|
3040
|
+
console.error();
|
|
3041
|
+
}
|
|
3042
|
+
process.exit(1);
|
|
3043
|
+
}
|
|
3044
|
+
let ir;
|
|
3045
|
+
try {
|
|
3046
|
+
ir = parseYamlToIr(yaml2);
|
|
3047
|
+
} catch (e) {
|
|
3048
|
+
const err = e;
|
|
3049
|
+
if (useJson) {
|
|
3050
|
+
const output = {
|
|
3051
|
+
valid: false,
|
|
3052
|
+
message: err.message,
|
|
3053
|
+
errors: err.errors ?? []
|
|
3054
|
+
};
|
|
3055
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3056
|
+
} else {
|
|
3057
|
+
console.error(`\u2717 ${src}`);
|
|
3058
|
+
console.error();
|
|
3059
|
+
console.error(`Parse error: ${err.message}`);
|
|
3060
|
+
if (err.errors && err.errors.length > 0) {
|
|
3061
|
+
console.error();
|
|
3062
|
+
console.error("Details:");
|
|
3063
|
+
for (const detail of err.errors) {
|
|
3064
|
+
console.error(` - ${detail}`);
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
console.error();
|
|
3068
|
+
}
|
|
3069
|
+
process.exit(1);
|
|
3070
|
+
}
|
|
3071
|
+
const report = validateProcess(ir);
|
|
3072
|
+
if (useJson) {
|
|
3073
|
+
const output = {
|
|
3074
|
+
valid: report.isValid,
|
|
3075
|
+
findings: report.findings.map((f) => ({
|
|
3076
|
+
ruleId: f.ruleId,
|
|
3077
|
+
severity: f.severity,
|
|
3078
|
+
message: f.message,
|
|
3079
|
+
elementId: f.elementId || null,
|
|
3080
|
+
hint: f.hint || null
|
|
3081
|
+
})),
|
|
3082
|
+
summary: report.summary
|
|
3083
|
+
};
|
|
3084
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3085
|
+
} else {
|
|
3086
|
+
if (report.findings.length === 0) {
|
|
3087
|
+
console.log(`\u2713 ${src} \u2014 valid`);
|
|
3088
|
+
console.log();
|
|
3089
|
+
} else {
|
|
3090
|
+
printValidationReport(src, report, false);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
if (!report.isValid) {
|
|
3094
|
+
process.exit(1);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
async function handleMetricsCommand(argv) {
|
|
3098
|
+
const parsed = parseCliFileArgv(argv);
|
|
3099
|
+
if (!parsed.ok) {
|
|
3100
|
+
console.error("transitrix: --ext requires a comma-separated list of suffixes.");
|
|
3101
|
+
process.exit(1);
|
|
3102
|
+
}
|
|
3103
|
+
const { positional, extList, wantsHelp } = parsed;
|
|
3104
|
+
const exts = extList.length > 0 ? extList : DEFAULT_CERVIN_FILE_EXTENSIONS;
|
|
3105
|
+
const useJson = argv.includes("--json");
|
|
3106
|
+
if (wantsHelp) {
|
|
3107
|
+
printUsage();
|
|
3108
|
+
process.exit(0);
|
|
3109
|
+
}
|
|
3110
|
+
const [src] = positional;
|
|
3111
|
+
if (!src) {
|
|
3112
|
+
console.error("transitrix metrics: missing input file");
|
|
3113
|
+
console.error(`usage: transitrix metrics <input.yaml> [--json]`);
|
|
3114
|
+
process.exit(1);
|
|
3115
|
+
}
|
|
3116
|
+
if (!inputMatchesExtension(src, exts)) {
|
|
3117
|
+
console.error(
|
|
3118
|
+
`transitrix: input file must end with one of: ${exts.join(", ")} (or pass --ext)`
|
|
3119
|
+
);
|
|
3120
|
+
process.exit(1);
|
|
3121
|
+
}
|
|
3122
|
+
try {
|
|
3123
|
+
const yaml2 = await readFile2(src, "utf8");
|
|
3124
|
+
const result = await compileTransitrixYamlWithLayout(yaml2);
|
|
3125
|
+
const metrics = computeLayoutMetrics(result.layout);
|
|
3126
|
+
if (useJson) {
|
|
3127
|
+
console.log(JSON.stringify(metrics, null, 2));
|
|
3128
|
+
} else {
|
|
3129
|
+
console.log();
|
|
3130
|
+
console.log(`\u2713 ${src}`);
|
|
3131
|
+
console.log();
|
|
3132
|
+
console.log("Layout Quality Metrics:");
|
|
3133
|
+
renderMetricsLines(metrics, true);
|
|
3134
|
+
console.log();
|
|
3135
|
+
}
|
|
3136
|
+
} catch (e) {
|
|
3137
|
+
const err = e;
|
|
3138
|
+
console.error(err.message);
|
|
3139
|
+
err.errors?.forEach((line) => console.error(` \u2022 ${line}`));
|
|
3140
|
+
process.exit(1);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
if (invokedAsCervin(process.argv[1])) {
|
|
3144
|
+
console.error(CERVIN_DEPRECATION_NOTICE);
|
|
3145
|
+
}
|
|
3146
|
+
var subcommand = process.argv[2];
|
|
3147
|
+
if (subcommand === "--help" || subcommand === "-h" || subcommand === "help") {
|
|
3148
|
+
printUsage();
|
|
3149
|
+
process.exit(0);
|
|
3150
|
+
}
|
|
3151
|
+
try {
|
|
3152
|
+
if (subcommand === "serve") {
|
|
3153
|
+
const serveArgv = process.argv.slice(3);
|
|
3154
|
+
try {
|
|
3155
|
+
const { cliServeArgv: cliServeArgv2 } = await Promise.resolve().then(() => (init_serve_ui(), serve_ui_exports));
|
|
3156
|
+
await cliServeArgv2(serveArgv);
|
|
3157
|
+
} catch (e) {
|
|
3158
|
+
const err = e;
|
|
3159
|
+
console.error(err.message);
|
|
3160
|
+
process.exit(1);
|
|
3161
|
+
}
|
|
3162
|
+
} else if (subcommand === "metrics") {
|
|
3163
|
+
await handleMetricsCommand(process.argv.slice(3));
|
|
3164
|
+
} else if (subcommand === "validate") {
|
|
3165
|
+
await handleValidateCommand(process.argv.slice(3));
|
|
3166
|
+
} else if (subcommand === "export-compliance") {
|
|
3167
|
+
const handlerModule = "./export-compliance.js";
|
|
3168
|
+
const { handleExportComplianceCommand } = await import(handlerModule);
|
|
3169
|
+
await handleExportComplianceCommand(process.argv.slice(3));
|
|
3170
|
+
} else if (subcommand === "migrate") {
|
|
3171
|
+
await handleMigrateCommand(process.argv.slice(3));
|
|
3172
|
+
} else if (!subcommand || subcommand === "compile") {
|
|
3173
|
+
const compileArgv = subcommand === "compile" ? process.argv.slice(3) : process.argv.slice(2);
|
|
3174
|
+
await handleCompileCommand(compileArgv);
|
|
3175
|
+
} else {
|
|
3176
|
+
console.error(`transitrix: unknown command '${subcommand}'`);
|
|
3177
|
+
printUsage();
|
|
3178
|
+
process.exit(1);
|
|
3179
|
+
}
|
|
3180
|
+
} catch (e) {
|
|
3181
|
+
const err = e;
|
|
3182
|
+
console.error(`transitrix: unexpected error: ${err.message ?? String(e)}`);
|
|
3183
|
+
process.exit(1);
|
|
3184
|
+
}
|