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