@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.
@@ -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 };
@@ -0,0 +1,2 @@
1
+ import { a as runCli, i as checkModelBaseline, n as checkDmnXml, r as checkModel, t as checkBpmnXml } from "./validate-C9ZythtW.js";
2
+ export { checkBpmnXml, checkDmnXml, checkModel, checkModelBaseline, runCli };
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
+ }