dsh-model-everything 0.0.0-stage → 0.2.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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +332 -0
  3. package/README.md +332 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +80 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +87 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +63 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/model-file.js +48 -0
  32. package/lib/option-shape.js +62 -0
  33. package/lib/report-diff-tool.js +63 -0
  34. package/lib/report-view.js +433 -0
  35. package/lib/structure-tool.js +91 -0
  36. package/lib/structure.js +711 -0
  37. package/lib/tool.js +94 -0
  38. package/lib/types/baseline.d.ts +65 -0
  39. package/lib/types/compose-tool.d.ts +7 -0
  40. package/lib/types/concurrency-tool.d.ts +8 -0
  41. package/lib/types/concurrency.d.ts +37 -0
  42. package/lib/types/data-engine.d.ts +211 -0
  43. package/lib/types/data-tool.d.ts +10 -0
  44. package/lib/types/design-engine.d.ts +215 -0
  45. package/lib/types/design-tool.d.ts +11 -0
  46. package/lib/types/engine.d.ts +494 -0
  47. package/lib/types/export-tool.d.ts +7 -0
  48. package/lib/types/exporters.d.ts +37 -0
  49. package/lib/types/gate.d.ts +91 -0
  50. package/lib/types/granularity.d.ts +106 -0
  51. package/lib/types/index.d.ts +45 -0
  52. package/lib/types/json-value.d.ts +21 -0
  53. package/lib/types/model-file.d.ts +7 -0
  54. package/lib/types/option-shape.d.ts +22 -0
  55. package/lib/types/report-diff-tool.d.ts +8 -0
  56. package/lib/types/report-view.d.ts +50 -0
  57. package/lib/types/structure-tool.d.ts +10 -0
  58. package/lib/types/structure.d.ts +152 -0
  59. package/lib/types/tool.d.ts +11 -0
  60. package/lib/types/uml-communication.d.ts +109 -0
  61. package/lib/types/uml-composite.d.ts +107 -0
  62. package/lib/types/uml-interaction-overview.d.ts +91 -0
  63. package/lib/types/uml-profile.d.ts +112 -0
  64. package/lib/types/uml-structure.d.ts +53 -0
  65. package/lib/types/uml-syntax.d.ts +296 -0
  66. package/lib/types/uml-timing.d.ts +152 -0
  67. package/lib/types/uml-tool.d.ts +12 -0
  68. package/lib/types/uml-usecase.d.ts +58 -0
  69. package/lib/types/uml.d.ts +303 -0
  70. package/lib/uml-communication.js +353 -0
  71. package/lib/uml-composite.js +442 -0
  72. package/lib/uml-interaction-overview.js +277 -0
  73. package/lib/uml-profile.js +373 -0
  74. package/lib/uml-structure.js +306 -0
  75. package/lib/uml-syntax.js +545 -0
  76. package/lib/uml-timing.js +974 -0
  77. package/lib/uml-tool.js +213 -0
  78. package/lib/uml-usecase.js +532 -0
  79. package/lib/uml.js +2110 -0
  80. package/locale/en.json +6 -0
  81. package/locale/zh.json +6 -0
  82. package/package.json +128 -6
  83. package/skills/model-everything/SKILL.md +450 -0
  84. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  85. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  86. package/skills/model-everything/references/hash-spec.md +152 -0
  87. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  88. package/skills/model-everything-datamodel/SKILL.md +130 -0
  89. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  90. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  91. package/skills/model-everything-design/SKILL.md +115 -0
  92. package/skills/model-everything-interleaving/SKILL.md +63 -0
  93. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  94. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  95. package/skills/model-everything-report-reading/SKILL.md +137 -0
  96. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  97. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  98. package/skills/model-everything-structure/SKILL.md +96 -0
  99. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  100. package/skills/model-everything-uml/SKILL.md +60 -0
  101. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  102. package/src/baseline.ts +288 -0
  103. package/src/compose-tool.ts +82 -0
  104. package/src/concurrency-tool.ts +44 -0
  105. package/src/concurrency.ts +254 -0
  106. package/src/data-engine.ts +1208 -0
  107. package/src/data-tool.ts +91 -0
  108. package/src/design-engine.ts +1263 -0
  109. package/src/design-tool.ts +68 -0
  110. package/src/engine.ts +4564 -0
  111. package/src/export-tool.ts +81 -0
  112. package/src/exporters.ts +399 -0
  113. package/src/gate.ts +330 -0
  114. package/src/granularity.ts +312 -0
  115. package/src/index.ts +289 -0
  116. package/src/json-value.ts +20 -0
  117. package/src/model-file.ts +49 -0
  118. package/src/option-shape.ts +64 -0
  119. package/src/report-diff-tool.ts +67 -0
  120. package/src/report-view.ts +483 -0
  121. package/src/structure-tool.ts +93 -0
  122. package/src/structure.ts +786 -0
  123. package/src/tool.ts +103 -0
  124. package/src/uml-communication.ts +428 -0
  125. package/src/uml-composite.ts +520 -0
  126. package/src/uml-interaction-overview.ts +321 -0
  127. package/src/uml-profile.ts +439 -0
  128. package/src/uml-structure.ts +317 -0
  129. package/src/uml-syntax.ts +764 -0
  130. package/src/uml-timing.ts +1106 -0
  131. package/src/uml-tool.ts +230 -0
  132. package/src/uml-usecase.ts +566 -0
  133. package/src/uml.ts +2139 -0
