@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,994 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join, posix, relative } from "node:path";
3
+ import { readdir } from "node:fs/promises";
4
+ import { parse } from "yaml";
5
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
6
+ //#region ../notations/index.ts
7
+ const NOTATIONS = [
8
+ {
9
+ id: "bpmn",
10
+ label: "BPMN 2.0",
11
+ noun: {
12
+ singular: "process",
13
+ plural: "processes"
14
+ },
15
+ extensions: [".bpmn"],
16
+ mediaKind: "xml",
17
+ docShape: "text",
18
+ monacoLanguage: "xml",
19
+ graphHints: {
20
+ flowEdgeKinds: ["sequenceFlow"],
21
+ entryNodeTypes: ["startEvent"]
22
+ }
23
+ },
24
+ {
25
+ id: "dmn",
26
+ label: "DMN",
27
+ noun: {
28
+ singular: "decision",
29
+ plural: "decisions"
30
+ },
31
+ extensions: [".dmn"],
32
+ mediaKind: "xml",
33
+ docShape: "text",
34
+ monacoLanguage: "xml",
35
+ graphHints: {
36
+ flowEdgeKinds: ["informationRequirement"],
37
+ entryNodeTypes: []
38
+ }
39
+ },
40
+ {
41
+ id: "wardley",
42
+ label: "Wardley Map",
43
+ noun: {
44
+ singular: "wardley map",
45
+ plural: "wardley maps"
46
+ },
47
+ extensions: [".owm", ".wmap"],
48
+ mediaKind: "dsl",
49
+ docShape: "text",
50
+ monacoLanguage: "plaintext",
51
+ graphHints: {
52
+ flowEdgeKinds: ["dependency"],
53
+ entryNodeTypes: []
54
+ }
55
+ },
56
+ {
57
+ id: "team-topology",
58
+ label: "Team Topology",
59
+ noun: {
60
+ singular: "team topology",
61
+ plural: "team topologies"
62
+ },
63
+ extensions: [".tt", ".ttm.json"],
64
+ mediaKind: "json",
65
+ docShape: "text",
66
+ monacoLanguage: "json"
67
+ },
68
+ {
69
+ id: "event-storming",
70
+ label: "Event Storming",
71
+ noun: {
72
+ singular: "event storming board",
73
+ plural: "event storming boards"
74
+ },
75
+ extensions: [".storm"],
76
+ mediaKind: "dsl",
77
+ docShape: "text",
78
+ monacoLanguage: "plaintext",
79
+ graphHints: {
80
+ flowEdgeKinds: ["arrow"],
81
+ entryNodeTypes: []
82
+ }
83
+ },
84
+ {
85
+ id: "context-map",
86
+ label: "Context Map",
87
+ noun: {
88
+ singular: "context map",
89
+ plural: "context maps"
90
+ },
91
+ extensions: [".cm.json"],
92
+ mediaKind: "json",
93
+ docShape: "text",
94
+ monacoLanguage: "json",
95
+ graphHints: {
96
+ flowEdgeKinds: [
97
+ "partnership",
98
+ "shared-kernel",
99
+ "customer-supplier",
100
+ "upstream-downstream",
101
+ "separate-ways"
102
+ ],
103
+ entryNodeTypes: []
104
+ }
105
+ },
106
+ {
107
+ id: "value-chain",
108
+ label: "Value Chain",
109
+ noun: {
110
+ singular: "value chain",
111
+ plural: "value chains"
112
+ },
113
+ extensions: [".vc.json"],
114
+ mediaKind: "json",
115
+ docShape: "text",
116
+ monacoLanguage: "json"
117
+ },
118
+ {
119
+ id: "markdown",
120
+ label: "Markdown",
121
+ noun: {
122
+ singular: "document",
123
+ plural: "documents"
124
+ },
125
+ extensions: [".md"],
126
+ mediaKind: "markdown",
127
+ docShape: "text",
128
+ monacoLanguage: "markdown"
129
+ }
130
+ ];
131
+ function byId(id) {
132
+ return NOTATIONS.find((n) => n.id === id);
133
+ }
134
+ /** longest-suffix winner across every registered extension, so ".vc.json"
135
+ * beats a hypothetical ".json" — shared by byExtension and modelStem */
136
+ function longestMatch(path) {
137
+ let best;
138
+ for (const n of NOTATIONS) for (const ext of n.extensions) if (path.endsWith(ext) && ext.length > (best?.ext.length ?? 0)) best = {
139
+ notation: n,
140
+ ext
141
+ };
142
+ return best;
143
+ }
144
+ function byExtension(path) {
145
+ return longestMatch(path)?.notation;
146
+ }
147
+ /**
148
+ * The file stem that IS a model's id (the content contract: a process is its
149
+ * .bpmn file, a decision its .dmn file). Strips the FULL registered extension,
150
+ * so compound suffixes resolve correctly ("a.vc.json" → "a", never "a.vc");
151
+ * unknown extensions fall back to stripping the final dot-suffix.
152
+ */
153
+ function modelStem(path) {
154
+ const base = path.split("/").pop() ?? path;
155
+ const ext = longestMatch(base)?.ext;
156
+ return ext ? base.slice(0, -ext.length) : base.replace(/\.[^.]+$/, "");
157
+ }
158
+ [...NOTATIONS.flatMap((n) => n.extensions)];
159
+ //#endregion
160
+ //#region ../notations/bpmn-kinds.ts
161
+ const BPMN_ELEMENT_KIND = /* @__PURE__ */ new Map([
162
+ ["task", "activity"],
163
+ ["userTask", "activity"],
164
+ ["serviceTask", "activity"],
165
+ ["scriptTask", "activity"],
166
+ ["businessRuleTask", "activity"],
167
+ ["manualTask", "activity"],
168
+ ["sendTask", "activity"],
169
+ ["receiveTask", "activity"],
170
+ ["callActivity", "activity"],
171
+ ["subProcess", "activity"],
172
+ ["adHocSubProcess", "activity"],
173
+ ["transaction", "activity"],
174
+ ["startEvent", "event"],
175
+ ["endEvent", "event"],
176
+ ["intermediateThrowEvent", "event"],
177
+ ["intermediateCatchEvent", "event"],
178
+ ["boundaryEvent", "event"],
179
+ ["exclusiveGateway", "gateway"],
180
+ ["parallelGateway", "gateway"],
181
+ ["inclusiveGateway", "gateway"],
182
+ ["eventBasedGateway", "gateway"],
183
+ ["complexGateway", "gateway"]
184
+ ]);
185
+ /** the kind of a flow-node tag; undefined = not a flow node (artifact, lane, …) */
186
+ const kindOf = (tag) => BPMN_ELEMENT_KIND.get(tag);
187
+ /** every flow-node tag — everything else (textAnnotation, dataObject, …) is a
188
+ * legal artifact but NOT a flow node; treating it as one produces false
189
+ * "unreachable"/"dead end" errors on perfectly valid models */
190
+ const BPMN_FLOW_NODE_TAGS = new Set(BPMN_ELEMENT_KIND.keys());
191
+ /** container tags whose children form their own flow graph */
192
+ const BPMN_SUB_CONTAINER_TAGS = /* @__PURE__ */ new Set([
193
+ "subProcess",
194
+ "adHocSubProcess",
195
+ "transaction"
196
+ ]);
197
+ /** non-flow-node artifacts that still need a BPMNDI shape/edge to render.
198
+ * Exactly these five: plain `dataObject` is deliberately ABSENT — bpmn-js
199
+ * draws the dataObjectReference, not the dataObject; requiring DI for the
200
+ * latter would false-error every model with one. */
201
+ const BPMN_DI_ARTIFACT_TAGS = /* @__PURE__ */ new Set([
202
+ "textAnnotation",
203
+ "association",
204
+ "dataObjectReference",
205
+ "dataStoreReference",
206
+ "group"
207
+ ]);
208
+ //#endregion
209
+ //#region ../notations/extract.ts
210
+ /**
211
+ * extract(raw) => ModelGraph — the analysis seam of the notation registry.
212
+ *
213
+ * Every notation knows how to turn its raw file content into ONE generic
214
+ * graph shape. Analyses (MCP tools, future dashboards) are written against
215
+ * ModelGraph and automatically cover every notation that implements extract —
216
+ * instead of one parser per tool per notation.
217
+ *
218
+ * Node-safe: fast-xml-parser + JSON + regex only, no DOM, no editor libraries.
219
+ */
220
+ const xml = new XMLParser({
221
+ ignoreAttributes: false,
222
+ attributeNamePrefix: "@_",
223
+ removeNSPrefix: true
224
+ });
225
+ /** the ONE fast-xml-parser configuration (the validator used to carry a
226
+ * byte-identical copy — that is how the two BPMN walks drifted) */
227
+ const parseXml = (raw) => xml.parse(raw);
228
+ const asArray = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
229
+ /**
230
+ * The decision a businessRuleTask delegates to. BPMN 2.0 has no standard
231
+ * attribute for it, so every engine invented its own — all of them are read
232
+ * here (namespace prefixes are already stripped by the parser):
233
+ *
234
+ * decisionRef="x" Camunda 7
235
+ * <extensionElements><calledDecision decisionId="x"/> Camunda 8 / Zeebe
236
+ * calledDecision="x" / calledElement="x" hand-written models
237
+ *
238
+ * The platform contract is the same as for callActivity: the value names the
239
+ * DECISION FILE STEM, so the link resolves against the repo's .dmn files.
240
+ *
241
+ * Exported as THE spelling list: the validator's link-integrity check must
242
+ * read exactly what the platform follows, or a spelling becomes a link that
243
+ * get_process reports and no check ever verifies (that drift shipped once).
244
+ */
245
+ function decisionRefOf(task) {
246
+ const called = task.extensionElements?.calledDecision;
247
+ const fromExtension = asArray(called)[0]?.["@_decisionId"];
248
+ const ref = task["@_decisionRef"] ?? fromExtension ?? task["@_calledDecision"] ?? task["@_calledElement"] ?? void 0;
249
+ const text = ref === void 0 || ref === null ? "" : String(ref).trim();
250
+ return text === "" ? void 0 : text;
251
+ }
252
+ /** parse-tree in (parseXml(raw).definitions), the shared model out */
253
+ function readBpmn(defs) {
254
+ const model = {
255
+ containers: [],
256
+ nodes: [],
257
+ flows: [],
258
+ diRequired: /* @__PURE__ */ new Set(),
259
+ lanes: [],
260
+ collaborations: []
261
+ };
262
+ const walk = (el, label, id, isSubProcess) => {
263
+ const container = {
264
+ label,
265
+ id,
266
+ isSubProcess,
267
+ triggeredByEvent: String(el["@_triggeredByEvent"]) === "true",
268
+ nodes: /* @__PURE__ */ new Map(),
269
+ flows: []
270
+ };
271
+ model.containers.push(container);
272
+ for (const [tag, value] of Object.entries(el)) {
273
+ if (tag.startsWith("@_")) continue;
274
+ if (tag === "sequenceFlow") {
275
+ for (const f of asArray(value)) {
276
+ const flow = {
277
+ id: f["@_id"] ?? "",
278
+ from: f["@_sourceRef"] ?? "",
279
+ to: f["@_targetRef"] ?? "",
280
+ name: f["@_name"]
281
+ };
282
+ container.flows.push(flow);
283
+ model.flows.push(flow);
284
+ if (f["@_id"]) model.diRequired.add(f["@_id"]);
285
+ }
286
+ continue;
287
+ }
288
+ if (BPMN_FLOW_NODE_TAGS.has(tag)) {
289
+ for (const node of asArray(value)) {
290
+ const rec = node;
291
+ const nodeId = rec["@_id"];
292
+ if (!nodeId) continue;
293
+ const decisionRef = tag === "businessRuleTask" ? decisionRefOf(node) : void 0;
294
+ const entry = {
295
+ id: nodeId,
296
+ tag,
297
+ name: rec["@_name"],
298
+ ...rec["@_attachedToRef"] !== void 0 ? { attachedTo: rec["@_attachedToRef"] } : {},
299
+ ...rec["@_calledElement"] !== void 0 ? { calledElement: rec["@_calledElement"] } : {},
300
+ ...decisionRef ? { decisionRef } : {},
301
+ container
302
+ };
303
+ model.nodes.push(entry);
304
+ container.nodes.set(nodeId, entry);
305
+ model.diRequired.add(nodeId);
306
+ if (BPMN_SUB_CONTAINER_TAGS.has(tag)) walk(node, `${tag} ${nodeId}`, nodeId, true);
307
+ }
308
+ continue;
309
+ }
310
+ if (BPMN_DI_ARTIFACT_TAGS.has(tag)) {
311
+ for (const node of asArray(value)) if (node["@_id"]) model.diRequired.add(node["@_id"]);
312
+ }
313
+ }
314
+ };
315
+ let processIndex = 0;
316
+ for (const proc of asArray(defs.process)) {
317
+ const rec = proc;
318
+ const pid = rec["@_id"];
319
+ walk(proc, `process ${pid ?? "(anonymous)"}`, pid, false);
320
+ for (const lane of asArray(rec.laneSet?.lane)) model.lanes.push({
321
+ id: lane["@_id"],
322
+ name: lane["@_name"],
323
+ nodeIds: asArray(lane.flowNodeRef).map(String),
324
+ processIndex
325
+ });
326
+ processIndex++;
327
+ }
328
+ for (const collab of asArray(defs.collaboration)) {
329
+ const rec = collab;
330
+ model.collaborations.push({
331
+ pools: asArray(rec.participant).map((pl) => ({
332
+ id: pl["@_id"],
333
+ name: pl["@_name"],
334
+ processRef: pl["@_processRef"]
335
+ })),
336
+ messageFlows: asArray(rec.messageFlow).map((mf) => ({
337
+ id: mf["@_id"],
338
+ from: mf["@_sourceRef"],
339
+ to: mf["@_targetRef"],
340
+ name: mf["@_name"]
341
+ }))
342
+ });
343
+ }
344
+ return model;
345
+ }
346
+ function extractBpmn(raw) {
347
+ const model = readBpmn(parseXml(raw).definitions ?? {});
348
+ const nodes = model.nodes.map((n) => ({
349
+ id: n.id,
350
+ type: n.tag,
351
+ name: n.name,
352
+ extra: {
353
+ ...n.container.isSubProcess && n.container.id ? { parent: n.container.id } : {},
354
+ ...n.calledElement ? { calledElement: n.calledElement } : {},
355
+ ...n.attachedTo ? { attachedTo: n.attachedTo } : {},
356
+ ...n.decisionRef ? { decisionRef: n.decisionRef } : {}
357
+ }
358
+ }));
359
+ const edges = model.flows.map((f) => ({
360
+ id: f.id,
361
+ from: f.from,
362
+ to: f.to,
363
+ kind: "sequenceFlow",
364
+ name: f.name
365
+ }));
366
+ for (const collab of model.collaborations) for (const mf of collab.messageFlows) edges.push({
367
+ id: mf.id ?? "",
368
+ from: mf.from ?? "",
369
+ to: mf.to ?? "",
370
+ kind: "messageFlow",
371
+ name: mf.name
372
+ });
373
+ return {
374
+ notation: "bpmn",
375
+ nodes,
376
+ edges,
377
+ meta: {
378
+ lanes: model.lanes.map((l) => ({
379
+ id: l.id,
380
+ name: l.name,
381
+ nodeIds: l.nodeIds
382
+ })),
383
+ pools: model.collaborations.flatMap((c) => c.pools.map((pl) => ({
384
+ id: pl.id ?? "",
385
+ name: pl.name ?? "",
386
+ processRef: pl.processRef ?? ""
387
+ })))
388
+ }
389
+ };
390
+ }
391
+ /**
392
+ * A tag's text content as a STRING. fast-xml-parser coerces tag values
393
+ * ("42" → 42, "true" → true), but every DMN entry is FEEL SOURCE TEXT — a
394
+ * rule entry `1` must stay the literal "1", never the number. Empty and
395
+ * self-closing tags collapse to "" (an empty entry means "any" in DMN).
396
+ */
397
+ function textOf(value) {
398
+ if (value === void 0 || value === null) return "";
399
+ if (typeof value === "object") return textOf(value["#text"]);
400
+ return String(value);
401
+ }
402
+ /**
403
+ * The target of an informationRequirement — requiredInput/requiredDecision,
404
+ * in that lookup order. THE spelling pair (it was byte-identical in the
405
+ * validator, the decisionRefOf duplication class): `local` means same-file
406
+ * ("#id"); a namespace-qualified href ("other.dmn#id") is NOT local — the
407
+ * validator's dangling check skips it, and extractDmn currently passes it
408
+ * through verbatim (known inconsistency vs analyze_decision, tracked in #86).
409
+ */
410
+ function requirementHref(req) {
411
+ const href = req.requiredInput?.["@_href"] ?? req.requiredDecision?.["@_href"];
412
+ if (href === void 0) return void 0;
413
+ return {
414
+ href,
415
+ local: href.startsWith("#")
416
+ };
417
+ }
418
+ /**
419
+ * DMN: decisions (with their full decision-table logic), input data and the
420
+ * information requirements between them. The table detail rides in
421
+ * node.extra — deriveDecision (derive.ts) turns it into the decision view,
422
+ * and the simulator (@designiq/decisions) into its evaluation model, so this is
423
+ * the ONE DMN parser on the server side.
424
+ */
425
+ function extractDmn(raw) {
426
+ const defs = parseXml(raw).definitions ?? {};
427
+ const nodes = [];
428
+ const edges = [];
429
+ for (const input of asArray(defs.inputData)) nodes.push({
430
+ id: input["@_id"] ?? "",
431
+ type: "inputData",
432
+ name: input["@_name"] ?? "",
433
+ extra: {
434
+ variable: input.variable?.["@_name"] ?? input["@_name"] ?? "",
435
+ typeRef: input.variable?.["@_typeRef"] ?? "string"
436
+ }
437
+ });
438
+ for (const decision of asArray(defs.decision)) {
439
+ const table = decision.decisionTable;
440
+ const literal = decision.literalExpression;
441
+ nodes.push({
442
+ id: decision["@_id"],
443
+ type: "decision",
444
+ name: decision["@_name"],
445
+ extra: {
446
+ variable: decision.variable?.["@_name"] ?? decision["@_name"] ?? decision["@_id"],
447
+ typeRef: decision.variable?.["@_typeRef"] ?? "string",
448
+ ...table ? {
449
+ kind: "decisionTable",
450
+ hitPolicy: table["@_hitPolicy"] ?? "UNIQUE",
451
+ ...table["@_aggregation"] ? { aggregation: table["@_aggregation"] } : {},
452
+ inputs: asArray(table.input).map((i, n) => ({
453
+ id: i["@_id"] ?? `Input_${n + 1}`,
454
+ label: i["@_label"] ?? textOf(i.inputExpression?.text),
455
+ expression: textOf(i.inputExpression?.text),
456
+ typeRef: i.inputExpression?.["@_typeRef"] ?? "string",
457
+ inputValues: splitFeelList(textOf(i.inputValues?.text))
458
+ })),
459
+ outputs: asArray(table.output).map((o, n) => ({
460
+ id: o["@_id"] ?? `Output_${n + 1}`,
461
+ name: o["@_name"] ?? o["@_label"] ?? "",
462
+ label: o["@_label"] ?? o["@_name"] ?? "",
463
+ typeRef: o["@_typeRef"] ?? "string",
464
+ outputValues: splitFeelList(textOf(o.outputValues?.text))
465
+ })),
466
+ rules: asArray(table.rule).map((r, n) => ({
467
+ id: r["@_id"] ?? `Rule_${n + 1}`,
468
+ description: textOf(r.description) || void 0,
469
+ inputEntries: asArray(r.inputEntry).map((e) => textOf(e.text)),
470
+ outputEntries: asArray(r.outputEntry).map((e) => textOf(e.text))
471
+ }))
472
+ } : literal ? {
473
+ kind: "literalExpression",
474
+ expression: textOf(literal.text)
475
+ } : { kind: "none" }
476
+ }
477
+ });
478
+ for (const req of asArray(decision.informationRequirement)) {
479
+ const target = requirementHref(req);
480
+ if (target && target.href) edges.push({
481
+ id: req["@_id"] ?? `req-${edges.length}`,
482
+ from: target.href.replace(/^#/, ""),
483
+ to: decision["@_id"],
484
+ kind: "informationRequirement"
485
+ });
486
+ }
487
+ }
488
+ return {
489
+ notation: "dmn",
490
+ nodes,
491
+ edges,
492
+ meta: {
493
+ name: defs["@_name"] ?? null,
494
+ namespace: defs["@_namespace"] ?? null
495
+ }
496
+ };
497
+ }
498
+ /** `"a","b"` (a DMN inputValues/outputValues list) → the raw FEEL items */
499
+ function splitFeelList(raw) {
500
+ const trimmed = raw.trim();
501
+ if (trimmed === "") return [];
502
+ return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
503
+ }
504
+ /** OWM DSL: `component Name [y, x]`, `Name -> Other`, `evolve Name x` */
505
+ function extractWardley(raw) {
506
+ const nodes = [];
507
+ const edges = [];
508
+ for (const m of raw.matchAll(/^component\s+([^[\n]+?)\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]/gm)) {
509
+ const evolution = Number(m[3]);
510
+ nodes.push({
511
+ id: m[1] ?? "",
512
+ type: "component",
513
+ name: m[1],
514
+ extra: {
515
+ visibility: Number(m[2]),
516
+ evolution,
517
+ stage: evolution < .25 ? "genesis" : evolution < .5 ? "custom-built" : evolution < .75 ? "product" : "commodity"
518
+ }
519
+ });
520
+ }
521
+ const directive = /^(title|component|anchor|market|note|annotation|annotations|style|evolution|submap|url|pipeline)\b/;
522
+ let i = 0;
523
+ for (const m of raw.matchAll(/^([^\s>[\n][^>[\n]*?)\s*->\s*([^\n;]+)$/gm)) {
524
+ if (directive.test(m[0] ?? "")) continue;
525
+ edges.push({
526
+ id: `dep-${i++}`,
527
+ from: m[1]?.trim() ?? "",
528
+ to: m[2]?.trim() ?? "",
529
+ kind: "dependency"
530
+ });
531
+ }
532
+ return {
533
+ notation: "wardley",
534
+ nodes,
535
+ edges
536
+ };
537
+ }
538
+ function extractTeamTopology(raw) {
539
+ const data = JSON.parse(raw);
540
+ return {
541
+ notation: "team-topology",
542
+ nodes: (data.nodes ?? []).map((n) => ({
543
+ id: n.id,
544
+ type: n.type ?? "team",
545
+ name: n.label ?? n.name,
546
+ extra: n.description ? { description: n.description } : void 0
547
+ })),
548
+ edges: (data.edges ?? []).map((e, i) => ({
549
+ id: e.id ?? `edge-${i}`,
550
+ from: e.source ?? e.from,
551
+ to: e.target ?? e.to,
552
+ kind: e.interaction ?? e.type ?? "interaction"
553
+ }))
554
+ };
555
+ }
556
+ const STORM_ID_PREFIX = {
557
+ event: "event",
558
+ command: "cmd",
559
+ actor: "actor",
560
+ aggregate: "agg",
561
+ policy: "policy",
562
+ readmodel: "read",
563
+ external: "ext",
564
+ hotspot: "hot"
565
+ };
566
+ /** stickies that can carry a pinned actor/hotspot/note — `(on <Host>)` */
567
+ const STORM_HOST_KINDS = /* @__PURE__ */ new Set([
568
+ "event",
569
+ "command",
570
+ "aggregate",
571
+ "policy",
572
+ "readmodel",
573
+ "external"
574
+ ]);
575
+ const STORM_ATTACHABLE_KINDS = /* @__PURE__ */ new Set([
576
+ "actor",
577
+ "hotspot",
578
+ "note"
579
+ ]);
580
+ const STORM_COORDS = /\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/;
581
+ const STORM_POINTS = /\[\s*(\[[^[\]]*\](?:\s*,\s*\[[^[\]]*\])+)\s*\]/;
582
+ const stormSlug = (label) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_/, "").replace(/_$/, "") || "x";
583
+ /** the `(on <Host>)` / `(id <id>)` / `(color <c>)` suffixes of a sticky line;
584
+ * `rest` is the text with EVERY parenthesised suffix removed (size/align are
585
+ * note layout — irrelevant to the graph) */
586
+ function stormSuffixes(text) {
587
+ let host;
588
+ let rest = text;
589
+ const on = /\(\s*on\s+/i.exec(text);
590
+ if (on) {
591
+ const start = on.index + on[0].length;
592
+ const close = text.lastIndexOf(")");
593
+ if (close >= start && text.slice(start, close).trim()) {
594
+ host = text.slice(start, close).trim();
595
+ rest = `${text.slice(0, on.index)} ${text.slice(close + 1)}`;
596
+ }
597
+ }
598
+ const id = /\(\s*id\s+([A-Za-z0-9_-]+)\s*\)/i.exec(rest)?.[1];
599
+ const color = /\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)/i.exec(rest)?.[1];
600
+ return {
601
+ host,
602
+ id,
603
+ color,
604
+ rest: rest.replace(/\([^)]*\)/g, " ")
605
+ };
606
+ }
607
+ function extractEventStorming(raw) {
608
+ const nodes = [];
609
+ const edges = [];
610
+ let title = null;
611
+ let level = null;
612
+ const used = /* @__PURE__ */ new Set();
613
+ const nameToId = /* @__PURE__ */ new Map();
614
+ const claim = (id) => {
615
+ if (!id || used.has(id)) return void 0;
616
+ used.add(id);
617
+ return id;
618
+ };
619
+ const alloc = (prefix, label) => {
620
+ const base = `${prefix}_${stormSlug(label)}`;
621
+ let id = base;
622
+ for (let i = 2; used.has(id); i++) id = `${base}_${i}`;
623
+ used.add(id);
624
+ return id;
625
+ };
626
+ const decode = (name) => name.replace(/\\n/g, "\n");
627
+ const arrows = [];
628
+ const pins = [];
629
+ for (const source of raw.replace(/\/\*[\s\S]*?\*\//g, " ").split(/\r?\n/)) {
630
+ const line = source.replace(/(?<!:)\/\/.*$/, "").trim();
631
+ if (!line) continue;
632
+ const kw = (/^[A-Za-z][\w-]*/.exec(line)?.[0] ?? "").toLowerCase();
633
+ const after = line.slice(kw.length).trim();
634
+ const semi = line.indexOf(";");
635
+ const core = (semi >= 0 ? line.slice(0, semi) : line).trim();
636
+ const arrowAt = core.indexOf("->");
637
+ if (arrowAt > 0 && !STORM_COORDS.test(core)) {
638
+ const from = core.slice(0, arrowAt).trim();
639
+ const to = core.slice(arrowAt + 2).trim();
640
+ if (from && to && (kw !== "title" || nameToId.has(decode(from)))) {
641
+ const label = semi >= 0 ? line.slice(semi + 1).trim() : "";
642
+ arrows.push({
643
+ from,
644
+ to,
645
+ ...label ? { label } : {}
646
+ });
647
+ continue;
648
+ }
649
+ }
650
+ if (kw === "title") title = after;
651
+ else if (kw === "level") {
652
+ const value = after.toLowerCase();
653
+ if ([
654
+ "big-picture",
655
+ "process",
656
+ "design"
657
+ ].includes(value)) level = value;
658
+ } else if (Object.hasOwn(STORM_ID_PREFIX, kw) || kw === "note") {
659
+ const at = STORM_COORDS.exec(after);
660
+ if (!at && after.includes("[")) continue;
661
+ const x = at ? Number(at[1]) : 0;
662
+ const y = at ? Number(at[2]) : 0;
663
+ if (Number.isNaN(x) || Number.isNaN(y)) continue;
664
+ const name = decode(at ? after.slice(0, at.index).trim() : stormSuffixes(after).rest.trim());
665
+ const { host, id: explicit, color } = stormSuffixes(at ? after.slice(at.index + at[0].length) : after);
666
+ if (kw !== "note" && !name) continue;
667
+ const id = kw === "note" ? alloc("note", name || "note") : claim(explicit) ?? alloc(STORM_ID_PREFIX[kw], name);
668
+ const node = {
669
+ id,
670
+ type: kw,
671
+ name,
672
+ extra: {
673
+ x,
674
+ y,
675
+ ...color ? { color } : {}
676
+ }
677
+ };
678
+ nodes.push(node);
679
+ if (kw !== "note" && !nameToId.has(name)) nameToId.set(name, id);
680
+ if (host && STORM_ATTACHABLE_KINDS.has(kw)) pins.push({
681
+ node,
682
+ host
683
+ });
684
+ } else if (kw === "line") {
685
+ const points = STORM_POINTS.exec(after)?.[1];
686
+ const tuples = points ? [...points.matchAll(/\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/g)] : [];
687
+ if (tuples.length < 2) continue;
688
+ nodes.push({
689
+ id: alloc("draw", "line"),
690
+ type: "drawing",
691
+ extra: {
692
+ x: Number(tuples[0][1]),
693
+ y: Number(tuples[0][2]),
694
+ points: tuples.length
695
+ }
696
+ });
697
+ }
698
+ }
699
+ const resolve = (token) => {
700
+ const id = token.startsWith("#") ? token.slice(1) : nameToId.get(decode(token));
701
+ return id === void 0 ? void 0 : nodes.find((n) => n.id === id);
702
+ };
703
+ for (const { node, host } of pins) {
704
+ const target = resolve(host);
705
+ if (target && STORM_HOST_KINDS.has(target.type)) node.extra = {
706
+ ...node.extra,
707
+ attachedTo: target.id
708
+ };
709
+ }
710
+ let n = 0;
711
+ for (const arrow of arrows) {
712
+ const from = resolve(arrow.from);
713
+ const to = resolve(arrow.to);
714
+ if (!from || !to || !Object.hasOwn(STORM_ID_PREFIX, from.type) || !Object.hasOwn(STORM_ID_PREFIX, to.type)) continue;
715
+ let id = `arrow_${++n}`;
716
+ while (used.has(id)) id = `arrow_${++n}`;
717
+ edges.push({
718
+ id,
719
+ from: from.id,
720
+ to: to.id,
721
+ kind: "arrow",
722
+ name: arrow.label
723
+ });
724
+ }
725
+ return {
726
+ notation: "event-storming",
727
+ nodes,
728
+ edges,
729
+ meta: {
730
+ title,
731
+ level
732
+ }
733
+ };
734
+ }
735
+ const CM_SUBDOMAIN_TYPES = /* @__PURE__ */ new Set([
736
+ "core",
737
+ "supporting",
738
+ "generic"
739
+ ]);
740
+ function extractContextMap(raw) {
741
+ const parsed = JSON.parse(raw);
742
+ const data = parsed !== null && typeof parsed === "object" ? parsed : {};
743
+ const contexts = Array.isArray(data.contexts) ? data.contexts : [];
744
+ const relationships = Array.isArray(data.relationships) ? data.relationships : [];
745
+ const text = (v) => typeof v === "string" && v !== "" ? v : void 0;
746
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
747
+ const roles = (v) => Array.isArray(v) ? v.filter((r) => typeof r === "string") : [];
748
+ const nodes = [];
749
+ for (const c of contexts) {
750
+ if (c === null || typeof c !== "object" || typeof c.id !== "string" || c.id === "") continue;
751
+ const subdomainType = text(c.subdomainType);
752
+ const team = text(c.team);
753
+ const description = text(c.description);
754
+ nodes.push({
755
+ id: c.id,
756
+ type: subdomainType && CM_SUBDOMAIN_TYPES.has(subdomainType) ? subdomainType : "context",
757
+ name: text(c.label),
758
+ extra: {
759
+ ...team ? { team } : {},
760
+ ...description ? { description } : {},
761
+ x: num(c.position?.x) ?? 0,
762
+ y: num(c.position?.y) ?? 0,
763
+ width: num(c.size?.width) ?? 0,
764
+ height: num(c.size?.height) ?? 0
765
+ }
766
+ });
767
+ }
768
+ const edges = [];
769
+ for (const [i, r] of relationships.entries()) {
770
+ if (r === null || typeof r !== "object") continue;
771
+ const upstreamRoles = roles(r.upstreamRoles);
772
+ const downstreamRoles = roles(r.downstreamRoles);
773
+ const implementationTechnology = text(r.implementationTechnology);
774
+ const description = text(r.description);
775
+ const extra = {
776
+ ...upstreamRoles.length > 0 ? { upstreamRoles } : {},
777
+ ...downstreamRoles.length > 0 ? { downstreamRoles } : {},
778
+ ...implementationTechnology ? { implementationTechnology } : {},
779
+ ...description ? { description } : {}
780
+ };
781
+ edges.push({
782
+ id: text(r.id) ?? `rel-${i}`,
783
+ from: text(r.from) ?? "",
784
+ to: text(r.to) ?? "",
785
+ kind: text(r.pattern) ?? "relationship",
786
+ name: text(r.label),
787
+ ...Object.keys(extra).length > 0 ? { extra } : {}
788
+ });
789
+ }
790
+ return {
791
+ notation: "context-map",
792
+ nodes,
793
+ edges,
794
+ meta: { title: text(data.title) ?? null }
795
+ };
796
+ }
797
+ function extractValueChain(raw) {
798
+ const data = JSON.parse(raw);
799
+ return {
800
+ notation: "value-chain",
801
+ nodes: (data.elements ?? []).map((el) => ({
802
+ id: el.id,
803
+ type: el.elementType ?? "element",
804
+ name: el.label ?? el.name
805
+ })),
806
+ edges: (data.connections ?? []).map((c, i) => ({
807
+ id: c.id ?? `conn-${i}`,
808
+ from: c.source,
809
+ to: c.target,
810
+ kind: c.connectionType ?? "connection"
811
+ }))
812
+ };
813
+ }
814
+ const EXTRACTORS = {
815
+ bpmn: extractBpmn,
816
+ dmn: extractDmn,
817
+ wardley: extractWardley,
818
+ "team-topology": extractTeamTopology,
819
+ "event-storming": extractEventStorming,
820
+ "context-map": extractContextMap,
821
+ "value-chain": extractValueChain
822
+ };
823
+ /** notation id or file path in, ModelGraph out; undefined = no extractor registered */
824
+ function extractModelGraph(notationOrPath, raw) {
825
+ const notation = byId(notationOrPath) ?? byExtension(notationOrPath);
826
+ const extractor = notation && EXTRACTORS[notation.id];
827
+ return extractor ? extractor(raw) : void 0;
828
+ }
829
+ //#endregion
830
+ //#region ../notations/content.ts
831
+ /**
832
+ * The content-repo contract (designiq.yml) — the ONE definition of "what is a
833
+ * content repo, and what are its models", shared by every consumer that reads
834
+ * a checkout: the Live Host, the MCP server, and the validator.
835
+ *
836
+ * # designiq.yml
837
+ * models: processes # or the legacy alias: processes: processes
838
+ *
839
+ * A repo is a content repo iff a root designiq.yml names the folder its model
840
+ * files live in. A model IS a file with a registered notation extension under
841
+ * that folder (@designiq/notations); its id is the file stem (modelStem). A
842
+ * process is the .bpmn special case, a decision the .dmn one. Nothing else
843
+ * about the layout is assumed.
844
+ *
845
+ * The contract file answers to two names (CONTENT_CONFIG_FILES, in precedence
846
+ * order): the first one that EXISTS at the root is THE config, the other is
847
+ * not even read. Selection is by existence only — a preferred file that is
848
+ * mid-edit or broken makes the repo "not a content repo" instead of silently
849
+ * handing the model folder to whatever the other file says. The second name
850
+ * is the legacy one: read forever, written never — the CLIs nudge a rename
851
+ * (legacyContentConfigFile).
852
+ *
853
+ * Node-only (fs + yaml) — imported via the "@designiq/notations/content" subpath,
854
+ * NEVER from the browser-safe package index.
855
+ */
856
+ /** the documented name of the contract file — the one messages and docs say,
857
+ * and the one a new repo gets (the starter template carries it) */
858
+ const CONTENT_CONFIG_FILE = "designiq.yml";
859
+ /**
860
+ * Every name the contract file is accepted under, in precedence order. The
861
+ * second one is the legacy name and stays readable permanently: customer
862
+ * repos, the starter template and every released image <= 4.2.0 carry it.
863
+ */
864
+ const CONTENT_CONFIG_FILES = ["designiq.yml", "bpmiq.yml"];
865
+ /**
866
+ * The NAME of the contract file at `root`: the first of CONTENT_CONFIG_FILES
867
+ * that exists. By existence ONLY — whether it parses is loadContentConfig's
868
+ * business. A present-but-broken first choice must NOT fall through to the
869
+ * next name: the file is plausibly mid-edit in a live session, and a fallback
870
+ * would silently flip the room-containment folder to a different one.
871
+ */
872
+ function resolveContentConfigFile(root) {
873
+ return CONTENT_CONFIG_FILES.find((name) => isContractFile(root, name));
874
+ }
875
+ /** a regular FILE of that name — a folder that merely carries a contract name
876
+ * (anyone may create one in a repo whose models live at the root) must not
877
+ * shadow the real contract file under the other name */
878
+ function isContractFile(root, name) {
879
+ try {
880
+ return statSync(join(root, name)).isFile();
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+ /**
886
+ * The LEGACY name `root`'s contract is read from — set only when that file is
887
+ * the one actually in use (no designiq.yml beside it). A repo carrying both
888
+ * names on purpose (to stay readable by images that predate designiq.yml) is
889
+ * fine and gets no nudge; contentConfigConflict covers the two disagreeing.
890
+ */
891
+ function legacyContentConfigFile(root) {
892
+ const used = resolveContentConfigFile(root);
893
+ return used === void 0 || used === "designiq.yml" ? void 0 : used;
894
+ }
895
+ /** how messages name the contract file: the documented name, then the legacy
896
+ * one an older repo still carries — its owner must recognise their file */
897
+ const CONTENT_CONFIG_NAMES = `${CONTENT_CONFIG_FILE} (or legacy ${CONTENT_CONFIG_FILES[1]})`;
898
+ /**
899
+ * The folder ONE contract file names — `models:`, the legacy `processes:` key
900
+ * stays a full alias (existing repos remain valid unchanged). `undefined` for
901
+ * an unreadable/unparseable/ill-typed file.
902
+ */
903
+ function readContentFolder(root, name) {
904
+ let parsed;
905
+ try {
906
+ parsed = parse(readFileSync(join(root, name), "utf8"));
907
+ } catch {
908
+ return;
909
+ }
910
+ const raw = parsed;
911
+ const folder = raw?.models ?? raw?.processes;
912
+ if (typeof folder !== "string" || folder.trim().length === 0) return void 0;
913
+ const normalized = posix.normalize(folder.trim()).replace(/\/+$/, "");
914
+ if (normalized === "" || normalized === ".") return ".";
915
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) return void 0;
916
+ return normalized;
917
+ }
918
+ /**
919
+ * Read + validate the root contract file (resolveContentConfigFile picks which
920
+ * name). `undefined` means "not a content repo" — an unreadable/unparseable/
921
+ * ill-typed config must degrade to that, never crash a listing (the file is
922
+ * plausibly mid-edit in a live session).
923
+ */
924
+ function loadContentConfig(root) {
925
+ const name = resolveContentConfigFile(root);
926
+ const folder = name === void 0 ? void 0 : readContentFolder(root, name);
927
+ return folder === void 0 ? void 0 : {
928
+ models: folder,
929
+ processes: folder
930
+ };
931
+ }
932
+ /**
933
+ * The one situation that needs a human: BOTH names exist and do not resolve
934
+ * to the same folder (a file naming no usable folder differs from one that
935
+ * does). `used` still wins — but a tool that predates it reads `ignored` and
936
+ * sees other models. Agreeing files are silent on purpose: a repo that must
937
+ * stay readable by older tools carries both for a long time. Two files that
938
+ * BOTH name nothing agree as well — that repo is simply not a content repo.
939
+ */
940
+ function contentConfigConflict(root) {
941
+ const [used, ignored] = CONTENT_CONFIG_FILES.filter((name) => isContractFile(root, name));
942
+ if (used === void 0 || ignored === void 0) return void 0;
943
+ const usedFolder = readContentFolder(root, used);
944
+ const ignoredFolder = readContentFolder(root, ignored);
945
+ return usedFolder === ignoredFolder ? void 0 : {
946
+ used,
947
+ ignored,
948
+ usedFolder,
949
+ ignoredFolder
950
+ };
951
+ }
952
+ /**
953
+ * Every file with a registered notation extension under the configured folder
954
+ * (recursive, sorted by path). The id is the file stem (modelStem) so it stays
955
+ * a single URL segment; ids are unique PER NOTATION (order.bpmn and order.dmn
956
+ * are legal separate namespaces) — a second same-notation file with the same
957
+ * stem elsewhere in the tree is skipped + logged rather than shadowing the
958
+ * first.
959
+ */
960
+ async function discoverModels(root, cfg) {
961
+ const dir = join(root, cfg.models);
962
+ let entries;
963
+ try {
964
+ entries = await readdir(dir, {
965
+ withFileTypes: true,
966
+ recursive: true
967
+ });
968
+ } catch {
969
+ return [];
970
+ }
971
+ const paths = entries.filter((e) => e.isFile()).map((e) => join(cfg.models, relative(dir, join(e.parentPath, e.name)))).filter((p) => !p.split("/").some((s) => s.startsWith(".") || s === "node_modules")).sort();
972
+ const out = [];
973
+ const seen = /* @__PURE__ */ new Map();
974
+ for (const path of paths) {
975
+ const notation = byExtension(path);
976
+ if (!notation) continue;
977
+ const id = modelStem(path);
978
+ const key = `${notation.id}/${id}`;
979
+ const first = seen.get(key);
980
+ if (first) {
981
+ console.log(`content: duplicate ${notation.id} id '${id}' (${path}) — keeping ${first}`);
982
+ continue;
983
+ }
984
+ seen.set(key, path);
985
+ out.push({
986
+ id,
987
+ path,
988
+ notation: notation.id
989
+ });
990
+ }
991
+ return out;
992
+ }
993
+ //#endregion
994
+ export { byId as _, discoverModels as a, resolveContentConfigFile as c, extractModelGraph as d, parseXml as f, byExtension as g, kindOf as h, contentConfigConflict as i, XMLValidator as l, requirementHref as m, CONTENT_CONFIG_FILES as n, legacyContentConfigFile as o, readBpmn as p, CONTENT_CONFIG_NAMES as r, loadContentConfig as s, CONTENT_CONFIG_FILE as t, asArray as u };