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.
- package/LICENSE +21 -0
- package/README.en-US.md +332 -0
- package/README.md +332 -3
- package/cordis.patch.yml +20 -0
- package/examples/README.md +48 -0
- package/examples/control-dispatcher.json +113 -0
- package/examples/control-dispatcher.overbudget.json +113 -0
- package/examples/ecommerce-data-model.json +185 -0
- package/examples/order-state-machine.after.json +116 -0
- package/examples/order-state-machine.before.json +101 -0
- package/examples/user-migration.after.json +29 -0
- package/examples/user-migration.before.json +29 -0
- package/gate/locale/en.json +6 -0
- package/gate/locale/zh.json +6 -0
- package/gate/package.json +5 -0
- package/lib/baseline.js +249 -0
- package/lib/compose-tool.js +80 -0
- package/lib/concurrency-tool.js +40 -0
- package/lib/concurrency.js +206 -0
- package/lib/data-engine.js +1141 -0
- package/lib/data-tool.js +87 -0
- package/lib/design-engine.js +1149 -0
- package/lib/design-tool.js +63 -0
- package/lib/engine.js +4343 -0
- package/lib/export-tool.js +78 -0
- package/lib/exporters.js +434 -0
- package/lib/gate.js +274 -0
- package/lib/granularity.js +237 -0
- package/lib/index.js +237 -0
- package/lib/json-value.js +18 -0
- package/lib/model-file.js +48 -0
- package/lib/option-shape.js +62 -0
- package/lib/report-diff-tool.js +63 -0
- package/lib/report-view.js +433 -0
- package/lib/structure-tool.js +91 -0
- package/lib/structure.js +711 -0
- package/lib/tool.js +94 -0
- package/lib/types/baseline.d.ts +65 -0
- package/lib/types/compose-tool.d.ts +7 -0
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +37 -0
- package/lib/types/data-engine.d.ts +211 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/design-engine.d.ts +215 -0
- package/lib/types/design-tool.d.ts +11 -0
- package/lib/types/engine.d.ts +494 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +37 -0
- package/lib/types/gate.d.ts +91 -0
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/json-value.d.ts +21 -0
- package/lib/types/model-file.d.ts +7 -0
- package/lib/types/option-shape.d.ts +22 -0
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/report-view.d.ts +50 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +152 -0
- package/lib/types/tool.d.ts +11 -0
- package/lib/types/uml-communication.d.ts +109 -0
- package/lib/types/uml-composite.d.ts +107 -0
- package/lib/types/uml-interaction-overview.d.ts +91 -0
- package/lib/types/uml-profile.d.ts +112 -0
- package/lib/types/uml-structure.d.ts +53 -0
- package/lib/types/uml-syntax.d.ts +296 -0
- package/lib/types/uml-timing.d.ts +152 -0
- package/lib/types/uml-tool.d.ts +12 -0
- package/lib/types/uml-usecase.d.ts +58 -0
- package/lib/types/uml.d.ts +303 -0
- package/lib/uml-communication.js +353 -0
- package/lib/uml-composite.js +442 -0
- package/lib/uml-interaction-overview.js +277 -0
- package/lib/uml-profile.js +373 -0
- package/lib/uml-structure.js +306 -0
- package/lib/uml-syntax.js +545 -0
- package/lib/uml-timing.js +974 -0
- package/lib/uml-tool.js +213 -0
- package/lib/uml-usecase.js +532 -0
- package/lib/uml.js +2110 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -6
- package/skills/model-everything/SKILL.md +450 -0
- package/skills/model-everything/references/dsh-model-schema.md +437 -0
- package/skills/model-everything/references/gap-routing-guide.md +42 -0
- package/skills/model-everything/references/hash-spec.md +152 -0
- package/skills/model-everything/references/logic-verification-guide.md +422 -0
- package/skills/model-everything-datamodel/SKILL.md +130 -0
- package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
- package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
- package/skills/model-everything-design/SKILL.md +115 -0
- package/skills/model-everything-interleaving/SKILL.md +63 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
- package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
- package/skills/model-everything-report-reading/SKILL.md +137 -0
- package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
- package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
- package/skills/model-everything-structure/SKILL.md +96 -0
- package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
- package/skills/model-everything-uml/SKILL.md +60 -0
- package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
- package/src/baseline.ts +288 -0
- package/src/compose-tool.ts +82 -0
- package/src/concurrency-tool.ts +44 -0
- package/src/concurrency.ts +254 -0
- package/src/data-engine.ts +1208 -0
- package/src/data-tool.ts +91 -0
- package/src/design-engine.ts +1263 -0
- package/src/design-tool.ts +68 -0
- package/src/engine.ts +4564 -0
- package/src/export-tool.ts +81 -0
- package/src/exporters.ts +399 -0
- package/src/gate.ts +330 -0
- package/src/granularity.ts +312 -0
- package/src/index.ts +289 -0
- package/src/json-value.ts +20 -0
- package/src/model-file.ts +49 -0
- package/src/option-shape.ts +64 -0
- package/src/report-diff-tool.ts +67 -0
- package/src/report-view.ts +483 -0
- package/src/structure-tool.ts +93 -0
- package/src/structure.ts +786 -0
- package/src/tool.ts +103 -0
- package/src/uml-communication.ts +428 -0
- package/src/uml-composite.ts +520 -0
- package/src/uml-interaction-overview.ts +321 -0
- package/src/uml-profile.ts +439 -0
- package/src/uml-structure.ts +317 -0
- package/src/uml-syntax.ts +764 -0
- package/src/uml-timing.ts +1106 -0
- package/src/uml-tool.ts +230 -0
- package/src/uml-usecase.ts +566 -0
- package/src/uml.ts +2139 -0
package/src/structure.ts
ADDED
|
@@ -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 }
|