@miragon/design-iq-validator 5.4.0 → 5.6.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/README.md CHANGED
@@ -3,12 +3,47 @@
3
3
  Deterministic validator for designIQ content repositories: `designiq.yml` discovery, BPMN
4
4
  structural checks (flow soundness, complete BPMNDI coverage), DMN structure and DMNDI coverage,
5
5
  the generic cross-model reference rule (every required reference — `callActivity` calls,
6
- `businessRuleTask` decides — must resolve in the repo), and a per-mediaKind parse baseline for
7
- every other registered notation (a broken `.tt`/`.vc.json` is an ERROR; DSL notations are
8
- lenient by design; a Mermaid diagram that does not start with a diagram type such as
9
- `flowchart` or `sequenceDiagram` is a WARN, `mermaid/diagram-type`). Every finding carries a stable `ruleId` (e.g. `bpmn/flow`, `dmn/di`,
10
- `refs/dangling`). It treats the target repo as pure data — it never executes content-repo code.
11
- Exit code 0 = no errors (warnings allowed), 1 = errors.
6
+ `businessRuleTask` decides — must resolve in the repo), the schema check of the Miragon JSON
7
+ notations, and a per-mediaKind parse baseline for every other registered notation (a file that
8
+ is no JSON or XML is an ERROR; DSL notations are lenient by design; a `mermaid` fence in a
9
+ Markdown document that does not start with a diagram type such as `flowchart` or
10
+ `sequenceDiagram` is a WARN, `mermaid/diagram-type`, naming the document's line). Every finding
11
+ carries a stable `ruleId` (e.g. `bpmn/flow`, `dmn/di`, `refs/dangling`). It treats the target
12
+ repo as pure data — it never executes content-repo code. Exit code 0 = no errors (warnings
13
+ allowed), 1 = errors.
14
+
15
+ ## Team Topologies, Context Maps and Value Chains
16
+
17
+ A `.tt`/`.ttm.json`, `.cm.json` or `.vc.json` file is parsed with the schema model its canvas
18
+ opens it with (`@miragon/team-topologies-schema-model`, `@miragon/context-maps-schema-model`,
19
+ `@miragon/value-chain-schema-model`). Whatever the schema model refuses, the canvas cannot open,
20
+ so each refusal is an ERROR (`team-topology/schema`, `context-map/schema`,
21
+ `value-chain/schema`) that names the offending value by its JSON path, e.g.
22
+ `nodes.1.id: duplicate id "a"`. Refused among others:
23
+
24
+ - a duplicate id, and the ids the diagram keeps for itself: `tt-root`, `cm-root`, `vc-root`,
25
+ every id starting with `__implicitroot`, and `assoc_<annotation id>` in a Team Topology;
26
+ - in all three notations, an id that names a built-in JavaScript object property such as
27
+ `constructor`, `toString` or `__proto__`: the canvas keeps its elements in a plain object and
28
+ cannot import it. The Team Topology and Context Map schema models still take these ids, so the
29
+ validator refuses them itself, under the same rule;
30
+ - in a Context Map: a JSON list instead of an object, a `version` newer than 1, coordinates or
31
+ sizes beyond 1,000,000;
32
+ - in a Value Chain: keys other than `x`, `y`, `width`, `height` in `bounds` and other than `x`,
33
+ `y` in a waypoint, coordinates beyond ±1e9, and a `__proto__` key anywhere.
34
+
35
+ A Context Map's strategic-DDD rules (`validateDocument`, e.g. `protect-or-conform`,
36
+ `endpoints-on-map`) are WARNs, `context-map/semantics`, with the rule named at the end of the
37
+ message: the canvas opens such a map and marks the finding. A repeated id is the exception: the
38
+ canvas refuses it, so it is an ERROR (`context-map/schema`) at the path of the repeat, e.g.
39
+ `contexts.2.id: Duplicate id "a". (unique-ids)`.
40
+
41
+ Paths count in the document as the schema model reads it. A Team Topology drops a legacy
42
+ interaction without `position` or `size` before the check, so the index of a later interaction
43
+ counts without it. Some rules only run once the document's shape is valid: the id rules of a Team
44
+ Topology and a Value Chain, and in a Context Map the repeated ids and the strategic-DDD rules.
45
+ Fixing the reported errors can therefore bring up further ones, so check again until the file is
46
+ clean.
12
47
 
13
48
  ## Usage
14
49
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runCli } from "./validate-Dy2lNoBR.js";
2
+ import { o as runCli } from "./validate-BqkEd0ma.js";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * CLI entry for the platform validator. A dedicated entry file (instead of an
@@ -126,18 +126,6 @@ const NOTATIONS = [
126
126
  mediaKind: "markdown",
127
127
  docShape: "text",
128
128
  monacoLanguage: "markdown"
129
- },
130
- {
131
- id: "mermaid",
132
- label: "Mermaid",
133
- noun: {
134
- singular: "diagram",
135
- plural: "diagrams"
136
- },
137
- extensions: [".mmd", ".mermaid"],
138
- mediaKind: "dsl",
139
- docShape: "text",
140
- monacoLanguage: "plaintext"
141
129
  }
142
130
  ];
