@miragon/design-iq-validator 5.0.0-rc.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 +36 -0
- package/dist/cli.js +21 -0
- package/dist/content-B_0zdBLT.js +2 -0
- package/dist/content-BoFksK67.js +994 -0
- package/dist/validate-C9ZythtW.js +502 -0
- package/dist/validate.js +2 -0
- package/package.json +48 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { _ as byId, c as resolveContentConfigFile, d as extractModelGraph, f as parseXml, g as byExtension, h as kindOf, l as XMLValidator, m as requirementHref, p as readBpmn, r as CONTENT_CONFIG_NAMES, u as asArray } from "./content-BoFksK67.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
//#region ../notations/refs.ts
|
|
5
|
+
const REF_EMITTERS = { bpmn: (graph) => {
|
|
6
|
+
const refs = [];
|
|
7
|
+
for (const n of graph.nodes) {
|
|
8
|
+
const calls = n.extra?.calledElement;
|
|
9
|
+
if (n.type === "callActivity" && typeof calls === "string" && calls) refs.push({
|
|
10
|
+
fromElement: n.id,
|
|
11
|
+
rel: "calls",
|
|
12
|
+
to: {
|
|
13
|
+
id: calls,
|
|
14
|
+
notation: "bpmn"
|
|
15
|
+
},
|
|
16
|
+
strength: "required"
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
for (const n of graph.nodes) {
|
|
20
|
+
const decides = n.extra?.decisionRef;
|
|
21
|
+
if (n.type === "businessRuleTask" && typeof decides === "string" && decides) refs.push({
|
|
22
|
+
fromElement: n.id,
|
|
23
|
+
rel: "decides",
|
|
24
|
+
to: {
|
|
25
|
+
id: decides,
|
|
26
|
+
notation: "dmn"
|
|
27
|
+
},
|
|
28
|
+
strength: "required"
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return refs;
|
|
32
|
+
} };
|
|
33
|
+
/**
|
|
34
|
+
* THE generic refs dispatch: every outgoing cross-model reference of the
|
|
35
|
+
* graph; [] when the notation emits none. Registering a notation's emitter
|
|
36
|
+
* here is the whole integration — resolver, index, validation and the
|
|
37
|
+
* release PR section pick it up with zero consumer changes.
|
|
38
|
+
*/
|
|
39
|
+
function refsOf(graph) {
|
|
40
|
+
return REF_EMITTERS[graph.notation]?.(graph) ?? [];
|
|
41
|
+
}
|
|
42
|
+
/** whether a notation emits refs at all — lets save paths skip the repo-wide
|
|
43
|
+
* id scan for notations whose checkRefs would be a no-op anyway */
|
|
44
|
+
function hasRefs(notation) {
|
|
45
|
+
return notation in REF_EMITTERS;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region ../notations/cli.ts
|
|
49
|
+
/**
|
|
50
|
+
* The content-repo CLI bootstrap — the `--root <dir>` contract every platform
|
|
51
|
+
* CLI speaks (validator, decisions check, MCP stdio server). It existed as
|
|
52
|
+
* three line-for-line copies; `pnpm validate` chains two of them in one gate,
|
|
53
|
+
* so a drift in `--root` semantics would half-break the gate.
|
|
54
|
+
*
|
|
55
|
+
* Node-only (process.exit/console), like ./content — never exported through
|
|
56
|
+
* the browser-safe index.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Parse `--root <dir>` out of argv (MUTATES argv, exactly like every caller
|
|
60
|
+
* did — the remaining args are positional filters). Missing directory
|
|
61
|
+
* argument → exit 2, the historical contract of all three CLIs.
|
|
62
|
+
*/
|
|
63
|
+
function cliRoot(argv, opts = {}) {
|
|
64
|
+
const rootFlag = argv.indexOf("--root");
|
|
65
|
+
const rootArg = rootFlag >= 0 ? argv[rootFlag + 1] : void 0;
|
|
66
|
+
if (rootFlag >= 0 && !rootArg) {
|
|
67
|
+
console.error("--root requires a directory argument");
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
const root = rootArg ? resolve(rootArg) : resolve(opts.defaultRoot ?? ".");
|
|
71
|
+
if (rootFlag >= 0) argv.splice(rootFlag, 2);
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The shared not-a-content-repo error line (byte-identical in two CLIs before).
|
|
76
|
+
* A contract file that exists but names no folder is reported BY ITS NAME,
|
|
77
|
+
* whichever of the two it is: "no <file>" would send the reader looking for a
|
|
78
|
+
* missing file while the one they just wrote is what is broken (a broken
|
|
79
|
+
* designiq.yml is never skipped in favour of the legacy one). Only a root with
|
|
80
|
+
* neither file gets the "no … at the root" line, naming both.
|
|
81
|
+
*/
|
|
82
|
+
const notContentRepoError = (root) => {
|
|
83
|
+
const found = resolveContentConfigFile(root);
|
|
84
|
+
return found === void 0 ? `[ERROR] ${root}: no ${CONTENT_CONFIG_NAMES} at the root — not a content repo (or wrong --root)` : `[ERROR] ${root}: ${found} at the root names no models folder — not a content repo (or wrong --root)`;
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/validate.ts
|
|
88
|
+
/**
|
|
89
|
+
* Deterministic validation of a BPM content repository (the slim contract).
|
|
90
|
+
*
|
|
91
|
+
* A content repo is a root `designiq.yml` naming the folder its BPMN processes
|
|
92
|
+
* live in (@designiq/notations/content); a process IS a `.bpmn` file there. This
|
|
93
|
+
* checks the mechanical invariants that make each model trustworthy and
|
|
94
|
+
* editable: well-formed XML, sound BPMN flow structure, and a COMPLETE BPMNDI
|
|
95
|
+
* section (every flow node has a shape — Hard Rule 2, or the visual editor
|
|
96
|
+
* breaks). It also cross-checks callActivity → calledElement against the other
|
|
97
|
+
* processes in the repo. Nothing else about the layout is assumed.
|
|
98
|
+
*
|
|
99
|
+
* Library use: import { checkBpmnXml } from "@miragon/design-iq-validator" — a PURE function
|
|
100
|
+
* over a single BPMN XML string (no filesystem, no process.exit). The CLI lives
|
|
101
|
+
* in src/cli.ts (a dedicated entry, so importing this module never runs it).
|
|
102
|
+
*/
|
|
103
|
+
/** collect DI element refs from every <keys> element anywhere under root —
|
|
104
|
+
* recursive, so drilldown planes of collapsed sub-processes are included; the
|
|
105
|
+
* descent is deliberately unconditional (it also enters matched shape records).
|
|
106
|
+
* One walk for BPMNDI and DMNDI (they differ only in key pair and attribute). */
|
|
107
|
+
function collectDiRefs(root, keys, attr) {
|
|
108
|
+
const di = /* @__PURE__ */ new Set();
|
|
109
|
+
(function collect(v) {
|
|
110
|
+
if (Array.isArray(v)) {
|
|
111
|
+
v.forEach(collect);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (v === null || typeof v !== "object") return;
|
|
115
|
+
for (const [k, child] of Object.entries(v)) {
|
|
116
|
+
if (keys.includes(k)) {
|
|
117
|
+
for (const s of asArray(child)) if (s[attr]) di.add(s[attr]);
|
|
118
|
+
}
|
|
119
|
+
collect(child);
|
|
120
|
+
}
|
|
121
|
+
})(root);
|
|
122
|
+
return di;
|
|
123
|
+
}
|
|
124
|
+
/** fast-xml-parser is not namespace-aware — check that every used prefix is declared. */
|
|
125
|
+
function checkXmlNamespaces(raw, err) {
|
|
126
|
+
const declared = new Set([...raw.matchAll(/xmlns:([\w.-]+)=/g)].map((m) => m[1]));
|
|
127
|
+
const used = /* @__PURE__ */ new Set();
|
|
128
|
+
for (const m of raw.matchAll(/<([\w.-]+):[\w.-]+[\s/>]/g)) if (m[1]) used.add(m[1]);
|
|
129
|
+
for (const m of raw.matchAll(/\s([\w.-]+):[\w.-]+="/g)) if (m[1]) used.add(m[1]);
|
|
130
|
+
for (const prefix of used) if (prefix !== "xml" && prefix !== "xmlns" && !declared.has(prefix)) err(`namespace prefix '${prefix}:' is used but never declared (missing xmlns:${prefix}=...) — strict XML parsers reject this`);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Validate one .bpmn file's XML string (structure + BPMNDI coverage). Pure: no
|
|
134
|
+
* filesystem, no process.exit. When `opts.processIds` is given, also warns on a
|
|
135
|
+
* callActivity whose calledElement is not a process in the repo; `decisionIds`
|
|
136
|
+
* does the same for a businessRuleTask and the repo's .dmn files. Returns the
|
|
137
|
+
* findings and the ids the model references.
|
|
138
|
+
*/
|
|
139
|
+
function checkBpmnXml(raw, opts = {}) {
|
|
140
|
+
const file = opts.file ?? "<bpmn>";
|
|
141
|
+
const findings = [];
|
|
142
|
+
const err = (ruleId, message) => {
|
|
143
|
+
findings.push({
|
|
144
|
+
severity: "ERROR",
|
|
145
|
+
ruleId,
|
|
146
|
+
file,
|
|
147
|
+
message
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const warn = (ruleId, message) => {
|
|
151
|
+
findings.push({
|
|
152
|
+
severity: "WARN",
|
|
153
|
+
ruleId,
|
|
154
|
+
file,
|
|
155
|
+
message
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const wf = XMLValidator.validate(raw);
|
|
159
|
+
if (wf !== true) {
|
|
160
|
+
err("bpmn/xml", `not well-formed XML: ${wf.err.msg}`);
|
|
161
|
+
return {
|
|
162
|
+
findings,
|
|
163
|
+
called: [],
|
|
164
|
+
decides: []
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
checkXmlNamespaces(raw, (m) => err("bpmn/xml", m));
|
|
168
|
+
const escaped = (/xmlns:([A-Za-z_][\w.-]*)="https:\/\/bpmiq\.io\/schema\/1\.0\/bpmiq"/.exec(raw)?.[1] ?? "bpmiq").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
169
|
+
const stickyCount = raw.match(new RegExp(`<${escaped}:sticky[\\s/>]`, "g"))?.length ?? 0;
|
|
170
|
+
if (stickyCount > 0) {
|
|
171
|
+
const questions = raw.match(new RegExp(`<${escaped}:sticky[^>]*\\bkind="question"`, "g"))?.length ?? 0;
|
|
172
|
+
warn("bpmn/workshop-residue", `open discussion: ${stickyCount} ${stickyCount === 1 ? "sticky remains" : "stickies remain"}` + (questions > 0 ? ` (${questions} open ${questions === 1 ? "question" : "questions"})` : ""));
|
|
173
|
+
}
|
|
174
|
+
const defs = parseXml(raw).definitions;
|
|
175
|
+
const processes = asArray(defs?.process);
|
|
176
|
+
if (processes.length === 0) {
|
|
177
|
+
err("bpmn/structure", "no <bpmn:process> element");
|
|
178
|
+
return {
|
|
179
|
+
findings,
|
|
180
|
+
called: [],
|
|
181
|
+
decides: []
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const model = readBpmn(defs ?? {});
|
|
185
|
+
const diRequired = model.diRequired;
|
|
186
|
+
const topContainers = model.containers.filter((c) => !c.isSubProcess);
|
|
187
|
+
const boundaries = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const n of model.nodes) if (n.tag === "boundaryEvent") boundaries.set(n.id, n.attachedTo ?? "");
|
|
189
|
+
const called = model.nodes.filter((n) => n.tag === "callActivity" && n.calledElement).map((n) => n.calledElement);
|
|
190
|
+
const decides = model.nodes.filter((n) => n.tag === "businessRuleTask" && n.decisionRef).map((n) => ({
|
|
191
|
+
id: n.id,
|
|
192
|
+
ref: n.decisionRef
|
|
193
|
+
}));
|
|
194
|
+
const eventSubProcesses = new Set(model.containers.filter((c) => c.isSubProcess && c.triggeredByEvent && c.id).map((c) => c.id));
|
|
195
|
+
/** every flow node of every container (incl. embedded sub-processes) */
|
|
196
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const c of model.containers) for (const [id, n] of c.nodes) nodes.set(id, n.tag);
|
|
198
|
+
for (const c of model.containers) {
|
|
199
|
+
const flowMap = new Map(c.flows.map((f) => [f.id, [f.from, f.to]]));
|
|
200
|
+
const inc = /* @__PURE__ */ new Map();
|
|
201
|
+
const out = /* @__PURE__ */ new Map();
|
|
202
|
+
for (const [fid, [s, t]] of flowMap) {
|
|
203
|
+
for (const ref of [s, t]) if (!c.nodes.has(ref)) err("bpmn/flow", `sequenceFlow ${fid} references missing node '${ref}' (${c.label})`);
|
|
204
|
+
out.set(s, (out.get(s) ?? 0) + 1);
|
|
205
|
+
inc.set(t, (inc.get(t) ?? 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
if (c.nodes.size === 0) continue;
|
|
208
|
+
const starts = [...c.nodes.values()].filter((n) => n.tag === "startEvent").length;
|
|
209
|
+
if (!c.isSubProcess && starts !== 1) err("bpmn/flow", `expected exactly one start event in ${c.label}, found ${starts}`);
|
|
210
|
+
if (c.isSubProcess && !c.triggeredByEvent && starts > 1) err("bpmn/flow", `${c.label} has ${starts} start events`);
|
|
211
|
+
for (const [id, { tag }] of c.nodes) {
|
|
212
|
+
if (eventSubProcesses.has(id)) continue;
|
|
213
|
+
if (tag === "startEvent" && (inc.get(id) ?? 0) > 0) err("bpmn/flow", `start event ${id} has incoming flows`);
|
|
214
|
+
if (tag === "endEvent" && (out.get(id) ?? 0) > 0) err("bpmn/flow", `end event ${id} has outgoing flows`);
|
|
215
|
+
if (tag !== "startEvent" && tag !== "boundaryEvent" && (inc.get(id) ?? 0) === 0) err("bpmn/flow", `${id} is unreachable (no incoming flow, ${c.label})`);
|
|
216
|
+
if (tag !== "endEvent" && (out.get(id) ?? 0) === 0) err("bpmn/flow", `${id} is a dead end (no outgoing flow, ${c.label})`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [b, attached] of boundaries) if (!nodes.has(attached)) err("bpmn/flow", `boundary event ${b} attached to missing '${attached}'`);
|
|
220
|
+
const participantIds = /* @__PURE__ */ new Set();
|
|
221
|
+
for (const collab of model.collaborations) {
|
|
222
|
+
for (const pl of collab.pools) if (pl.id) {
|
|
223
|
+
participantIds.add(pl.id);
|
|
224
|
+
diRequired.add(pl.id);
|
|
225
|
+
}
|
|
226
|
+
for (const mf of collab.messageFlows) {
|
|
227
|
+
if (mf.id) diRequired.add(mf.id);
|
|
228
|
+
for (const ref of [mf.from, mf.to]) if (ref && !nodes.has(ref) && !participantIds.has(ref)) err("bpmn/flow", `messageFlow ${mf.id} references missing element '${ref}'`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const di = collectDiRefs(defs, ["BPMNShape", "BPMNEdge"], "@_bpmnElement");
|
|
232
|
+
for (const id of diRequired) if (!di.has(id)) err("bpmn/di", `${id} has no BPMNDI shape/edge (breaks the visual editor)`);
|
|
233
|
+
for (let i = 0; i < processes.length; i++) {
|
|
234
|
+
const lanes = model.lanes.filter((l) => l.processIndex === i);
|
|
235
|
+
if (lanes.length === 0) continue;
|
|
236
|
+
const laned = /* @__PURE__ */ new Set();
|
|
237
|
+
for (const lane of lanes) {
|
|
238
|
+
if (lane.id && !di.has(lane.id)) err("bpmn/di", `lane ${lane.id} has no BPMNDI shape (breaks the visual editor)`);
|
|
239
|
+
for (const ref of lane.nodeIds) laned.add(ref);
|
|
240
|
+
}
|
|
241
|
+
const topContainer = topContainers[i];
|
|
242
|
+
if (topContainer) for (const [id, { tag }] of topContainer.nodes) {
|
|
243
|
+
if (tag === "boundaryEvent") continue;
|
|
244
|
+
if (!laned.has(id)) err("bpmn/lanes", `${id} is not assigned to any lane`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const activities = [...nodes.values()].filter((tag) => kindOf(tag) === "activity").length;
|
|
248
|
+
if (activities > 9) warn("bpmn/complexity", `${activities} activities — consider extracting a sub-process (7±2 rule)`);
|
|
249
|
+
if (opts.processIds) {
|
|
250
|
+
for (const ref of called) if (!opts.processIds.has(ref)) warn("refs/dangling", `callActivity calls '${ref}', which is not a process in this repo (external or dangling?)`);
|
|
251
|
+
}
|
|
252
|
+
if (opts.decisionIds) {
|
|
253
|
+
for (const { id, ref } of decides) if (!opts.decisionIds.has(ref)) warn("refs/dangling", `businessRuleTask ${id} decides '${ref}', which is not a decision in this repo (external or dangling?)`);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
findings,
|
|
257
|
+
called,
|
|
258
|
+
decides: decides.map((d) => d.ref)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Validate one .dmn file's XML string — the MECHANICAL invariants, the same
|
|
263
|
+
* class this module checks for BPMN: well-formed XML, declared namespaces, a
|
|
264
|
+
* decision to speak of, rule rows that line up with the columns, complete
|
|
265
|
+
* DMNDI (Hard Rule 2: no shape, no visual editor) and information
|
|
266
|
+
* requirements that point at elements which exist.
|
|
267
|
+
*
|
|
268
|
+
* Deliberately NOT here: FEEL semantics, hit-policy reachability, dead wiring.
|
|
269
|
+
* Those need the FEEL engine and live in @designiq/decisions (`analyze_decision`),
|
|
270
|
+
* which would drag a browser-side dependency into this zero-dep CLI.
|
|
271
|
+
*/
|
|
272
|
+
function checkDmnXml(raw, opts = {}) {
|
|
273
|
+
const file = opts.file ?? "<dmn>";
|
|
274
|
+
const findings = [];
|
|
275
|
+
const err = (ruleId, message) => {
|
|
276
|
+
findings.push({
|
|
277
|
+
severity: "ERROR",
|
|
278
|
+
ruleId,
|
|
279
|
+
file,
|
|
280
|
+
message
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
const warn = (ruleId, message) => {
|
|
284
|
+
findings.push({
|
|
285
|
+
severity: "WARN",
|
|
286
|
+
ruleId,
|
|
287
|
+
file,
|
|
288
|
+
message
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
const wf = XMLValidator.validate(raw);
|
|
292
|
+
if (wf !== true) {
|
|
293
|
+
err("dmn/xml", `not well-formed XML: ${wf.err.msg}`);
|
|
294
|
+
return { findings };
|
|
295
|
+
}
|
|
296
|
+
checkXmlNamespaces(raw, (m) => err("dmn/xml", m));
|
|
297
|
+
const defs = parseXml(raw).definitions;
|
|
298
|
+
const decisions = asArray(defs?.decision);
|
|
299
|
+
const inputData = asArray(defs?.inputData);
|
|
300
|
+
if (decisions.length === 0) {
|
|
301
|
+
err("dmn/structure", "no <decision> element");
|
|
302
|
+
return { findings };
|
|
303
|
+
}
|
|
304
|
+
const known = /* @__PURE__ */ new Set();
|
|
305
|
+
for (const el of [...decisions, ...inputData]) if (el["@_id"]) known.add(String(el["@_id"]));
|
|
306
|
+
const diRequired = /* @__PURE__ */ new Set();
|
|
307
|
+
for (const decision of decisions) {
|
|
308
|
+
const id = decision["@_id"] ? String(decision["@_id"]) : "(anonymous)";
|
|
309
|
+
if (!decision["@_id"]) err("dmn/structure", "a <decision> has no id");
|
|
310
|
+
else diRequired.add(id);
|
|
311
|
+
const table = decision.decisionTable;
|
|
312
|
+
if (!table && !decision.literalExpression) warn("dmn/structure", `decision ${id} has neither a decisionTable nor a literalExpression — it cannot produce a result`);
|
|
313
|
+
if (table) {
|
|
314
|
+
const inputs = asArray(table.input).length;
|
|
315
|
+
const outputs = asArray(table.output).length;
|
|
316
|
+
if (outputs === 0) err("dmn/structure", `decision ${id} has no output column`);
|
|
317
|
+
for (const rule of asArray(table.rule)) {
|
|
318
|
+
const ruleId = rule["@_id"] ? String(rule["@_id"]) : "(anonymous rule)";
|
|
319
|
+
const given = asArray(rule.inputEntry).length;
|
|
320
|
+
const produced = asArray(rule.outputEntry).length;
|
|
321
|
+
if (given !== inputs) err("dmn/rules", `rule ${ruleId} of decision ${id} has ${given} input entries, expected ${inputs}`);
|
|
322
|
+
if (produced !== outputs) err("dmn/rules", `rule ${ruleId} of decision ${id} has ${produced} output entries, expected ${outputs}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const req of asArray(decision.informationRequirement)) {
|
|
326
|
+
const r = requirementHref(req);
|
|
327
|
+
if (r === void 0) continue;
|
|
328
|
+
const target = r.href.replace(/^#/, "");
|
|
329
|
+
if (r.local && !known.has(target)) err("dmn/requirements", `decision ${id} requires '${target}', which does not exist in this file`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const input of inputData) if (input["@_id"]) diRequired.add(String(input["@_id"]));
|
|
333
|
+
const di = collectDiRefs(defs, ["DMNShape", "DMNEdge"], "@_dmnElementRef");
|
|
334
|
+
if (di.size === 0) warn("dmn/di", "no DMNDI section — the DRD opens empty in the visual editor");
|
|
335
|
+
else for (const id of diRequired) if (!di.has(id)) err("dmn/di", `${id} has no DMNDI shape (breaks the visual editor)`);
|
|
336
|
+
return { findings };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Baseline parse gate for models WITHOUT a dedicated checker (any registered
|
|
340
|
+
* notation that is not .bpmn/.dmn): a discovered model must at minimum parse
|
|
341
|
+
* per its notation's mediaKind — a broken .tt/.vc.json used to pass
|
|
342
|
+
* validation untouched. Pure: no filesystem, no process.exit. Deliberately
|
|
343
|
+
* mechanical: notation-specific structure checks arrive with the per-notation
|
|
344
|
+
* checkers (epic #118, step 3).
|
|
345
|
+
*/
|
|
346
|
+
function checkModelBaseline(raw, opts) {
|
|
347
|
+
const file = opts.file ?? `<${opts.notation}>`;
|
|
348
|
+
const findings = [];
|
|
349
|
+
if (byId(opts.notation)?.docShape === "structured") return findings;
|
|
350
|
+
const err = (message) => {
|
|
351
|
+
findings.push({
|
|
352
|
+
severity: "ERROR",
|
|
353
|
+
ruleId: "baseline/parse",
|
|
354
|
+
file,
|
|
355
|
+
message
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
switch (byId(opts.notation)?.mediaKind) {
|
|
359
|
+
case "json":
|
|
360
|
+
try {
|
|
361
|
+
JSON.parse(raw);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
err(`not parseable as JSON: ${e.message}`);
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
case "xml": {
|
|
367
|
+
const wf = XMLValidator.validate(raw);
|
|
368
|
+
if (wf !== true) err(`not well-formed XML: ${wf.err.msg}`);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case "dsl": try {
|
|
372
|
+
extractModelGraph(opts.notation, raw);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
err(`not parseable: ${e.message}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return findings;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* THE one check dispatch: the platform check for whatever notation `ctx.path`
|
|
381
|
+
* is — full checkers for BPMN/DMN, the mediaKind baseline for everything
|
|
382
|
+
* else, plus the generic dangling-reference rule for ANY notation with a
|
|
383
|
+
* refs emitter; `undefined` when the path matches no registered notation
|
|
384
|
+
* (the file is not a model; nothing to check). Consumed by the CLI (runCli)
|
|
385
|
+
* AND the Live Host's save gate (application/content.ts) so both always agree.
|
|
386
|
+
*/
|
|
387
|
+
function checkModel(raw, ctx) {
|
|
388
|
+
const notation = ctx.notation ?? byExtension(ctx.path)?.id;
|
|
389
|
+
if (!notation) return void 0;
|
|
390
|
+
return [...notation === "bpmn" ? checkBpmnXml(raw, { file: ctx.path }).findings : notation === "dmn" ? checkDmnXml(raw, { file: ctx.path }).findings : checkModelBaseline(raw, {
|
|
391
|
+
file: ctx.path,
|
|
392
|
+
notation
|
|
393
|
+
}), ...checkRefs(notation, raw, ctx)];
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The generic dangling-reference rule: every REQUIRED reference the model
|
|
397
|
+
* emits (@designiq/notations/refs) must resolve against the repo-wide ids —
|
|
398
|
+
* ONE rule for every notation, replacing the bespoke processIds/decisionIds
|
|
399
|
+
* plumbing. Skipped without ctx.modelIds (single-file use has no repo view).
|
|
400
|
+
*/
|
|
401
|
+
function checkRefs(notation, raw, ctx) {
|
|
402
|
+
if (!ctx.modelIds || !hasRefs(notation)) return [];
|
|
403
|
+
if (byId(notation)?.mediaKind === "xml" && XMLValidator.validate(raw) !== true) return [];
|
|
404
|
+
let graph;
|
|
405
|
+
try {
|
|
406
|
+
graph = extractModelGraph(notation, raw);
|
|
407
|
+
} catch {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
if (!graph) return [];
|
|
411
|
+
const findings = [];
|
|
412
|
+
for (const ref of refsOf(graph)) {
|
|
413
|
+
if (ref.strength !== "required") continue;
|
|
414
|
+
if (ref.to.notation ? ctx.modelIds.get(ref.to.notation)?.has(ref.to.id) ?? false : [...ctx.modelIds.values()].some((ids) => ids.has(ref.to.id))) continue;
|
|
415
|
+
findings.push({
|
|
416
|
+
severity: "WARN",
|
|
417
|
+
ruleId: "refs/dangling",
|
|
418
|
+
file: ctx.path,
|
|
419
|
+
message: danglingMessage(ref)
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return findings;
|
|
423
|
+
}
|
|
424
|
+
/** rel-specific wording, pinned to the historical link warnings — the texts
|
|
425
|
+
* are wire-visible (save results, CLI output) and must not drift */
|
|
426
|
+
function danglingMessage(ref) {
|
|
427
|
+
if (ref.rel === "calls") return `callActivity calls '${ref.to.id}', which is not a process in this repo (external or dangling?)`;
|
|
428
|
+
if (ref.rel === "decides" && ref.fromElement) return `businessRuleTask ${ref.fromElement} decides '${ref.to.id}', which is not a decision in this repo (external or dangling?)`;
|
|
429
|
+
const target = ref.to.notation ? `${ref.to.notation} '${ref.to.id}'` : `'${ref.to.id}'`;
|
|
430
|
+
return `${ref.fromElement ?? "the model"} references ${target}, which does not resolve in this repo (${ref.rel})`;
|
|
431
|
+
}
|
|
432
|
+
async function runCli() {
|
|
433
|
+
const argv = process.argv.slice(2);
|
|
434
|
+
/** content root: the checkout being validated (defaults to the cwd) */
|
|
435
|
+
const ROOT = cliRoot(argv);
|
|
436
|
+
/** optional single-model filter (a model file stem) */
|
|
437
|
+
const only = argv[0];
|
|
438
|
+
const rel = (p) => p.startsWith("/") ? relative(ROOT, p) : p;
|
|
439
|
+
const { CONTENT_CONFIG_FILE, contentConfigConflict, discoverModels, legacyContentConfigFile, loadContentConfig } = await import("./content-B_0zdBLT.js");
|
|
440
|
+
const cfg = loadContentConfig(ROOT);
|
|
441
|
+
if (!cfg) {
|
|
442
|
+
console.error(notContentRepoError(ROOT));
|
|
443
|
+
console.error("\n1 error(s), 0 warning(s) — FAIL (0 model(s) checked)");
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
const models = await discoverModels(ROOT, cfg);
|
|
447
|
+
const all = models.filter((m) => m.notation === "bpmn");
|
|
448
|
+
const allDecisions = models.filter((m) => m.notation === "dmn");
|
|
449
|
+
const allOthers = models.filter((m) => m.notation !== "bpmn" && m.notation !== "dmn");
|
|
450
|
+
const processes = only ? all.filter((p) => p.id === only) : all;
|
|
451
|
+
const decisions = only ? allDecisions.filter((d) => d.id === only) : allDecisions;
|
|
452
|
+
const others = only ? allOthers.filter((m) => m.id === only) : allOthers;
|
|
453
|
+
if (only && processes.length === 0 && decisions.length === 0 && others.length === 0) {
|
|
454
|
+
const available = models.map((m) => m.id).join(", ") || "(none)";
|
|
455
|
+
console.error(`[ERROR] unknown model '${only}'. Available: ${available}`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
const modelIds = /* @__PURE__ */ new Map();
|
|
459
|
+
for (const m of models) (modelIds.get(m.notation) ?? modelIds.set(m.notation, /* @__PURE__ */ new Set()).get(m.notation)).add(m.id);
|
|
460
|
+
const findings = [];
|
|
461
|
+
const conflict = contentConfigConflict(ROOT);
|
|
462
|
+
if (conflict) {
|
|
463
|
+
const show = (folder) => folder === void 0 ? "no usable folder" : `'${folder}'`;
|
|
464
|
+
findings.push({
|
|
465
|
+
severity: "WARN",
|
|
466
|
+
ruleId: "content/config-conflict",
|
|
467
|
+
file: conflict.used,
|
|
468
|
+
message: `names a different models folder than ${conflict.ignored} (${show(conflict.usedFolder)} vs ${show(conflict.ignoredFolder)}) — ${conflict.used} wins; tools that predate ${conflict.used} read only ${conflict.ignored}`
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const legacy = legacyContentConfigFile(ROOT);
|
|
472
|
+
if (legacy) findings.push({
|
|
473
|
+
severity: "WARN",
|
|
474
|
+
ruleId: "content/legacy-config-name",
|
|
475
|
+
file: legacy,
|
|
476
|
+
message: `legacy contract file name — rename it to ${CONTENT_CONFIG_FILE} (git mv ${legacy} ${CONTENT_CONFIG_FILE}); still read`
|
|
477
|
+
});
|
|
478
|
+
for (const model of [
|
|
479
|
+
...processes,
|
|
480
|
+
...decisions,
|
|
481
|
+
...others
|
|
482
|
+
]) {
|
|
483
|
+
const abs = resolve(ROOT, model.path);
|
|
484
|
+
findings.push(...checkModel(readFileSync(abs, "utf8"), {
|
|
485
|
+
path: rel(abs),
|
|
486
|
+
modelIds
|
|
487
|
+
}) ?? []);
|
|
488
|
+
}
|
|
489
|
+
for (const f of findings.sort((a, b) => a.file.localeCompare(b.file))) console.log(`[${f.severity}] ${f.file}: ${f.message}`);
|
|
490
|
+
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
|
491
|
+
const warnCount = findings.length - errorCount;
|
|
492
|
+
const verdict = errorCount === 0 ? "OK" : "FAIL";
|
|
493
|
+
const checked = [
|
|
494
|
+
`${processes.length} process(es)`,
|
|
495
|
+
...decisions.length > 0 ? [`${decisions.length} decision(s)`] : [],
|
|
496
|
+
...others.length > 0 ? [`${others.length} other model(s)`] : []
|
|
497
|
+
].join(", ");
|
|
498
|
+
console.log(`\n${errorCount} error(s), ${warnCount} warning(s) — ${verdict} (${checked} checked)`);
|
|
499
|
+
process.exit(errorCount === 0 ? 0 : 1);
|
|
500
|
+
}
|
|
501
|
+
//#endregion
|
|
502
|
+
export { runCli as a, checkModelBaseline as i, checkDmnXml as n, checkModel as r, checkBpmnXml as t };
|
package/dist/validate.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@miragon/design-iq-validator",
|
|
3
|
+
"version": "5.0.0-rc.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Platform-owned validator for BPM content repos — designiq.yml discovery + BPMN structure and BPMNDI coverage. Runs against any checkout via --root; never executes content-repo code.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Miragon/design-iq.git",
|
|
10
|
+
"directory": "packages/validator"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"bpm",
|
|
14
|
+
"bpmn",
|
|
15
|
+
"validator",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"designiq-validate": "dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/validate.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"//": "fast-xml-parser stays a RUNTIME dependency although src/ no longer imports it directly: tsdown inlines @designiq/notations (whose extract.ts imports it) but keeps npm deps external, so the published dist/ resolves it from here. Removing it breaks the npm artifact at import time while the workspace keeps working.",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"fast-xml-parser": "5.11.1",
|
|
36
|
+
"yaml": "2.9.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"tsdown": "0.23.0",
|
|
40
|
+
"@designiq/notations": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"validate": "node src/cli.ts",
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "node --test test/*.test.ts"
|
|
47
|
+
}
|
|
48
|
+
}
|