dsh-model-everything 0.4.1 → 0.5.1

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 (114) hide show
  1. package/README.en-US.md +75 -19
  2. package/README.md +377 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +3494 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/channels.js +243 -0
  14. package/lib/render/collapse.js +155 -0
  15. package/lib/render/components.js +99 -0
  16. package/lib/render/coords.js +288 -0
  17. package/lib/render/cycle-break.js +147 -0
  18. package/lib/render/diff.js +138 -0
  19. package/lib/render/dummy.js +43 -0
  20. package/lib/render/from-mermaid.js +235 -0
  21. package/lib/render/from-plantuml.js +430 -0
  22. package/lib/render/index.js +42 -0
  23. package/lib/render/layer.js +91 -0
  24. package/lib/render/layout.js +68 -0
  25. package/lib/render/model.js +17 -0
  26. package/lib/render/network-simplex.js +272 -0
  27. package/lib/render/order.js +122 -0
  28. package/lib/render/paint.js +891 -0
  29. package/lib/render/palette.js +127 -0
  30. package/lib/render/place.js +482 -0
  31. package/lib/render/route.js +225 -0
  32. package/lib/render/svg.js +229 -0
  33. package/lib/render/symbol.js +108 -0
  34. package/lib/render/text.js +74 -0
  35. package/lib/render/viewmodel.js +126 -0
  36. package/lib/render/wrap.js +87 -0
  37. package/lib/render/write.js +14 -0
  38. package/lib/scan.js +248 -8
  39. package/lib/structure-tool.js +2 -2
  40. package/lib/types/client.d.ts +83 -0
  41. package/lib/types/diagram-diff-tool.d.ts +8 -0
  42. package/lib/types/diagram-render-tool.d.ts +8 -0
  43. package/lib/types/diagram-review-tool.d.ts +8 -0
  44. package/lib/types/diagram.d.ts +34 -0
  45. package/lib/types/presentation.d.ts +7 -0
  46. package/lib/types/render/budget.d.ts +55 -0
  47. package/lib/types/render/channels.d.ts +63 -0
  48. package/lib/types/render/collapse.d.ts +23 -0
  49. package/lib/types/render/components.d.ts +57 -0
  50. package/lib/types/render/coords.d.ts +64 -0
  51. package/lib/types/render/cycle-break.d.ts +45 -0
  52. package/lib/types/render/diff.d.ts +44 -0
  53. package/lib/types/render/dummy.d.ts +32 -0
  54. package/lib/types/render/from-mermaid.d.ts +27 -0
  55. package/lib/types/render/from-plantuml.d.ts +33 -0
  56. package/lib/types/render/index.d.ts +38 -0
  57. package/lib/types/render/layer.d.ts +28 -0
  58. package/lib/types/render/layout.d.ts +54 -0
  59. package/lib/types/render/model.d.ts +73 -0
  60. package/lib/types/render/network-simplex.d.ts +54 -0
  61. package/lib/types/render/order.d.ts +27 -0
  62. package/lib/types/render/paint.d.ts +127 -0
  63. package/lib/types/render/palette.d.ts +41 -0
  64. package/lib/types/render/place.d.ts +55 -0
  65. package/lib/types/render/route.d.ts +77 -0
  66. package/lib/types/render/svg.d.ts +19 -0
  67. package/lib/types/render/symbol.d.ts +73 -0
  68. package/lib/types/render/text.d.ts +38 -0
  69. package/lib/types/render/viewmodel.d.ts +20 -0
  70. package/lib/types/render/wrap.d.ts +27 -0
  71. package/lib/types/render/write.d.ts +14 -0
  72. package/lib/types/scan.d.ts +10 -2
  73. package/lib/types/structure.d.ts +1 -0
  74. package/package.json +148 -132
  75. package/skills/model-everything/SKILL.md +454 -454
  76. package/skills/model-everything/references/architecture-domains.md +137 -0
  77. package/skills/model-everything/references/architecture-views.md +119 -0
  78. package/skills/model-everything-diagram/SKILL.md +84 -0
  79. package/src/client.ts +307 -0
  80. package/src/diagram-diff-tool.ts +94 -0
  81. package/src/diagram-render-tool.ts +108 -0
  82. package/src/diagram-review-tool.ts +109 -0
  83. package/src/diagram.ts +61 -0
  84. package/src/gate.ts +14 -12
  85. package/src/presentation.ts +12 -0
  86. package/src/render/budget.ts +135 -0
  87. package/src/render/channels.ts +309 -0
  88. package/src/render/collapse.ts +150 -0
  89. package/src/render/components.ts +126 -0
  90. package/src/render/coords.ts +328 -0
  91. package/src/render/cycle-break.ts +164 -0
  92. package/src/render/diff.ts +162 -0
  93. package/src/render/dummy.ts +65 -0
  94. package/src/render/from-mermaid.ts +261 -0
  95. package/src/render/from-plantuml.ts +469 -0
  96. package/src/render/index.ts +73 -0
  97. package/src/render/layer.ts +98 -0
  98. package/src/render/layout.ts +101 -0
  99. package/src/render/model.ts +86 -0
  100. package/src/render/network-simplex.ts +290 -0
  101. package/src/render/order.ts +139 -0
  102. package/src/render/paint.ts +1006 -0
  103. package/src/render/palette.ts +164 -0
  104. package/src/render/place.ts +527 -0
  105. package/src/render/route.ts +269 -0
  106. package/src/render/svg.ts +270 -0
  107. package/src/render/symbol.ts +137 -0
  108. package/src/render/text.ts +86 -0
  109. package/src/render/viewmodel.ts +136 -0
  110. package/src/render/wrap.ts +103 -0
  111. package/src/render/write.ts +17 -0
  112. package/src/scan.ts +807 -564
  113. package/src/structure-tool.ts +2 -2
  114. package/src/structure.ts +1 -1
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Coordinate assignment: how far right each box sits. Brandes-Kopf.
3
+ *
4
+ * The layers are rows and the order inside them is settled; the only freedom left is the x of each box. Three
5
+ * properties make a drawing readable:
6
+ *
7
+ * - A box sits over what it is connected to, and a chain of boxes sits on one vertical line. Brandes and Kopf get
8
+ * this by *aligning*: every box is offered the median of its neighbours in the layer just above (or just below) and
9
+ * is joined to that neighbour's block when the join crosses no other edge. A block is then one node of a smaller
10
+ * graph, and a box that aligned downwards through five rows comes out as one straight segment.
11
+ * - Nothing overlaps, and the empty strip between two boxes in a row is the same everywhere. The blocks are compacted
12
+ * against each other by the least distance their rows allow, which is a longest path over the block graph: a block
13
+ * sits as far left as the block to its left permits, and no further.
14
+ * - The drawing is balanced rather than packed to one side. Aligning downwards alone would lean everything left, so
15
+ * the same two passes are run from the bottom up, and each pair is run once more mirrored left to right. The four
16
+ * layouts are then averaged. Averaging is a convex combination, and the separation constraints are linear, so the
17
+ * average of four layouts that keep the boxes apart also keeps them apart - the balance is free of overlaps by
18
+ * construction. A pass that starts at the top and one that starts at the bottom compact their blocks from opposite
19
+ * ends, so each side is first slid onto the origin of the first pass of that side, and only then averaged.
20
+ *
21
+ * Three things this module owes the rest of the pipeline rather than the paper:
22
+ *
23
+ * - A long edge's dummies share one x. They are one lane, not one point per row, so each lane is collapsed to the mean
24
+ * of the x its two ends want - the straight drop a reader follows. Collapsing can put a lane where a box already is,
25
+ * so the last pass spends the lane's space: boxes are pushed right past it and never the other way, and a row keeps
26
+ * the width its placeholders were given, which is the width a corridor needs when the lane has to go around a box.
27
+ * The lane is a proposal either way - the channel stage moves a lane into a free corridor when routing needs it, and
28
+ * a box never moves for a lane.
29
+ * - A lane is not a neighbour to be aligned with. A box that aligned itself to a lane passing through its row would be
30
+ * pulled off the boxes it does connect to, which is how a decision with one long edge comes out as a staircase. Lanes
31
+ * are placed by the compaction and then by the channel stage, which is where a lane belongs.
32
+ * - The empty strip between two rows is the caller's, not this module's: the y of a row is a running sum with one gap.
33
+ *
34
+ * Pure: layers and chains in, coordinates out.
35
+ */
36
+
37
+ import type { Chain } from './dummy.js'
38
+
39
+ export interface Sized {
40
+ width: number
41
+ height: number
42
+ }
43
+
44
+ export interface CoordInput {
45
+ layers: string[][]
46
+ chains: Chain[]
47
+ sizeOf: (id: string) => Sized
48
+ /** A dummy instance, and the shared lane it belongs to. */
49
+ chainOf: ReadonlyMap<string, string>
50
+ }
51
+
52
+ export interface CoordOptions {
53
+ /** The empty space between two side-by-side boxes. */
54
+ gap: number
55
+ /** The empty strip between two layers. */
56
+ layerGap: number
57
+ }
58
+
59
+ export interface Coordinates {
60
+ /** Centre x, keyed by node id and by lane id. A long edge's dummies share one entry. */
61
+ x: Map<string, number>
62
+ /** The top y of each layer. */
63
+ layerY: number[]
64
+ layerHeight: number[]
65
+ layerWidth: number[]
66
+ width: number
67
+ height: number
68
+ }
69
+
70
+ /** What one pass needs: the rows in its own order, and where each id's neighbours in the row above it are. */
71
+ interface Frame {
72
+ /** The rows, top to bottom in this pass's frame, each already in the pass's left-to-right order. */
73
+ rows: string[][]
74
+ /** For each id, the ids it is joined to in the previous row of this frame. */
75
+ near: ReadonlyMap<string, string[]>
76
+ /** True when this pass reads the rows mirror-image, so its coordinates have to be flipped back. */
77
+ mirrored: boolean
78
+ }
79
+
80
+ /** Where each id sits inside its row, in the row's own order. */
81
+ function positions(rows: string[][]): Map<string, number> {
82
+ const at = new Map<string, number>()
83
+ for (const row of rows) row.forEach((id, index) => at.set(id, index))
84
+ return at
85
+ }
86
+
87
+ /**
88
+ * Join each id to the median of its neighbours in the row above, unless that join would cross another edge.
89
+ *
90
+ * One block per chain of joins, reported as the block's root per id. The `previous` cursor is the whole of the
91
+ * crossing test: as ids are taken left to right, a neighbour has to be strictly right of the one the previous id was
92
+ * joined to, and since the ids are taken in order that is exactly "the joins do not cross".
93
+ */
94
+ function alignBlocks(frame: Frame, at: Map<string, number>): Map<string, string> {
95
+ const root = new Map<string, string>()
96
+ const joined = new Map<string, string>()
97
+ for (const row of frame.rows) {
98
+ for (const id of row) {
99
+ root.set(id, id)
100
+ joined.set(id, id)
101
+ }
102
+ }
103
+ for (let depth = 1; depth < frame.rows.length; depth += 1) {
104
+ let previous = -1
105
+ for (const id of frame.rows[depth]) {
106
+ const close = frame.near.get(id) ?? []
107
+ if (close.length === 0) continue
108
+ const sorted = [...close].sort((left, right) => (at.get(left) as number) - (at.get(right) as number))
109
+ const middle = (sorted.length - 1) / 2
110
+ // An even number of neighbours has two medians. Both are tried, nearest first, so a box with two parents is as
111
+ // happy under either as the paper's version.
112
+ for (let cursor = Math.floor(middle); cursor <= Math.ceil(middle); cursor += 1) {
113
+ if (joined.get(id) !== id) break
114
+ const target = sorted[cursor]
115
+ if (previous >= (at.get(target) as number)) continue
116
+ joined.set(target, id)
117
+ root.set(id, root.get(target) as string)
118
+ joined.set(id, root.get(id) as string)
119
+ previous = at.get(target) as number
120
+ }
121
+ }
122
+ }
123
+ return root
124
+ }
125
+
126
+ /**
127
+ * Place the blocks: each one as far left as the block to its left in any row allows.
128
+ *
129
+ * That is a longest path over the block graph, whose edges run from a block to the block it has to clear, weighted by
130
+ * the least distance between the two ids that forced the edge. Blocks cannot cross by construction, so the graph has
131
+ * no cycle; the guard is there because a cycle would otherwise be an infinite recursion rather than a wrong drawing.
132
+ */
133
+ function compactBlocks(rows: string[][], root: Map<string, string>, widthOf: (id: string) => number, gap: number): Map<string, number> {
134
+ const clears = new Map<string, Array<{ block: string; space: number }>>()
135
+ for (const row of rows) {
136
+ for (let index = 1; index < row.length; index += 1) {
137
+ const left = row[index - 1]
138
+ const right = row[index]
139
+ const leftBlock = root.get(left) as string
140
+ const rightBlock = root.get(right) as string
141
+ const space = (widthOf(left) + widthOf(right)) / 2 + gap
142
+ clears.set(rightBlock, [...(clears.get(rightBlock) ?? []), { block: leftBlock, space }])
143
+ }
144
+ }
145
+ const placed = new Map<string, number>()
146
+ const place = (block: string): number => {
147
+ const known = placed.get(block)
148
+ if (known !== undefined) return known
149
+ let value = 0
150
+ for (const step of clears.get(block) ?? []) value = Math.max(value, place(step.block) + step.space)
151
+ placed.set(block, value)
152
+ return value
153
+ }
154
+ const x = new Map<string, number>()
155
+ for (const row of rows) {
156
+ for (const id of row) {
157
+ const block = root.get(id) as string
158
+ if (!placed.has(block)) place(block)
159
+ x.set(id, placed.get(block) as number)
160
+ }
161
+ }
162
+ return x
163
+ }
164
+
165
+ /** The left edge of the leftmost box and the right edge of the rightmost, in one pass's coordinates. */
166
+ function extentOf(x: Map<string, number>, rows: string[][], widthOf: (id: string) => number): { left: number; right: number } {
167
+ let left = Infinity
168
+ let right = -Infinity
169
+ for (const row of rows) {
170
+ for (const id of row) {
171
+ const value = x.get(id) as number
172
+ left = Math.min(left, value - widthOf(id) / 2)
173
+ right = Math.max(right, value + widthOf(id) / 2)
174
+ }
175
+ }
176
+ return { left, right }
177
+ }
178
+
179
+ /** One pass: align the rows in this frame, compact the blocks, and flip the answer back if the frame is mirrored. */
180
+ function onePass(frame: Frame, widthOf: (id: string) => number, gap: number): Map<string, number> {
181
+ const at = positions(frame.rows)
182
+ const root = alignBlocks(frame, at)
183
+ const x = compactBlocks(frame.rows, root, widthOf, gap)
184
+ if (!frame.mirrored) return x
185
+ const mirrored = new Map<string, number>()
186
+ for (const [id, value] of x) mirrored.set(id, -value)
187
+ return mirrored
188
+ }
189
+
190
+ /** The four passes a balanced layout needs: down and up, each read left to right and right to left. */
191
+ function frames(layers: string[][], above: ReadonlyMap<string, string[]>, below: ReadonlyMap<string, string[]>): Frame[] {
192
+ const fromTop = layers
193
+ const fromBottom = [...layers].reverse()
194
+ const flip = (rows: string[][]): string[][] => rows.map((row) => [...row].reverse())
195
+ return [
196
+ { rows: fromTop, near: above, mirrored: false },
197
+ { rows: flip(fromTop), near: above, mirrored: true },
198
+ { rows: fromBottom, near: below, mirrored: false },
199
+ { rows: flip(fromBottom), near: below, mirrored: true },
200
+ ]
201
+ }
202
+
203
+ export function assignCoordinates(input: CoordInput, options: CoordOptions): Coordinates {
204
+ const { layers, chains, sizeOf, chainOf } = input
205
+ const gap = options.gap
206
+ const laneIds = new Set<string>(chainOf.values())
207
+ const isDummy = (id: string): boolean => chainOf.has(id)
208
+ const isPoint = (id: string): boolean => isDummy(id) || laneIds.has(id)
209
+ const widthOf = (id: string): number => (isPoint(id) ? 0 : sizeOf(id).width)
210
+ const heightOf = (id: string): number => (isPoint(id) ? 0 : sizeOf(id).height)
211
+ const laneOf = (id: string): string => chainOf.get(id) ?? id
212
+ const x = new Map<string, number>()
213
+ const read = (id: string): number => x.get(laneOf(id)) as number
214
+
215
+ const above = new Map<string, string[]>()
216
+ const below = new Map<string, string[]>()
217
+ const laneEnds = new Map<string, string[]>()
218
+ const link = (into: Map<string, string[]>, id: string, other: string): void => {
219
+ into.set(id, [...(into.get(id) ?? []), other])
220
+ }
221
+ for (const chain of chains) {
222
+ for (let cursor = 0; cursor + 1 < chain.path.length; cursor += 1) {
223
+ const top = chain.path[cursor]
224
+ const bottom = chain.path[cursor + 1]
225
+ // A lane is not a neighbour to align with: only two boxes are, which is what keeps a box over the boxes it
226
+ // connects to when a long edge crosses its row.
227
+ if (!isDummy(top) && !isDummy(bottom)) {
228
+ link(below, top, bottom)
229
+ link(above, bottom, top)
230
+ }
231
+ }
232
+ if (chain.path.length > 2) {
233
+ const lane = chainOf.get(chain.path[1])
234
+ if (lane !== undefined) laneEnds.set(lane, [chain.path[0], chain.path[chain.path.length - 1]])
235
+ }
236
+ }
237
+
238
+ // The four layouts, averaged. A pass that starts at the top and a pass that starts at the bottom compact their
239
+ // blocks from opposite ends, so the four answers do not share an origin: each side is first slid onto the origin of
240
+ // the first pass of that side, and only then are they averaged. Averaging is what balances them - a box that aligned
241
+ // in one pass and not in another keeps the part the passes agreed on, which is the paper's last step, at the cost of
242
+ // one average.
243
+ const candidates = frames(layers, above, below).map((frame) => onePass(frame, widthOf, gap))
244
+ const leftOrigin = extentOf(candidates[0], layers, widthOf).left
245
+ const rightOrigin = extentOf(candidates[1], layers, widthOf).right
246
+ for (const [index, candidate] of candidates.entries()) {
247
+ const extent = extentOf(candidate, layers, widthOf)
248
+ const shift = index % 2 === 0 ? leftOrigin - extent.left : rightOrigin - extent.right
249
+ for (const [id, value] of candidate) candidate.set(id, value + shift)
250
+ }
251
+ for (const row of layers) {
252
+ for (const id of row) {
253
+ let total = 0
254
+ for (const candidate of candidates) total += candidate.get(id) as number
255
+ x.set(id, total / candidates.length)
256
+ }
257
+ }
258
+
259
+ // What each row has to make room for. The boxes are one part; the space the lane's placeholders were given by the
260
+ // compaction is the other, because that is where a corridor can run when the lane's own proposal lands on a box. A
261
+ // row that reserved nothing for its lanes is as wide as its boxes; a row whose lane had to go around a box keeps the
262
+ // width that detour needed, so the page is planned for the drawing that will be painted rather than the boxes alone.
263
+ const reserved = layers.map((layer) => {
264
+ let lowest = Infinity
265
+ let highest = -Infinity
266
+ for (const id of layer) {
267
+ const value = x.get(id) as number
268
+ lowest = Math.min(lowest, value - widthOf(id) / 2)
269
+ highest = Math.max(highest, value + widthOf(id) / 2)
270
+ }
271
+ return { lowest, highest }
272
+ })
273
+
274
+ // A lane is one variable across every row it crosses. The four passes give each of its dummies its own x; the lane
275
+ // takes the mean of what its two ends want, because the straight drop between them is what a reader follows. The rows
276
+ // it crosses are the channel stage's to keep it clear of. It stays a proposal either way: `channels.ts` moves a lane
277
+ // that lands on a box.
278
+ for (const [lane, ends] of laneEnds) x.set(lane, ((x.get(ends[0]) as number) + (x.get(ends[1]) as number)) / 2)
279
+ for (const id of chainOf.keys()) x.delete(id)
280
+
281
+ // The rows keep their order and their gap. A lane in a row does not move a box out of the way; a box does move for
282
+ // the box before it, because two boxes on top of each other is the one thing a drawing may not do.
283
+ for (const row of layers) {
284
+ let cursor = -Infinity
285
+ for (const id of row) {
286
+ if (isDummy(id)) {
287
+ cursor = Math.max(cursor, read(id))
288
+ continue
289
+ }
290
+ const width = widthOf(id)
291
+ const centre = Math.max(x.get(id) as number, cursor + width / 2)
292
+ x.set(id, centre)
293
+ cursor = centre + width / 2 + gap
294
+ }
295
+ }
296
+
297
+ let left = 0
298
+ for (const [id, value] of x) left = Math.min(left, value - widthOf(id) / 2)
299
+ for (const [id, value] of x) x.set(id, value - left)
300
+
301
+ const layerHeight = layers.map((layer) => Math.max(0, ...layer.map((id) => heightOf(id))))
302
+ const layerWidth = layers.map((layer, depth) => {
303
+ let lowest = (reserved[depth].lowest as number) - left
304
+ let highest = (reserved[depth].highest as number) - left
305
+ for (const id of layer) {
306
+ const value = read(id)
307
+ lowest = Math.min(lowest, value - widthOf(id) / 2)
308
+ highest = Math.max(highest, value + widthOf(id) / 2)
309
+ }
310
+ return highest - lowest
311
+ })
312
+ const layerY: number[] = []
313
+ let top = 0
314
+ for (const [depth, height] of layerHeight.entries()) {
315
+ layerY.push(top)
316
+ top += height + options.layerGap
317
+ }
318
+ let width = 0
319
+ for (const [id, value] of x) width = Math.max(width, value + widthOf(id) / 2)
320
+ return {
321
+ x,
322
+ layerY,
323
+ layerHeight,
324
+ layerWidth,
325
+ width,
326
+ height: layerY.length === 0 ? 0 : (layerY[layerY.length - 1] as number) + (layerHeight[layerHeight.length - 1] as number),
327
+ }
328
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Cycle breaking, in the order a layered layout does it.
3
+ *
4
+ * A layered drawing is a drawing of a DAG: an edge that runs back up the page has no layer to go to. The fix is to
5
+ * lay such an edge out backwards and draw its arrow the way the source wrote it - but *which* edges to turn around
6
+ * is a decision, and deciding it edge by edge is what makes two back edges of one cycle end up pointing opposite
7
+ * ways.
8
+ *
9
+ * So the graph is decomposed first. Tarjan's algorithm finds the strongly connected components; contracting each to
10
+ * a node gives the condensation, which is a DAG, so every edge between components already points one way and nothing
11
+ * between them needs reversing. Only inside a component - where by definition every edge lies on a cycle - is there
12
+ * an order to choose: the nodes are ranked once, greedily, and every edge inside the component is oriented along that
13
+ * single ranking. One ranking per component is what "consistently" means: after the pass, every surviving edge of a
14
+ * component points from a lower rank to a higher one, so the component cannot contain a cycle any more.
15
+ *
16
+ * The initial state is a special case: it is layer zero by definition, so an edge that runs into it is a back edge
17
+ * before the decomposition even starts. Turning those around leaves the state with no predecessor, so the ranking
18
+ * of its component puts it first and nothing is ever oriented back into it.
19
+ *
20
+ * Pure: the same graph in, the same edge directions out.
21
+ */
22
+
23
+ import type { LayoutEdge, LayoutGraph } from './layout.js'
24
+
25
+ /** One strongly connected component: a set of nodes that can all reach each other. */
26
+ export interface Component {
27
+ /** The component's index in the condensation, numbered in topological order. */
28
+ id: number
29
+ members: string[]
30
+ }
31
+
32
+ export interface BrokenGraph {
33
+ /** The same edges, in the same order, with the ones that close a cycle turned around. */
34
+ edges: LayoutEdge[]
35
+ /** The diagram edges whose direction the layout changed. */
36
+ reversed: Set<number>
37
+ /** The strongly connected components, numbered in topological order. */
38
+ components: Component[]
39
+ componentOf: Map<string, number>
40
+ /** Each node's rank inside its own component: every intra-component edge points from a lower rank to a higher. */
41
+ rank: Map<string, number>
42
+ /** The condensation: an edge per pair of components that has at least one edge between them. Always acyclic. */
43
+ condensation: Array<{ from: number; to: number }>
44
+ }
45
+
46
+ /** Tarjan's strongly connected components, iterative enough to be read: the recursion is the algorithm. */
47
+ function tarjan(nodes: string[], outgoing: Map<string, string[]>): string[][] {
48
+ let counter = 0
49
+ const index = new Map<string, number>()
50
+ const low = new Map<string, number>()
51
+ const stack: string[] = []
52
+ const onStack = new Set<string>()
53
+ const found: string[][] = []
54
+ const visit = (node: string): void => {
55
+ index.set(node, counter)
56
+ low.set(node, counter)
57
+ counter += 1
58
+ stack.push(node)
59
+ onStack.add(node)
60
+ for (const next of outgoing.get(node) ?? []) {
61
+ if (!index.has(next)) {
62
+ visit(next)
63
+ low.set(node, Math.min(low.get(node) as number, low.get(next) as number))
64
+ continue
65
+ }
66
+ if (onStack.has(next)) low.set(node, Math.min(low.get(node) as number, index.get(next) as number))
67
+ }
68
+ if (low.get(node) !== index.get(node)) return
69
+ const component: string[] = []
70
+ let member: string
71
+ do {
72
+ member = stack.pop() as string
73
+ onStack.delete(member)
74
+ component.push(member)
75
+ } while (member !== node)
76
+ found.push(component)
77
+ }
78
+ for (const node of nodes) if (!index.has(node)) visit(node)
79
+ return found
80
+ }
81
+
82
+ export function breakCycles(graph: LayoutGraph): BrokenGraph {
83
+ const known = new Set(graph.nodes.map((node) => node.id))
84
+ const position = new Map(graph.nodes.map((node, offset) => [node.id, offset] as const))
85
+ const edges = graph.edges.map((edge) => ({ ...edge }))
86
+ const reversed = new Set<number>()
87
+ const flip = (index: number): void => {
88
+ const edge = edges[index]
89
+ const next = edge.from
90
+ edge.from = edge.to
91
+ edge.to = next
92
+ }
93
+
94
+ // The initial state is layer zero: every edge running into it is a back edge already.
95
+ if (graph.init !== undefined && known.has(graph.init)) {
96
+ for (const [index, edge] of edges.entries()) {
97
+ if (edge.to === graph.init && edge.from !== graph.init) flip(index)
98
+ }
99
+ }
100
+
101
+ const outgoing = new Map<string, string[]>()
102
+ for (const edge of edges) {
103
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to) continue
104
+ outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to])
105
+ }
106
+ const nodes = graph.nodes.map((node) => node.id)
107
+ const raw = tarjan(nodes, outgoing)
108
+ // Tarjan emits a component after everything it reaches, which is the reverse of a topological order.
109
+ raw.reverse()
110
+
111
+ const componentOf = new Map<string, number>()
112
+ const components: Component[] = raw.map((members, id) => {
113
+ for (const member of members) componentOf.set(member, id)
114
+ return { id, members }
115
+ })
116
+
117
+ const condensation: Array<{ from: number; to: number }> = []
118
+ const seen = new Set<string>()
119
+ for (const edge of edges) {
120
+ const from = componentOf.get(edge.from)
121
+ const to = componentOf.get(edge.to)
122
+ if (from === undefined || to === undefined || from === to) continue
123
+ const key = String(from) + '>' + String(to)
124
+ if (seen.has(key)) continue
125
+ seen.add(key)
126
+ condensation.push({ from, to })
127
+ }
128
+
129
+ // Inside each component: one ranking, decided greedily by how much of the flow leaves the node rather than enters
130
+ // it. That is the order the edges are made to follow, so no two decisions can contradict each other.
131
+ const rank = new Map<string, number>()
132
+ for (const component of components) {
133
+ if (component.members.length < 2) {
134
+ rank.set(component.members[0], 0)
135
+ continue
136
+ }
137
+ const inside = new Set(component.members)
138
+ const score = new Map<string, number>()
139
+ for (const member of component.members) score.set(member, 0)
140
+ for (const edge of edges) {
141
+ if (!inside.has(edge.from) || !inside.has(edge.to) || edge.from === edge.to) continue
142
+ score.set(edge.from, (score.get(edge.from) as number) + 1)
143
+ score.set(edge.to, (score.get(edge.to) as number) - 1)
144
+ }
145
+ const ordered = [...component.members].sort((left, right) => {
146
+ const difference = (score.get(right) as number) - (score.get(left) as number)
147
+ return difference !== 0 ? difference : (position.get(left) as number) - (position.get(right) as number)
148
+ })
149
+ ordered.forEach((member, offset) => rank.set(member, offset))
150
+ }
151
+
152
+ for (const [index, edge] of edges.entries()) {
153
+ const from = rank.get(edge.from)
154
+ const to = rank.get(edge.to)
155
+ if (from === undefined || to === undefined || edge.from === edge.to) continue
156
+ if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue
157
+ if (from > to) flip(index)
158
+ }
159
+
160
+ edges.forEach((edge, index) => {
161
+ if (graph.edges[index].from !== edge.from) reversed.add(edge.index)
162
+ })
163
+ return { edges, reversed, components, componentOf, rank, condensation }
164
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Structural diff between two drawings.
3
+ *
4
+ * A text diff of two diagrams tells a reader that a line moved; it does not tell them that `Cache` left the
5
+ * `Platform` package, or that `Core` gained a nested boundary. Those are the changes a design review is about,
6
+ * and they are decided by comparing the containment tree, not the source.
7
+ *
8
+ * The diff judges movement, not merit: a group that moved, a member that changed owner, a boundary added or
9
+ * removed. Whether the new arrangement is better is the reader's call, so the verdict only says whether anything
10
+ * changed and whether the comparison could run at all.
11
+ */
12
+
13
+ import type { Diagram, DiagramGroup } from './model.js'
14
+
15
+ export type ChangeKind = 'added' | 'removed' | 'renamed' | 'moved' | 'regrouped'
16
+
17
+ export interface StructuralChange {
18
+ code: string
19
+ severity: 'error' | 'warning' | 'info'
20
+ kind: ChangeKind
21
+ /** What changed, by id: a group id or a node id. */
22
+ subject: string
23
+ message: string
24
+ }
25
+
26
+ export interface DiffReport {
27
+ verdict: 'pass' | 'pass_with_findings' | 'fail'
28
+ changes: StructuralChange[]
29
+ /** Both sides, so an empty change list cannot be confused with a comparison that never ran. */
30
+ measured: { before: { nodes: number; groups: number }; after: { nodes: number; groups: number } }
31
+ }
32
+
33
+ /** The group that directly holds a node, from the side's own declarations. */
34
+ function ownerOf(groups: DiagramGroup[], nodeId: string): string | undefined {
35
+ return groups.find((group) => group.members.includes(nodeId))?.id
36
+ }
37
+
38
+ function labels(groups: DiagramGroup[]): Map<string, string> {
39
+ return new Map(groups.map((group) => [group.id, group.label] as const))
40
+ }
41
+
42
+ function parents(groups: DiagramGroup[]): Map<string, string | undefined> {
43
+ return new Map(groups.map((group) => [group.id, group.parent] as const))
44
+ }
45
+
46
+ /** How a group is named in a finding: its label, falling back to its id when the other side has no label. */
47
+ function nameOf(labelsById: Map<string, string>, id: string): string {
48
+ return labelsById.get(id) ?? id
49
+ }
50
+
51
+ /**
52
+ * Compare the containment structure of two drawings.
53
+ *
54
+ * Renames are reported by id: a group that kept its id but changed its text is a rename, while a group whose id
55
+ * is gone and another whose id is new are an unrelated removal and addition, because the ids are what a caller
56
+ * tracks across versions.
57
+ */
58
+ export function diffStructure(before: Diagram, after: Diagram): DiffReport {
59
+ const beforeGroups = before.groups ?? []
60
+ const afterGroups = after.groups ?? []
61
+ const changes: StructuralChange[] = []
62
+
63
+ const beforeIds = new Set(beforeGroups.map((group) => group.id))
64
+ const afterIds = new Set(afterGroups.map((group) => group.id))
65
+ const beforeLabels = labels(beforeGroups)
66
+ const afterLabels = labels(afterGroups)
67
+ const beforeParents = parents(beforeGroups)
68
+ const afterParents = parents(afterGroups)
69
+
70
+ for (const group of afterGroups) {
71
+ if (beforeIds.has(group.id)) continue
72
+ changes.push({
73
+ code: 'H1_GROUP_ADDED',
74
+ severity: 'info',
75
+ kind: 'added',
76
+ subject: group.id,
77
+ message: 'group "' + group.label + '" is new' + (group.parent === undefined ? '' : ' inside "' + nameOf(afterLabels, group.parent) + '"'),
78
+ })
79
+ }
80
+ for (const group of beforeGroups) {
81
+ if (afterIds.has(group.id)) continue
82
+ changes.push({
83
+ code: 'H2_GROUP_REMOVED',
84
+ severity: 'info',
85
+ kind: 'removed',
86
+ subject: group.id,
87
+ message: 'group "' + group.label + '" is gone',
88
+ })
89
+ }
90
+
91
+ for (const group of afterGroups) {
92
+ if (!beforeIds.has(group.id)) continue
93
+ const wasCalled = nameOf(beforeLabels, group.id)
94
+ if (wasCalled !== group.label) {
95
+ changes.push({
96
+ code: 'H3_GROUP_RENAMED',
97
+ severity: 'info',
98
+ kind: 'renamed',
99
+ subject: group.id,
100
+ message: 'group "' + wasCalled + '" is now called "' + group.label + '"',
101
+ })
102
+ }
103
+ const was = beforeParents.get(group.id)
104
+ const now = group.parent
105
+ if (was !== now) {
106
+ const from = was === undefined ? 'the top level' : '"' + nameOf(afterLabels, was) + '"'
107
+ const to = now === undefined ? 'the top level' : '"' + nameOf(afterLabels, now) + '"'
108
+ changes.push({
109
+ code: 'H4_GROUP_MOVED',
110
+ severity: 'warning',
111
+ kind: 'moved',
112
+ subject: group.id,
113
+ message: 'group "' + group.label + '" moved from ' + from + ' to ' + to,
114
+ })
115
+ }
116
+ }
117
+
118
+ const beforeNodes = new Set(before.nodes.map((node) => node.id))
119
+ const afterNodes = new Set(after.nodes.map((node) => node.id))
120
+ for (const node of after.nodes) {
121
+ if (beforeNodes.has(node.id)) continue
122
+ changes.push({ code: 'H5_NODE_ADDED', severity: 'info', kind: 'added', subject: node.id, message: 'node "' + node.id + '" is new' })
123
+ }
124
+ for (const node of before.nodes) {
125
+ if (afterNodes.has(node.id)) continue
126
+ changes.push({ code: 'H6_NODE_REMOVED', severity: 'info', kind: 'removed', subject: node.id, message: 'node "' + node.id + '" is gone' })
127
+ }
128
+
129
+ // Movement is only meaningful for a node that exists on both sides.
130
+ for (const node of after.nodes) {
131
+ if (!beforeNodes.has(node.id)) continue
132
+ const was = ownerOf(beforeGroups, node.id)
133
+ const now = ownerOf(afterGroups, node.id)
134
+ if (was === now) continue
135
+ const from = was === undefined ? 'no group' : '"' + nameOf(beforeLabels, was) + '"'
136
+ const to = now === undefined ? 'no group' : '"' + nameOf(afterLabels, now) + '"'
137
+ changes.push({
138
+ code: 'H7_NODE_REGROUPED',
139
+ severity: 'warning',
140
+ kind: 'regrouped',
141
+ subject: node.id,
142
+ message: 'node "' + node.id + '" moved from ' + from + ' to ' + to,
143
+ })
144
+ }
145
+
146
+ const measured = {
147
+ before: { nodes: before.nodes.length, groups: beforeGroups.length },
148
+ after: { nodes: after.nodes.length, groups: afterGroups.length },
149
+ }
150
+ // Nothing to compare is not a pass: an empty report from an empty pair would read as "unchanged".
151
+ if (before.nodes.length === 0 && after.nodes.length === 0) {
152
+ return {
153
+ verdict: 'fail',
154
+ changes: [
155
+ { code: 'D0_NO_NODES', severity: 'error', kind: 'removed', subject: '-', message: 'neither side holds a node, so there was nothing to compare' },
156
+ ...changes,
157
+ ],
158
+ measured,
159
+ }
160
+ }
161
+ return { verdict: changes.length === 0 ? 'pass' : 'pass_with_findings', changes, measured }
162
+ }