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,1263 @@
1
+ /**
2
+ * The design-review engine: shape, and the declared design principles.
3
+ *
4
+ * This is not the verification engine. That one decides whether a machine can misbehave;
5
+ * this one decides whether a design keeps the contracts it declares about *itself* - which
6
+ * layers may depend on which, which responsibility owns which dependency, which subtype may
7
+ * refuse which event. The two answer different questions and their verdicts mean different
8
+ * things, which is why they are two engines and two tools.
9
+ *
10
+ * Three levels, kept apart in the report because they are not the same claim:
11
+ *
12
+ * - **violations** - the design contradicts something it declared. These decide `verdict`.
13
+ * - **signals** - a structural fact worth a reader's attention, reported without claiming it
14
+ * is wrong. A hub with high fan-in is a fact about a graph; whether it is a design defect
15
+ * depends on the design, and nothing here knows that.
16
+ * - **facts** - counts and computed structure, for a reader or another tool.
17
+ *
18
+ * What this engine does *not* do is judge a design. It never says a decomposition is good,
19
+ * a responsibility name is meaningful, or an abstraction is the right one. It compares a
20
+ * design against its own declarations and computes features of its graph, and the wording of
21
+ * every finding says which of those it is.
22
+ *
23
+ * @module model-everything-design-engine
24
+ */
25
+
26
+ import { createHash } from 'node:crypto'
27
+ import { REPORT_SCHEMAS, valueHash, type Finding, type Verdict } from './engine.js'
28
+
29
+ export { REPORT_SCHEMAS }
30
+
31
+ /** How a type is drawn and what it promises. */
32
+ export type DesignStereotype = 'class' | 'interface' | 'abstract-class' | 'enum' | 'component' | 'abstract-component'
33
+
34
+ export interface DesignOperation {
35
+ name: string
36
+ /** Parameter type names, positional: `['Money']`. */
37
+ parameters?: string[]
38
+ returns?: string
39
+ }
40
+
41
+ /** A declared state change, used only where behaviour is part of the contract. */
42
+ export interface DesignEvent {
43
+ event: string
44
+ /** A guard the model states in words; compared as text, never parsed. */
45
+ guard?: string
46
+ }
47
+
48
+ export interface DesignResponsibility {
49
+ name: string
50
+ /** The dependencies this responsibility owns. */
51
+ uses: string[]
52
+ }
53
+
54
+ /** A type, with whatever the model chose to say about it. */
55
+ export interface DesignType {
56
+ name: string
57
+ stereotype: DesignStereotype
58
+ /** The type this one specialises, when the model declares one. */
59
+ extends?: string
60
+ implements?: string[]
61
+ /** Operations the type exposes, for signature and interface checks. */
62
+ operations?: DesignOperation[]
63
+ /** Events the type handles. Its presence is what makes behaviour checkable. */
64
+ transitions?: DesignEvent[]
65
+ /** Events the type refuses. A refusal its supertype does not also declare is a violation. */
66
+ forbids?: DesignEvent[]
67
+ /** Declared responsibilities, each owning a subset of the dependencies. */
68
+ responsibilities?: DesignResponsibility[]
69
+ /** Types this one depends on, by name. */
70
+ dependsOn?: string[]
71
+ /** What this type calls on another, operation by operation: the interface-segregation input. */
72
+ uses?: Array<{ type: string; operations: string[] }>
73
+ /** The layer the model places this type in, when it places it. */
74
+ layer?: string
75
+ }
76
+
77
+ /** A declared layer: an ordered band of the design. */
78
+ export interface DesignLayer {
79
+ name: string
80
+ members: string[]
81
+ }
82
+
83
+ /** A declared boundary of responsibility; not ordered, unlike a layer. */
84
+ export interface DesignBoundary {
85
+ name: string
86
+ members: string[]
87
+ }
88
+
89
+ /** One rule about which edges may exist. Deny beats allow. */
90
+ export interface DesignEdgeRule {
91
+ id: string
92
+ source: string
93
+ allow?: string[]
94
+ deny?: string[]
95
+ }
96
+
97
+ /** A dependency the design says must exist. */
98
+ export interface DesignRequirement {
99
+ id: string
100
+ from: string
101
+ to: string
102
+ }
103
+
104
+ /**
105
+ * A design as a model.
106
+ *
107
+ * `architecture` is the dependency graph every shape feature is computed from. `types` is the
108
+ * vocabulary that makes the principle checks possible at all: with no `extends` there is no
109
+ * substitutability to check, with no `uses` no interface can be called fat. An empty
110
+ * vocabulary is not a pass - it is a design that declared nothing for those checks to hold
111
+ * it to, and the report says so.
112
+ */
113
+ export interface DesignModelV1 {
114
+ schemaVersion: 1
115
+ /** What this design is, for a reader; carried into the report unchanged. */
116
+ artifact?: string
117
+ architecture: { nodes: string[]; edges: Array<[string, string]> }
118
+ types?: DesignType[]
119
+ declare?: {
120
+ layers?: DesignLayer[]
121
+ boundaries?: DesignBoundary[]
122
+ allowedEdges?: DesignEdgeRule[]
123
+ requiredEdges?: DesignRequirement[]
124
+ }
125
+ narrative?: { types?: Record<string, string> }
126
+ }
127
+
128
+ const STEREOTYPES: readonly DesignStereotype[] = ['class', 'interface', 'abstract-class', 'enum', 'component', 'abstract-component']
129
+ const MODEL_KEYS = new Set(['schemaVersion', 'artifact', 'architecture', 'types', 'declare', 'narrative'])
130
+ const ARCHITECTURE_KEYS = new Set(['nodes', 'edges'])
131
+ const TYPE_KEYS = new Set(['name', 'stereotype', 'extends', 'implements', 'operations', 'transitions', 'forbids', 'responsibilities', 'dependsOn', 'uses', 'layer'])
132
+ const OPERATION_KEYS = new Set(['name', 'parameters', 'returns'])
133
+ const DECLARE_KEYS = new Set(['layers', 'boundaries', 'allowedEdges', 'requiredEdges'])
134
+
135
+ export interface DesignValidation {
136
+ ok: boolean
137
+ errors: string[]
138
+ model?: DesignModelV1
139
+ }
140
+
141
+ function expectKeys(value: Record<string, unknown>, allowed: Set<string>, where: string, errors: string[]): void {
142
+ for (const key of Object.keys(value)) {
143
+ if (key.startsWith('_')) continue
144
+ if (!allowed.has(key)) errors.push(where + '.' + key + ': unknown field (the schema is closed; a typo would silently change the design under review)')
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Validate a candidate design.
150
+ *
151
+ * The schema is closed, for the reason the verification engine gives: a mistyped field is not
152
+ * a missing feature, it is a different design, and reviewing the wrong one silently is worse
153
+ * than refusing.
154
+ */
155
+ export function validateDesignModel(input: unknown): DesignValidation {
156
+ const errors: string[] = []
157
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
158
+ return { ok: false, errors: ['a design model must be an object with schemaVersion 1, architecture and types'] }
159
+ }
160
+ const model = input as Record<string, unknown>
161
+ expectKeys(model, MODEL_KEYS, 'model', errors)
162
+ if (model.schemaVersion !== 1) errors.push('schemaVersion must be 1')
163
+ if (model.architecture === null || typeof model.architecture !== 'object' || Array.isArray(model.architecture)) {
164
+ errors.push('architecture is required and must be an object with nodes and edges')
165
+ return { ok: false, errors }
166
+ }
167
+ const architecture = model.architecture as Record<string, unknown>
168
+ expectKeys(architecture, ARCHITECTURE_KEYS, 'architecture', errors)
169
+ if (!Array.isArray(architecture.nodes)) errors.push('architecture.nodes must be an array of ids')
170
+ if (!Array.isArray(architecture.edges)) errors.push('architecture.edges must be an array of [from, to] pairs')
171
+ if (errors.length > 0) return { ok: false, errors }
172
+
173
+ const nodes = architecture.nodes as unknown[]
174
+ const nodeIds = new Set<string>()
175
+ for (const node of nodes) {
176
+ if (typeof node !== 'string' || node.trim() === '') {
177
+ errors.push('architecture.nodes entries must be non-empty strings')
178
+ continue
179
+ }
180
+ if (nodeIds.has(node)) errors.push('architecture.nodes has a duplicate: ' + node)
181
+ nodeIds.add(node)
182
+ }
183
+ for (const edge of architecture.edges as unknown[]) {
184
+ if (!Array.isArray(edge) || edge.length !== 2 || typeof edge[0] !== 'string' || typeof edge[1] !== 'string') {
185
+ errors.push('architecture.edges entries must be [from, to] pairs of strings')
186
+ continue
187
+ }
188
+ if (!nodeIds.has(edge[0])) errors.push('architecture.edges names a node that is not declared: ' + edge[0])
189
+ if (!nodeIds.has(edge[1])) errors.push('architecture.edges names a node that is not declared: ' + edge[1])
190
+ }
191
+
192
+ // A section the design did not declare is not a field that is missing: `declare` and
193
+ // `types` are how the design states its own contracts, and a design that states none is
194
+ // reviewed for its shape alone. Only a *present* section is checked.
195
+ const types = (model.types ?? []) as unknown[]
196
+ if (model.types !== undefined && !Array.isArray(model.types)) errors.push('types must be an array when it is present')
197
+ const typeNames = new Set<string>()
198
+ for (const entry of (Array.isArray(model.types) ? (model.types as unknown[]) : []) as Record<string, unknown>[]) {
199
+ if (entry === null || typeof entry !== 'object') {
200
+ errors.push('every type must be an object')
201
+ continue
202
+ }
203
+ const name = entry.name
204
+ if (typeof name !== 'string' || name.trim() === '') {
205
+ errors.push('every type needs a non-empty name')
206
+ continue
207
+ }
208
+ expectKeys(entry, TYPE_KEYS, 'type ' + name, errors)
209
+ if (typeNames.has(name)) errors.push('duplicate type name: ' + name)
210
+ typeNames.add(name)
211
+ if (typeof entry.stereotype !== 'string' || !STEREOTYPES.includes(entry.stereotype as DesignStereotype)) {
212
+ errors.push('type ' + name + ': stereotype must be one of ' + STEREOTYPES.join(' | '))
213
+ }
214
+ for (const operation of (entry.operations ?? []) as Record<string, unknown>[]) {
215
+ if (operation === null || typeof operation !== 'object' || typeof operation.name !== 'string') {
216
+ errors.push('type ' + name + ': every operation needs a name')
217
+ continue
218
+ }
219
+ expectKeys(operation, OPERATION_KEYS, 'type ' + name + ' operation ' + operation.name, errors)
220
+ }
221
+ }
222
+
223
+ const declare = (model.declare ?? {}) as Record<string, unknown>
224
+ if (declare !== null && typeof declare === 'object' && !Array.isArray(declare)) expectKeys(declare, DECLARE_KEYS, 'declare', errors)
225
+ else if (model.declare !== undefined) errors.push('declare must be an object when it is present')
226
+
227
+ if (errors.length > 0) return { ok: false, errors }
228
+ return { ok: true, errors: [], model: input as DesignModelV1 }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Graph features
233
+ // ---------------------------------------------------------------------------
234
+
235
+ export interface GraphFeatures {
236
+ nodes: number
237
+ edges: number
238
+ components: number
239
+ /** McCabe's cyclomatic complexity over the dependency graph: E - V + 2P. */
240
+ cyclomatic: number
241
+ density: number
242
+ /** The largest strongly connected component, and its members when it has more than one. */
243
+ largestCycle: string[]
244
+ /** One canonical peeling: remove these and the graph is acyclic. */
245
+ /** The edges whose removal makes the graph acyclic, and whether that count is a proven minimum. */
246
+ feedbackArcs: { edges: Array<[string, string]>; exact: boolean; note: string }
247
+ /** Removing one of these disconnects the graph. */
248
+ articulationPoints: string[]
249
+ /** Removing one of these disconnects the graph. */
250
+ bridges: Array<[string, string]>
251
+ /** Longest-path rank over the condensation; one band per layer the design actually has. */
252
+ ranks: Record<string, number>
253
+ /** Fan-out per node: how many distinct dependencies it has. */
254
+ fanOut: Record<string, number>
255
+ /** Fan-in per node: how many distinct dependents it has. */
256
+ fanIn: Record<string, number>
257
+ /** Pairs that depend on each other. */
258
+ mutualPairs: Array<[string, string]>
259
+ }
260
+
261
+ function adjacencyOf(nodes: string[], edges: Array<[string, string]>): Map<string, string[]> {
262
+ const adjacency = new Map<string, string[]>(nodes.map((id) => [id, []]))
263
+ for (const [from, to] of edges) adjacency.get(from)?.push(to)
264
+ return adjacency
265
+ }
266
+
267
+ function undirectedOf(nodes: string[], edges: Array<[string, string]>): Map<string, Set<string>> {
268
+ const adjacency = new Map<string, Set<string>>(nodes.map((id) => [id, new Set()]))
269
+ for (const [from, to] of edges) {
270
+ adjacency.get(from)?.add(to)
271
+ adjacency.get(to)?.add(from)
272
+ }
273
+ return adjacency
274
+ }
275
+
276
+ /** Strongly connected components (Tarjan), returned largest first. */
277
+ function stronglyConnected(nodes: string[], edges: Array<[string, string]>): string[][] {
278
+ const adjacency = adjacencyOf(nodes, edges)
279
+ const index = new Map<string, number>()
280
+ const low = new Map<string, number>()
281
+ const onStack = new Set<string>()
282
+ const stack: string[] = []
283
+ const components: string[][] = []
284
+ let counter = 0
285
+ const visit = (id: string): void => {
286
+ index.set(id, counter)
287
+ low.set(id, counter)
288
+ counter += 1
289
+ stack.push(id)
290
+ onStack.add(id)
291
+ for (const next of adjacency.get(id) ?? []) {
292
+ if (!index.has(next)) {
293
+ visit(next)
294
+ low.set(id, Math.min(low.get(id) as number, low.get(next) as number))
295
+ } else if (onStack.has(next)) {
296
+ low.set(id, Math.min(low.get(id) as number, index.get(next) as number))
297
+ }
298
+ }
299
+ if (low.get(id) === index.get(id)) {
300
+ const component: string[] = []
301
+ for (;;) {
302
+ const popped = stack.pop() as string
303
+ onStack.delete(popped)
304
+ component.push(popped)
305
+ if (popped === id) break
306
+ }
307
+ components.push(component.sort())
308
+ }
309
+ }
310
+ for (const id of nodes) if (!index.has(id)) visit(id)
311
+ return components.sort((left, right) => right.length - left.length || left[0].localeCompare(right[0]))
312
+ }
313
+
314
+ /**
315
+ * The edges a canonical peel leaves: repeatedly remove every node nothing depends on; what is
316
+ * left cannot be peeled, because every remaining node has something depending on it.
317
+ *
318
+ * The wording matters. This is not a *minimum* feedback arc set: deleting exactly these is not
319
+ * guaranteed to be the fewest edges that make the graph acyclic, and finding a minimum is
320
+ * NP-hard (Garey & Johnson, 1979). For a directed cycle of `n` nodes it leaves all `n` edges,
321
+ * because a peel has nowhere to start; breaking that cycle costs `n - 1` edges. So the report
322
+ * says "these edges cannot be peeled", never "delete these".
323
+ */
324
+ function unpeelableEdges(nodes: string[], edges: Array<[string, string]>): Array<[string, string]> {
325
+ // Keyed by edge position: the peel walks out-edges, and `edges[position]` is what tells it
326
+ // where an edge lands. Indexing by node id here silently peeled every edge of every cycle.
327
+ const outgoing = new Map<string, number[]>(nodes.map((id) => [id, []]))
328
+ const indegree = new Map<string, number>(nodes.map((id) => [id, 0]))
329
+ edges.forEach(([from, to], position) => {
330
+ outgoing.get(from)?.push(position)
331
+ indegree.set(to, (indegree.get(to) ?? 0) + 1)
332
+ })
333
+ const queue = nodes.filter((id) => indegree.get(id) === 0).sort()
334
+ const peeled = new Set<number>()
335
+ while (queue.length > 0) {
336
+ const id = queue.shift() as string
337
+ for (const position of outgoing.get(id) ?? []) {
338
+ peeled.add(position)
339
+ const next = edges[position][1]
340
+ indegree.set(next, (indegree.get(next) ?? 0) - 1)
341
+ if (indegree.get(next) === 0) queue.push(next)
342
+ }
343
+ }
344
+ return edges.filter((_, position) => !peeled.has(position))
345
+ }
346
+
347
+ /** Most nodes an exact minimum feedback arc set is attempted on. */
348
+ const EXACT_FAS_LIMIT = 14
349
+
350
+ /**
351
+ * The fewest edges whose removal makes the graph acyclic, exactly where that is affordable.
352
+ *
353
+ * A minimum feedback arc set is NP-hard (Garey & Johnson, 1979), so this is a decision, not a
354
+ * default: the edges of every cycle form the *core*, and only the core is searched. The search
355
+ * is the standard subset dynamic program - build an ordering node by node, paying one for each
356
+ * edge that points from a node not yet placed back to the one being placed, which is exactly
357
+ * the set of backward edges the ordering induces. It solves graphs up to `EXACT_FAS_LIMIT`
358
+ * nodes; past that the peel's leftover is reported instead, with `exact: false`, because a
359
+ * wrong number presented as a minimum would be worse than a stated upper bound.
360
+ */
361
+ function minimumFeedbackArcs(nodes: string[], edges: Array<[string, string]>): { edges: Array<[string, string]>; exact: boolean; note: string } {
362
+ const unpeelable = unpeelableEdges(nodes, edges)
363
+ if (unpeelable.length === 0) {
364
+ return { edges: [], exact: true, note: 'the graph is acyclic: no edge has to go' }
365
+ }
366
+ const core = [...new Set(unpeelable.flatMap(([from, to]) => [from, to]))].sort()
367
+ if (core.length > EXACT_FAS_LIMIT) {
368
+ return {
369
+ edges: unpeelable,
370
+ exact: false,
371
+ note: 'the cyclic part has ' + String(core.length) + ' nodes, past the ' + String(EXACT_FAS_LIMIT) + ' this search decides exactly (a minimum feedback arc set is NP-hard, Garey & Johnson 1979). These are the edges a canonical peel cannot remove: an upper bound, not a minimum.',
372
+ }
373
+ }
374
+ const index = new Map(core.map((id, position) => [id, position]))
375
+ const size = core.length
376
+ const total = 1 << size
377
+ const cost = new Int32Array(total)
378
+ const choice = new Int8Array(total)
379
+ const INFINITE = 0x3fffffff
380
+ cost.fill(INFINITE)
381
+ cost[0] = 0
382
+ for (let mask = 0; mask < total; mask += 1) {
383
+ const base = cost[mask]
384
+ if (base === INFINITE) continue
385
+ for (let candidate = 0; candidate < size; candidate += 1) {
386
+ const bit = 1 << candidate
387
+ if ((mask & bit) !== 0) continue
388
+ // Edges from a node not yet placed into the node being placed: they point backwards in
389
+ // the ordering being built, so each one is an edge the ordering would have to cut.
390
+ let backwards = 0
391
+ for (const [from, to] of edges) {
392
+ if (index.get(to) !== candidate) continue
393
+ const source = index.get(from)
394
+ if (source === undefined || source === candidate) continue
395
+ if ((mask & (1 << source)) === 0) backwards += 1
396
+ }
397
+ const next = mask | bit
398
+ if (base + backwards < cost[next]) {
399
+ cost[next] = base + backwards
400
+ choice[next] = candidate
401
+ }
402
+ }
403
+ }
404
+ const order: number[] = []
405
+ for (let mask = total - 1; mask !== 0;) {
406
+ const candidate = choice[mask]
407
+ order.push(candidate)
408
+ mask &= ~(1 << candidate)
409
+ }
410
+ order.reverse()
411
+ const placed = new Map(order.map((node, position) => [node, position]))
412
+ const cuts = edges.filter(([from, to]) => {
413
+ const left = index.get(from)
414
+ const right = index.get(to)
415
+ if (left === undefined || right === undefined) return false
416
+ // An edge inside the core that runs against the chosen order is one of the removed edges.
417
+ return (placed.get(right) as number) < (placed.get(left) as number)
418
+ })
419
+ return {
420
+ edges: cuts,
421
+ exact: true,
422
+ note: 'a minimum: no smaller set of edges makes this graph acyclic (found by subset search over the ' + String(size) + '-node cyclic part)',
423
+ }
424
+ }
425
+
426
+ function articulationAndBridges(nodes: string[], edges: Array<[string, string]>): { points: string[]; bridges: Array<[string, string]> } {
427
+ const adjacency = new Map<string, Array<[string, number]>>(nodes.map((id) => [id, []]))
428
+ edges.forEach(([from, to], position) => {
429
+ adjacency.get(from)?.push([to, position])
430
+ adjacency.get(to)?.push([from, position])
431
+ })
432
+ const discovery = new Map<string, number>()
433
+ const low = new Map<string, number>()
434
+ const points = new Set<string>()
435
+ const bridges: Array<[string, string]> = []
436
+ let counter = 0
437
+ const visit = (id: string, viaEdge: number, isRoot: boolean): void => {
438
+ discovery.set(id, counter)
439
+ low.set(id, counter)
440
+ counter += 1
441
+ let children = 0
442
+ for (const [next, position] of adjacency.get(id) ?? []) {
443
+ if (position === viaEdge) continue
444
+ if (!discovery.has(next)) {
445
+ children += 1
446
+ visit(next, position, false)
447
+ low.set(id, Math.min(low.get(id) as number, low.get(next) as number))
448
+ if (!isRoot && (low.get(next) as number) >= (discovery.get(id) as number)) points.add(id)
449
+ if ((low.get(next) as number) > (discovery.get(id) as number)) bridges.push(edges[position])
450
+ } else {
451
+ low.set(id, Math.min(low.get(id) as number, discovery.get(next) as number))
452
+ }
453
+ }
454
+ if (isRoot && children > 1) points.add(id)
455
+ }
456
+ for (const id of nodes) if (!discovery.has(id)) visit(id, -1, true)
457
+ return { points: [...points].sort(), bridges: bridges.sort((left, right) => left[0].localeCompare(right[0]) || left[1].localeCompare(right[1])) }
458
+ }
459
+
460
+ /** Longest-path rank over the condensation: the depth a node sits at once cycles are collapsed. */
461
+ function ranksOf(nodes: string[], edges: Array<[string, string]>): Record<string, number> {
462
+ const components = stronglyConnected(nodes, edges)
463
+ const componentOf = new Map<string, number>()
464
+ components.forEach((members, index) => members.forEach((id) => componentOf.set(id, index)))
465
+ const dag = new Map<number, Set<number>>(components.map((_, index) => [index, new Set()]))
466
+ const indegree = new Map<number, number>(components.map((_, index) => [index, 0]))
467
+ for (const [from, to] of edges) {
468
+ const left = componentOf.get(from) as number
469
+ const right = componentOf.get(to) as number
470
+ if (left === right || (dag.get(left) as Set<number>).has(right)) continue
471
+ ;(dag.get(left) as Set<number>).add(right)
472
+ indegree.set(right, (indegree.get(right) ?? 0) + 1)
473
+ }
474
+ const rank = new Map<number, number>(components.map((_, index) => [index, 0]))
475
+ const queue = [...indegree.entries()].filter(([, degree]) => degree === 0).map(([index]) => index).sort((left, right) => left - right)
476
+ while (queue.length > 0) {
477
+ const current = queue.shift() as number
478
+ for (const next of dag.get(current) ?? []) {
479
+ rank.set(next, Math.max(rank.get(next) ?? 0, (rank.get(current) ?? 0) + 1))
480
+ indegree.set(next, (indegree.get(next) ?? 0) - 1)
481
+ if (indegree.get(next) === 0) queue.push(next)
482
+ }
483
+ }
484
+ const out: Record<string, number> = {}
485
+ for (const id of [...nodes].sort()) out[id] = rank.get(componentOf.get(id) as number) ?? 0
486
+ return out
487
+ }
488
+
489
+ /** Every shape feature, computed once so every check reads the same numbers. */
490
+ export function designFeatures(model: DesignModelV1): GraphFeatures {
491
+ const nodes = model.architecture.nodes
492
+ const edges = model.architecture.edges
493
+ const adjacency = undirectedOf(nodes, edges)
494
+ const seen = new Set<string>()
495
+ let components = 0
496
+ for (const id of nodes) {
497
+ if (seen.has(id)) continue
498
+ components += 1
499
+ const queue = [id]
500
+ while (queue.length > 0) {
501
+ const current = queue.pop() as string
502
+ if (seen.has(current)) continue
503
+ seen.add(current)
504
+ for (const next of adjacency.get(current) ?? []) queue.push(next)
505
+ }
506
+ }
507
+ const directed = adjacencyOf(nodes, edges)
508
+ const fanOut: Record<string, number> = {}
509
+ const fanIn: Record<string, number> = {}
510
+ for (const id of nodes) fanOut[id] = new Set(directed.get(id) ?? []).size
511
+ for (const id of nodes) fanIn[id] = 0
512
+ for (const [, to] of edges) fanIn[to] = (fanIn[to] ?? 0) + 1
513
+ const mutualPairs: Array<[string, string]> = []
514
+ for (const [from, to] of edges) {
515
+ if (from < to && edges.some(([otherFrom, otherTo]) => otherFrom === to && otherTo === from)) mutualPairs.push([from, to])
516
+ }
517
+ const scc = stronglyConnected(nodes, edges)
518
+ const largest = scc[0] ?? []
519
+ const { points, bridges } = articulationAndBridges(nodes, edges)
520
+ return {
521
+ nodes: nodes.length,
522
+ edges: edges.length,
523
+ components,
524
+ cyclomatic: edges.length - nodes.length + 2 * components,
525
+ density: nodes.length < 2 ? 0 : Number((edges.length / (nodes.length * (nodes.length - 1))).toFixed(6)),
526
+ largestCycle: largest.length > 1 ? largest : [],
527
+ feedbackArcs: minimumFeedbackArcs(nodes, edges),
528
+ articulationPoints: points,
529
+ bridges,
530
+ ranks: ranksOf(nodes, edges),
531
+ fanOut,
532
+ fanIn,
533
+ mutualPairs: mutualPairs.sort((left, right) => left[0].localeCompare(right[0]) || left[1].localeCompare(right[1])),
534
+ }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Checks
539
+ // ---------------------------------------------------------------------------
540
+
541
+ interface CheckResult {
542
+ id: string
543
+ name: string
544
+ status: 'pass' | 'fail' | 'skip'
545
+ detail: string
546
+ findings: Finding[]
547
+ }
548
+
549
+ function check(id: string, name: string, findings: Finding[], detail: string): CheckResult {
550
+ const errors = findings.filter((finding) => finding.severity === 'error').length
551
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
552
+ return {
553
+ id,
554
+ name,
555
+ status: errors + warnings === 0 ? 'pass' : 'fail',
556
+ detail: detail !== '' ? detail : (errors + warnings === 0 ? 'nothing to report' : String(errors) + ' error(s), ' + String(warnings) + ' warning(s)'),
557
+ findings,
558
+ }
559
+ }
560
+
561
+ /**
562
+ * A check that had nothing to hold the design to.
563
+ *
564
+ * This is deliberately not `pass`. A design that declares no layers cannot violate a layer
565
+ * order, and reporting that as a pass would read as "this design's layering was verified".
566
+ * The distinction reaches the reader through `facts.checksSkipped` and `nextSteps`.
567
+ */
568
+ function skip(id: string, name: string, why: string): CheckResult {
569
+ return { id, name, status: 'skip', detail: 'skipped: ' + why, findings: [] }
570
+ }
571
+
572
+ const typesByName = (model: DesignModelV1): Map<string, DesignType> => new Map((model.types ?? []).map((entry) => [entry.name, entry]))
573
+
574
+ /**
575
+ * DES12: a name a type relation points at, resolved.
576
+ *
577
+ * `architecture.edges` had both endpoints resolved and `requiredEdges` had its edge checked, but
578
+ * every type relation was taken on trust: `extends: 'Ghost'`, a type extending itself, a cycle of
579
+ * extends, `implements: ['Ghost']`, an interface implementing itself, `dependsOn: ['Ghost']`,
580
+ * `uses: [{type: 'Ghost'}]` and a layer member that is not an architecture node all returned
581
+ * verdict=pass with no findings. A reader had no way to tell a design that satisfies its
582
+ * declarations from one whose declarations point at nothing.
583
+ *
584
+ * DES7 already skipped a base it could not find (`if (base === undefined) continue`) and then
585
+ * reported "no declared contract is strengthened or weakened" - the same shape of empty all-clear
586
+ * the robustness audit found in the other two engines.
587
+ */
588
+ function DES12_typeRelations(model: DesignModelV1): CheckResult {
589
+ const findings: Finding[] = []
590
+ const types = model.types ?? []
591
+ const byName = typesByName(model)
592
+ const architectureNodes = new Set(model.architecture.nodes)
593
+
594
+ const missingType = (type: DesignType, field: string, name: string): void => {
595
+ findings.push({
596
+ code: 'DES12_UNKNOWN_TYPE',
597
+ severity: 'error',
598
+ message: type.name + '.' + field + ' names a type the model does not declare: ' + JSON.stringify(name) + '.',
599
+ detail: 'A relation that resolves to nothing is not checked, so the report would say the design keeps a contract it never read.',
600
+ evidence: { type: type.name, field, name },
601
+ })
602
+ }
603
+
604
+ for (const type of types) {
605
+ if (type.extends !== undefined) {
606
+ if (type.extends === type.name) {
607
+ findings.push({
608
+ code: 'DES12_TYPE_CYCLE',
609
+ severity: 'error',
610
+ message: type.name + ' extends itself, so it can never be the base of anything.',
611
+ evidence: { type: type.name, cycle: [type.name, type.name] },
612
+ })
613
+ } else if (!byName.has(type.extends)) {
614
+ missingType(type, 'extends', type.extends)
615
+ }
616
+ }
617
+ for (const implemented of type.implements ?? []) {
618
+ if (implemented === type.name) {
619
+ findings.push({
620
+ code: 'DES12_TYPE_CYCLE',
621
+ severity: 'error',
622
+ message: type.name + ' implements itself, which is not a relation.',
623
+ evidence: { type: type.name, cycle: [type.name, type.name] },
624
+ })
625
+ } else if (!byName.has(implemented)) {
626
+ missingType(type, 'implements', implemented)
627
+ }
628
+ }
629
+ for (const dependency of type.dependsOn ?? []) {
630
+ // A dependency may name anything in the architecture, not only a declared type: the
631
+ // architecture is the graph the shape features are computed from, and a node without a type
632
+ // is normal.
633
+ if (!byName.has(dependency) && !architectureNodes.has(dependency)) missingType(type, 'dependsOn', dependency)
634
+ }
635
+ for (const use of type.uses ?? []) {
636
+ if (!byName.has(use.type) && !architectureNodes.has(use.type)) missingType(type, 'uses', use.type)
637
+ }
638
+ }
639
+
640
+ // A cycle through `extends`, not only a self-loop: a chain that closes on itself has no
641
+ // most-general type, so the substitutability question has no answer rather than a yes.
642
+ const chain = (start: DesignType): string[] | undefined => {
643
+ const seen: string[] = []
644
+ let current: DesignType | undefined = start
645
+ while (current !== undefined && current.extends !== undefined) {
646
+ if (seen.includes(current.name)) return [...seen, current.name]
647
+ seen.push(current.name)
648
+ current = byName.get(current.extends)
649
+ }
650
+ return undefined
651
+ }
652
+ const reported = new Set<string>()
653
+ for (const type of types) {
654
+ const cycle = chain(type)
655
+ if (cycle === undefined) continue
656
+ const key = [...cycle].sort().join(',')
657
+ if (reported.has(key)) continue
658
+ reported.add(key)
659
+ findings.push({
660
+ code: 'DES12_TYPE_CYCLE',
661
+ severity: 'error',
662
+ message: 'the inheritance chain ' + cycle.join(' -> ') + ' closes on itself, so none of these types has a most-general base.',
663
+ detail: 'Substitutability is decided over this chain: a cycle means the question has no answer, not that the answer is yes.',
664
+ evidence: { cycle },
665
+ })
666
+ }
667
+
668
+ // Every declared layer and boundary member must be a node of the architecture, or the layer
669
+ // order and the boundary rules are computed over names that do not exist.
670
+ const declare = model.declare ?? {}
671
+ for (const [kind, groups] of [['layers', declare.layers ?? []], ['boundaries', declare.boundaries ?? []]] as const) {
672
+ for (const group of groups) {
673
+ for (const member of group.members) {
674
+ if (architectureNodes.has(member)) continue
675
+ findings.push({
676
+ code: 'DES12_UNKNOWN_NODE',
677
+ severity: 'error',
678
+ message: group.name + ' (declared under ' + kind + ') lists ' + JSON.stringify(member) + ', which the architecture does not declare.',
679
+ detail: 'A member that is not a node cannot break a layer order or a boundary rule, so listing it hides the member that should have been there.',
680
+ evidence: { group: group.name, kind, member },
681
+ })
682
+ }
683
+ }
684
+ }
685
+ // A rule whose source names no declared group judges nothing, and a required edge with an
686
+ // unknown endpoint could never have been satisfied.
687
+ const groupNames = new Set([
688
+ ...(declare.layers ?? []).map((group) => group.name),
689
+ ...(declare.boundaries ?? []).map((group) => group.name),
690
+ ])
691
+ for (const rule of declare.allowedEdges ?? []) {
692
+ if (rule.source === '*' || groupNames.has(rule.source)) continue
693
+ findings.push({
694
+ code: 'DES12_UNKNOWN_NODE',
695
+ severity: 'error',
696
+ message: 'allowedEdges rule ' + rule.id + ' has source ' + JSON.stringify(rule.source) + ', which names no declared layer or boundary.',
697
+ detail: 'A rule with no source judges no edge, so its allow and deny lists are never consulted.',
698
+ evidence: { rule: rule.id, source: rule.source },
699
+ })
700
+ }
701
+ for (const requirement of declare.requiredEdges ?? []) {
702
+ if (architectureNodes.has(requirement.from) && architectureNodes.has(requirement.to)) continue
703
+ const unknown = [requirement.from, requirement.to].filter((node) => !architectureNodes.has(node))
704
+ findings.push({
705
+ code: 'DES12_UNKNOWN_NODE',
706
+ severity: 'error',
707
+ message: 'requiredEdges entry ' + requirement.id + ' names ' + unknown.map((node) => JSON.stringify(node)).join(' and ') + ', which the architecture does not declare.',
708
+ detail: 'DES6 can only report that the edge is absent; an endpoint that is not a node means the requirement was never satisfiable.',
709
+ evidence: { requirement: requirement.id, from: requirement.from, to: requirement.to, unknown },
710
+ })
711
+ }
712
+
713
+ return check('DES12', 'Type Relations', findings, findings.length === 0 ? 'every type relation and declared member resolves' : 'Unresolved relations: ' + String(findings.length))
714
+ }
715
+
716
+ /** DES1: a cycle in a design, named with the canonical peeling that would break it. */
717
+ function DES1_cycles(model: DesignModelV1, features: GraphFeatures): CheckResult {
718
+ const findings: Finding[] = []
719
+ if (features.feedbackArcs.edges.length > 0) {
720
+ const members = features.largestCycle
721
+ findings.push({
722
+ code: 'DES1_CYCLE',
723
+ severity: 'error',
724
+ message: 'the dependency graph is cyclic: ' + (features.feedbackArcs.exact ? 'the fewest edges that make it acyclic is ' : 'at least ') + String(features.feedbackArcs.edges.length) + ', and the largest mutual-dependency group has ' + String(members.length) + ' members.',
725
+ detail: (features.feedbackArcs.exact
726
+ ? 'A minimum: these ' + String(features.feedbackArcs.edges.length) + ' edge(s) are the fewest whose removal makes the graph acyclic. '
727
+ : 'An upper bound, not a minimum: these are the edges a canonical peel cannot remove. ') + features.feedbackArcs.edges.map(([from, to]) => from + ' -> ' + to).join(', ') + '. ' + features.feedbackArcs.note + (members.length > 1 ? ' Mutually reachable: ' + members.join(', ') + '.' : ''),
728
+ evidence: { feedbackArcs: features.feedbackArcs.edges, feedbackArcsExact: features.feedbackArcs.exact, largestCycle: members, ranks: features.ranks },
729
+ })
730
+ }
731
+ return check('DES1', 'Acyclic Dependency', findings, findings.length === 0 ? 'the graph is acyclic' : 'cycles: ' + String(features.feedbackArcs.edges.length))
732
+ }
733
+
734
+ /** DES2: the edges a declared layering forbids — upward, or skipping a band. */
735
+ function DES2_layerOrder(model: DesignModelV1, features: GraphFeatures): CheckResult {
736
+ const layers = model.declare?.layers ?? []
737
+ if (layers.length === 0) return skip('DES2', 'Layer Order', 'the design declares no layers')
738
+ const layerOf = new Map<string, number>()
739
+ layers.forEach((layer, index) => layer.members.forEach((member) => layerOf.set(member, index)))
740
+ const findings: Finding[] = []
741
+ for (const [from, to] of model.architecture.edges) {
742
+ const left = layerOf.get(from)
743
+ const right = layerOf.get(to)
744
+ if (left === undefined || right === undefined) continue
745
+ if (left === right) continue
746
+ if (right > left + 1) {
747
+ findings.push({
748
+ code: 'DES2_LAYER_ORDER',
749
+ severity: 'error',
750
+ message: from + ' (layer ' + layers[left].name + ') depends on ' + to + ' (layer ' + layers[right].name + '), skipping ' + layers.slice(left + 1, right).map((layer) => layer.name).join(', ') + '.',
751
+ detail: 'A declared layering says which bands may depend on which; an edge that jumps a band is not a style question, it is a dependency the declaration does not admit.',
752
+ evidence: { from, to, fromLayer: layers[left].name, toLayer: layers[right].name, skipped: layers.slice(left + 1, right).map((layer) => layer.name) },
753
+ })
754
+ } else if (right < left) {
755
+ findings.push({
756
+ code: 'DES2_LAYER_ORDER',
757
+ severity: 'error',
758
+ message: from + ' (layer ' + layers[left].name + ') depends upward on ' + to + ' (layer ' + layers[right].name + ').',
759
+ detail: 'The layers are declared in order, so a later band depending on an earlier one inverts the declaration.',
760
+ evidence: { from, to, fromLayer: layers[left].name, toLayer: layers[right].name },
761
+ })
762
+ }
763
+ }
764
+ // Two layers claiming the same member is a declaration defect: no edge can satisfy both.
765
+ const claimed = new Map<string, string>()
766
+ for (const layer of layers) {
767
+ for (const member of layer.members) {
768
+ const previous = claimed.get(member)
769
+ if (previous !== undefined && previous !== layer.name) {
770
+ findings.push({
771
+ code: 'DES2_LAYER_ORDER',
772
+ severity: 'error',
773
+ message: member + ' is claimed by two layers (' + previous + ' and ' + layer.name + '), so the ordering says two things at once.',
774
+ evidence: { member, layers: [previous, layer.name] },
775
+ })
776
+ }
777
+ claimed.set(member, layer.name)
778
+ }
779
+ }
780
+ void features
781
+ return check('DES2', 'Layer Order', findings, findings.length === 0 ? 'every edge follows the declared order' : String(findings.length) + ' edge(s) against the declaration')
782
+ }
783
+
784
+ /** DES3: an edge that crosses a declared boundary the rules do not allow. */
785
+ function DES3_boundaries(model: DesignModelV1): CheckResult {
786
+ const boundaries = model.declare?.boundaries ?? []
787
+ const rules = model.declare?.allowedEdges ?? []
788
+ if (boundaries.length === 0) return skip('DES3', 'Boundary Leak', 'the design declares no boundaries')
789
+ const boundaryOf = new Map<string, string>()
790
+ for (const boundary of boundaries) for (const member of boundary.members) boundaryOf.set(member, boundary.name)
791
+ const findings: Finding[] = []
792
+ for (const [from, to] of model.architecture.edges) {
793
+ const left = boundaryOf.get(from)
794
+ const right = boundaryOf.get(to)
795
+ if (left === undefined || right === undefined || left === right) continue
796
+ const permitted = rules.some((rule) => {
797
+ const sourceMatches = rule.source === left || rule.source === '*'
798
+ const denied = (rule.deny ?? []).some((target) => target === right || target === '*')
799
+ if (denied) return false
800
+ const allowed = rule.allow ?? []
801
+ return allowed.length === 0 ? sourceMatches : allowed.some((target) => target === right || target === '*')
802
+ })
803
+ if (permitted) continue
804
+ findings.push({
805
+ code: 'DES3_BOUNDARY_LEAK',
806
+ severity: 'error',
807
+ message: from + ' (' + left + ') depends on ' + to + ' (' + right + '), and no declared rule allows ' + left + ' -> ' + right + '.',
808
+ detail: 'Boundaries partition responsibility; an edge across one that the declaration does not admit means the two are not as separate as the model says.',
809
+ evidence: { from, to, fromBoundary: left, toBoundary: right },
810
+ })
811
+ }
812
+ return check('DES3', 'Boundary Leak', findings, findings.length === 0 ? 'no edge crosses a boundary without a rule allowing it' : String(findings.length) + ' crossing(s) not allowed')
813
+ }
814
+
815
+ /** DES4: a node whose removal disconnects the design — a single point of failure, as a fact. */
816
+ function DES4_loadBearing(model: DesignModelV1, features: GraphFeatures): CheckResult {
817
+ const findings: Finding[] = []
818
+ for (const id of features.articulationPoints) {
819
+ findings.push({
820
+ code: 'DES4_LOAD_BEARING_ELEMENT',
821
+ severity: 'warning',
822
+ message: 'removing ' + id + ' disconnects the dependency graph: everything on one side reaches the other only through it. ' + id + ' is an articulation point.',
823
+ detail: 'This is a fact about the graph, not a verdict about the design. A gateway layer is supposed to be such a node; the point is that it was not stated.',
824
+ evidence: { node: id, kind: 'articulation point' },
825
+ })
826
+ }
827
+ for (const [from, to] of features.bridges) {
828
+ findings.push({
829
+ code: 'DES4_LOAD_BEARING_ELEMENT',
830
+ severity: 'warning',
831
+ message: 'the edge ' + from + ' -> ' + to + ' is a bridge: it is the only dependency joining its two sides.',
832
+ evidence: { from, to, kind: 'bridge' },
833
+ })
834
+ }
835
+ return check('DES4', 'Load-Bearing Elements', findings, findings.length === 0 ? 'no articulation point and no bridge' : String(findings.length) + ' element(s) the graph depends on')
836
+ }
837
+
838
+ /** DES5: something the design never placed — no layer, no boundary, no rule mentions it. */
839
+ function DES5_undeclared(model: DesignModelV1): CheckResult {
840
+ const layers = model.declare?.layers ?? []
841
+ const boundaries = model.declare?.boundaries ?? []
842
+ if (layers.length === 0 && boundaries.length === 0) return skip('DES5', 'Placement', 'the design declares no layers and no boundaries')
843
+ const placed = new Set<string>()
844
+ for (const layer of layers) for (const member of layer.members) placed.add(member)
845
+ for (const boundary of boundaries) for (const member of boundary.members) placed.add(member)
846
+ const findings: Finding[] = []
847
+ for (const id of model.architecture.nodes) {
848
+ if (placed.has(id)) continue
849
+ findings.push({
850
+ code: 'DES5_UNDECLARED_NODE',
851
+ severity: 'error',
852
+ message: id + ' is in the architecture but in no declared layer and no declared boundary, so the design says nothing about where it belongs.',
853
+ evidence: { node: id, layers: layers.map((layer) => layer.name), boundaries: boundaries.map((boundary) => boundary.name) },
854
+ })
855
+ }
856
+ return check('DES5', 'Placement', findings, findings.length === 0 ? 'every node is placed' : String(findings.length) + ' node(s) unplaced')
857
+ }
858
+
859
+ /** DES6: a dependency the design says must exist, and does not. */
860
+ function DES6_required(model: DesignModelV1): CheckResult {
861
+ const required = model.declare?.requiredEdges ?? []
862
+ if (required.length === 0) return skip('DES6', 'Required Dependencies', 'the design declares no required edges')
863
+ const present = new Set(model.architecture.edges.map(([from, to]) => from + '\u0000' + to))
864
+ const findings: Finding[] = []
865
+ for (const requirement of required) {
866
+ if (present.has(requirement.from + '\u0000' + requirement.to)) continue
867
+ findings.push({
868
+ code: 'DES6_MISSING_REQUIRED_EDGE',
869
+ severity: 'error',
870
+ message: requirement.id + ': the design requires ' + requirement.from + ' -> ' + requirement.to + ', and the architecture does not have it.',
871
+ evidence: { id: requirement.id, from: requirement.from, to: requirement.to },
872
+ })
873
+ }
874
+ return check('DES6', 'Required Dependencies', findings, findings.length === 0 ? 'every required edge is present' : String(findings.length) + ' missing')
875
+ }
876
+
877
+ /**
878
+ * DES7: substitutability, where the model states the contract.
879
+ *
880
+ * The decidable fragment, in the vocabulary this model has: a subtype may not refuse an event
881
+ * its supertype accepts (that is a strengthened precondition), and a signature may only stay
882
+ * or widen in parameters and stay or narrow in results. Liskov & Wing, *A Behavioral Notion
883
+ * of Subtyping*, TOPLAS 1994.
884
+ */
885
+ function DES7_substitution(model: DesignModelV1): CheckResult {
886
+ const types = model.types ?? []
887
+ const byName = typesByName(model)
888
+ const withBehaviour = types.filter((entry) => entry.transitions !== undefined || entry.forbids !== undefined)
889
+ if (withBehaviour.length === 0) return skip('DES7', 'Substitutability', 'no type declares transitions or forbids, so no behavioural contract is stated')
890
+ const findings: Finding[] = []
891
+ for (const entry of types) {
892
+ if (entry.extends === undefined) continue
893
+ const base = byName.get(entry.extends)
894
+ if (base === undefined) continue
895
+ // Behaviour: the supertype accepts an event the subtype refuses.
896
+ const baseEvents = new Set((base.transitions ?? []).map((transition) => transition.event))
897
+ const baseForbidden = new Set((base.forbids ?? []).map((rule) => rule.event))
898
+ for (const rule of entry.forbids ?? []) {
899
+ if (baseForbidden.has(rule.event)) continue
900
+ if (!baseEvents.has(rule.event)) continue
901
+ findings.push({
902
+ code: 'DES7_LSP_CONTRACT',
903
+ severity: 'error',
904
+ message: entry.name + ' refuses ' + rule.event + ', which its supertype ' + base.name + ' declares it handles. A subtype may not strengthen a precondition.',
905
+ detail: 'Liskov & Wing (1994): a subtype must accept everything its supertype accepts. Refusing an event the supertype handles means the subtype cannot stand in for it.',
906
+ evidence: { subtype: entry.name, base: base.name, event: rule.event, kind: 'forbidden event' },
907
+ })
908
+ }
909
+ // A guard the subtype states in words and the supertype does not: a strengthened
910
+ // precondition, only when the text differs and both exist.
911
+ for (const transition of entry.transitions ?? []) {
912
+ const baseTransition = (base.transitions ?? []).find((candidate) => candidate.event === transition.event)
913
+ if (baseTransition?.guard === undefined || transition.guard === undefined) continue
914
+ if (baseTransition.guard === transition.guard) continue
915
+ findings.push({
916
+ code: 'DES7_LSP_CONTRACT',
917
+ severity: 'warning',
918
+ message: entry.name + ' guards ' + transition.event + ' with ' + JSON.stringify(transition.guard) + ' where ' + base.name + ' states ' + JSON.stringify(baseTransition.guard) + '.',
919
+ detail: 'The guards are compared as text, not parsed: a differing guard on an inherited event is reported for a reader to judge, because whether it strengthens or weakens the precondition is not decidable from two strings.',
920
+ evidence: { subtype: entry.name, base: base.name, event: transition.event, subGuard: transition.guard, baseGuard: baseTransition.guard, kind: 'guard text differs' },
921
+ })
922
+ }
923
+ // Signature: parameters may widen, results may narrow. Both need the model to say what
924
+ // the supertype relationship is, so only an explicit widening/narrowing declares it.
925
+ for (const operation of entry.operations ?? []) {
926
+ const inherited = (base.operations ?? []).find((candidate) => candidate.name === operation.name)
927
+ if (inherited === undefined) continue
928
+ const baseParameters = inherited.parameters ?? []
929
+ const parameters = operation.parameters ?? []
930
+ if (parameters.length !== baseParameters.length) {
931
+ findings.push({
932
+ code: 'DES7_LSP_CONTRACT',
933
+ severity: 'error',
934
+ message: entry.name + '.' + operation.name + ' takes ' + String(parameters.length) + ' parameter(s) where ' + base.name + '.' + operation.name + ' takes ' + String(baseParameters.length) + '.',
935
+ detail: 'A subtype operation must accept at least what the supertype operation accepts.',
936
+ evidence: { subtype: entry.name, base: base.name, operation: operation.name, kind: 'arity changed' },
937
+ })
938
+ }
939
+ }
940
+ }
941
+ return check('DES7', 'Substitutability', findings, findings.length === 0 ? 'no declared contract is strengthened or weakened' : String(findings.length) + ' contract(s) against the declaration')
942
+ }
943
+
944
+ /**
945
+ * DES8: an interface carrying operations no client calls.
946
+ *
947
+ * Martin (2002); and the empirical line on interface design anomalies, where unused methods
948
+ * and fat interfaces are the measured defect. The check needs `uses`, which is the model's
949
+ * statement of what each client actually calls.
950
+ */
951
+ function DES8_interfaceWidth(model: DesignModelV1): CheckResult {
952
+ const types = model.types ?? []
953
+ const interfaces = types.filter((entry) => entry.stereotype === 'interface' && (entry.operations ?? []).length > 0)
954
+ if (interfaces.length === 0) return skip('DES8', 'Interface Width', 'the design declares no interface with operations')
955
+ const called = new Map<string, Set<string>>()
956
+ for (const client of types) {
957
+ for (const dependency of client.uses ?? []) {
958
+ const set = called.get(dependency.type) ?? new Set<string>()
959
+ for (const operation of dependency.operations) set.add(operation)
960
+ called.set(dependency.type, set)
961
+ }
962
+ for (const implemented of client.implements ?? []) {
963
+ // An implementer must provide every operation, so implementing does not tell us which
964
+ // ones are needed - only calling does.
965
+ void implemented
966
+ }
967
+ }
968
+ const findings: Finding[] = []
969
+ for (const entry of interfaces) {
970
+ const used = called.get(entry.name) ?? new Set<string>()
971
+ const unused = (entry.operations ?? []).map((operation) => operation.name).filter((name) => !used.has(name))
972
+ if (unused.length === 0) continue
973
+ findings.push({
974
+ code: 'DES8_ISP_UNUSED_OPERATION',
975
+ severity: 'warning',
976
+ message: entry.name + ' declares ' + String(unused.length) + ' operation(s) no client in this model calls: ' + unused.join(', ') + '.',
977
+ detail: 'A client that must know about operations it never calls is coupled to them: that is the fat-interface signal (Martin, 2002; the interface-anomaly literature measures unused methods as the defect). This is a signal, not a verdict - a published interface may keep operations for callers outside this model.',
978
+ evidence: { interface: entry.name, unused, called: [...used].sort() },
979
+ })
980
+ }
981
+ return check('DES8', 'Interface Width', findings, findings.length === 0 ? 'every declared operation is called by some client' : String(findings.length) + ' interface(s) wider than their callers')
982
+ }
983
+
984
+ /** DES9: an interface whose clients split into groups sharing no operation. */
985
+ function DES9_interfaceSplit(model: DesignModelV1): CheckResult {
986
+ const types = model.types ?? []
987
+ const interfaces = new Set(types.filter((entry) => entry.stereotype === 'interface').map((entry) => entry.name))
988
+ if (interfaces.size === 0) return skip('DES9', 'Interface Cohesion', 'the design declares no interface')
989
+ const groups = new Map<string, Map<string, string[]>>()
990
+ for (const client of types) {
991
+ for (const dependency of client.uses ?? []) {
992
+ if (!interfaces.has(dependency.type)) continue
993
+ const key = [...dependency.operations].sort().join(',')
994
+ if (key === '') continue
995
+ const byInterface = groups.get(dependency.type) ?? new Map<string, string[]>()
996
+ byInterface.set(key, [...(byInterface.get(key) ?? []), client.name])
997
+ groups.set(dependency.type, byInterface)
998
+ }
999
+ }
1000
+ const findings: Finding[] = []
1001
+ for (const [name, byInterface] of groups) {
1002
+ if (byInterface.size < 2) continue
1003
+ const sets = [...byInterface.keys()].map((key) => new Set(key.split(',')))
1004
+ let overlap = false
1005
+ for (let left = 0; left < sets.length && !overlap; left += 1) {
1006
+ for (let right = left + 1; right < sets.length; right += 1) {
1007
+ for (const operation of sets[left]) if (sets[right].has(operation)) { overlap = true; break }
1008
+ }
1009
+ }
1010
+ if (overlap) continue
1011
+ findings.push({
1012
+ code: 'DES9_ISP_DISJOINT_CLIENTS',
1013
+ severity: 'error',
1014
+ message: name + ' is called by client groups that share no operation: ' + [...byInterface.entries()].map(([operations, clients]) => clients.join('/') + ' call ' + operations).join('; ') + '.',
1015
+ detail: 'Two sets of clients with no operation in common are two interfaces wearing one name: neither group can be changed without touching the other. Martin (2002) states this as the interface segregation principle; here it is decided over the calls the model declares.',
1016
+ evidence: { interface: name, groups: [...byInterface.entries()].map(([operations, clients]) => ({ operations: operations.split(','), clients })) },
1017
+ })
1018
+ }
1019
+ return check('DES9', 'Interface Cohesion', findings, findings.length === 0 ? 'no interface is called by disjoint client groups' : String(findings.length) + ' split interface(s)')
1020
+ }
1021
+
1022
+ /**
1023
+ * DES10: a declared responsibility that does not match the dependencies the model gives it.
1024
+ *
1025
+ * The comparison is only as good as the tags: this says the tags and the dependency list
1026
+ * disagree - a dependency no responsibility owns, or a responsibility owning nothing - and it
1027
+ * cannot say whether a responsibility is the right one, or named honestly.
1028
+ */
1029
+ function DES10_responsibilities(model: DesignModelV1): CheckResult {
1030
+ const types = (model.types ?? []).filter((entry) => (entry.responsibilities ?? []).length > 0)
1031
+ if (types.length === 0) return skip('DES10', 'Responsibility Boundaries', 'no type declares responsibilities')
1032
+ const findings: Finding[] = []
1033
+ for (const entry of types) {
1034
+ const declared = entry.responsibilities ?? []
1035
+ const dependencies = [...new Set(entry.dependsOn ?? [])]
1036
+ const owners = new Map<string, string[]>()
1037
+ for (const responsibility of declared) {
1038
+ for (const dependency of responsibility.uses) {
1039
+ owners.set(dependency, [...(owners.get(dependency) ?? []), responsibility.name])
1040
+ }
1041
+ }
1042
+ const unclaimed = dependencies.filter((dependency) => !owners.has(dependency))
1043
+ if (unclaimed.length > 0) {
1044
+ findings.push({
1045
+ code: 'DES10_RESPONSIBILITY_GAP',
1046
+ severity: 'warning',
1047
+ message: entry.name + ' depends on ' + unclaimed.join(', ') + ', which no declared responsibility owns.',
1048
+ detail: 'The responsibilities and the dependency list disagree. Either the dependency belongs to a responsibility that was not declared, or the tag list is incomplete - the model cannot say which, so this is reported as the disagreement it is.',
1049
+ evidence: { type: entry.name, unclaimed, responsibilities: declared.map((responsibility) => responsibility.name) },
1050
+ })
1051
+ }
1052
+ const empty = declared.filter((responsibility) => responsibility.uses.length === 0)
1053
+ if (empty.length > 0) {
1054
+ findings.push({
1055
+ code: 'DES10_RESPONSIBILITY_GAP',
1056
+ severity: 'warning',
1057
+ message: entry.name + ' declares responsibilities that own no dependency: ' + empty.map((responsibility) => responsibility.name).join(', ') + '.',
1058
+ detail: 'A responsibility owning nothing is either not implemented here or a rename that was never finished.',
1059
+ evidence: { type: entry.name, empty: empty.map((responsibility) => responsibility.name) },
1060
+ })
1061
+ }
1062
+ const shared = [...owners.entries()].filter(([, names]) => names.length > 1)
1063
+ for (const [dependency, names] of shared) {
1064
+ findings.push({
1065
+ code: 'DES10_RESPONSIBILITY_GAP',
1066
+ severity: 'warning',
1067
+ message: entry.name + ': ' + dependency + ' is claimed by ' + String(names.length) + ' responsibilities (' + names.join(', ') + ').',
1068
+ detail: 'Two responsibilities reaching the same dependency are not separable: changing that dependency touches both.',
1069
+ evidence: { type: entry.name, dependency, responsibilities: names },
1070
+ })
1071
+ }
1072
+ }
1073
+ return check('DES10', 'Responsibility Boundaries', findings, findings.length === 0 ? 'every declared responsibility matches the dependencies' : String(findings.length) + ' mismatch(es)')
1074
+ }
1075
+
1076
+ /** DES11: an abstraction that depends on a concrete type. */
1077
+ function DES11_inversion(model: DesignModelV1): CheckResult {
1078
+ const types = model.types ?? []
1079
+ const abstractions = new Set(types.filter((entry) => entry.stereotype === 'interface' || entry.stereotype === 'abstract-class' || entry.stereotype === 'abstract-component').map((entry) => entry.name))
1080
+ if (abstractions.size === 0) return skip('DES11', 'Dependency Inversion', 'the design declares no interface and no abstract type')
1081
+ const concretes = new Map<string, DesignType>(types.filter((entry) => entry.stereotype === 'class' || entry.stereotype === 'component').map((entry) => [entry.name, entry]))
1082
+ const findings: Finding[] = []
1083
+ for (const entry of types) {
1084
+ if (!abstractions.has(entry.name)) continue
1085
+ for (const dependency of entry.dependsOn ?? []) {
1086
+ if (!concretes.has(dependency)) continue
1087
+ findings.push({
1088
+ code: 'DES11_CONCRETE_DEPENDENCY',
1089
+ severity: 'warning',
1090
+ message: entry.name + ' is declared an abstraction and depends on the concrete type ' + dependency + '.',
1091
+ detail: 'Dependency inversion (Martin, 2002) asks the abstraction to depend on nothing concrete, so the concrete type can be replaced. This is a signal about the declared stereotype and the declared dependencies; whether the dependency is real is the extractor\'s question.',
1092
+ evidence: { abstraction: entry.name, concrete: dependency, stereotype: entry.stereotype },
1093
+ })
1094
+ }
1095
+ }
1096
+ return check('DES11', 'Dependency Inversion', findings, findings.length === 0 ? 'no abstraction depends on a declared concrete type' : String(findings.length) + ' concrete dependency(ies)')
1097
+ }
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Report
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ export type DesignPrincipleLevel = 'violation' | 'signal' | 'fact'
1104
+
1105
+ /** Which level each code belongs to, so a reader never has to guess. */
1106
+ export const DESIGN_CODE_LEVEL: Record<string, DesignPrincipleLevel> = {
1107
+ DES1_CYCLE: 'violation',
1108
+ DES2_LAYER_ORDER: 'violation',
1109
+ DES3_BOUNDARY_LEAK: 'violation',
1110
+ DES4_LOAD_BEARING_ELEMENT: 'signal',
1111
+ DES5_UNDECLARED_NODE: 'violation',
1112
+ DES6_MISSING_REQUIRED_EDGE: 'violation',
1113
+ DES7_LSP_CONTRACT: 'violation',
1114
+ DES8_ISP_UNUSED_OPERATION: 'signal',
1115
+ DES9_ISP_DISJOINT_CLIENTS: 'violation',
1116
+ DES10_RESPONSIBILITY_GAP: 'signal',
1117
+ DES11_CONCRETE_DEPENDENCY: 'signal',
1118
+ DES12_UNKNOWN_TYPE: 'violation',
1119
+ DES12_TYPE_CYCLE: 'violation',
1120
+ DES12_UNKNOWN_NODE: 'violation',
1121
+ }
1122
+
1123
+ export interface DesignReport {
1124
+ ok: boolean
1125
+ ran: boolean
1126
+ verdict: Verdict
1127
+ verdictReason: string
1128
+ schema: typeof REPORT_SCHEMAS.design
1129
+ artifact?: string
1130
+ /** The check-by-check results, in a fixed order. */
1131
+ checks: CheckResult[]
1132
+ /** Violations of something the design declared. These decide `verdict`. */
1133
+ violations: Finding[]
1134
+ /** Structural facts a reader should see, reported without claiming they are wrong. */
1135
+ signals: Finding[]
1136
+ /** Counts and computed structure. */
1137
+ facts: Record<string, unknown>
1138
+ summary: { errors: number; warnings: number; info: number; checksRun: number; nodes: number; edges: number }
1139
+ hashes: { modelHash: string }
1140
+ modelHash: string
1141
+ nextSteps: string[]
1142
+ }
1143
+
1144
+ function verdictOf(errors: number, warnings: number, first?: string): { verdict: Verdict; verdictReason: string } {
1145
+ // `first error:` rather than `first:` - what follows is a code, and the reader should be able to tell.
1146
+ if (errors > 0) return { verdict: 'fail', verdictReason: String(errors) + ' error finding(s)' + (first === undefined ? '' : ' (first error: ' + first + ')') }
1147
+ if (warnings > 0) return { verdict: 'pass_with_findings', verdictReason: String(warnings) + ' warning finding(s)' }
1148
+ return { verdict: 'pass', verdictReason: 'no error or warning findings' }
1149
+ }
1150
+
1151
+ /**
1152
+ * Review a design: its graph, and the contracts it declares about itself.
1153
+ *
1154
+ * Read `verdict`, never `ok`. A design that declares nothing gets `pass` with an empty
1155
+ * vocabulary only for the checks that had something to hold it to - which is why the report
1156
+ * carries each check's `status` and `detail`, and why `facts.declared` says how much the
1157
+ * model actually declared.
1158
+ */
1159
+ export function runDesignReview(input: unknown): DesignReport {
1160
+ const validation = validateDesignModel(input)
1161
+ if (!validation.ok || validation.model === undefined) {
1162
+ const findings: Finding[] = validation.errors.map((message) => ({ code: 'DESIGN_INVALID', severity: 'error' as const, message }))
1163
+ return {
1164
+ ok: false,
1165
+ ran: true,
1166
+ ...verdictOf(findings.length, 0, 'DESIGN_INVALID'),
1167
+ schema: REPORT_SCHEMAS.design,
1168
+ checks: [{ id: 'DESIGN', name: 'Design Model Validation', status: 'fail', detail: 'the model was rejected before any check ran', findings }],
1169
+ violations: findings,
1170
+ signals: [],
1171
+ facts: { nodes: 0, edges: 0, declared: {} },
1172
+ summary: { errors: findings.length, warnings: 0, info: 0, checksRun: 0, nodes: 0, edges: 0 },
1173
+ hashes: { modelHash: '' },
1174
+ modelHash: '',
1175
+ nextSteps: ['Fix the reported field(s) and run the review again: the schema is closed, so an unknown key is a typo that would change the design under review.'],
1176
+ }
1177
+ }
1178
+ const model = validation.model
1179
+ const features = designFeatures(model)
1180
+ const checks = [
1181
+ DES1_cycles(model, features),
1182
+ DES2_layerOrder(model, features),
1183
+ DES3_boundaries(model),
1184
+ DES4_loadBearing(model, features),
1185
+ DES5_undeclared(model),
1186
+ DES6_required(model),
1187
+ DES7_substitution(model),
1188
+ DES8_interfaceWidth(model),
1189
+ DES9_interfaceSplit(model),
1190
+ DES10_responsibilities(model),
1191
+ DES11_inversion(model),
1192
+ DES12_typeRelations(model),
1193
+ ]
1194
+ const all = checks.flatMap((result) => result.findings)
1195
+ const violations = all.filter((finding) => DESIGN_CODE_LEVEL[finding.code] === 'violation')
1196
+ const signals = all.filter((finding) => DESIGN_CODE_LEVEL[finding.code] === 'signal')
1197
+ const facts = all.filter((finding) => DESIGN_CODE_LEVEL[finding.code] === 'fact')
1198
+ const errors = violations.filter((finding) => finding.severity === 'error').length
1199
+ const warnings = signals.filter((finding) => finding.severity === 'warning').length
1200
+ const info = all.length - errors - warnings
1201
+ const declared = {
1202
+ layers: (model.declare?.layers ?? []).length,
1203
+ boundaries: (model.declare?.boundaries ?? []).length,
1204
+ edgeRules: (model.declare?.allowedEdges ?? []).length,
1205
+ requiredEdges: (model.declare?.requiredEdges ?? []).length,
1206
+ types: (model.types ?? []).length,
1207
+ withBehaviour: (model.types ?? []).filter((entry) => entry.transitions !== undefined || entry.forbids !== undefined).length,
1208
+ withResponsibilities: (model.types ?? []).filter((entry) => (entry.responsibilities ?? []).length > 0).length,
1209
+ }
1210
+ // Name first, code in parentheses, and the code is kept because the `includes` tests below need it -
1211
+ // a bare list of six codes tells a reader nothing about what went unchecked.
1212
+ const skipped = checks.filter((result) => result.status === 'skip')
1213
+ const skippedCodes = skipped.map((result) => result.id)
1214
+ const skippedLabels = skipped.map((result) => result.name + ' (' + result.id + ')')
1215
+ const modelHash = valueHash(model)
1216
+ const nextSteps: string[] = []
1217
+ if (violations.length > 0) nextSteps.push('Fix the ' + String(violations.length) + ' violation(s) the design contradicts itself about, or change the declaration they contradict.')
1218
+ if (signals.length > 0) nextSteps.push('Read the ' + String(signals.length) + ' signal(s) and decide which are intended: they are structural facts, not defects. To see whether the drawing this design came from holds up against a dependency matrix, use `uml_structure_verify`.')
1219
+ if (skipped.length > 0) nextSteps.push('These had nothing to hold the design to: ' + skippedLabels.join('; ') + '. Declare ' + (skippedCodes.includes('DES2') ? 'layers, ' : '') + (skippedCodes.includes('DES7') ? 'transitions/forbids, ' : '') + (skippedCodes.includes('DES8') || skippedCodes.includes('DES9') ? 'interfaces with callers, ' : '') + 'or accept that those principles are unverified here. For behaviour rather than structure, `model_verify` decides a state machine on its own and `model_compose_verify` decides two that interact.')
1220
+ if (nextSteps.length === 0) nextSteps.push('Nothing to fix. The design keeps every contract it declares; the shape facts in `facts` are for a reader. To audit the drawing this design came from against a dependency matrix, use `uml_structure_verify`; for a state machine or protocol among these types, use `model_verify`; to accept a later revision, compare the two reports with `report_diff`.')
1221
+ void facts
1222
+
1223
+ return {
1224
+ ok: true,
1225
+ ran: true,
1226
+ ...verdictOf(errors, warnings, violations[0]?.code),
1227
+ schema: REPORT_SCHEMAS.design,
1228
+ ...(model.artifact === undefined ? {} : { artifact: model.artifact }),
1229
+ checks,
1230
+ violations,
1231
+ signals,
1232
+ facts: {
1233
+ nodes: features.nodes,
1234
+ edges: features.edges,
1235
+ components: features.components,
1236
+ cyclomatic: features.cyclomatic,
1237
+ density: features.density,
1238
+ ranks: features.ranks,
1239
+ fanIn: features.fanIn,
1240
+ fanOut: features.fanOut,
1241
+ largestCycle: features.largestCycle,
1242
+ feedbackArcs: features.feedbackArcs.edges,
1243
+ feedbackArcsExact: features.feedbackArcs.exact,
1244
+ articulationPoints: features.articulationPoints,
1245
+ bridges: features.bridges,
1246
+ mutualPairs: features.mutualPairs,
1247
+ declared,
1248
+ checksSkipped: skippedCodes,
1249
+ },
1250
+ // `checksRun` counts the checks that examined the design: a skipped check examined nothing, so counting it
1251
+ // here answered "fully reviewed" for a design that declared nothing to review it against. The roster is
1252
+ // `checks`, and `facts.checksSkipped` names the ones this number excludes.
1253
+ summary: { errors, warnings, info, checksRun: checks.length - skipped.length, nodes: features.nodes, edges: features.edges },
1254
+ hashes: { modelHash },
1255
+ modelHash,
1256
+ nextSteps,
1257
+ }
1258
+ }
1259
+
1260
+ /** A stable hash of the design, for a baseline diff or an archive record. */
1261
+ export function designHash(model: DesignModelV1): string {
1262
+ return createHash('sha256').update(JSON.stringify(model)).digest('hex')
1263
+ }