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
|
@@ -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
|
+
}
|