@@ -0,0 +1,711 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Structure-diagram review: a dependency graph is not a state machine.
4
+ *
5
+ * The state-machine front end (`uml.ts`) reads a component, package, class or
6
+ * deployment diagram as a *by-product* and says so (`UML_NOT_A_STATE_DIAGRAM`).
7
+ * This module reads the same text as what it actually is — a directed graph of
8
+ * nodes and dependency edges — and audits it against a declared dependency matrix.
9
+ *
10
+ * The checks are the structural ones a reader performs by eye and gets wrong:
11
+ * an isolated node nobody notices, an arrow whose endpoint was never declared, a
12
+ * dependency cycle, an edge that violates the allowed-dependency matrix, a child
13
+ * depending on its parent, and an edge the matrix says must exist but the diagram
14
+ * does not draw. Every finding names the rule id it hit, so the diagram and the
15
+ * source-side checker can be reconciled rule by rule.
16
+ *
17
+ * @module model-everything-structure
18
+ */
19
+ import { REPORT_SCHEMAS, canonicalJson, verdictOfFindings } from './engine.js';
20
+ const CONSTRUCT_KIND = {
21
+ component: 'component',
22
+ class: 'class',
23
+ interface: 'interface',
24
+ abstract: 'class',
25
+ enum: 'class',
26
+ object: 'component',
27
+ actor: 'actor',
28
+ usecase: 'component',
29
+ database: 'database',
30
+ node: 'node',
31
+ artifact: 'node',
32
+ deployment: 'node',
33
+ rectangle: 'rectangle',
34
+ folder: 'package',
35
+ frame: 'rectangle',
36
+ cloud: 'cloud',
37
+ queue: 'queue',
38
+ stack: 'queue',
39
+ storage: 'database',
40
+ collections: 'queue',
41
+ agent: 'component',
42
+ boundary: 'component',
43
+ control: 'component',
44
+ entity: 'component',
45
+ package: 'package',
46
+ namespace: 'package',
47
+ module: 'package',
48
+ componentDiagram: 'component',
49
+ };
50
+ const CONTAINER_KINDS = new Set(['package']);
51
+ /** Constructs that declare a node in a PlantUML structure diagram. */
52
+ const DECLARATION = /^\s*(component|componentDiagram|class|abstract|interface|enum|object|actor|usecase|database|node|artifact|deployment|rectangle|folder|frame|cloud|queue|stack|storage|collections|agent|boundary|control|entity|package|namespace|module)\b\s*(.*)$/i;
53
+ /** PlantUML arrow forms used between structure nodes. */
54
+ const PLANTUML_EDGE = /^\s*(.+?)\s*(-->|->|\.\.>|--|==>|<--|<\.\.)\s*(.+?)\s*$/;
55
+ /** Mermaid class-diagram edge forms: `A --> B : label`, `A ..|> B`, `A -- B`. */
56
+ const MERMAID_CLASS_EDGE = /^\s*(.+?)\s*(-->|\.\.>|--\|>|\.\.\|>|--|\.\.)\s*(.+?)\s*$/;
57
+ const MERMAID_CLASS_DECLARATION = /^\s*class\s+([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:\["([^"]*)"\])?\s*$/;
58
+ const MERMAID_CLASS_MEMBER = /^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*(?::|\{)/;
59
+ /**
60
+ * Parse a structure diagram (PlantUML component/package/class/deployment, Mermaid
61
+ * class diagram) into nodes and dependency edges.
62
+ *
63
+ * A structure diagram is *not* refused here: refusing is the state-machine front
64
+ * end's job (`parseUml`). This parser is the honest reading of the same text.
65
+ */
66
+ export function parseStructure(text, notation = 'auto') {
67
+ const resolved = notation === 'auto' ? detectStructureNotation(text) : notation;
68
+ return resolved === 'mermaid' ? parseMermaidClass(text) : parsePlantUmlStructure(text);
69
+ }
70
+ function detectStructureNotation(text) {
71
+ if (/^\s*@start/m.test(text))
72
+ return 'plantuml';
73
+ if (/^\s*classDiagram\b/m.test(text))
74
+ return 'mermaid';
75
+ if (/^\s*(component|package|deployment)\b/im.test(text))
76
+ return 'plantuml';
77
+ return 'plantuml';
78
+ }
79
+ /** Read `X as Y`, `"X" as Y`, `[X] as Y`, `X` → the node id (and its display label). */
80
+ function readDeclarationTarget(rest) {
81
+ const trimmed = rest.trim().replace(/[;{]\s*$/, '').trim();
82
+ if (trimmed === '')
83
+ return {};
84
+ const aliased = /^(.*?)\s+as\s+([A-Za-z_][A-Za-z0-9_.-]*)\s*$/.exec(trimmed);
85
+ if (aliased !== null)
86
+ return { id: aliased[2], label: cleanLabel(aliased[1]) };
87
+ const bare = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*$/.exec(trimmed);
88
+ if (bare !== null)
89
+ return { id: bare[1] };
90
+ const quoted = /^"([^"]+)"\s*$/.exec(trimmed);
91
+ if (quoted !== null)
92
+ return { id: quoted[1], label: quoted[1] };
93
+ // `[Order Service]` and other bracketed spellings become their own id when nothing
94
+ // else is available: an unnamed node still has to appear in the graph.
95
+ const bracketed = /^\[([^\]]+)\]\s*$/.exec(trimmed);
96
+ if (bracketed !== null)
97
+ return { id: bracketed[1].trim(), label: bracketed[1].trim() };
98
+ return { label: cleanLabel(trimmed) };
99
+ }
100
+ function cleanLabel(value) {
101
+ const trimmed = value.trim().replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').trim();
102
+ return trimmed === '' ? undefined : trimmed;
103
+ }
104
+ /** Strip a trailing `: label` from an edge target and return both halves. */
105
+ function splitEdgeTarget(text) {
106
+ const colon = text.indexOf(':');
107
+ if (colon < 0)
108
+ return { id: text.trim() };
109
+ return { id: text.slice(0, colon).trim(), label: text.slice(colon + 1).trim() };
110
+ }
111
+ function nodeIdFromReference(reference) {
112
+ const trimmed = reference.trim();
113
+ const quoted = /^"([^"]+)"$/.exec(trimmed);
114
+ if (quoted !== null)
115
+ return quoted[1];
116
+ const bracketed = /^\[([^\]]+)\]$/.exec(trimmed);
117
+ if (bracketed !== null)
118
+ return bracketed[1].trim();
119
+ return trimmed.replace(/^"|"$/g, '');
120
+ }
121
+ function declareNode(builder, id, kind, line, label) {
122
+ const existing = builder.nodes.get(id);
123
+ const container = CONTAINER_KINDS.has(kind);
124
+ if (existing === undefined) {
125
+ builder.nodes.set(id, {
126
+ id,
127
+ kind,
128
+ container,
129
+ line,
130
+ declared: true,
131
+ ...(label === undefined ? {} : { label }),
132
+ ...(builder.stack.length === 0 ? {} : { parent: builder.stack[builder.stack.length - 1] }),
133
+ });
134
+ return;
135
+ }
136
+ // A second declaration upgrades an implicitly-created node and keeps the first line.
137
+ existing.declared = true;
138
+ existing.kind = kind;
139
+ existing.container = container;
140
+ if (label !== undefined && existing.label === undefined)
141
+ existing.label = label;
142
+ if (existing.line === undefined)
143
+ existing.line = line;
144
+ }
145
+ /** A node that only an edge mentioned: kept, and reported as dangling (UML021). */
146
+ function referenceNode(builder, id, line) {
147
+ if (builder.nodes.has(id))
148
+ return;
149
+ builder.nodes.set(id, {
150
+ id,
151
+ kind: 'unknown',
152
+ container: false,
153
+ line,
154
+ declared: false,
155
+ ...(builder.stack.length === 0 ? {} : { parent: builder.stack[builder.stack.length - 1] }),
156
+ });
157
+ }
158
+ function parsePlantUmlStructure(text) {
159
+ const builder = { nodes: new Map(), edges: [], warnings: [], stack: [] };
160
+ const lines = text.split(/\r?\n/);
161
+ let inNote = false;
162
+ lines.forEach((rawLine, index) => {
163
+ const lineNumber = index + 1;
164
+ const line = rawLine.trim();
165
+ if (line === '')
166
+ return;
167
+ if (/^@start|^@end/.test(line))
168
+ return;
169
+ if (/^'/i.test(line) || /^\/\//.test(line))
170
+ return;
171
+ const noteStart = /^note\b/i.test(line);
172
+ const noteEnd = /^end\s*note$/i.test(line);
173
+ if (noteEnd) {
174
+ inNote = false;
175
+ return;
176
+ }
177
+ if (inNote)
178
+ return;
179
+ if (noteStart && !/:\s*.+$/.test(line)) {
180
+ inNote = true;
181
+ return;
182
+ }
183
+ if (noteStart)
184
+ return;
185
+ if (/^skinparam\b|^scale\b|^title\b|^hide\b|^left to right direction|^top to bottom direction|^!theme|^legend\b|^end legend$/i.test(line))
186
+ return;
187
+ if (/^\/'/.test(line) || /^'/i.test(line))
188
+ return;
189
+ if (line === '}') {
190
+ builder.stack.pop();
191
+ return;
192
+ }
193
+ const declaration = DECLARATION.exec(line);
194
+ if (declaration !== null) {
195
+ const kind = CONSTRUCT_KIND[declaration[1].toLowerCase()] ?? 'unknown';
196
+ const target = readDeclarationTarget(declaration[2]);
197
+ const id = target.id ?? declaration[2].trim();
198
+ if (id !== '') {
199
+ declareNode(builder, id, kind, lineNumber, target.label);
200
+ if (line.endsWith('{'))
201
+ builder.stack.push(id);
202
+ }
203
+ return;
204
+ }
205
+ const edge = PLANTUML_EDGE.exec(line);
206
+ if (edge !== null) {
207
+ const from = nodeIdFromReference(edge[1]);
208
+ const target = splitEdgeTarget(edge[3]);
209
+ const to = nodeIdFromReference(target.id);
210
+ if (from === '' || to === '')
211
+ return;
212
+ referenceNode(builder, from, lineNumber);
213
+ referenceNode(builder, to, lineNumber);
214
+ builder.edges.push({ from, to, line: lineNumber, ...(target.label === undefined ? {} : { label: target.label }) });
215
+ return;
216
+ }
217
+ builder.warnings.push('STRUCTURE_IGNORED_LINE: "' + line + '" is not a component, package, class or edge statement; it was ignored.');
218
+ });
219
+ return { notation: 'plantuml', nodes: [...builder.nodes.values()], edges: builder.edges, warnings: builder.warnings };
220
+ }
221
+ function parseMermaidClass(text) {
222
+ const builder = { nodes: new Map(), edges: [], warnings: [], stack: [] };
223
+ const lines = text.split(/\r?\n/);
224
+ for (const [index, rawLine] of lines.entries()) {
225
+ const lineNumber = index + 1;
226
+ const line = rawLine.trim();
227
+ if (line === '' || /^classDiagram\b/.test(line) || /^%%/.test(line))
228
+ continue;
229
+ if (line === '}') {
230
+ builder.stack.pop();
231
+ continue;
232
+ }
233
+ const declaration = MERMAID_CLASS_DECLARATION.exec(line);
234
+ if (declaration !== null) {
235
+ declareNode(builder, declaration[1], 'class', lineNumber, declaration[2]);
236
+ continue;
237
+ }
238
+ // `class OrderService { ... }` opens a member block; members are not dependency nodes.
239
+ const block = /^class\s+([A-Za-z_][A-Za-z0-9_.-]*)\s*\{$/.exec(line);
240
+ if (block !== null) {
241
+ declareNode(builder, block[1], 'class', lineNumber);
242
+ builder.stack.push(block[1]);
243
+ continue;
244
+ }
245
+ const edge = MERMAID_CLASS_EDGE.exec(line);
246
+ if (edge !== null) {
247
+ const from = nodeIdFromReference(edge[1]);
248
+ const target = splitEdgeTarget(edge[3]);
249
+ const to = nodeIdFromReference(target.id);
250
+ if (from === '' || to === '')
251
+ continue;
252
+ referenceNode(builder, from, lineNumber);
253
+ referenceNode(builder, to, lineNumber);
254
+ builder.edges.push({ from, to, line: lineNumber, ...(target.label === undefined ? {} : { label: target.label }) });
255
+ continue;
256
+ }
257
+ if (builder.stack.length > 0)
258
+ continue; // a member line inside `class X { ... }`
259
+ if (MERMAID_CLASS_MEMBER.test(line)) {
260
+ // `Service : +read()` declares a node with a member description.
261
+ const id = MERMAID_CLASS_MEMBER.exec(line)[1];
262
+ declareNode(builder, id, 'class', lineNumber);
263
+ continue;
264
+ }
265
+ builder.warnings.push('STRUCTURE_IGNORED_LINE: "' + line + '" is not a class or edge statement; it was ignored.');
266
+ }
267
+ return { notation: 'mermaid', nodes: [...builder.nodes.values()], edges: builder.edges, warnings: builder.warnings };
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // Dependency matrix
271
+ // ---------------------------------------------------------------------------
272
+ /** `*` matches any run of characters, `?` exactly one. Ids keep their own punctuation. */
273
+ export function globMatches(pattern, value) {
274
+ if (pattern === value)
275
+ return true;
276
+ if (!pattern.includes('*') && !pattern.includes('?'))
277
+ return false;
278
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
279
+ return new RegExp('^' + escaped + '$').test(value);
280
+ }
281
+ function matchesAny(patterns, value) {
282
+ if (patterns === undefined)
283
+ return undefined;
284
+ return patterns.find((pattern) => globMatches(pattern, value));
285
+ }
286
+ export function validateMatrix(input) {
287
+ const errors = [];
288
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
289
+ return { ok: false, errors: ['matrix: must be an object'] };
290
+ }
291
+ const root = input;
292
+ const allowed = new Set(['rules', 'layers', 'default']);
293
+ for (const key of Object.keys(root)) {
294
+ if (!allowed.has(key))
295
+ errors.push('matrix.' + key + ': unknown field (allowed: rules, layers, default)');
296
+ }
297
+ if (root.default !== undefined && root.default !== 'allow' && root.default !== 'deny') {
298
+ errors.push("matrix.default: must be 'allow' or 'deny'");
299
+ }
300
+ if (root.rules !== undefined) {
301
+ if (!Array.isArray(root.rules))
302
+ errors.push('matrix.rules: must be an array');
303
+ else {
304
+ const seen = new Set();
305
+ root.rules.forEach((entry, index) => {
306
+ const path = 'matrix.rules[' + String(index) + ']';
307
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
308
+ errors.push(path + ': must be an object');
309
+ return;
310
+ }
311
+ const rule = entry;
312
+ for (const key of Object.keys(rule)) {
313
+ if (!['id', 'source', 'allow', 'deny', 'require'].includes(key))
314
+ errors.push(path + '.' + key + ': unknown field (allowed: id, source, allow, deny, require)');
315
+ }
316
+ if (typeof rule.id !== 'string' || rule.id === '')
317
+ errors.push(path + '.id: must be a non-empty string');
318
+ else if (seen.has(rule.id))
319
+ errors.push(path + '.id: duplicate rule id ' + rule.id);
320
+ else
321
+ seen.add(rule.id);
322
+ if (rule.source !== undefined && typeof rule.source !== 'string')
323
+ errors.push(path + '.source: must be a string');
324
+ for (const list of ['allow', 'deny']) {
325
+ if (rule[list] === undefined)
326
+ continue;
327
+ if (!Array.isArray(rule[list]) || rule[list].length === 0)
328
+ errors.push(path + '.' + list + ': must be a non-empty array');
329
+ else if (rule[list].some((value) => typeof value !== 'string' || value === ''))
330
+ errors.push(path + '.' + list + ': every entry must be a non-empty string');
331
+ }
332
+ if (rule.require !== undefined && typeof rule.require !== 'boolean')
333
+ errors.push(path + '.require: must be a boolean');
334
+ if (rule.require === true && rule.allow === undefined)
335
+ errors.push(path + '.require: needs `allow` to say which edges must exist');
336
+ });
337
+ }
338
+ }
339
+ if (root.layers !== undefined) {
340
+ if (!Array.isArray(root.layers))
341
+ errors.push('matrix.layers: must be an array');
342
+ else {
343
+ root.layers.forEach((entry, index) => {
344
+ const path = 'matrix.layers[' + String(index) + ']';
345
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
346
+ errors.push(path + ': must be an object');
347
+ return;
348
+ }
349
+ const layer = entry;
350
+ for (const key of Object.keys(layer)) {
351
+ if (!['name', 'members'].includes(key))
352
+ errors.push(path + '.' + key + ': unknown field (allowed: name, members)');
353
+ }
354
+ if (typeof layer.name !== 'string' || layer.name === '')
355
+ errors.push(path + '.name: must be a non-empty string');
356
+ if (!Array.isArray(layer.members) || layer.members.length === 0)
357
+ errors.push(path + '.members: must be a non-empty array');
358
+ else if (layer.members.some((value) => typeof value !== 'string' || value === ''))
359
+ errors.push(path + '.members: every entry must be a non-empty string');
360
+ });
361
+ }
362
+ }
363
+ if (errors.length > 0)
364
+ return { ok: false, errors };
365
+ return { ok: true, matrix: root };
366
+ }
367
+ /** The layer index of a node id, or undefined when no layer claims it. */
368
+ function layerOf(layers, id) {
369
+ for (const [index, layer] of layers.entries()) {
370
+ if (matchesAny(layer.members, id) !== undefined)
371
+ return index;
372
+ }
373
+ return undefined;
374
+ }
375
+ // ---------------------------------------------------------------------------
376
+ // Checks
377
+ // ---------------------------------------------------------------------------
378
+ /** Shortest directed cycle through the graph, as a node path with the start repeated. */
379
+ export function shortestCycle(nodes, edges) {
380
+ const adjacency = new Map();
381
+ for (const node of nodes)
382
+ adjacency.set(node, []);
383
+ for (const edge of edges) {
384
+ if (edge.from === edge.to)
385
+ return [edge.from, edge.from];
386
+ const list = adjacency.get(edge.from);
387
+ if (list !== undefined && !list.includes(edge.to))
388
+ list.push(edge.to);
389
+ }
390
+ let best;
391
+ for (const start of nodes) {
392
+ // BFS from `start` back to `start`; the first hit is the shortest cycle through it.
393
+ const previous = new Map();
394
+ const queue = [start];
395
+ const seen = new Set([start]);
396
+ let found = false;
397
+ while (queue.length > 0 && !found) {
398
+ const current = queue.shift();
399
+ for (const next of adjacency.get(current) ?? []) {
400
+ if (next === start) {
401
+ // `previous` holds back-pointers from `start`, so walk them to `current`,
402
+ // reverse into forward order, then close the loop.
403
+ const chain = [];
404
+ let cursor = current;
405
+ while (cursor !== start) {
406
+ chain.push(cursor);
407
+ cursor = previous.get(cursor);
408
+ }
409
+ const path = [start, ...chain.reverse(), start];
410
+ if (best === undefined || path.length < best.length)
411
+ best = path;
412
+ found = true;
413
+ break;
414
+ }
415
+ if (seen.has(next))
416
+ continue;
417
+ seen.add(next);
418
+ previous.set(next, current);
419
+ queue.push(next);
420
+ }
421
+ }
422
+ }
423
+ return best;
424
+ }
425
+ /**
426
+ * Audit a structure diagram. Every check is structural: it reads the drawn graph and
427
+ * the declared matrix, and never claims anything about the code behind them.
428
+ */
429
+ /**
430
+ * The diagram text, or a refusal naming what arrived instead.
431
+ *
432
+ * `reviewStructure({ diagram: 42 })` threw `text.split is not a function`, and `{}` threw a read of
433
+ * `undefined`. The shape of the argument is the caller's mistake, and the caller can route on a
434
+ * code but not on a TypeError. A raw throw is also indistinguishable, at the tool boundary, from a
435
+ * crash inside the engine.
436
+ */
437
+ function requireDiagramText(value) {
438
+ if (typeof value !== 'string') {
439
+ const arrived = value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value;
440
+ throw new Error('diagram must be a string of diagram text, got ' + arrived + '. Pass the diagram source itself.');
441
+ }
442
+ return value;
443
+ }
444
+ export function reviewStructure(options) {
445
+ // Not `if (options.diagram !== undefined)`: a missing `diagram` reached the parser as `undefined`
446
+ // and threw a read of `split` on it - the same defect, one guard narrower.
447
+ requireDiagramText(options.diagram);
448
+ const graph = parseStructure(options.diagram, options.notation ?? 'auto');
449
+ const warnings = [...graph.warnings];
450
+ const findings = [];
451
+ const nodes = graph.nodes;
452
+ const nodeIds = nodes.map((node) => node.id);
453
+ const byId = new Map(nodes.map((node) => [node.id, node]));
454
+ if (nodes.length === 0) {
455
+ findings.push({
456
+ code: 'UML026_NO_NODES',
457
+ severity: 'error',
458
+ message: 'no node was declared in this text, so there is no structure to review.',
459
+ evidence: { notation: graph.notation },
460
+ });
461
+ }
462
+ // -- UML021 dangling references: an endpoint no declaration ever introduced -----
463
+ const dangling = nodes.filter((node) => !node.declared);
464
+ if (dangling.length > 0) {
465
+ findings.push({
466
+ code: 'UML021_DANGLING_REFERENCE',
467
+ severity: 'error',
468
+ message: String(dangling.length) + ' arrow endpoint(s) are used without ever being declared: ' + dangling.map((node) => node.id).join(', ') + '. In a component diagram this is how a dependency on a component that does not exist stays invisible.',
469
+ evidence: { nodes: dangling.map((node) => ({ id: node.id, line: node.line })) },
470
+ });
471
+ }
472
+ // -- UML020 isolated nodes: declared, but nothing depends on it and it depends on nothing --
473
+ const degree = new Map();
474
+ for (const id of nodeIds)
475
+ degree.set(id, 0);
476
+ for (const edge of graph.edges) {
477
+ // A container's members are not its dependencies, so only real edges count.
478
+ degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
479
+ degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
480
+ }
481
+ const isolated = nodes.filter((node) => !node.container && node.declared && (degree.get(node.id) ?? 0) === 0);
482
+ if (isolated.length > 0) {
483
+ findings.push({
484
+ code: 'UML020_ISOLATED_NODE',
485
+ severity: 'warning',
486
+ message: String(isolated.length) + ' node(s) have no edge at all: ' + isolated.map((node) => node.id).join(', ') + '. Either they are dead entries or the diagram is missing their dependencies.',
487
+ evidence: { nodes: isolated.map((node) => ({ id: node.id, line: node.line })) },
488
+ });
489
+ }
490
+ // -- UML022 cycles --------------------------------------------------------------
491
+ const cycle = shortestCycle(nodeIds, graph.edges);
492
+ if (cycle !== undefined) {
493
+ findings.push({
494
+ code: 'UML022_CYCLE',
495
+ severity: 'error',
496
+ message: 'the dependency graph has a directed cycle: ' + cycle.join(' → ') + '. A cycle means no build order and no layering can hold.',
497
+ path: cycle.slice(0, -1).map((from, index) => ({ from, event: 'depends-on', to: cycle[index + 1] })),
498
+ evidence: { cycle },
499
+ });
500
+ }
501
+ // -- Matrix checks: UML023 disallowed edge, UML024 layer violation, UML025 missing edge --
502
+ const edgeVerdicts = [];
503
+ let rules = [];
504
+ let layers = [];
505
+ let matrixDefault = 'allow';
506
+ if (options.matrix !== undefined) {
507
+ const validation = validateMatrix(options.matrix);
508
+ if (!validation.ok) {
509
+ const matrixFindings = validation.errors.map((message) => ({ code: 'MATRIX_INVALID', severity: 'error', message }));
510
+ findings.push(...matrixFindings);
511
+ }
512
+ else {
513
+ rules = validation.matrix.rules ?? [];
514
+ layers = validation.matrix.layers ?? [];
515
+ matrixDefault = validation.matrix.default ?? 'allow';
516
+ // A matrix that judges nothing, or a layer order computed over names that are not nodes.
517
+ //
518
+ // These are warnings rather than errors because the diagram is not wrong: the MATRIX is
519
+ // incomplete, and the report would otherwise say `verdict: 'pass'` with
520
+ // `summary.layerViolations: 0` - which reads as "the layering holds" when in truth no layer
521
+ // rule was ever consulted. A reader cannot tell an empty rule from a satisfied one.
522
+ //
523
+ // A rule whose source matches nothing is NOT reported here: `UML025_MISSING_EXPECTED_EDGE`
524
+ // already covers it, with `noSourceMatch` in its evidence, and `default: 'allow'` is a
525
+ // documented permissive mode in which an unlisted edge passing through is not a finding.
526
+ const nodeIds = new Set(nodes.map((node) => node.id));
527
+ if (rules.length === 0 && layers.length === 0) {
528
+ findings.push({
529
+ code: 'MATRIX_JUDGES_NOTHING',
530
+ severity: 'warning',
531
+ message: 'the dependency matrix carries no rules and no layers, so every edge was judged by `default: ' + matrixDefault + '` alone. The matrix takes no part in this verdict.',
532
+ detail: 'An empty matrix reads as a satisfied one. Either declare the rules and layers, or omit the matrix so the report does not imply a check that never ran.',
533
+ });
534
+ }
535
+ if (layers.length > 0) {
536
+ for (const layer of layers) {
537
+ for (const member of layer.members) {
538
+ if (nodeIds.has(member))
539
+ continue;
540
+ findings.push({
541
+ code: 'MATRIX_LAYER_MEMBER_UNKNOWN',
542
+ severity: 'warning',
543
+ message: 'layer ' + layer.name + ' lists ' + JSON.stringify(member) + ', which is not a node of the diagram.',
544
+ detail: 'A member that is not a node cannot break a layer order, so listing it hides the member that should have been there.',
545
+ });
546
+ }
547
+ }
548
+ // A node no layer claims is exempt from UML024 entirely: it has no rank to compare, so an
549
+ // edge through it can never be an upward edge as far as this matrix is concerned.
550
+ for (const node of nodes) {
551
+ if (layers.some((layer) => layer.members.includes(node.id)))
552
+ continue;
553
+ findings.push({
554
+ code: 'MATRIX_NODE_UNLAYERED',
555
+ severity: 'warning',
556
+ message: node.id + ' belongs to no declared layer, so every edge touching it is exempt from the layer order.',
557
+ detail: 'A node with no rank cannot be compared to one, which is why the exemption exists - but it also means an edge that skips layers through this node is never reported. Add it to a layer, or remove it from the diagram.',
558
+ });
559
+ }
560
+ }
561
+ }
562
+ }
563
+ for (const edge of graph.edges) {
564
+ const matchedRules = [];
565
+ let deniedBy;
566
+ let allowedBy;
567
+ for (const rule of rules) {
568
+ if (rule.source === undefined)
569
+ continue;
570
+ if (matchesAny([rule.source], edge.from) === undefined)
571
+ continue;
572
+ matchedRules.push(rule.id);
573
+ if (matchesAny(rule.deny, edge.to) !== undefined && deniedBy === undefined)
574
+ deniedBy = rule;
575
+ if (matchesAny(rule.allow, edge.to) !== undefined && allowedBy === undefined)
576
+ allowedBy = rule;
577
+ }
578
+ // Deny wins globally: a later rule's allow must not resurrect an edge an earlier
579
+ // rule forbade, or the matrix would depend on rule order.
580
+ let allowed;
581
+ let basis;
582
+ if (deniedBy !== undefined) {
583
+ allowed = false;
584
+ basis = 'deny';
585
+ }
586
+ else if (allowedBy !== undefined) {
587
+ allowed = true;
588
+ basis = 'allow';
589
+ }
590
+ else if (matrixDefault === 'deny') {
591
+ allowed = false;
592
+ basis = 'unlisted';
593
+ }
594
+ else {
595
+ allowed = true;
596
+ basis = 'default';
597
+ }
598
+ edgeVerdicts.push({ from: edge.from, to: edge.to, line: edge.line, matchedRules, allowed, basis });
599
+ if (!allowed) {
600
+ const rule = deniedBy;
601
+ findings.push({
602
+ code: 'UML023_DISALLOWED_EDGE',
603
+ severity: 'error',
604
+ message: rule === undefined
605
+ ? 'edge ' + edge.from + ' → ' + edge.to + ' is not permitted: no rule allows it and the matrix default is deny.'
606
+ : 'edge ' + edge.from + ' → ' + edge.to + ' violates rule ' + rule.id + (rule.deny === undefined ? '' : ' (deny ' + rule.deny.join(', ') + ')') + '.',
607
+ // `rule` is omitted rather than set to undefined: a tool result must stay lossless JSON.
608
+ evidence: { edge: { from: edge.from, to: edge.to, line: edge.line }, matchedRules, basis, ...(rule === undefined ? {} : { rule: rule.id }) },
609
+ });
610
+ }
611
+ }
612
+ // UML024: an edge from a lower layer into a higher one (a child depending on its parent).
613
+ const layerViolations = [];
614
+ if (layers.length > 0) {
615
+ for (const edge of graph.edges) {
616
+ const fromLayer = layerOf(layers, edge.from);
617
+ const toLayer = layerOf(layers, edge.to);
618
+ if (fromLayer === undefined || toLayer === undefined)
619
+ continue;
620
+ if (fromLayer > toLayer) {
621
+ layerViolations.push({ from: edge.from, to: edge.to, fromLayer: layers[fromLayer].name, toLayer: layers[toLayer].name, line: edge.line });
622
+ }
623
+ }
624
+ if (layerViolations.length > 0) {
625
+ findings.push({
626
+ code: 'UML024_LAYER_VIOLATION',
627
+ severity: 'error',
628
+ message: String(layerViolations.length) + ' edge(s) point from a lower layer into a higher one: ' + layerViolations.map((entry) => entry.from + ' (' + entry.fromLayer + ') → ' + entry.to + ' (' + entry.toLayer + ')').join('; ') + '. Declared layers allow downward dependencies only.',
629
+ evidence: { violations: layerViolations },
630
+ });
631
+ }
632
+ }
633
+ // UML025: an edge the matrix requires but the diagram does not draw. The check is
634
+ // pattern-based on purpose: a required target that the diagram does not contain at
635
+ // all is the strongest form of "missing", and deriving the expectation from declared
636
+ // nodes would hide exactly that case.
637
+ const missing = [];
638
+ for (const rule of rules) {
639
+ if (rule.require !== true || rule.source === undefined || rule.allow === undefined)
640
+ continue;
641
+ const sources = nodeIds.filter((id) => matchesAny([rule.source], id) !== undefined);
642
+ if (sources.length === 0) {
643
+ missing.push({ rule: rule.id, source: rule.source, expected: rule.allow, unsatisfiedFrom: [], noSourceMatch: true });
644
+ continue;
645
+ }
646
+ const unsatisfied = sources.filter((source) => !graph.edges.some((edge) => edge.from === source && matchesAny(rule.allow, edge.to) !== undefined));
647
+ if (unsatisfied.length > 0)
648
+ missing.push({ rule: rule.id, source: rule.source, expected: rule.allow, unsatisfiedFrom: unsatisfied, noSourceMatch: false });
649
+ }
650
+ if (missing.length > 0) {
651
+ findings.push({
652
+ code: 'UML025_MISSING_EXPECTED_EDGE',
653
+ severity: 'warning',
654
+ message: String(missing.length) + ' required edge(s) are absent from the diagram: ' + missing.map((entry) => entry.noSourceMatch
655
+ ? entry.rule + ' requires an edge from ' + entry.source + ' to ' + entry.expected.join(', ') + ', but no node matches ' + entry.source
656
+ : entry.rule + ' requires an edge from ' + entry.unsatisfiedFrom.join(', ') + ' to ' + entry.expected.join(', ') + ', and the diagram draws none').join('; ') + '. The matrix and the diagram disagree.',
657
+ evidence: { missing },
658
+ });
659
+ }
660
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
661
+ const warningCount = findings.filter((finding) => finding.severity === 'warning').length;
662
+ const summary = {
663
+ nodes: nodes.length,
664
+ edges: graph.edges.length,
665
+ containers: nodes.filter((node) => node.container).length,
666
+ isolated: isolated.length,
667
+ dangling: dangling.length,
668
+ cycles: cycle === undefined ? 0 : 1,
669
+ disallowedEdges: edgeVerdicts.filter((verdict) => !verdict.allowed).length,
670
+ layerViolations: layerViolations.length,
671
+ missingExpectedEdges: missing.length,
672
+ errors,
673
+ warnings: warningCount,
674
+ rules: rules.length,
675
+ };
676
+ const nextSteps = [];
677
+ if (errors > 0)
678
+ nextSteps.push('Resolve the error findings first: a dangling endpoint or a layer violation means the diagram and the intended architecture disagree.');
679
+ if (rules.length === 0)
680
+ nextSteps.push('Supply a dependency matrix (rules/layers) to have every edge judged and named; without one only the structural checks run.');
681
+ nextSteps.push('Reconcile the result with the source-side checker: this audits the diagram, an include/graph scan audits the code, and the difference between them is the finding worth chasing.');
682
+ if (graph.notation === 'plantuml')
683
+ nextSteps.push('State machines are a different question — for a state or activity diagram use uml action=review and model_verify.');
684
+ return {
685
+ ok: true,
686
+ ran: true,
687
+ ...verdictOfFindings(findings),
688
+ schema: REPORT_SCHEMAS.structure,
689
+ notation: graph.notation,
690
+ graph,
691
+ ...(options.matrix === undefined ? {} : { edgeVerdicts }),
692
+ findings,
693
+ summary,
694
+ hashes: {
695
+ diagram: createHash('sha256').update(options.diagram).digest('hex'),
696
+ ...(options.matrix === undefined ? {} : { matrix: createHash('sha256').update(canonicalJson(options.matrix)).digest('hex') }),
697
+ },
698
+ warnings,
699
+ nextSteps,
700
+ };
701
+ }
702
+ /** The finding codes this module can emit, for documentation and tests. */
703
+ export const STRUCTURE_FINDING_CODES = [
704
+ 'UML020_ISOLATED_NODE',
705
+ 'UML021_DANGLING_REFERENCE',
706
+ 'UML022_CYCLE',
707
+ 'UML023_DISALLOWED_EDGE',
708
+ 'UML024_LAYER_VIOLATION',
709
+ 'UML025_MISSING_EXPECTED_EDGE',
710
+ 'UML026_NO_NODES',
711
+ ];