143
131
  function byId(id) {
@@ -549,20 +537,23 @@ function extractWardley(raw) {
549
537
  }
550
538
  function extractTeamTopology(raw) {
551
539
  const data = JSON.parse(raw);
540
+ const list = (v) => Array.isArray(v) ? v : [];
541
+ const teams = list(data.nodes).map((n) => ({
542
+ id: n.id,
543
+ type: n.type ?? "team",
544
+ name: n.label ?? n.name,
545
+ extra: n.description ? { description: n.description } : void 0
546
+ }));
547
+ const interactions = list(data.interactions).filter((i) => i !== null && typeof i === "object" && "position" in i && "size" in i).map((i) => ({
548
+ id: i.id,
549
+ type: "interaction",
550
+ name: i.label,
551
+ extra: { mode: i.mode }
552
+ }));
552
553
  return {
553
554
  notation: "team-topology",
554
- nodes: (data.nodes ?? []).map((n) => ({
555
- id: n.id,
556
- type: n.type ?? "team",
557
- name: n.label ?? n.name,
558
- extra: n.description ? { description: n.description } : void 0
559
- })),
560
- edges: (data.edges ?? []).map((e, i) => ({
561
- id: e.id ?? `edge-${i}`,
562
- from: e.source ?? e.from,
563
- to: e.target ?? e.to,
564
- kind: e.interaction ?? e.type ?? "interaction"
565
- }))
555
+ nodes: [...teams, ...interactions],
556
+ edges: []
566
557
  };
567
558
  }
568
559
  const STORM_ID_PREFIX = {
@@ -813,7 +804,7 @@ function extractValueChain(raw) {
813
804
  nodes: (data.elements ?? []).map((el) => ({
814
805
  id: el.id,
815
806
  type: el.elementType ?? "element",
816
- name: el.label ?? el.name
807
+ name: el.name
817
808
  })),
818
809
  edges: (data.connections ?? []).map((c, i) => ({
819
810
  id: c.id ?? `conn-${i}`,
@@ -1 +1 @@
1
- export { t as CONTENT_CONFIG_FILE, i as contentConfigConflict, a as discoverModels, o as legacyContentConfigFile, s as loadContentConfig } from "./content-DJ1x-ykR.js";
1
+ export { t as CONTENT_CONFIG_FILE, i as contentConfigConflict, a as discoverModels, o as legacyContentConfigFile, s as loadContentConfig } from "./content-Bu1bNrez.js";
@@ -1,6 +1,9 @@
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-DJ1x-ykR.js";
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-Bu1bNrez.js";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve } from "node:path";
4
+ import { parseDocumentText, validateDocument } from "@miragon/context-maps-schema-model";
5
+ import { parseDocument } from "@miragon/team-topologies-schema-model";
6
+ import { documentIssues } from "@miragon/value-chain-schema-model";
4
7
  //#region ../notations/refs.ts
5
8
  const REF_EMITTERS = { bpmn: (graph) => {
6
9
  const refs = [];
@@ -84,6 +87,343 @@ const notContentRepoError = (root) => {
84
87
  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
88
  };
86
89
  //#endregion
90
+ //#region ../notations/markdown.ts
91
+ const NONE = { kind: "none" };
92
+ const PARAGRAPH = { kind: "paragraph" };
93
+ const CODE = { kind: "code" };
94
+ const FENCE_OPEN = /^(`{3,}|~{3,})(.*)$/;
95
+ const THEMATIC_BREAK = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
96
+ const ATX_HEADING = /^#{1,6}(?:[ \t]|$)/;
97
+ const SETEXT_UNDERLINE = /^(?:=+|-+)[ \t]*$/;
98
+ const BULLET = /^[-+*](?=[ \t]|$)/;
99
+ const ORDERED = /^(\d{1,9})[.)](?=[ \t]|$)/;
100
+ const BLANK = /^[ \t]*$/;
101
+ /** a link reference definition on one line: [label]: destination "title" (a
102
+ * "[^" label is a footnote, which render.ts keeps as paragraph text; a bare
103
+ * destination ends at a space or a control character, as in markdown-it) */
104
+ const LINK_DEFINITION = /^\[(?!\^)((?:[^[\]\\]|\\.)*)\]:[ \t]*(<(?:[^<>\\]|\\.)*>|[^ <\x00-\x1f\x7f][^ \x00-\x1f\x7f]*)(?:[ \t]+(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()\\]|\\.)*\)))?[ \t]*$/;
105
+ /** the destinations markdown-it's validateLink refuses: no definition then */
106
+ const BAD_LINK = /^(?:vbscript|javascript|file|data):/i;
107
+ const GOOD_DATA_LINK = /^data:image\/(?:gif|png|jpeg|webp);/i;
108
+ /** markdown-it's maxNesting: content nested this deep (a Container's `level`)
109
+ * it does not parse, and drops the rest of the innermost quote around it,
110
+ * or of the document */
111
+ const MAX_NESTING = 100;
112
+ /** start, end and whether it may interrupt a paragraph; an end of /^$/ is the next blank line */
113
+ const HTML_BLOCKS = [
114
+ [
115
+ /^<(?:script|pre|style|textarea)(?=\s|>|$)/i,
116
+ /<\/(?:script|pre|style|textarea)>/i,
117
+ true
118
+ ],
119
+ [
120
+ /^<!--/,
121
+ /-->/,
122
+ true
123
+ ],
124
+ [
125
+ /^<\?/,
126
+ /\?>/,
127
+ true
128
+ ],
129
+ [
130
+ /^<![A-Za-z]/,
131
+ />/,
132
+ true
133
+ ],
134
+ [
135
+ /^<!\[CDATA\[/,
136
+ /\]\]>/,
137
+ true
138
+ ],
139
+ [
140
+ new RegExp(`^</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?=\\s|/?>|$)`, "i"),
141
+ /^$/,
142
+ true
143
+ ],
144
+ [
145
+ new RegExp(`^(?:<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^"'=<>\`\\x00-\\x20]+|'[^']*'|"[^"]*"))?)*\\s*\\/?>|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)\\s*$`),
146
+ /^$/,
147
+ false
148
+ ]
149
+ ];
150
+ /** the leading blanks of a rest */
151
+ function leading(rest) {
152
+ let col = rest.col;
153
+ let i = 0;
154
+ for (; i < rest.text.length; i++) {
155
+ const ch = rest.text[i];
156
+ if (ch === " ") col++;
157
+ else if (ch === " ") col += 4 - col % 4;
158
+ else break;
159
+ }
160
+ return {
161
+ indent: col - rest.col,
162
+ blank: i === rest.text.length
163
+ };
164
+ }
165
+ /** the columns of leading blanks */
166
+ const indentOf = (rest) => leading(rest).indent;
167
+ /** drop up to `n` columns of leading blanks; a tab cut in two leaves its other columns as spaces */
168
+ function dropIndent(rest, n) {
169
+ const target = rest.col + n;
170
+ let col = rest.col;
171
+ let i = 0;
172
+ while (i < rest.text.length && col < target) {
173
+ const ch = rest.text[i];
174
+ if (ch === " ") col++;
175
+ else if (ch === " ") {
176
+ const next = col + 4 - col % 4;
177
+ if (next > target) return {
178
+ text: " ".repeat(next - target) + rest.text.slice(i + 1),
179
+ col: target
180
+ };
181
+ col = next;
182
+ } else break;
183
+ i++;
184
+ }
185
+ return {
186
+ text: rest.text.slice(i),
187
+ col
188
+ };
189
+ }
190
+ /** the line after its indent, when that is at most three columns (more is indented code) */
191
+ function blockStart(rest) {
192
+ const indent = indentOf(rest);
193
+ return indent < 4 ? dropIndent(rest, indent) : void 0;
194
+ }
195
+ /** a quote's ">" and the one column of blank after it: a space, or the first
196
+ * column of a tab (which stays, a column narrower, as in markdown-it) */
197
+ function afterQuoteMarker(at) {
198
+ const rest = {
199
+ text: at.text.slice(1),
200
+ col: at.col + 1
201
+ };
202
+ if (rest.text.startsWith(" ") || rest.text.startsWith(" ") && rest.col % 4 === 3) return {
203
+ text: rest.text.slice(1),
204
+ col: rest.col + 1
205
+ };
206
+ return rest.text.startsWith(" ") ? {
207
+ text: rest.text,
208
+ col: rest.col + 1
209
+ } : rest;
210
+ }
211
+ /** the rest of the line when it continues the container, undefined when the
212
+ * container ends. `lead` is the rest's leading blanks, kept up to date across
213
+ * the columns a list item drops, so a line is not measured once per container */
214
+ function continues(container, rest, lead) {
215
+ if (container.kind === "quote") {
216
+ const at = dropIndent(rest, lead.indent);
217
+ return at.text.startsWith(">") ? afterQuoteMarker(at) : void 0;
218
+ }
219
+ const fresh = container.fresh;
220
+ container.fresh = false;
221
+ if (lead.blank ? fresh : lead.indent < container.indent) return void 0;
222
+ lead.indent = Math.max(0, lead.indent - container.indent);
223
+ return dropIndent(rest, container.indent);
224
+ }
225
+ /** a list item opening at `at` (the line after its indent of `indent` columns),
226
+ * inside containers nested `level` deep */
227
+ function listItem(at, indent, level, interruptsParagraph) {
228
+ const ordered = ORDERED.exec(at.text);
229
+ const marker = ordered ?? BULLET.exec(at.text);
230
+ if (!marker) return void 0;
231
+ const after = {
232
+ text: at.text.slice(marker[0].length),
233
+ col: at.col + marker[0].length
234
+ };
235
+ const empty = BLANK.test(after.text);
236
+ if (interruptsParagraph && (empty || ordered && Number(ordered[1]) !== 1)) return void 0;
237
+ const blanks = indentOf(after);
238
+ const gap = empty || blanks > 4 ? 1 : blanks;
239
+ return {
240
+ item: {
241
+ kind: "item",
242
+ level: level + 2,
243
+ indent: indent + marker[0].length + gap,
244
+ fresh: empty
245
+ },
246
+ rest: dropIndent(after, gap)
247
+ };
248
+ }
249
+ /** the HTML block starting at `text` (a line after its indent), if any */
250
+ function htmlBlock(text) {
251
+ return text.startsWith("<") ? HTML_BLOCKS.find(([start]) => start.test(text)) : void 0;
252
+ }
253
+ /** whether a line that does not continue a paragraph's containers starts a
254
+ * block of its own (else it is a lazy continuation line of the paragraph) */
255
+ function startsBlock(rest) {
256
+ const at = blockStart(rest);
257
+ if (!at) return false;
258
+ const text = at.text;
259
+ return text.startsWith(">") || fenceOpen(text) !== void 0 || THEMATIC_BREAK.test(text) || BULLET.test(text) || ORDERED.test(text) || ATX_HEADING.test(text) || htmlBlock(text)?.[2] === true;
260
+ }
261
+ /** whether `text` (a line after its indent) is a link reference definition that
262
+ * fits on it and that markdown-it takes: a label with more than blanks, a
263
+ * destination with balanced parentheses and a scheme its validateLink allows */
264
+ function linkDefinition(text) {
265
+ const definition = LINK_DEFINITION.exec(text);
266
+ if (!definition || definition[1].trim() === "") return false;
267
+ let destination = definition[2];
268
+ if (destination.startsWith("<")) destination = destination.slice(1, -1);
269
+ else {
270
+ let depth = 0;
271
+ for (let i = 0; i < destination.length && depth >= 0; i++) {
272
+ const ch = destination[i];
273
+ if (ch === "\\") i++;
274
+ else if (ch === "(") depth = depth < 32 ? depth + 1 : -1;
275
+ else if (ch === ")") depth--;
276
+ }
277
+ if (depth !== 0) return false;
278
+ }
279
+ destination = destination.trim();
280
+ return !BAD_LINK.test(destination) || GOOD_DATA_LINK.test(destination);
281
+ }
282
+ /** the opening run of a fence at `text` (a line after its indent), if any */
283
+ function fenceOpen(text) {
284
+ const open = FENCE_OPEN.exec(text);
285
+ if (!open) return void 0;
286
+ const run = open[1];
287
+ const info = open[2];
288
+ if (run.startsWith("`") && info.includes("`")) return void 0;
289
+ return {
290
+ char: run[0],
291
+ size: run.length,
292
+ info: info.trim()
293
+ };
294
+ }
295
+ /** whether the line is the closing run of `fence` */
296
+ function closes(rest, fence) {
297
+ const at = blockStart(rest);
298
+ if (!at) return false;
299
+ let n = 0;
300
+ while (at.text[n] === fence.char) n++;
301
+ return n >= fence.size && BLANK.test(at.text.slice(n));
302
+ }
303
+ /** every fenced code block of a Markdown document, in document order */
304
+ function markdownFences(source) {
305
+ const lines = source.split(/\r\n|\r|\n/);
306
+ const brokenLast = BLANK.test(lines[lines.length - 1]);
307
+ if (brokenLast) lines.pop();
308
+ const fences = [];
309
+ const stack = [];
310
+ const level = () => stack.length > 0 ? stack[stack.length - 1].level : 0;
311
+ let leaf = NONE;
312
+ let dropping;
313
+ const finish = (fence, closed) => {
314
+ fences.push({
315
+ line: fence.line,
316
+ endLine: fence.line + fence.content.length + (closed ? 1 : 0),
317
+ info: fence.info,
318
+ lang: (fence.info.split(/\s+/)[0] ?? "").toLowerCase(),
319
+ content: fence.content.join(""),
320
+ closed
321
+ });
322
+ };
323
+ for (let i = 0; i < lines.length; i++) {
324
+ let rest = {
325
+ text: lines[i],
326
+ col: 0
327
+ };
328
+ const lineBreak = i < lines.length - 1 || brokenLast ? "\n" : "";
329
+ let matched = 0;
330
+ let lead;
331
+ for (const container of stack) {
332
+ const next = continues(container, rest, lead ??= leading(rest));
333
+ if (!next) break;
334
+ rest = next;
335
+ if (container.kind === "quote") lead = void 0;
336
+ matched++;
337
+ }
338
+ const all = matched === stack.length;
339
+ if (dropping) {
340
+ if (all) {
341
+ dropping.lastBlank = BLANK.test(rest.text);
342
+ continue;
343
+ }
344
+ if (!dropping.lastBlank && !BLANK.test(rest.text) && !startsBlock(rest)) continue;
345
+ dropping = void 0;
346
+ }
347
+ if (leaf.kind === "fence" && all) {
348
+ if (closes(rest, leaf)) {
349
+ finish(leaf, true);
350
+ leaf = NONE;
351
+ } else leaf.content.push(dropIndent(rest, leaf.indent).text + lineBreak);
352
+ continue;
353
+ }
354
+ if (leaf.kind === "html" && all) {
355
+ if (leaf.end.test(dropIndent(rest, indentOf(rest)).text)) leaf = NONE;
356
+ continue;
357
+ }
358
+ if (!all) {
359
+ if (leaf.kind === "paragraph" && !BLANK.test(rest.text) && !startsBlock(rest)) continue;
360
+ if (leaf.kind === "fence") finish(leaf, false);
361
+ stack.length = matched;
362
+ leaf = NONE;
363
+ }
364
+ while (level() < MAX_NESTING && !BLANK.test(rest.text)) {
365
+ const at = blockStart(rest);
366
+ if (!at) break;
367
+ if (at.text.startsWith(">")) {
368
+ stack.push({
369
+ kind: "quote",
370
+ level: level() + 1
371
+ });
372
+ rest = afterQuoteMarker(at);
373
+ } else {
374
+ if (THEMATIC_BREAK.test(at.text)) break;
375
+ const opening = listItem(at, indentOf(rest), level(), leaf.kind === "paragraph");
376
+ if (!opening) break;
377
+ stack.push(opening.item);
378
+ rest = opening.rest;
379
+ }
380
+ leaf = NONE;
381
+ }
382
+ if (BLANK.test(rest.text)) {
383
+ if (leaf.kind === "paragraph") leaf = NONE;
384
+ continue;
385
+ }
386
+ if (level() >= MAX_NESTING) {
387
+ const quote = stack.findLastIndex((container) => container.kind === "quote");
388
+ if (quote < 0) break;
389
+ stack.length = quote + 1;
390
+ leaf = NONE;
391
+ dropping = { lastBlank: false };
392
+ continue;
393
+ }
394
+ const at = blockStart(rest);
395
+ if (!at) {
396
+ if (leaf.kind !== "paragraph") leaf = CODE;
397
+ continue;
398
+ }
399
+ const open = fenceOpen(at.text);
400
+ if (open) {
401
+ leaf = {
402
+ kind: "fence",
403
+ ...open,
404
+ indent: indentOf(rest),
405
+ line: i + 1,
406
+ content: []
407
+ };
408
+ continue;
409
+ }
410
+ const html = htmlBlock(at.text);
411
+ if (html && (html[2] || leaf.kind !== "paragraph")) {
412
+ leaf = html[1].test(at.text) ? NONE : {
413
+ kind: "html",
414
+ end: html[1]
415
+ };
416
+ continue;
417
+ }
418
+ if (leaf.kind !== "paragraph" && linkDefinition(at.text)) leaf = NONE;
419
+ else if (ATX_HEADING.test(at.text) || THEMATIC_BREAK.test(at.text)) leaf = NONE;
420
+ else if (leaf.kind === "paragraph" && SETEXT_UNDERLINE.test(at.text)) leaf = NONE;
421
+ else leaf = PARAGRAPH;
422
+ }
423
+ if (leaf.kind === "fence") finish(leaf, false);
424
+ return fences;
425
+ }
426
+ //#endregion
87
427
  //#region ../notations/mermaid.ts
88
428
  /**
89
429
  * Every diagram type MERMAID_VERSION renders, spelled as its header keyword
@@ -381,7 +721,7 @@ function checkBpmnXml(raw, opts = {}) {
381
721
  *
382
722
  * Deliberately NOT here: FEEL semantics, hit-policy reachability, dead wiring.
383
723
  * Those need the FEEL engine and live in @designiq/decisions (`analyze_decision`),
384
- * which would drag a browser-side dependency into this zero-dep CLI.
724
+ * which would drag a browser-side dependency into this CLI.
385
725
  */
386
726
  function checkDmnXml(raw, opts = {}) {
387
727
  const file = opts.file ?? "<dmn>";
@@ -453,34 +793,27 @@ function checkDmnXml(raw, opts = {}) {
453
793
  const COMMON_MERMAID_TYPES = "flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, gantt, pie, mindmap";
454
794
  /**
455
795
  * Mermaid's own first step (its type detection, @designiq/notations/mermaid
456
- * mirrors it): after the front matter, directives and comments, the first
457
- * line must start with a diagram type. Light on purpose: the grammar itself
458
- * needs mermaid and a DOM, which the validator never loads; the web viewer
459
- * shows those errors live. A WARN, not an ERROR: .mmd files a content repo
460
- * already keeps (snippets, a zenuml diagram mermaid does not bundle) turn
461
- * up as models with this notation, and must neither fail its CI nor block a
462
- * save.
796
+ * mirrors it) for a ```mermaid fence opening on document line `line`: after
797
+ * the front matter, directives and comments, the diagram's first line must
798
+ * start with a diagram type. Light on purpose: the grammar itself needs
799
+ * mermaid and a DOM, which the validator never loads; the Markdown view
800
+ * shows those errors live. The message names the document's line (a Finding
801
+ * has no line of its own).
463
802
  */
464
- function mermaidHeaderProblem(raw) {
465
- const header = mermaidHeader(raw);
466
- if (!header) return `empty Mermaid diagram: the first line must name a diagram type (${COMMON_MERMAID_TYPES}, …)`;
803
+ function mermaidFenceProblem(content, line) {
804
+ const header = mermaidHeader(content);
805
+ if (!header) return `line ${line}: empty Mermaid diagram: the first line must name a diagram type (${COMMON_MERMAID_TYPES}, …)`;
467
806
  if (header.diagramType) return void 0;
468
807
  const word = header.text.split(/[\s:;]/, 1)[0];
469
- return `line ${header.line}: '${word}' is not a Mermaid diagram type (${COMMON_MERMAID_TYPES}, …)`;
808
+ return `line ${line + header.line}: '${word}' is not a Mermaid diagram type (${COMMON_MERMAID_TYPES}, …)`;
470
809
  }
471
- /** notation id → the check of its DSL's header line (a WARN under its own
472
- * rule id); a DSL without one stays as lenient as its extractor */
473
- const DSL_HEADER_CHECKS = { mermaid: {
474
- ruleId: "mermaid/diagram-type",
475
- problem: mermaidHeaderProblem
476
- } };
477
810
  /**
478
811
  * Baseline parse gate for models WITHOUT a dedicated checker (any registered
479
812
  * notation that is not .bpmn/.dmn): a discovered model must at minimum parse
480
813
  * per its notation's mediaKind — a broken .tt/.vc.json used to pass
481
814
  * validation untouched. Pure: no filesystem, no process.exit. Deliberately
482
- * mechanical: notation-specific structure checks arrive with the per-notation
483
- * checkers (epic #118, step 3).
815
+ * mechanical: notation-specific structure checks are the per-notation
816
+ * checkers' (epic #118, step 3; checkSchemaModel runs this gate first).
484
817
  */
485
818
  function checkModelBaseline(raw, opts) {
486
819
  const file = opts.file ?? `<${opts.notation}>`;
@@ -507,37 +840,181 @@ function checkModelBaseline(raw, opts) {
507
840
  if (wf !== true) err(`not well-formed XML: ${wf.err.msg}`);
508
841
  break;
509
842
  }
510
- case "dsl": {
843
+ case "dsl":
511
844
  try {
512
845
  extractModelGraph(opts.notation, raw);
513
846
  } catch (e) {
514
847
  err(`not parseable: ${e.message}`);
515
848
  }
516
- const header = DSL_HEADER_CHECKS[opts.notation];
517
- const problem = header?.problem(raw);
518
- if (header && problem) findings.push({
849
+ break;
850
+ case "markdown": for (const fence of markdownFences(raw)) {
851
+ const problem = fence.lang === "mermaid" ? mermaidFenceProblem(fence.content, fence.line) : void 0;
852
+ if (problem) findings.push({
519
853
  severity: "WARN",
520
- ruleId: header.ruleId,
854
+ ruleId: "mermaid/diagram-type",
521
855
  file,
522
856
  message: problem
523
857
  });
524
- break;
525
858
  }
526
859
  }
527
860
  return findings;
528
861
  }
862
+ /** a schema model's issue location as the canvases print it ("nodes.1.id"),
863
+ * "(root)" for the document itself */
864
+ const issuePath = (path) => path.join(".") || "(root)";
865
+ const schemaError = (ruleId, file) => (issue) => ({
866
+ severity: "ERROR",
867
+ ruleId,
868
+ file,
869
+ message: `${issuePath(issue.path)}: ${issue.message}`
870
+ });
871
+ /**
872
+ * The ids a Team Topology or Context Map canvas cannot hold although its
873
+ * schema model takes them: diagram-js keeps its elements in a plain object,
874
+ * so an id that names a built-in object property ("constructor",
875
+ * "__proto__") reads as already added and the import fails. The Value Chain
876
+ * schema model refuses these ids itself; the other two do not yet.
877
+ */
878
+ function builtInPropertyIds(collections) {
879
+ return Object.entries(collections).flatMap(([collection, elements]) => (Array.isArray(elements) ? elements : []).flatMap((element, index) => {
880
+ const id = element?.id;
881
+ return typeof id === "string" && Object.hasOwn(Object.prototype, id) ? [{
882
+ path: [
883
+ collection,
884
+ index,
885
+ "id"
886
+ ],
887
+ message: `the id "${id}" is reserved: it names a built-in property of JavaScript objects`
888
+ }] : [];
889
+ }));
890
+ }
891
+ /**
892
+ * The context-map rules the canvas enforces itself: its importer refuses a
893
+ * repeated id (CmImporter.assertImportable), so the file does not open. The
894
+ * other strategic-DDD rules are advisory there: the canvas opens the map and
895
+ * marks the finding, so a save must not refuse it either.
896
+ */
897
+ const CONTEXT_MAP_REFUSED_RULES = /* @__PURE__ */ new Set(["unique-ids"]);
898
+ /** the path of every repeated id, in the order validateDocument's unique-ids
899
+ * rule reports them (contexts, then relationships): its findings name no
900
+ * position, and an agent must know which of two entries to change */
901
+ function repeatedIdPaths({ contexts, relationships }) {
902
+ const seen = /* @__PURE__ */ new Set();
903
+ return Object.entries({
904
+ contexts,
905
+ relationships
906
+ }).flatMap(([collection, elements]) => elements.flatMap(({ id }, index) => {
907
+ const repeated = seen.has(id);
908
+ seen.add(id);
909
+ return repeated ? [issuePath([
910
+ collection,
911
+ index,
912
+ "id"
913
+ ])] : [];
914
+ }));
915
+ }
916
+ function checkTeamTopology(raw, file) {
917
+ const parsed = parseDocument(JSON.parse(raw));
918
+ if (!parsed.ok) return parsed.issues.map(schemaError("team-topology/schema", file));
919
+ const { nodes, interactions, flows, annotations } = parsed.document;
920
+ return builtInPropertyIds({
921
+ nodes,
922
+ interactions,
923
+ flows,
924
+ annotations
925
+ }).map(schemaError("team-topology/schema", file));
926
+ }
927
+ function checkContextMap(raw, file) {
928
+ const parsed = parseDocumentText(raw);
929
+ const issues = parsed.ok ? [] : parsed.issues ?? [{
930
+ path: [],
931
+ message: parsed.error
932
+ }];
933
+ if (issues.some(({ path }) => path.length === 0 || path[0] === "version")) return issues.map(schemaError("context-map/schema", file));
934
+ const { contexts, relationships } = JSON.parse(raw);
935
+ const schemaFindings = [...issues, ...builtInPropertyIds({
936
+ contexts,
937
+ relationships
938
+ })].map(schemaError("context-map/schema", file));
939
+ if (!parsed.ok) return schemaFindings;
940
+ const repeatedIds = repeatedIdPaths(parsed.document);
941
+ const subject = (finding) => {
942
+ const path = finding.rule === "unique-ids" ? repeatedIds.shift() : void 0;
943
+ if (path) return `${path}: `;
944
+ return finding.relationshipId === void 0 ? "" : `relationship "${finding.relationshipId}": `;
945
+ };
946
+ const { errors, warnings } = validateDocument(parsed.document);
947
+ return [...schemaFindings, ...[...errors, ...warnings].map((finding) => {
948
+ const refused = CONTEXT_MAP_REFUSED_RULES.has(finding.rule);
949
+ return {
950
+ severity: refused ? "ERROR" : "WARN",
951
+ ruleId: refused ? "context-map/schema" : "context-map/semantics",
952
+ file,
953
+ message: `${subject(finding)}${finding.message} (${finding.rule})`
954
+ };
955
+ })];
956
+ }
957
+ function checkValueChain(raw, file) {
958
+ return documentIssues(JSON.parse(raw)).map(schemaError("value-chain/schema", file));
959
+ }
960
+ /** each JSON notation's schema model, read the way its canvas reads the file
961
+ * (a Map, not an object: the notation id may be a caller's free text such
962
+ * as "constructor") */
963
+ const SCHEMA_CHECKS = /* @__PURE__ */ new Map([
964
+ ["team-topology", {
965
+ ruleId: "team-topology/schema",
966
+ check: checkTeamTopology
967
+ }],
968
+ ["context-map", {
969
+ ruleId: "context-map/schema",
970
+ check: checkContextMap
971
+ }],
972
+ ["value-chain", {
973
+ ruleId: "value-chain/schema",
974
+ check: checkValueChain
975
+ }]
976
+ ]);
977
+ /**
978
+ * The schema check of a Miragon JSON notation (Team Topology, Context Map,
979
+ * Value Chain): the file is parsed with the notation's schema model, the
980
+ * same parse its canvas opens it with, so everything the model refuses (a
981
+ * duplicate or reserved id, a coordinate out of range, a newer version) is
982
+ * an ERROR — a save would otherwise land a file the canvas cannot open.
983
+ * Text that is no JSON keeps the baseline's finding, and a notation without
984
+ * a schema model gets the baseline alone. Pure: no filesystem, no
985
+ * process.exit.
986
+ */
987
+ function checkSchemaModel(raw, opts) {
988
+ const baseline = checkModelBaseline(raw, opts);
989
+ const schema = SCHEMA_CHECKS.get(opts.notation);
990
+ if (!schema || baseline.length > 0 || byId(opts.notation)?.docShape === "structured") return baseline;
991
+ const file = opts.file ?? `<${opts.notation}>`;
992
+ try {
993
+ return schema.check(raw, file);
994
+ } catch (error) {
995
+ const reason = error instanceof Error ? error.message : String(error);
996
+ return [{
997
+ severity: "ERROR",
998
+ ruleId: schema.ruleId,
999
+ file,
1000
+ message: `(root): could not be checked: ${reason}`
1001
+ }];
1002
+ }
1003
+ }
529
1004
  /**
530
1005
  * THE one check dispatch: the platform check for whatever notation `ctx.path`
531
- * is — full checkers for BPMN/DMN, the mediaKind baseline for everything
532
- * else, plus the generic dangling-reference rule for ANY notation with a
533
- * refs emitter; `undefined` when the path matches no registered notation
534
- * (the file is not a model; nothing to check). Consumed by the CLI (runCli)
535
- * AND the Live Host's save gate (application/content.ts) so both always agree.
1006
+ * is — full checkers for BPMN/DMN, the schema model for the Miragon JSON
1007
+ * notations, the mediaKind baseline for everything else, plus the generic
1008
+ * dangling-reference rule for ANY notation with a refs emitter; `undefined`
1009
+ * when the path matches no registered notation (the file is not a model;
1010
+ * nothing to check) or `ctx.notation` names none (a mistyped id must not
1011
+ * pass a file unchecked). Consumed by the CLI (runCli) AND the Live Host's
1012
+ * save gate (application/content.ts) so both always agree.
536
1013
  */
537
1014
  function checkModel(raw, ctx) {
538
1015
  const notation = ctx.notation ?? byExtension(ctx.path)?.id;
539
- if (!notation) return void 0;
540
- return [...notation === "bpmn" ? checkBpmnXml(raw, { file: ctx.path }).findings : notation === "dmn" ? checkDmnXml(raw, { file: ctx.path }).findings : checkModelBaseline(raw, {
1016
+ if (!notation || !byId(notation)) return void 0;
1017
+ return [...notation === "bpmn" ? checkBpmnXml(raw, { file: ctx.path }).findings : notation === "dmn" ? checkDmnXml(raw, { file: ctx.path }).findings : checkSchemaModel(raw, {
541
1018
  file: ctx.path,
542
1019
  notation
543
1020
  }), ...checkRefs(notation, raw, ctx)];
@@ -586,7 +1063,7 @@ async function runCli() {
586
1063
  /** optional single-model filter (a model file stem) */
587
1064
  const only = argv[0];
588
1065
  const rel = (p) => p.startsWith("/") ? relative(ROOT, p) : p;
589
- const { CONTENT_CONFIG_FILE, contentConfigConflict, discoverModels, legacyContentConfigFile, loadContentConfig } = await import("./content-Dld6ZUbs.js");
1066
+ const { CONTENT_CONFIG_FILE, contentConfigConflict, discoverModels, legacyContentConfigFile, loadContentConfig } = await import("./content-CE4SulnP.js");
590
1067
  const cfg = loadContentConfig(ROOT);
591
1068
  if (!cfg) {
592
1069
  console.error(notContentRepoError(ROOT));
@@ -649,4 +1126,4 @@ async function runCli() {
649
1126
  process.exit(errorCount === 0 ? 0 : 1);
650
1127
  }
651
1128
  //#endregion
652
- export { runCli as a, checkModelBaseline as i, checkDmnXml as n, checkModel as r, checkBpmnXml as t };
1129
+ export { checkSchemaModel as a, checkModelBaseline as i, checkDmnXml as n, runCli as o, checkModel as r, checkBpmnXml as t };
package/dist/validate.js CHANGED
@@ -1 +1 @@
1
- export { t as checkBpmnXml, n as checkDmnXml, r as checkModel, i as checkModelBaseline, a as runCli } from "./validate-Dy2lNoBR.js";
1
+ export { t as checkBpmnXml, n as checkDmnXml, r as checkModel, i as checkModelBaseline, a as checkSchemaModel, o as runCli } from "./validate-BqkEd0ma.js";
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@miragon/design-iq-validator",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "type": "module",
5
- "description": "Platform-owned validator for designIQ content repos — designiq.yml discovery, BPMN/DMN structure and diagram coverage, cross-model references, and a parse check for every other notation. Runs against any checkout via --root; never executes content-repo code.",
5
+ "description": "Platform-owned validator for designIQ content repos — designiq.yml discovery, BPMN/DMN structure and diagram coverage, cross-model references, the schema check of Team Topologies, Context Maps and Value Chains, and a parse check for every other notation. Runs against any checkout via --root; never executes content-repo code.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -32,10 +32,14 @@
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
- "//": "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.",
35
+ "//": "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. zod is a RUNTIME dependency for the same reason: src/ never imports it, but the team-topologies and context-maps schema models take it as a peer, and the published package is what provides it.",
36
36
  "dependencies": {
37
+ "@miragon/context-maps-schema-model": "0.3.0",
38
+ "@miragon/team-topologies-schema-model": "0.11.2",
39
+ "@miragon/value-chain-schema-model": "0.5.0",
37
40
  "fast-xml-parser": "5.11.2",
38
- "yaml": "2.9.1"
41
+ "yaml": "2.9.1",
42
+ "zod": "4.6.7"
39
43
  },
40
44
  "devDependencies": {
41
45
  "tsdown": "0.23.1",