aontu 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/aontu.d.ts +3 -2
  2. package/dist/aontu.js +36 -7
  3. package/dist/aontu.js.map +1 -1
  4. package/dist/cli.d.ts +2 -1
  5. package/dist/cli.js +426 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/ctx.d.ts +5 -0
  8. package/dist/ctx.js +1 -0
  9. package/dist/ctx.js.map +1 -1
  10. package/dist/diff.js.map +1 -1
  11. package/dist/err.js +7 -1
  12. package/dist/err.js.map +1 -1
  13. package/dist/graph.d.ts +2 -5
  14. package/dist/graph.js +83 -46
  15. package/dist/graph.js.map +1 -1
  16. package/dist/hcanon.js +9 -10
  17. package/dist/hcanon.js.map +1 -1
  18. package/dist/hints.js +90 -26
  19. package/dist/hints.js.map +1 -1
  20. package/dist/jsonschema.js +34 -0
  21. package/dist/jsonschema.js.map +1 -1
  22. package/dist/lang.js +593 -191
  23. package/dist/lang.js.map +1 -1
  24. package/dist/lsp.d.ts +1 -1
  25. package/dist/lsp.js +86 -6
  26. package/dist/lsp.js.map +1 -1
  27. package/dist/mcp.js +133 -6
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/mod-tool.js +42 -8
  30. package/dist/mod-tool.js.map +1 -1
  31. package/dist/mod.d.ts +4 -0
  32. package/dist/mod.js +97 -2
  33. package/dist/mod.js.map +1 -1
  34. package/dist/patch.d.ts +5 -0
  35. package/dist/patch.js +25 -25
  36. package/dist/patch.js.map +1 -1
  37. package/dist/provenance.d.ts +1 -0
  38. package/dist/provenance.js +2 -1
  39. package/dist/provenance.js.map +1 -1
  40. package/dist/query.js.map +1 -1
  41. package/dist/reach.d.ts +1 -0
  42. package/dist/reach.js +49 -21
  43. package/dist/reach.js.map +1 -1
  44. package/dist/relation.d.ts +4 -0
  45. package/dist/relation.js +125 -200
  46. package/dist/relation.js.map +1 -1
  47. package/dist/sig.d.ts +25 -0
  48. package/dist/sig.js +277 -0
  49. package/dist/sig.js.map +1 -0
  50. package/dist/sigdecl.d.ts +2 -0
  51. package/dist/sigdecl.js +11 -0
  52. package/dist/sigdecl.js.map +1 -0
  53. package/dist/siggate.d.ts +4 -0
  54. package/dist/siggate.js +90 -0
  55. package/dist/siggate.js.map +1 -0
  56. package/dist/std.js +72 -16
  57. package/dist/std.js.map +1 -1
  58. package/dist/subsume.js +57 -10
  59. package/dist/subsume.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/dist/unify.d.ts +2 -2
  62. package/dist/unify.js +93 -114
  63. package/dist/unify.js.map +1 -1
  64. package/dist/utility.d.ts +1 -2
  65. package/dist/utility.js +7 -61
  66. package/dist/utility.js.map +1 -1
  67. package/dist/val/AggFuncVal.d.ts +12 -1
  68. package/dist/val/AggFuncVal.js +165 -3
  69. package/dist/val/AggFuncVal.js.map +1 -1
  70. package/dist/val/BagVal.d.ts +2 -0
  71. package/dist/val/BagVal.js +56 -8
  72. package/dist/val/BagVal.js.map +1 -1
  73. package/dist/val/ConstraintVal.js +33 -5
  74. package/dist/val/ConstraintVal.js.map +1 -1
  75. package/dist/val/ContainerKindVal.d.ts +35 -0
  76. package/dist/val/ContainerKindVal.js +99 -0
  77. package/dist/val/ContainerKindVal.js.map +1 -0
  78. package/dist/val/CopyFuncVal.js +0 -7
  79. package/dist/val/CopyFuncVal.js.map +1 -1
  80. package/dist/val/DisjunctVal.d.ts +1 -2
  81. package/dist/val/DisjunctVal.js +146 -39
  82. package/dist/val/DisjunctVal.js.map +1 -1
  83. package/dist/val/ExpectVal.js +37 -2
  84. package/dist/val/ExpectVal.js.map +1 -1
  85. package/dist/val/FuncBaseVal.d.ts +1 -0
  86. package/dist/val/FuncBaseVal.js +19 -8
  87. package/dist/val/FuncBaseVal.js.map +1 -1
  88. package/dist/val/GraphAtomVal.d.ts +39 -0
  89. package/dist/val/GraphAtomVal.js +184 -0
  90. package/dist/val/GraphAtomVal.js.map +1 -0
  91. package/dist/val/JunctionVal.js +22 -5
  92. package/dist/val/JunctionVal.js.map +1 -1
  93. package/dist/val/ListVal.js +27 -34
  94. package/dist/val/ListVal.js.map +1 -1
  95. package/dist/val/MapVal.d.ts +1 -0
  96. package/dist/val/MapVal.js +82 -35
  97. package/dist/val/MapVal.js.map +1 -1
  98. package/dist/val/PathFuncVal.d.ts +2 -2
  99. package/dist/val/PathFuncVal.js +75 -16
  100. package/dist/val/PathFuncVal.js.map +1 -1
  101. package/dist/val/PathVal.d.ts +25 -0
  102. package/dist/val/PathVal.js +150 -0
  103. package/dist/val/PathVal.js.map +1 -0
  104. package/dist/val/PlusOpVal.d.ts +2 -1
  105. package/dist/val/PlusOpVal.js +50 -35
  106. package/dist/val/PlusOpVal.js.map +1 -1
  107. package/dist/val/PrefVal.d.ts +2 -0
  108. package/dist/val/PrefVal.js +159 -32
  109. package/dist/val/PrefVal.js.map +1 -1
  110. package/dist/val/RecurseVal.d.ts +19 -0
  111. package/dist/val/RecurseVal.js +217 -0
  112. package/dist/val/RecurseVal.js.map +1 -0
  113. package/dist/val/RefVal.d.ts +2 -1
  114. package/dist/val/RefVal.js +105 -72
  115. package/dist/val/RefVal.js.map +1 -1
  116. package/dist/val/ReferFuncVal.d.ts +30 -7
  117. package/dist/val/ReferFuncVal.js +395 -94
  118. package/dist/val/ReferFuncVal.js.map +1 -1
  119. package/dist/val/ScalarKindVal.d.ts +4 -2
  120. package/dist/val/ScalarKindVal.js +12 -1
  121. package/dist/val/ScalarKindVal.js.map +1 -1
  122. package/dist/val/SuperFuncVal.d.ts +4 -2
  123. package/dist/val/SuperFuncVal.js +118 -14
  124. package/dist/val/SuperFuncVal.js.map +1 -1
  125. package/dist/val/TopVal.d.ts +1 -1
  126. package/dist/val/Val.d.ts +2 -3
  127. package/dist/val/Val.js +39 -20
  128. package/dist/val/Val.js.map +1 -1
  129. package/dist/val/arith.js +4 -1
  130. package/dist/val/arith.js.map +1 -1
  131. package/dist/vet.js +27 -1
  132. package/dist/vet.js.map +1 -1
  133. package/dist/view.d.ts +85 -0
  134. package/dist/view.js +1948 -0
  135. package/dist/view.js.map +1 -0
  136. package/grammar/aontu.gbnf +18 -10
  137. package/grammar/aontu.lark +15 -10
  138. package/grammar/aontu.tmLanguage.json +184 -0
  139. package/package.json +10 -3
  140. package/skill/grammar-card.md +1 -2
  141. package/src/aontu.ts +37 -7
  142. package/src/cli.ts +472 -7
  143. package/src/ctx.ts +20 -0
  144. package/src/diff.ts +4 -2
  145. package/src/err.ts +8 -1
  146. package/src/graph.ts +125 -75
  147. package/src/hcanon.ts +9 -11
  148. package/src/hints.ts +108 -29
  149. package/src/jsonschema.ts +41 -0
  150. package/src/lang.ts +642 -203
  151. package/src/lsp.ts +75 -6
  152. package/src/mcp.ts +143 -6
  153. package/src/mod-tool.ts +48 -9
  154. package/src/mod.ts +110 -1
  155. package/src/patch.ts +31 -27
  156. package/src/provenance.ts +8 -1
  157. package/src/query.ts +4 -2
  158. package/src/reach.ts +52 -23
  159. package/src/relation.ts +139 -236
  160. package/src/sig.ts +345 -0
  161. package/src/sigdecl.ts +11 -0
  162. package/src/siggate.ts +144 -0
  163. package/src/std.ts +74 -16
  164. package/src/subsume.ts +59 -10
  165. package/src/unify.ts +102 -125
  166. package/src/utility.ts +7 -67
  167. package/src/val/AggFuncVal.ts +231 -4
  168. package/src/val/BagVal.ts +58 -9
  169. package/src/val/ConstraintVal.ts +34 -5
  170. package/src/val/ContainerKindVal.ts +158 -0
  171. package/src/val/CopyFuncVal.ts +0 -7
  172. package/src/val/DisjunctVal.ts +152 -40
  173. package/src/val/ExpectVal.ts +39 -4
  174. package/src/val/FuncBaseVal.ts +21 -8
  175. package/src/val/GraphAtomVal.ts +264 -0
  176. package/src/val/JunctionVal.ts +23 -6
  177. package/src/val/ListVal.ts +30 -37
  178. package/src/val/MapVal.ts +87 -39
  179. package/src/val/PathFuncVal.ts +107 -19
  180. package/src/val/PathVal.ts +221 -0
  181. package/src/val/PlusOpVal.ts +56 -37
  182. package/src/val/PrefVal.ts +186 -38
  183. package/src/val/RecurseVal.ts +285 -0
  184. package/src/val/RefVal.ts +105 -83
  185. package/src/val/ReferFuncVal.ts +445 -100
  186. package/src/val/ScalarKindVal.ts +12 -0
  187. package/src/val/SuperFuncVal.ts +137 -13
  188. package/src/val/TopVal.ts +1 -1
  189. package/src/val/Val.ts +44 -35
  190. package/src/val/arith.ts +4 -1
  191. package/src/vet.ts +36 -3
  192. package/src/view.ts +2563 -0
  193. package/dist/val/IdFuncVal.d.ts +0 -13
  194. package/dist/val/IdFuncVal.js +0 -54
  195. package/dist/val/IdFuncVal.js.map +0 -1
  196. package/src/val/IdFuncVal.ts +0 -91
package/src/view.ts ADDED
@@ -0,0 +1,2563 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+
3
+ // THE VIEWS (docs/design/VIEWS.0.md and VIEWS-ORDER.0.md): figures of
4
+ // an evaluated document, drawn as deterministic text a golden diff can
5
+ // check. Seven kinds:
6
+ //
7
+ // tree the dependency tree of one relation
8
+ // matrix the dependency matrix over one relation, in canon or
9
+ // partition order, with closure and the unmirrored mark
10
+ // graph the node-link drawing, as Mermaid, DOT or an ER diagram
11
+ // layer the architecture layers: stacked bands, one per value of
12
+ // a field, with the relation's upward edges called out
13
+ // sets the set-intersection panel over a named set family
14
+ // layers which document contributed which path (provenance)
15
+ // ladder the meet ladder at one path (the `why` record, drawn)
16
+ // poset the subsumption order over a set of documents
17
+ //
18
+ // A view consumes a REPORT, never the Val tree: the edge set `graphOf`
19
+ // derives (ts/src/graph.ts), the relation declarations, the generated
20
+ // value, the provenance record, the subsumption verdict. That is what
21
+ // keeps the two ports at parity -- Go's exported Val interface is five
22
+ // methods, and a Val-walking view would be TypeScript-only on the day
23
+ // it landed.
24
+ //
25
+ // Everything here is deterministic: nodes and edges are sorted by code
26
+ // point before emission, nothing iterates a map in insertion order, no
27
+ // coordinate is computed and no number is formatted beyond its decimal
28
+ // digits. The Go twin is go/view.go; what the two ports must agree on
29
+ // -- the rendered text, the loss report and the refusals -- is
30
+ // test/spec/view.tsv.
31
+ //
32
+ // EVERY RUN CARRIES A LOSS REPORT: what the figure could not draw, or
33
+ // drew differently from the model, aggregated by code with a count.
34
+ // Three codes are informational -- `edges_deduped` (several written
35
+ // positions, one fact), `inverse_suppressed` (a declared mirror,
36
+ // implied by the edge drawn) and `crossings` (a property of the
37
+ // emitted order, not of the model) -- and leave the verdict `rendered`. Every
38
+ // other code makes it `lossy`, which `--strict` refuses: a figure that
39
+ // quietly omits things is the failure this capability exists to avoid.
40
+
41
+ import { basename, dirname, isAbsolute, relative, resolve } from 'path'
42
+
43
+ import { Aontu } from './aontu'
44
+ import { failureFinding, anchorAt } from './vet'
45
+ import type { VetFinding } from './vet'
46
+ import type { TrustOptions } from './type'
47
+ import { graphOf } from './graph'
48
+ import type { Graph } from './graph'
49
+ import { cmpCodePoint } from './keyorder'
50
+ import { Provenance } from './provenance'
51
+ import type { WhyConjunct } from './provenance'
52
+ import { why, pathParts } from './query'
53
+ import { subsume } from './subsume'
54
+ import type { SubsumeProfile } from './subsume'
55
+
56
+
57
+ export type ViewVerdict = 'rendered' | 'lossy' | 'error'
58
+
59
+ export type ViewKind =
60
+ 'tree' | 'matrix' | 'graph' | 'layer' | 'sets' | 'layers' | 'ladder' | 'poset'
61
+
62
+ // The target grammars. Each kind declares the profiles it can render
63
+ // into, and the first is its default (PROFILES below).
64
+ export type ViewProfile = 'text' | 'mermaid' | 'dot' | 'er' | 'svg'
65
+
66
+ export type ViewOrder = 'canon' | 'partition'
67
+
68
+ // Which of the relation's edges the layer figure DRAWS. The bands
69
+ // already say which way every edge goes, so the default shows the ones
70
+ // that break the rule: `upward`. `all` draws the relation over the
71
+ // bands -- what a reader tracing one module's dependencies wants --
72
+ // and `none` leaves the bands alone. The default is `all` for a
73
+ // profile that lays edges out itself (mermaid) and `upward` for the
74
+ // fixed grids (text, svg), which is what each drew before the option
75
+ // existed.
76
+ export type ViewEdges = 'upward' | 'all' | 'none'
77
+
78
+ // One row of the loss report.
79
+ export type ViewLoss = {
80
+ code: string
81
+ count: number
82
+ detail?: string[]
83
+ }
84
+
85
+ // A further document of a poset, beside the entry.
86
+ export type ViewDoc = {
87
+ src: string
88
+ // Where it came from, so a relative include inside it resolves and
89
+ // so its label (the file name without `.aon`) is known.
90
+ path?: string
91
+ // The label to draw, overriding the one derived from `path`.
92
+ name?: string
93
+ }
94
+
95
+ export type ViewReport = {
96
+ verdict: ViewVerdict
97
+ kind: ViewKind
98
+
99
+ // The figure, as text. Present ONLY on `rendered` and `lossy` -- and
100
+ // present EMPTY for a document with nothing to draw, because an
101
+ // empty drawing of a model with nothing in it is the honest one.
102
+ text?: string
103
+
104
+ // The loss report, in code order. Empty on `error`.
105
+ loss: ViewLoss[]
106
+
107
+ // WHY the figure could not be drawn, in vet's finding shape. Present
108
+ // ONLY on `error`.
109
+ errors?: VetFinding[]
110
+ }
111
+
112
+ // One figure of a VIEW DOCUMENT: the declaration's key, what it drew,
113
+ // and the file the author says it belongs in.
114
+ export type ViewFigure = {
115
+ name: string
116
+ kind: ViewKind
117
+ // Where the declaration says to write it. The library never writes:
118
+ // the caller does, and only when every figure of the set rendered.
119
+ out: string
120
+ verdict: ViewVerdict
121
+ text?: string
122
+ loss: ViewLoss[]
123
+ errors?: VetFinding[]
124
+ }
125
+
126
+
127
+ // N figures of one document, one verdict. `error` if ANY figure
128
+ // refused -- a set of figures of one model is only meaningful whole.
129
+ export type ViewSetReport = {
130
+ verdict: ViewVerdict
131
+ views: ViewFigure[]
132
+ // WHY the set itself could not be read: the document does not stand
133
+ // up, or the declarations are not the shape a declaration has.
134
+ // A figure's own refusal rides on the figure.
135
+ errors?: VetFinding[]
136
+ }
137
+
138
+
139
+ export type ViewOptions = {
140
+ // The figure to draw. Absent means `tree`.
141
+ kind?: ViewKind
142
+ // The target grammar. Absent means the kind's first profile.
143
+ as?: ViewProfile
144
+ // Where the document CAME FROM, so a relative `@"file"` load inside
145
+ // it resolves from its own directory (relationCheck's precedent).
146
+ path?: string
147
+ // The include capability this document evaluates under
148
+ // (docs/trust.md).
149
+ trust?: TrustOptions
150
+ // Restrict the figure to nodes (or paths) under this path. For the
151
+ // ladder it is the path drawn, and required; for the poset it is
152
+ // where the documents are compared.
153
+ at?: string
154
+ // Refuse a figure with more than this many rows (matrix rows, graph
155
+ // and tree nodes, set rows, poset nodes, ladder rungs). Absent means
156
+ // sixty. A REFUSAL, not a truncation: a view that quietly omits
157
+ // things is the failure this capability exists to avoid.
158
+ maxRows?: number
159
+
160
+ // tree, matrix: draw OVER THIS RELATION. The tree draws every
161
+ // relation when absent; the matrix needs exactly one and refuses an
162
+ // ambiguous document.
163
+ relation?: string
164
+ // tree: draw only these subtrees. Absent means every root the edge
165
+ // set derives: a node nothing depends on.
166
+ roots?: string[]
167
+
168
+ // matrix: `canon` (label order, the default) or `partition` (leaves
169
+ // first, so an acyclic relation is a lower triangle).
170
+ order?: ViewOrder
171
+ // matrix: mark transitively reachable cells `+`.
172
+ closure?: boolean
173
+
174
+ // graph: restrict to these predicates. Absent means every one.
175
+ relations?: string[]
176
+ // graph: one subgraph per distinct value of this field of each node;
177
+ // layer: one band per distinct value, and required.
178
+ groupBy?: string
179
+ // layer: the bands in this order, top first. Absent means the order
180
+ // derived from the relation, which a model with an upward edge
181
+ // cannot settle on its own.
182
+ layers?: string[]
183
+ // layer: which edges to draw over the bands.
184
+ edges?: ViewEdges
185
+ // graph: the node label is this field's value rather than the path.
186
+ label?: string
187
+
188
+ // sets: the map whose keys are the sets, the field holding each
189
+ // set's members, and optionally the full element domain.
190
+ sets?: string
191
+ member?: string
192
+ universe?: string
193
+ // sets: drop intersections below this degree.
194
+ minDegree?: number
195
+ // sets, layers: elide columns beyond this many, counted in the loss
196
+ // report.
197
+ maxCols?: number
198
+
199
+ // layers: drop intersections holding fewer than this many paths.
200
+ minSize?: number
201
+
202
+ // poset: the subsumption profile, and the further documents.
203
+ profile?: SubsumeProfile
204
+ docs?: ViewDoc[]
205
+
206
+ // The file the figure belongs in. THE LIBRARY NEVER WRITES: this is
207
+ // carried through to the caller, which does -- and, for a view
208
+ // document, only once every figure of the set rendered.
209
+ out?: string
210
+
211
+ // The VIEW DOCUMENT (VIEWS.0.md, "6. The view document"): the path of
212
+ // a map whose values declare figures. `viewSet` reads it; `view`
213
+ // ignores it, because one call draws one figure.
214
+ views?: string
215
+ }
216
+
217
+
218
+ // Each kind's profiles, the first being its default. There is no
219
+ // global default, because there is no sensible text form of a
220
+ // node-link drawing and no sensible Mermaid form of a matrix.
221
+ const PROFILES: Record<ViewKind, ViewProfile[]> = {
222
+ tree: ['text', 'svg'],
223
+ matrix: ['text', 'svg'],
224
+ graph: ['mermaid', 'dot', 'er'],
225
+ layer: ['text', 'mermaid', 'svg'],
226
+ sets: ['text', 'svg'],
227
+ layers: ['text', 'svg'],
228
+ ladder: ['mermaid', 'dot'],
229
+ poset: ['mermaid', 'dot'],
230
+ }
231
+
232
+ // Loss codes that describe the drawing rather than a gap in it.
233
+ const INFORMATIONAL = ['edges_deduped', 'inverse_suppressed', 'crossings']
234
+
235
+ const DEFAULT_MAX_ROWS = 60
236
+
237
+ // The separator inside a composite map key: a character no path holds.
238
+ const SEP = '\u0000'
239
+
240
+
241
+ // ---------------------------------------------------------------------
242
+ // Findings
243
+
244
+ function finding(
245
+ code: string, cls: string, path: string, message: string, note?: string
246
+ ): VetFinding {
247
+ return {
248
+ code,
249
+ class: cls as any,
250
+ severity: 'error',
251
+ path,
252
+ message,
253
+ sites: [],
254
+ ...(undefined === note ? {} : { note }),
255
+ }
256
+ }
257
+
258
+
259
+ // A relation that draws nothing is a typo, and is refused for the same
260
+ // reason a misspelled root is: an empty figure and a misspelled name
261
+ // are the same file on disk, so the one that means nothing must not be
262
+ // renderable. NOT `refer_unresolved`: a relation name is not an
263
+ // address.
264
+ function relationFinding(relation: string, have: string[]): VetFinding {
265
+ return finding('view_relation_unknown', 'reference', '$',
266
+ `${relation} names no relation with edges in this document.`,
267
+ 'relations with edges: ' + have.join(', '))
268
+ }
269
+
270
+
271
+ // A root is a node of the DRAWN graph, the rule the node set follows:
272
+ // a path that exists in the document but takes no part in the relation
273
+ // is not in the drawing, and a root naming it is refused rather than
274
+ // drawn as an empty tree.
275
+ function rootFinding(
276
+ root: string, relation: string | undefined, nodes: string[]): VetFinding {
277
+ return finding('refer_unresolved', 'reference', '$',
278
+ `${root} is not a node of the ` +
279
+ `${undefined === relation ? '' : relation + ' '}graph.`,
280
+ 0 === nodes.length ? undefined : 'nodes in the graph: ' + nodes.join(', '))
281
+ }
282
+
283
+
284
+ // `--max-rows` is a REFUSAL, and the message names the narrowing
285
+ // options.
286
+ function rowsFinding(rows: number, max: number, narrow: string): VetFinding {
287
+ return finding('view_rows_exceeded', 'budget', '$',
288
+ `The figure has ${rows} rows, above --max-rows ${max}; ` +
289
+ `narrow it with ${narrow}, or raise the limit.`,
290
+ `rows: ${rows}, max: ${max}`)
291
+ }
292
+
293
+
294
+ // An inline piece may not contain a line terminator: a line is a
295
+ // line, which is what makes every renderer a total fold.
296
+ function lineBreakFinding(path: string): VetFinding {
297
+ return finding('view_line_break', 'parse', path,
298
+ 'A label holds a line terminator, which no figure line can carry.')
299
+ }
300
+
301
+
302
+ // ---------------------------------------------------------------------
303
+ // The edge set as the figures read it
304
+
305
+ // One distinct fact of the graph: a `(from, key, to)` triple, however
306
+ // many positions wrote it.
307
+ type Triple = { from: string, key: string, to: string }
308
+
309
+ type RelDecls = Map<string, { acyclic?: boolean, inverses: Set<string> }>
310
+
311
+
312
+ // A prefix test on PATHS, not strings: `$.a` covers `$.a.b` and `$.a`
313
+ // itself, and not `$.ab`.
314
+ function under(path: string, at: string | undefined): boolean {
315
+ return undefined === at || path === at || path.startsWith(at + '.')
316
+ }
317
+
318
+
319
+ // The deduplicated edge set, with the hidden contributions and the
320
+ // out-of-scope edges removed and the loss report told.
321
+ //
322
+ // `graphOf` emits one edge per WRITTEN POSITION by design, because each
323
+ // `at` is an editable site, and an identity-merged model declares each
324
+ // entity at two positions. Deduplication is part of the extraction
325
+ // contract, not a renderer's private cleverness, and the count is
326
+ // reported so nobody has to guess which number they are looking at.
327
+ //
328
+ // A HIDDEN edge -- one written inside a `hide()`-marked subtree -- is
329
+ // not drawn. A figure is committed to a repository, so anything drawn
330
+ // is disclosed, and the subtree's whole purpose is to say "not
331
+ // output". It is reported with its path instead, and `--strict`
332
+ // refuses the figure.
333
+ function triplesOf(
334
+ graph: Graph, at: string | undefined, loss: ViewLoss[]): Triple[] {
335
+ const edges = graph.edges
336
+ const hidden: string[] = []
337
+ const seen = new Map<string, Triple>()
338
+ let positions = 0
339
+ for (const e of edges) {
340
+ if (true === e.hidden) {
341
+ hidden.push(e.at)
342
+ continue
343
+ }
344
+ if (!under(e.from, at) || !under(e.to, at)) {
345
+ continue
346
+ }
347
+ positions++
348
+ seen.set(e.from + SEP + e.key + SEP + e.to,
349
+ { from: e.from, key: e.key, to: e.to })
350
+ }
351
+ if (0 < hidden.length) {
352
+ loss.push({
353
+ code: 'hidden_contribution', count: hidden.length,
354
+ detail: hidden.sort(cmpCodePoint),
355
+ })
356
+ }
357
+ // A link under an UNRESOLVED DISJUNCTION is not an edge (ADR-007),
358
+ // and the figure says so rather than dropping it in silence: the
359
+ // document has not decided, and a drawing that quietly picked an arm
360
+ // would be inventing the decision.
361
+ const undecided = (graph.disjunct ?? []).filter((p) => under(p, at))
362
+ if (0 < undecided.length) {
363
+ loss.push({
364
+ code: 'edges_in_disjunct', count: undecided.length, detail: undecided,
365
+ })
366
+ }
367
+ const out = [...seen.values()].sort((a, b) =>
368
+ cmpCodePoint(a.from, b.from) || cmpCodePoint(a.key, b.key)
369
+ || cmpCodePoint(a.to, b.to))
370
+ if (out.length < positions) {
371
+ loss.push({
372
+ code: 'edges_deduped', count: positions - out.length,
373
+ detail: [`${positions} written positions -> ` +
374
+ `${out.length} distinct triples`],
375
+ })
376
+ }
377
+ return out
378
+ }
379
+
380
+
381
+ // The relations with edges, in code-point order.
382
+ function keysOf(triples: Triple[]): string[] {
383
+ return [...new Set(triples.map((e) => e.key))].sort(cmpCodePoint)
384
+ }
385
+
386
+
387
+ // The node set is what the drawn edges CONNECT, in code-point order.
388
+ function nodesOf(triples: { from: string, to: string }[]): string[] {
389
+ const ns = new Set<string>()
390
+ for (const e of triples) {
391
+ ns.add(e.from)
392
+ ns.add(e.to)
393
+ }
394
+ return [...ns].sort(cmpCodePoint)
395
+ }
396
+
397
+
398
+ // THE SHORTEST SUFFIX THAT IS STILL UNIQUE, as a node's visible label.
399
+ //
400
+ // A node's name IS its path (ADR-014), and the paths in a real model
401
+ // are long: eight nodes labelled `$.catalog.domains.identity.services.auth`
402
+ // and its siblings is a correct diagram nobody can read. The label is
403
+ // therefore the fewest trailing segments that still tell this node from
404
+ // every other in the same drawing -- `auth` where that is unambiguous,
405
+ // `identity.auth` where it is not.
406
+ //
407
+ // The rule is a function of the node SET, so a drawing is deterministic
408
+ // while two drawings of different slices may label the same node
409
+ // differently -- which is correct, because uniqueness is a property of
410
+ // the set being drawn. The search is unbounded on purpose: at the full
411
+ // segment count the candidate is the whole path, which no other node
412
+ // shares, so it always ends.
413
+ function labelsOf(nodes: string[]): Map<string, string> {
414
+ const segs = new Map<string, string[]>(
415
+ nodes.map((n) => [n, n.replace(/^\$\.?/, '').split('.')]))
416
+ const out = new Map<string, string>()
417
+ for (const n of nodes) {
418
+ const parts = segs.get(n) as string[]
419
+ for (let take = 1; ; take++) {
420
+ const cand = parts.slice(Math.max(0, parts.length - take)).join('.')
421
+ const clash = nodes.some((m) => {
422
+ const ms = segs.get(m) as string[]
423
+ return m !== n &&
424
+ ms.slice(Math.max(0, ms.length - take)).join('.') === cand
425
+ })
426
+ if (!clash) {
427
+ out.set(n, cand)
428
+ break
429
+ }
430
+ }
431
+ }
432
+ return out
433
+ }
434
+
435
+
436
+ // Reachability over a directed edge set: node -> the set of nodes it
437
+ // reaches in one or more steps. Iterative closure, O(n * e), which is
438
+ // nothing at the sizes a figure can hold.
439
+ function reachOf(
440
+ nodes: string[], succ: Map<string, string[]>
441
+ ): Map<string, Set<string>> {
442
+ const out = new Map<string, Set<string>>()
443
+ for (const n of nodes) {
444
+ const seen = new Set<string>()
445
+ const stack = [...(succ.get(n) as string[])]
446
+ while (0 < stack.length) {
447
+ const m = stack.pop() as string
448
+ if (!seen.has(m)) {
449
+ seen.add(m)
450
+ stack.push(...(succ.get(m) as string[]))
451
+ }
452
+ }
453
+ out.set(n, seen)
454
+ }
455
+ return out
456
+ }
457
+
458
+
459
+ // ---------------------------------------------------------------------
460
+ // Text helpers, all on code units, none formatting a number
461
+
462
+ const pad = (s: string, n: number): string =>
463
+ s + ' '.repeat(Math.max(0, n - s.length))
464
+
465
+ const lpad = (s: string, n: number): string =>
466
+ ' '.repeat(Math.max(0, n - s.length)) + s
467
+
468
+ const widest = (ss: string[]): number =>
469
+ ss.reduce((w, s) => Math.max(w, s.length), 0)
470
+
471
+
472
+ // ---------------------------------------------------------------------
473
+ // Identifiers and escapes (VIEWS.0.md, "The renderers and the profiles")
474
+
475
+ // Injective by construction, with two disjoint prefixes and one
476
+ // predicate: `n_` + the name when its first code point is an ASCII
477
+ // letter and every code point is an ASCII letter, digit or `_`;
478
+ // otherwise `nq_` + the name with every other code point replaced by
479
+ // `_` and its lower-case hex. A code-point class test, not a regular
480
+ // expression: pattern matching is the one subsystem with a stated
481
+ // RE2-versus-RegExp divergence, and an encoder runs on every name.
482
+ function ident(name: string): string {
483
+ const letter = (c: number): boolean =>
484
+ (65 <= c && c <= 90) || (97 <= c && c <= 122)
485
+ const digit = (c: number): boolean => 48 <= c && c <= 57
486
+ const cps = [...name].map((ch) => ch.codePointAt(0) as number)
487
+ const plain = 0 < cps.length && letter(cps[0]) &&
488
+ cps.every((c) => letter(c) || digit(c) || 95 === c)
489
+ if (plain) {
490
+ return 'n_' + name
491
+ }
492
+ let out = 'nq_'
493
+ for (const c of cps) {
494
+ out += letter(c) || digit(c)
495
+ ? String.fromCodePoint(c) : '_' + lpad(c.toString(16), 2)
496
+ }
497
+ return out
498
+ }
499
+
500
+
501
+ // One pass, per code point, from a table keyed by DECIMAL CODE POINT.
502
+ // Mermaid: numeric entities only, never HTML names, so there is no
503
+ // name table to diverge; 124 is in it because `|` is the edge-label
504
+ // delimiter. DOT: the two escapes that also make it impossible for user
505
+ // text to forge DOT's own `\n` / `\l` / `\r` justification escapes.
506
+ const MERMAID_ESC: Record<number, string> = {
507
+ 34: '#34;', 35: '#35;', 38: '#38;', 60: '#60;', 62: '#62;',
508
+ 123: '#123;', 124: '#124;', 125: '#125;',
509
+ }
510
+ const DOT_ESC: Record<number, string> = { 34: '\\"', 92: '\\\\' }
511
+
512
+ function escape(text: string, table: Record<number, string>): string {
513
+ let out = ''
514
+ for (const ch of text) {
515
+ const rep = table[ch.codePointAt(0) as number]
516
+ out += undefined === rep ? ch : rep
517
+ }
518
+ return out
519
+ }
520
+
521
+ // U+000A, U+000D, U+2028 and U+2029: the four code points that end a
522
+ // line somewhere.
523
+ function hasLineBreak(text: string): boolean {
524
+ return /[\n\r\u2028\u2029]/.test(text)
525
+ }
526
+
527
+
528
+ // ---------------------------------------------------------------------
529
+ // SVG (VIEWS.0.md, "No SVG in v1" -- the phase after the text kinds)
530
+ //
531
+ // The cell-based kinds draw into SVG under the INTEGER RULE: every
532
+ // coordinate is a whole number of a fixed cell -- 8 units per
533
+ // character, 20 per line -- from the same counts that lay the text
534
+ // figure out, so no font is measured and both ports emit the same
535
+ // bytes. The reader's browser shapes the text; the geometry is ours.
536
+ // A figure is standalone (its own style block, with default colours)
537
+ // and themeable (every colour a CSS variable a host page can set).
538
+
539
+ const CH = 8
540
+ const LH = 20
541
+ const PAD = 8
542
+
543
+ const SVG_ESC: Record<number, string> = {
544
+ 34: '&quot;', 38: '&amp;', 60: '&lt;', 62: '&gt;',
545
+ }
546
+
547
+ const SVG_STYLE = '<style>' +
548
+ '.av{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px}' +
549
+ '.av-t{fill:var(--av-ink,#1f2328)}' +
550
+ '.av-m{fill:var(--av-muted,#6e7781)}' +
551
+ '.av-box{fill:var(--av-bg,#f6f8fa);stroke:var(--av-rule,#8c959f);stroke-width:1}' +
552
+ '.av-cell{fill:var(--av-bg,#f6f8fa);stroke:var(--av-rule-faint,#d0d7de);stroke-width:1}' +
553
+ '.av-direct{fill:var(--av-ink,#1f2328);stroke:var(--av-rule-faint,#d0d7de);stroke-width:1}' +
554
+ '.av-closure{fill:var(--av-closure,#9ec5fe);stroke:var(--av-rule-faint,#d0d7de);stroke-width:1}' +
555
+ '.av-unmirrored{fill:var(--av-warn,#e3b341);stroke:var(--av-rule-faint,#d0d7de);stroke-width:1}' +
556
+ '.av-line{stroke:var(--av-rule,#8c959f);stroke-width:1;fill:none}' +
557
+ '.av-up{stroke:var(--av-alert,#d1242f);stroke-width:1.5;fill:none;stroke-dasharray:4 3}' +
558
+ '.av-dot{fill:var(--av-ink,#1f2328)}' +
559
+ '.av-hole{fill:var(--av-bg,#f6f8fa);stroke:var(--av-rule-faint,#d0d7de);stroke-width:1}' +
560
+ '.av-bar{fill:var(--av-bar,#57606a)}' +
561
+ '</style>'
562
+
563
+ const svgEsc = (s: string): string => escape(s, SVG_ESC)
564
+
565
+ // The document: a viewBox the size of the figure, the style, and the
566
+ // parts, one per line, so the bytes read as a figure and diff as one.
567
+ function svgDoc(w: number, h: number, about: string, parts: string[]): string {
568
+ return [
569
+ `<svg xmlns="http://www.w3.org/2000/svg" class="av" viewBox="0 0 ${w} ${h}" ` +
570
+ `width="${w}" height="${h}" role="img" aria-label="${svgEsc(about)}">`,
571
+ SVG_STYLE,
572
+ ...parts,
573
+ '</svg>',
574
+ ].join('\n')
575
+ }
576
+
577
+ // A text run at a baseline. `anchor` is SVG's own vocabulary.
578
+ function svgText(
579
+ x: number, y: number, cls: string, text: string, anchor?: string
580
+ ): string {
581
+ return `<text x="${x}" y="${y}" class="${cls}"` +
582
+ (undefined === anchor ? '' : ` text-anchor="${anchor}"`) +
583
+ `>${svgEsc(text)}</text>`
584
+ }
585
+
586
+ // The relation a figure is over, for its description; a document with
587
+ // no edges has none to name.
588
+ const over = (relation: string | undefined): string =>
589
+ undefined === relation || '' === relation ? '' : ' over ' + relation
590
+
591
+ function svgRect(x: number, y: number, w: number, h: number, cls: string): string {
592
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" class="${cls}"/>`
593
+ }
594
+
595
+ function svgPath(d: string, cls: string): string {
596
+ return `<path d="${d}" class="${cls}"/>`
597
+ }
598
+
599
+
600
+ // ---------------------------------------------------------------------
601
+ // The tree
602
+
603
+ // One edge as the tree draws it: a declared inverse pair collapsed to
604
+ // one edge, and the label the branch carries.
605
+ type Drawn = { from: string, to: string, label: string }
606
+
607
+
608
+ // THE EDGE SET WITH DECLARED INVERSE PAIRS COLLAPSED to one logical
609
+ // edge, the tree's way: a relation with a declared inverse arrives
610
+ // twice -- once per direction -- and drawing it raw doubles every such
611
+ // relation.
612
+ //
613
+ // WHAT IS NOT COLLAPSED IS A MUTUAL RELATION: `a dependsOn b` and `b
614
+ // dependsOn a` are two facts under ONE key, and folding them into a
615
+ // single undirected edge erases the shortest cycle a model can have.
616
+ // The collapse is therefore per KEY PAIR rather than per node pair --
617
+ // two keys facing each other are an inverse, one key facing itself is
618
+ // a loop -- which is what makes `acyclic()`'s refusal drawable.
619
+ function collapse(triples: Triple[], relation: string | undefined): Drawn[] {
620
+ const pairs = new Map<string, Triple[]>()
621
+ for (const e of triples) {
622
+ const pair = [e.from, e.to].sort(cmpCodePoint).join(SEP)
623
+ const group = pairs.get(pair)
624
+ if (undefined === group) {
625
+ pairs.set(pair, [e])
626
+ }
627
+ else {
628
+ group.push(e)
629
+ }
630
+ }
631
+
632
+ const out: Drawn[] = []
633
+ for (const group of pairs.values()) {
634
+ // ONE KEY WINS THE PAIR, and every edge written under it stands.
635
+ // The named relation wins; otherwise the code-point-least key,
636
+ // which is arbitrary but stable. Keeping every edge under the
637
+ // winner is what preserves a MUTUAL relation, while the losing keys
638
+ // are the declared inverses, implied by the winner and not drawn
639
+ // again. With a relation named, its inverse is implied and naming
640
+ // both would double the label; without one, every key is shown,
641
+ // because picking silently would hide that two predicates are in
642
+ // play.
643
+ const keys = keysOf(group)
644
+ const named = undefined !== relation && keys.includes(relation)
645
+ const winner = named ? (relation as string) : keys[0]
646
+ const label = named ? winner : keys.join('/')
647
+ for (const e of group) {
648
+ if (e.key === winner) {
649
+ out.push({ from: e.from, to: e.to, label })
650
+ }
651
+ }
652
+ }
653
+
654
+ // One winner per pair, so (from, to) is unique and orders the set.
655
+ return out.sort((x, y) =>
656
+ cmpCodePoint(x.from, y.from) || cmpCodePoint(x.to, y.to))
657
+ }
658
+
659
+
660
+ type Kid = { to: string, label: string }
661
+
662
+ type Figure = { text?: string, errors?: VetFinding[] }
663
+
664
+
665
+ // THE DEPENDENCY TREE: the drawn edges, walked from each root, indented.
666
+ //
667
+ // A dependency graph is a DAG and not a tree -- two modules may share a
668
+ // dependency, and drawing that shared node once under each parent is
669
+ // what makes `cargo tree` and `npm ls` readable rather than
670
+ // exponential. So this is a SPANNING WALK with two honest marks: `(*)`
671
+ // where a subtree is elided because the node was expanded earlier, and
672
+ // `(cycle)` where an edge closes a loop. The first is routine in a
673
+ // correct model -- a diamond is good engineering, not a fault. The
674
+ // second cannot arise from a model whose relation declares
675
+ // `acyclic()`, and is drawn rather than thrown because a renderer that
676
+ // hangs on a hostile input is a renderer that cannot be pointed at one.
677
+ //
678
+ // Which nodes are roots is DERIVED, not asked for: a root is a node
679
+ // nothing depends on. `roots` overrides that to draw named subtrees.
680
+ // The order of everything -- roots, children, the choice of which
681
+ // occurrence of a shared node is the expanded one -- follows the label
682
+ // sort, so the drawing is a function of the model alone.
683
+ // One drawn row of the tree, for the SVG: its depth, its text, the
684
+ // mark after it, and the row of its parent (-1 for a root). A blank
685
+ // separator between roots is `null`.
686
+ type TreeRow = { depth: number, text: string, mark: string, parent: number }
687
+
688
+
689
+ function drawTree(
690
+ all: Drawn[], relation: string | undefined, roots: string[], max: number,
691
+ as: ViewProfile
692
+ ): Figure {
693
+ // With a relation named, the tree is OVER THAT RELATION. A node-link
694
+ // diagram can label each edge and so draw every relation at once; a
695
+ // tree cannot without becoming unreadable, and walking two relations
696
+ // as though they were one would draw a containment the model does
697
+ // not state.
698
+ const kept = undefined === relation
699
+ ? all : all.filter((e) => e.label === relation)
700
+
701
+ if (undefined !== relation && 0 === kept.length && 0 < all.length) {
702
+ const have = [...new Set(all.flatMap((e) => e.label.split('/')))]
703
+ .sort(cmpCodePoint)
704
+ return { errors: [relationFinding(relation, have)] }
705
+ }
706
+
707
+ // The node set is what the drawn relation CONNECTS. A root naming
708
+ // anything else is a typo, and it is refused rather than drawn.
709
+ const nodes = nodesOf(kept)
710
+ if (max < nodes.length) {
711
+ return {
712
+ errors: [rowsFinding(nodes.length, max, '--at, --relation or --root')],
713
+ }
714
+ }
715
+ const lab = labelsOf(nodes)
716
+ const label = (n: string): string => lab.get(n) as string
717
+
718
+ const kids = new Map<string, Kid[]>(nodes.map((n) => [n, []]))
719
+ for (const e of kept) {
720
+ (kids.get(e.from) as Kid[]).push({ to: e.to, label: e.label })
721
+ }
722
+ for (const list of kids.values()) {
723
+ list.sort((x, y) => cmpCodePoint(label(x.to), label(y.to)))
724
+ }
725
+
726
+ // The relation is named on the branch only where more than one is
727
+ // drawn. Naming the single relation on every line of a tree that has
728
+ // exactly one is noise; leaving it off where there are two would
729
+ // hide which edge was walked.
730
+ const many = 1 < new Set(kept.map((e) => e.label)).size
731
+ const byLabel = (a: string, b: string): number =>
732
+ cmpCodePoint(label(a), label(b))
733
+
734
+ let named: string[]
735
+ if (0 < roots.length) {
736
+ const missing = roots.filter((r) => !kids.has(r))
737
+ if (0 < missing.length) {
738
+ return { errors: missing.map((r) => rootFinding(r, relation, nodes)) }
739
+ }
740
+ named = [...new Set(roots)].sort(byLabel)
741
+ }
742
+ else {
743
+ // A root is a node nothing depends on. A SELF-EDGE does not make a
744
+ // node depended upon for this purpose: a module that names itself
745
+ // would otherwise stop being a root and take its whole subtree out
746
+ // of the drawing.
747
+ const depended = new Set(
748
+ kept.filter((e) => e.to !== e.from).map((e) => e.to))
749
+ named = nodes.filter((n) => !depended.has(n)).sort(byLabel)
750
+ }
751
+
752
+ const out: string[] = []
753
+ const rows: (TreeRow | null)[] = []
754
+ const expanded = new Set<string>()
755
+
756
+ const draw = (root: string): void => {
757
+ if (0 < out.length) {
758
+ out.push('')
759
+ rows.push(null)
760
+ }
761
+ out.push(label(root))
762
+ rows.push({ depth: 0, text: label(root), mark: '', parent: rows.length })
763
+ expanded.add(root)
764
+
765
+ // ITERATIVE, with the ancestor chain carried as a set that is added
766
+ // to on the way down and removed from on the way up. A recursive
767
+ // walk is O(depth) stack frames and a deep dependency chain is a
768
+ // real shape, so the drawing of a model must not depend on how deep
769
+ // the interpreter lets it go.
770
+ const chain = new Set<string>([root])
771
+ const stack: { node: string, prefix: string, at: number, row: number }[] =
772
+ [{ node: root, prefix: '', at: 0, row: rows.length - 1 }]
773
+ while (0 < stack.length) {
774
+ const frame = stack[stack.length - 1]
775
+ const list = kids.get(frame.node) as Kid[]
776
+ if (frame.at >= list.length) {
777
+ chain.delete(frame.node)
778
+ stack.pop()
779
+ continue
780
+ }
781
+ const edge = list[frame.at++]
782
+ const last = frame.at === list.length
783
+ const loop = chain.has(edge.to)
784
+ const seen = expanded.has(edge.to)
785
+ const grown = 0 < (kids.get(edge.to) as Kid[]).length
786
+ const text = label(edge.to) + (many ? ' (' + edge.label + ')' : '')
787
+ const mark = loop ? ' (cycle)' : (seen && grown ? ' (*)' : '')
788
+ out.push(frame.prefix + (last ? '└── ' : '├── ') + text + mark)
789
+ rows.push({ depth: stack.length, text, mark, parent: frame.row })
790
+ if (loop || seen) {
791
+ continue
792
+ }
793
+ expanded.add(edge.to)
794
+ chain.add(edge.to)
795
+ stack.push({
796
+ node: edge.to,
797
+ prefix: frame.prefix + (last ? ' ' : '│ '),
798
+ at: 0,
799
+ row: rows.length - 1,
800
+ })
801
+ }
802
+ }
803
+
804
+ for (const root of named) {
805
+ draw(root)
806
+ }
807
+
808
+ // EVERY NODE IS DRAWN. A component whose nodes all depend on each
809
+ // other has no node nothing depends on, so the derived roots miss it
810
+ // entirely -- and a graph with roots elsewhere would drop it in
811
+ // silence, which is the one thing a drawing must not do. The
812
+ // least-labelled node left is taken as a root of its own, until
813
+ // nothing is left. An explicitly named root is a request for one
814
+ // subtree and is left alone.
815
+ if (0 === roots.length) {
816
+ for (const n of nodes) {
817
+ if (!expanded.has(n)) {
818
+ draw(n)
819
+ }
820
+ }
821
+ }
822
+
823
+ return { text: 'svg' === as ? treeSvg(rows, nodes.length) : out.join('\n') }
824
+ }
825
+
826
+
827
+ // The tree as SVG: one line per row, each node indented one unit per
828
+ // depth, joined to its parent by a path that drops from the parent's
829
+ // row and turns in to the child. The marks are muted text after the
830
+ // label.
831
+ function treeSvg(rows: (TreeRow | null)[], count: number): string {
832
+ const U = 24
833
+ const parts: string[] = []
834
+ let width = 0
835
+ rows.forEach((r, i) => {
836
+ if (null === r) {
837
+ return
838
+ }
839
+ const y = i * LH
840
+ const x = r.depth * U + 4
841
+ if (0 < r.depth) {
842
+ const px = (r.depth - 1) * U + 8
843
+ parts.push(svgPath(`M${px} ${r.parent * LH + LH}V${y + 10}H${x - 2}`, 'av-line'))
844
+ }
845
+ parts.push('' === r.mark
846
+ ? svgText(x, y + 14, 'av-t', r.text)
847
+ : `<text x="${x}" y="${y + 14}"><tspan class="av-t">${svgEsc(r.text)}` +
848
+ `</tspan><tspan class="av-m">${svgEsc(r.mark)}</tspan></text>`)
849
+ width = Math.max(width, x + (r.text.length + r.mark.length) * CH)
850
+ })
851
+ return svgDoc(width + PAD, rows.length * LH + PAD,
852
+ `Dependency tree: ${count} nodes`, parts)
853
+ }
854
+
855
+
856
+ // ---------------------------------------------------------------------
857
+ // The matrix (Ghoniem et al. 2004; Sangal et al. 2005)
858
+
859
+ // THE PARTITION ORDER: leaves first. Repeatedly take every unplaced
860
+ // node whose every successor is placed, in label order, as the next
861
+ // layer. That is a topological sort with a canonical tiebreak, and on
862
+ // an acyclic relation it yields a perfect lower triangle -- which IS
863
+ // the acyclicity proof, in the picture's own shape. Where nothing can
864
+ // be placed the relation has a cycle: the least unplaced node is
865
+ // placed alone, the strongly connected component it sits in is
866
+ // reported as `cycle_block`, and the walk continues -- the cycle's
867
+ // above-diagonal cell is then the acyclicity violation, drawn.
868
+ function partition(
869
+ nodes: string[], succ: Map<string, string[]>,
870
+ reach: Map<string, Set<string>>, label: (n: string) => string,
871
+ loss: ViewLoss[]
872
+ ): string[] {
873
+ const order = nodes.slice().sort((a, b) => cmpCodePoint(label(a), label(b)))
874
+ const placed = new Set<string>()
875
+ const out: string[] = []
876
+ const blocks: string[] = []
877
+ while (out.length < order.length) {
878
+ const ready = order.filter((n) => !placed.has(n) &&
879
+ (succ.get(n) as string[]).every((s) => s === n || placed.has(s)))
880
+ if (0 < ready.length) {
881
+ for (const n of ready) {
882
+ placed.add(n)
883
+ out.push(n)
884
+ }
885
+ continue
886
+ }
887
+ const least = order.find((n) => !placed.has(n)) as string
888
+ const scc = order.filter((n) => !placed.has(n) && (n === least ||
889
+ ((reach.get(least) as Set<string>).has(n) &&
890
+ (reach.get(n) as Set<string>).has(least))))
891
+ blocks.push(scc.map(label).join(' '))
892
+ placed.add(least)
893
+ out.push(least)
894
+ }
895
+ if (0 < blocks.length) {
896
+ loss.push({ code: 'cycle_block', count: blocks.length, detail: blocks })
897
+ }
898
+ return out
899
+ }
900
+
901
+
902
+ // The relation a matrix draws: the one named, else the only one with
903
+ // edges, else a refusal -- a matrix over two predicates at once would
904
+ // draw a containment the model does not state.
905
+ function pickRelation(
906
+ relation: string | undefined, keys: string[]
907
+ ): { relation?: string, error?: VetFinding } {
908
+ if (undefined !== relation) {
909
+ return keys.includes(relation) || 0 === keys.length
910
+ ? { relation } : { error: relationFinding(relation, keys) }
911
+ }
912
+ if (1 < keys.length) {
913
+ return {
914
+ error: finding('view_relation_ambiguous', 'reference', '$',
915
+ 'The document has several relations with edges; ' +
916
+ 'name one with --relation.',
917
+ 'relations with edges: ' + keys.join(', ')),
918
+ }
919
+ }
920
+ // No edges at all: no relation, and the empty name says so, as it
921
+ // does in the Go port.
922
+ return { relation: keys[0] ?? '' }
923
+ }
924
+
925
+
926
+ function drawMatrix(
927
+ triples: Triple[], decls: RelDecls,
928
+ o: { relation?: string, order: ViewOrder, closure: boolean, as: ViewProfile },
929
+ max: number, loss: ViewLoss[]
930
+ ): Figure {
931
+ const picked = pickRelation(o.relation, keysOf(triples))
932
+ if (undefined !== picked.error) {
933
+ return { errors: [picked.error] }
934
+ }
935
+ const relation = picked.relation as string
936
+ const rel = triples.filter((e) => e.key === relation)
937
+ const nodes = nodesOf(rel)
938
+ if (max < nodes.length) {
939
+ return { errors: [rowsFinding(nodes.length, max, '--at or --relation')] }
940
+ }
941
+ const lab = labelsOf(nodes)
942
+ const label = (n: string): string => lab.get(n) as string
943
+
944
+ const succ = new Map<string, string[]>(nodes.map((n) => [n, []]))
945
+ const direct = new Set<string>()
946
+ for (const e of rel) {
947
+ (succ.get(e.from) as string[]).push(e.to)
948
+ direct.add(e.from + SEP + e.to)
949
+ }
950
+ const reach = reachOf(nodes, succ)
951
+
952
+ // The `unmirrored` mark: an edge under a predicate that declares
953
+ // `inverse(n)` whose mirror is absent from the full edge set. The
954
+ // matrix shows in one glyph what `aontu relations` reports as
955
+ // `relation_inverse_missing`, and both read one edge set.
956
+ const inverses = [...(decls.get(relation)?.inverses ?? [])]
957
+ const mirrored = (from: string, to: string): boolean =>
958
+ 0 === inverses.length || triples.some((e) =>
959
+ e.from === to && e.to === from && inverses.includes(e.key))
960
+
961
+ const order = 'partition' === o.order
962
+ ? partition(nodes, succ, reach, label, loss)
963
+ : nodes.slice().sort((a, b) => cmpCodePoint(label(a), label(b)))
964
+
965
+ const idx = order.map((_, i) => String(i + 1))
966
+ const iw = widest(idx)
967
+ const w = widest(order.map(label))
968
+ const lines: string[] = []
969
+
970
+ // The index header, one line per digit when the count needs more
971
+ // than one: the digits stack, most significant line first, so every
972
+ // column stays one character wide.
973
+ for (let d = 0; d < iw; d++) {
974
+ lines.push(' '.repeat(w + 1 + iw + 1) +
975
+ idx.map((s) => lpad(s, iw)[d]).join(' '))
976
+ }
977
+
978
+ let above = 0
979
+ const grid: string[][] = []
980
+ order.forEach((r, ri) => {
981
+ const cells = order.map((c, ci) => {
982
+ const isDirect = direct.has(r + SEP + c)
983
+ if (isDirect && ci > ri) {
984
+ above++
985
+ }
986
+ // A SELF-DEPENDENCY is drawn on the diagonal rather than hidden
987
+ // by it: it is the shortest cycle a model can have, and exactly
988
+ // the fact a dependency matrix is read for.
989
+ return isDirect ? (mirrored(r, c) ? 'X' : '!')
990
+ : ri === ci ? '\\'
991
+ : o.closure && (reach.get(r) as Set<string>).has(c) ? '+' : '.'
992
+ })
993
+ grid.push(cells)
994
+ lines.push(
995
+ pad(label(r), w) + ' ' + lpad(idx[ri], iw) + ' ' + cells.join(' '))
996
+ })
997
+ const footer = `# above-diagonal direct cells: ${above}`
998
+ lines.push(footer)
999
+ if ('svg' === o.as) {
1000
+ return {
1001
+ text: matrixSvg(order.map(label), idx, grid, footer,
1002
+ `Dependency matrix${over(relation)}: ${order.length} rows, ` +
1003
+ `${above} direct cells above the diagonal`),
1004
+ }
1005
+ }
1006
+ return { text: lines.join('\n') }
1007
+ }
1008
+
1009
+
1010
+ // The matrix as SVG: the same glyph grid as cells, each a square whose
1011
+ // class is its state, the diagonal drawn as a line through its cell.
1012
+ const CELL_CLASS: Record<string, string> = {
1013
+ X: 'av-direct', '!': 'av-unmirrored', '+': 'av-closure',
1014
+ '.': 'av-cell', '\\': 'av-cell',
1015
+ }
1016
+
1017
+ function matrixSvg(
1018
+ labels: string[], idx: string[], grid: string[][], footer: string,
1019
+ about: string
1020
+ ): string {
1021
+ const S = 20
1022
+ const w = widest(labels)
1023
+ const iw = widest(idx)
1024
+ const gutter = w * CH + 8 + iw * CH + 8
1025
+ const y0 = LH + 4
1026
+ const parts: string[] = []
1027
+ idx.forEach((s, c) => {
1028
+ parts.push(svgText(gutter + c * S + 10, 14, 'av-m', s, 'middle'))
1029
+ })
1030
+ labels.forEach((l, r) => {
1031
+ const y = y0 + r * S
1032
+ parts.push(svgText(4, y + 14, 'av-t', l))
1033
+ parts.push(svgText(gutter - 8, y + 14, 'av-m', idx[r], 'end'))
1034
+ grid[r].forEach((g, c) => {
1035
+ const x = gutter + c * S
1036
+ parts.push(svgRect(x, y, S, S, CELL_CLASS[g]))
1037
+ if ('\\' === g) {
1038
+ parts.push(svgPath(`M${x} ${y}L${x + S} ${y + S}`, 'av-line'))
1039
+ }
1040
+ })
1041
+ })
1042
+ const n = labels.length
1043
+ parts.push(svgText(4, y0 + n * S + 16, 'av-m', footer))
1044
+ const width = Math.max(gutter + n * S, 4 + footer.length * CH) + PAD
1045
+ return svgDoc(width, y0 + n * S + LH + PAD, about, parts)
1046
+ }
1047
+
1048
+
1049
+ // ---------------------------------------------------------------------
1050
+ // The node-link graph
1051
+
1052
+ type GNode = { path: string, label: string, id: string, group?: string }
1053
+ type GEdge = { from: string, key: string, to: string }
1054
+
1055
+
1056
+ // A node's field, as label text: the value of a scalar leaf at
1057
+ // `path.field`, taken as its canon for anything but a string. A value
1058
+ // the document leaves open is `unresolved_field` rather than an error.
1059
+ function fieldOf(root: any, path: string, field: string): string | undefined {
1060
+ const v: any = anchorAt(root, path + '.' + field)
1061
+ if (null == v || true !== v.isVal) {
1062
+ return undefined
1063
+ }
1064
+ if ('string' === typeof v.peg) {
1065
+ return v.peg
1066
+ }
1067
+ return true === v.isScalar ? v.canon : undefined
1068
+ }
1069
+
1070
+
1071
+ function drawGraph(
1072
+ triples: Triple[], decls: RelDecls, root: any,
1073
+ o: { relations: string[], groupBy?: string, label?: string, as: ViewProfile },
1074
+ max: number, loss: ViewLoss[]
1075
+ ): Figure {
1076
+ const keys = keysOf(triples)
1077
+ for (const r of o.relations) {
1078
+ if (!keys.includes(r)) {
1079
+ return { errors: [relationFinding(r, keys)] }
1080
+ }
1081
+ }
1082
+ const kept = 0 === o.relations.length
1083
+ ? triples : triples.filter((e) => o.relations.includes(e.key))
1084
+
1085
+ // INVERSE SUPPRESSION: a hand-maintained mirror under a declared
1086
+ // `inverse(n)` is one fact drawn twice, so the mirror half is not
1087
+ // drawn and the count is reported. The declaring direction wins.
1088
+ const declared = (key: string, mirror: string): boolean =>
1089
+ true === decls.get(key)?.inverses.has(mirror)
1090
+ const edges: GEdge[] = []
1091
+ let suppressed = 0
1092
+ for (const e of kept) {
1093
+ const mirror = kept.some((m) =>
1094
+ m.from === e.to && m.to === e.from && declared(m.key, e.key))
1095
+ if (mirror) {
1096
+ suppressed++
1097
+ }
1098
+ else {
1099
+ edges.push(e)
1100
+ }
1101
+ }
1102
+ if (0 < suppressed) {
1103
+ loss.push({ code: 'inverse_suppressed', count: suppressed })
1104
+ }
1105
+
1106
+ const paths = nodesOf(edges)
1107
+ if (max < paths.length) {
1108
+ return { errors: [rowsFinding(paths.length, max, '--at or --relation')] }
1109
+ }
1110
+ const lab = labelsOf(paths)
1111
+
1112
+ // `--group-by` and `--label` read a field of each node; a node
1113
+ // without a value there is counted, and drawn ungrouped or under
1114
+ // its path.
1115
+ const unresolved: string[] = []
1116
+ const nodes: GNode[] = paths.map((p) => {
1117
+ const short = lab.get(p) as string
1118
+ const node: GNode = { path: p, label: short, id: ident(short) }
1119
+ if (undefined !== o.groupBy) {
1120
+ const g = fieldOf(root, p, o.groupBy)
1121
+ if (undefined === g) {
1122
+ unresolved.push(p + '.' + o.groupBy)
1123
+ }
1124
+ else {
1125
+ node.group = g
1126
+ }
1127
+ }
1128
+ if (undefined !== o.label) {
1129
+ const l = fieldOf(root, p, o.label)
1130
+ if (undefined === l) {
1131
+ unresolved.push(p + '.' + o.label)
1132
+ }
1133
+ else {
1134
+ node.label = l
1135
+ }
1136
+ }
1137
+ return node
1138
+ })
1139
+ if (0 < unresolved.length) {
1140
+ loss.push({
1141
+ code: 'unresolved_field', count: unresolved.length,
1142
+ detail: unresolved.sort(cmpCodePoint),
1143
+ })
1144
+ }
1145
+
1146
+ for (const n of nodes) {
1147
+ if (hasLineBreak(n.label) || hasLineBreak(n.group ?? '')) {
1148
+ return { errors: [lineBreakFinding(n.path)] }
1149
+ }
1150
+ }
1151
+
1152
+ // Groups in label order, ids ordinal; nodes within a group, and the
1153
+ // ungrouped after them, in label order. That order is the emitted
1154
+ // order, and the crossing count is a property of it.
1155
+ const groups = [...new Set(nodes.filter((n) => undefined !== n.group)
1156
+ .map((n) => n.group as string))].sort(cmpCodePoint)
1157
+ const byLabel = (a: GNode, b: GNode): number =>
1158
+ cmpCodePoint(a.label, b.label) || cmpCodePoint(a.path, b.path)
1159
+ const emitted: GNode[] = []
1160
+ for (const g of groups) {
1161
+ emitted.push(...nodes.filter((n) => n.group === g).sort(byLabel))
1162
+ }
1163
+ const loose = nodes.filter((n) => undefined === n.group).sort(byLabel)
1164
+ emitted.push(...loose)
1165
+
1166
+ const byPath = new Map<string, GNode>(nodes.map((n) => [n.path, n]))
1167
+ const node = (p: string): GNode => byPath.get(p) as GNode
1168
+ const at = new Map<string, number>(emitted.map((n, i) => [n.path, i]))
1169
+ const drawn = edges.slice().sort((a, b) =>
1170
+ cmpCodePoint(node(a.from).label, node(b.from).label)
1171
+ || cmpCodePoint(node(a.to).label, node(b.to).label)
1172
+ || cmpCodePoint(a.key, b.key))
1173
+
1174
+ // Crossings in the emitted order: two edges cross when their spans
1175
+ // interleave. A count, not a layout -- the consumer lays the picture
1176
+ // out, and this says how tangled the order it is handed is.
1177
+ let crossings = 0
1178
+ const span = (e: GEdge): [number, number] => {
1179
+ const a = at.get(e.from) as number
1180
+ const b = at.get(e.to) as number
1181
+ return a < b ? [a, b] : [b, a]
1182
+ }
1183
+ for (let i = 0; i < drawn.length; i++) {
1184
+ for (let j = i + 1; j < drawn.length; j++) {
1185
+ const [a1, b1] = span(drawn[i])
1186
+ const [a2, b2] = span(drawn[j])
1187
+ if ((a1 < a2 && a2 < b1 && b1 < b2) || (a2 < a1 && a1 < b2 && b2 < b1)) {
1188
+ crossings++
1189
+ }
1190
+ }
1191
+ }
1192
+ if (0 < crossings) {
1193
+ loss.push({ code: 'crossings', count: crossings })
1194
+ }
1195
+
1196
+ const id = (p: string): string => node(p).id
1197
+ const out: string[] = []
1198
+ if ('mermaid' === o.as) {
1199
+ const esc = (s: string): string => escape(s, MERMAID_ESC)
1200
+ out.push('flowchart LR')
1201
+ groups.forEach((g, gi) => {
1202
+ out.push(` subgraph g${gi}["${esc(g)}"]`)
1203
+ for (const n of emitted.filter((n) => n.group === g)) {
1204
+ out.push(` ${n.id}["${esc(n.label)}"]`)
1205
+ }
1206
+ out.push(' end')
1207
+ })
1208
+ for (const n of loose) {
1209
+ out.push(` ${n.id}["${esc(n.label)}"]`)
1210
+ }
1211
+ for (const e of drawn) {
1212
+ out.push(` ${id(e.from)} -->|"${esc(e.key)}"| ${id(e.to)}`)
1213
+ }
1214
+ }
1215
+ else if ('dot' === o.as) {
1216
+ const esc = (s: string): string => escape(s, DOT_ESC)
1217
+ out.push('digraph G {', ' rankdir=LR;', ' node [shape=box];')
1218
+ groups.forEach((g, gi) => {
1219
+ out.push(` subgraph cluster_g${gi} {`, ` label="${esc(g)}";`)
1220
+ for (const n of emitted.filter((n) => n.group === g)) {
1221
+ out.push(` ${n.id} [label="${esc(n.label)}"];`)
1222
+ }
1223
+ out.push(' }')
1224
+ })
1225
+ for (const n of loose) {
1226
+ out.push(` ${n.id} [label="${esc(n.label)}"];`)
1227
+ }
1228
+ for (const e of drawn) {
1229
+ out.push(` ${id(e.from)} -> ${id(e.to)} [label="${esc(e.key)}"];`)
1230
+ }
1231
+ out.push('}')
1232
+ }
1233
+ else {
1234
+ // Entity relationships, as Mermaid's own erDiagram. Cardinality is
1235
+ // not something the model states, so every relationship is drawn
1236
+ // many-to-many and the label carries the predicate: drawing a
1237
+ // cardinality the model does not assert would be an invention. An
1238
+ // erDiagram has no separate label -- the identifier IS what the
1239
+ // reader sees -- so it is the encoded label, unique by the label
1240
+ // rule. Every node is in some relationship, since the node set is
1241
+ // what the edges connect.
1242
+ const esc = (s: string): string => escape(s, MERMAID_ESC)
1243
+ out.push('erDiagram')
1244
+ for (const e of drawn) {
1245
+ out.push(` ${id(e.from)} }o--o{ ${id(e.to)} : "${esc(e.key)}"`)
1246
+ }
1247
+ }
1248
+ return { text: out.join('\n') }
1249
+ }
1250
+
1251
+
1252
+ // ---------------------------------------------------------------------
1253
+ // The architecture layers: the classic stacked-band drawing
1254
+
1255
+ type Band = { name: string, nodes: GNode[] }
1256
+
1257
+
1258
+ // THE LAYER DIAGRAM every architecture document has a hand-drawn
1259
+ // version of: one band per layer, the layers stacked with the one
1260
+ // nothing depends on at the top, each module in its band, and the
1261
+ // rule -- dependencies point DOWN -- read off the bands. The band a
1262
+ // node belongs to is the value of `--group-by`; the order of the
1263
+ // bands is DERIVED from the relation, as the partition order over the
1264
+ // layer-level graph (a layer depends on the layers its modules depend
1265
+ // on), so it is a function of the model and not of a list somebody has
1266
+ // to keep in step with it -- unless the model has an upward edge, when
1267
+ // the layer graph is cyclic and no order is derivable, which is what
1268
+ // `--layers` (top first) is for. A sideways edge (within one band) is
1269
+ // ordinary engineering and counted; an UPWARD edge is the violation
1270
+ // the drawing exists to show, and is named under the figure.
1271
+ function drawLayer(
1272
+ triples: Triple[], root: any,
1273
+ o: {
1274
+ relation?: string, groupBy?: string, layers: string[],
1275
+ edges?: ViewEdges, as: ViewProfile,
1276
+ },
1277
+ max: number, loss: ViewLoss[]
1278
+ ): Figure {
1279
+ if (undefined === o.groupBy) {
1280
+ return {
1281
+ errors: [finding('view_group_required', 'reference', '$',
1282
+ 'The layer diagram needs the field that names each node\'s layer; ' +
1283
+ 'name it with --group-by.')],
1284
+ }
1285
+ }
1286
+ const picked = pickRelation(o.relation, keysOf(triples))
1287
+ if (undefined !== picked.error) {
1288
+ return { errors: [picked.error] }
1289
+ }
1290
+ const relation = picked.relation as string
1291
+ const rel = triples.filter((e) => e.key === relation)
1292
+ const paths = nodesOf(rel)
1293
+ if (max < paths.length) {
1294
+ return { errors: [rowsFinding(paths.length, max, '--at or --relation')] }
1295
+ }
1296
+ const lab = labelsOf(paths)
1297
+
1298
+ // A node whose layer field is unresolved is counted and drawn in a
1299
+ // band of its own at the bottom, named `-`.
1300
+ const unresolved: string[] = []
1301
+ const nodes: GNode[] = paths.map((p) => {
1302
+ const short = lab.get(p) as string
1303
+ const g = fieldOf(root, p, o.groupBy as string)
1304
+ if (undefined === g) {
1305
+ unresolved.push(p + '.' + o.groupBy)
1306
+ }
1307
+ return { path: p, label: short, id: ident(short), group: g ?? '-' }
1308
+ })
1309
+ if (0 < unresolved.length) {
1310
+ loss.push({
1311
+ code: 'unresolved_field', count: unresolved.length,
1312
+ detail: unresolved.sort(cmpCodePoint),
1313
+ })
1314
+ }
1315
+ for (const n of nodes) {
1316
+ if (hasLineBreak(n.group as string)) {
1317
+ return { errors: [lineBreakFinding(n.path)] }
1318
+ }
1319
+ }
1320
+ const byPath = new Map<string, GNode>(nodes.map((n) => [n.path, n]))
1321
+ const node = (p: string): GNode => byPath.get(p) as GNode
1322
+
1323
+ // The layer-level graph, and its partition order: leaves first, so
1324
+ // the band nothing depends on is placed LAST and drawn at the top.
1325
+ const names = [...new Set(nodes.map((n) => n.group as string))]
1326
+ .filter((g) => '-' !== g).sort(cmpCodePoint)
1327
+ const succ = new Map<string, string[]>(names.map((g) => [g, []]))
1328
+ for (const e of rel) {
1329
+ const from = node(e.from).group as string
1330
+ const to = node(e.to).group as string
1331
+ if (from !== to && '-' !== from && '-' !== to
1332
+ && !o.layers.includes(from) && !o.layers.includes(to)) {
1333
+ (succ.get(from) as string[]).push(to)
1334
+ }
1335
+ }
1336
+ // Named bands first, in the order given; the rest derived, and the
1337
+ // unresolved band last.
1338
+ const given = o.layers.filter((g) => names.includes(g))
1339
+ const rest = names.filter((g) => !given.includes(g))
1340
+ const same = (g: string): string => g
1341
+ const order = given.concat(
1342
+ partition(rest, succ, reachOf(rest, succ), same, loss).reverse())
1343
+ if (nodes.some((n) => '-' === n.group)) {
1344
+ order.push('-')
1345
+ }
1346
+ // Labels are unique in a drawing, so they order a band on their own.
1347
+ const bands: Band[] = order.map((name) => ({
1348
+ name,
1349
+ nodes: nodes.filter((n) => n.group === name).sort((a, b) =>
1350
+ cmpCodePoint(a.label, b.label)),
1351
+ }))
1352
+ const level = new Map<string, number>(order.map((g, i) => [g, i]))
1353
+
1354
+ // Every edge is downward, sideways or upward by the bands it joins.
1355
+ const drawn = rel.slice().sort((a, b) =>
1356
+ cmpCodePoint(node(a.from).label, node(b.from).label)
1357
+ || cmpCodePoint(node(a.to).label, node(b.to).label))
1358
+ let down = 0
1359
+ let side = 0
1360
+ const classed: Drawing[] = drawn.map((e) => {
1361
+ const fi = level.get(node(e.from).group as string) as number
1362
+ const ti = level.get(node(e.to).group as string) as number
1363
+ if (fi < ti) {
1364
+ down++
1365
+ return { edge: e, way: 'downward' }
1366
+ }
1367
+ if (fi === ti) {
1368
+ side++
1369
+ return { edge: e, way: 'sideways' }
1370
+ }
1371
+ return { edge: e, way: 'upward' }
1372
+ })
1373
+ const upward = classed.filter((c) => 'upward' === c.way).length
1374
+
1375
+ // WHICH EDGES ARE SHOWN. Mermaid lays edges out itself and drew every
1376
+ // one before this option existed; the fixed grids drew the upward
1377
+ // ones, which are the violations the bands cannot show on their own.
1378
+ const edges = o.edges ?? ('mermaid' === o.as ? 'all' : 'upward')
1379
+ const shown = 'all' === edges ? classed
1380
+ : 'none' === edges ? []
1381
+ : classed.filter((c) => 'upward' === c.way)
1382
+
1383
+ // A document with no edges has no relation to count under; the
1384
+ // footer names the absence as the panels do.
1385
+ const footer = [`# ${'' === relation ? '-' : relation}: ${down} downward, ` +
1386
+ `${side} sideways, ${upward} upward`]
1387
+ for (const c of shown) {
1388
+ footer.push(`# ${c.way}: ${node(c.edge.from).label} -> ` +
1389
+ `${node(c.edge.to).label}`)
1390
+ }
1391
+ const out: string[] = []
1392
+ if ('svg' === o.as) {
1393
+ // The description says WHAT WAS DRAWN, because two layer figures of
1394
+ // one model on one page differ by exactly that, and a reader who
1395
+ // cannot see them has only this to tell them apart.
1396
+ const drew = 'all' === edges
1397
+ ? `${shown.length} edges drawn, ${upward} of them upward`
1398
+ : 'none' === edges
1399
+ ? `${upward} upward edges, none drawn`
1400
+ : `${upward} upward edges`
1401
+ return {
1402
+ text: layerSvg(bands, shown, footer,
1403
+ `Architecture layers${over(relation)}: ${bands.length} bands, ${drew}`),
1404
+ }
1405
+ }
1406
+ if ('text' === o.as) {
1407
+ const w = widest(bands.map((b) => b.name))
1408
+ const rows = bands.map((b) =>
1409
+ pad(b.name, w) + ' ' + b.nodes.map((n) => n.label).join(' '))
1410
+ const inner = widest(rows)
1411
+ const rule = '+' + '-'.repeat(inner + 2) + '+'
1412
+ out.push(rule)
1413
+ for (const row of rows) {
1414
+ out.push('| ' + pad(row, inner) + ' |', rule)
1415
+ }
1416
+ out.push(...footer)
1417
+ }
1418
+ else {
1419
+ const esc = (s: string): string => escape(s, MERMAID_ESC)
1420
+ out.push('flowchart TB')
1421
+ bands.forEach((b, i) => {
1422
+ out.push(` subgraph g${i}["${esc(b.name)}"]`, ' direction LR')
1423
+ for (const n of b.nodes) {
1424
+ out.push(` ${n.id}["${esc(n.label)}"]`)
1425
+ }
1426
+ out.push(' end')
1427
+ })
1428
+ for (const c of shown) {
1429
+ out.push('upward' === c.way
1430
+ ? ` ${node(c.edge.from).id} -.->|"upward"| ${node(c.edge.to).id}`
1431
+ : ` ${node(c.edge.from).id} --> ${node(c.edge.to).id}`)
1432
+ }
1433
+ }
1434
+ return { text: out.join('\n') }
1435
+ }
1436
+
1437
+ // One drawn edge of the layer figure, and which way it goes between
1438
+ // the bands.
1439
+ type Drawing = { edge: GEdge, way: 'downward' | 'sideways' | 'upward' }
1440
+
1441
+
1442
+ // The layers as SVG: one band per row, its modules as boxes laid left
1443
+ // to right, and every SHOWN edge drawn between them -- an upward one
1444
+ // dashed and alert-coloured, because it is the violation the bands
1445
+ // cannot show on their own; a downward one straight down from the
1446
+ // bottom of its box to the top of the one it names; a sideways one
1447
+ // dipped below the boxes, since two modules of one band sit on the
1448
+ // same line and a straight edge between them would cross whatever
1449
+ // stands between.
1450
+ function layerSvg(
1451
+ bands: Band[], shown: Drawing[], footer: string[], about: string
1452
+ ): string {
1453
+ const BH = 44
1454
+ const gutter = widest(bands.map((b) => b.name)) * CH + 16
1455
+ const box = new Map<string, { x: number, y: number, w: number }>()
1456
+ let width = 0
1457
+ bands.forEach((b, i) => {
1458
+ let x = gutter
1459
+ for (const n of b.nodes) {
1460
+ const w = n.label.length * CH + 12
1461
+ box.set(n.path, { x, y: 4 + i * BH + 10, w })
1462
+ x += w + 10
1463
+ }
1464
+ width = Math.max(width, x - 10)
1465
+ })
1466
+ for (const f of footer) {
1467
+ width = Math.max(width, 4 + f.length * CH)
1468
+ }
1469
+ width += PAD
1470
+ const parts: string[] = []
1471
+ bands.forEach((b, i) => {
1472
+ const y = 4 + i * BH
1473
+ parts.push(svgRect(4, y, width - 8, BH, 'av-cell'))
1474
+ parts.push(svgText(12, y + 27, 'av-m', b.name))
1475
+ for (const n of b.nodes) {
1476
+ const at = box.get(n.path) as { x: number, y: number, w: number }
1477
+ parts.push(svgRect(at.x, at.y, at.w, 24, 'av-box'))
1478
+ parts.push(svgText(at.x + 6, at.y + 16, 'av-t', n.label))
1479
+ }
1480
+ })
1481
+ if (0 < shown.length) {
1482
+ parts.push('<defs>' +
1483
+ '<marker id="av-arrow" viewBox="0 0 8 8" refX="8" refY="4" ' +
1484
+ 'markerWidth="8" markerHeight="8" orient="auto">' +
1485
+ '<path d="M0 0L8 4L0 8Z" fill="var(--av-alert,#d1242f)"/></marker>' +
1486
+ '<marker id="av-tip" viewBox="0 0 8 8" refX="8" refY="4" ' +
1487
+ 'markerWidth="8" markerHeight="8" orient="auto">' +
1488
+ '<path d="M0 0L8 4L0 8Z" fill="var(--av-rule,#8c959f)"/></marker>' +
1489
+ '</defs>')
1490
+ }
1491
+ for (const c of shown) {
1492
+ const from = box.get(c.edge.from) as { x: number, y: number, w: number }
1493
+ const to = box.get(c.edge.to) as { x: number, y: number, w: number }
1494
+ const fx = from.x + Math.floor(from.w / 2)
1495
+ const tx = to.x + Math.floor(to.w / 2)
1496
+ if ('upward' === c.way) {
1497
+ parts.push(`<path d="M${fx} ${from.y}L${tx} ${to.y + 24}" ` +
1498
+ 'class="av-up" marker-end="url(#av-arrow)"/>')
1499
+ }
1500
+ else if ('downward' === c.way) {
1501
+ parts.push(`<path d="M${fx} ${from.y + 24}L${tx} ${to.y}" ` +
1502
+ 'class="av-line" marker-end="url(#av-tip)"/>')
1503
+ }
1504
+ else {
1505
+ // Below the boxes and back up, staying inside the band.
1506
+ const y = from.y + 24
1507
+ parts.push(`<path d="M${fx} ${y}V${y + 6}H${tx}V${y}" ` +
1508
+ 'class="av-line" marker-end="url(#av-tip)"/>')
1509
+ }
1510
+ }
1511
+ const y1 = 4 + bands.length * BH + 4
1512
+ footer.forEach((f, i) => {
1513
+ parts.push(svgText(4, y1 + i * LH + 14, 'av-m', f))
1514
+ })
1515
+ return svgDoc(width, y1 + footer.length * LH + PAD, about, parts)
1516
+ }
1517
+
1518
+
1519
+ // ---------------------------------------------------------------------
1520
+ // The set panel (Lex et al. 2014), shared by `sets` and `layers`
1521
+
1522
+ // One intersection column: the sets it lies in, and its elements, as
1523
+ // shown.
1524
+ type Column = { sig: boolean[], items: string[] }
1525
+
1526
+ type Panel = {
1527
+ header: string
1528
+ names: string[]
1529
+ sizes: number[]
1530
+ cols: Column[]
1531
+ // `sets` draws a bar per set and a bar per column; `layers` draws
1532
+ // the count instead.
1533
+ bars: boolean
1534
+ // The label of the degree-zero column, when there is one.
1535
+ none: string
1536
+ }
1537
+
1538
+
1539
+ // Elements grouped by their exact membership signature; columns by
1540
+ // degree descending, then cardinality descending, then signature (the
1541
+ // names of the sets it lies in) in code-point order. Elements within a
1542
+ // column in code-point order.
1543
+ function columnsOf(
1544
+ names: string[], members: Map<string, Set<string>>, elements: string[],
1545
+ shown: (el: string) => string
1546
+ ): Column[] {
1547
+ const groups = new Map<string, Column>()
1548
+ const sorted = elements.slice().sort((a, b) => cmpCodePoint(shown(a), shown(b)))
1549
+ for (const el of sorted) {
1550
+ const sig = names.map((n) => (members.get(n) as Set<string>).has(el))
1551
+ const key = sig.map((b) => b ? '1' : '0').join('')
1552
+ const col = groups.get(key)
1553
+ if (undefined === col) {
1554
+ groups.set(key, { sig, items: [shown(el)] })
1555
+ }
1556
+ else {
1557
+ col.items.push(shown(el))
1558
+ }
1559
+ }
1560
+ const degree = (c: Column): number => c.sig.filter((b) => b).length
1561
+ const sigText = (c: Column): string =>
1562
+ names.filter((_n, i) => c.sig[i]).join(' ')
1563
+ return [...groups.values()].sort((a, b) =>
1564
+ degree(b) - degree(a) || b.items.length - a.items.length
1565
+ || cmpCodePoint(sigText(a), sigText(b)))
1566
+ }
1567
+
1568
+
1569
+ function renderPanel(p: Panel): string {
1570
+ const w = widest(p.names)
1571
+ const out: string[] = [p.header, '']
1572
+ const most = p.sizes.reduce((m, n) => Math.max(m, n), 0)
1573
+ p.names.forEach((n, i) => {
1574
+ out.push(pad(n, w) + ' ' +
1575
+ (p.bars ? pad('#'.repeat(p.sizes[i]), most) + ' ' : '') +
1576
+ String(p.sizes[i]))
1577
+ })
1578
+ out.push('')
1579
+ p.names.forEach((n, i) => {
1580
+ out.push(pad(n, w) + ' | ' +
1581
+ p.cols.map((c) => c.sig[i] ? '*' : '.').join(' '))
1582
+ })
1583
+ out.push(pad('', w) + ' +' + '-'.repeat(2 * p.cols.length))
1584
+ if (p.bars) {
1585
+ const tallest = p.cols.reduce((m, c) => Math.max(m, c.items.length), 0)
1586
+ // The bars, tallest column first; a line ends at its last bar.
1587
+ for (let h = tallest; 0 < h; h--) {
1588
+ out.push((pad('', w) + ' |' +
1589
+ p.cols.map((c) => h <= c.items.length ? ' #' : ' ').join(''))
1590
+ .replace(/ +$/, ''))
1591
+ }
1592
+ }
1593
+ out.push(pad('', w) + ' ' +
1594
+ p.cols.map((c) => String(c.items.length)).join(' '))
1595
+ out.push('')
1596
+ p.cols.forEach((c, i) => {
1597
+ const shown = 4 < c.items.length && !p.bars
1598
+ ? c.items.slice(0, 3).join(' ') + ' ...' : c.items.join(' ')
1599
+ out.push(` col ${i + 1}${p.bars ? '' : ` (${c.items.length})`}: ${shown}` +
1600
+ (c.sig.some((b) => b) ? '' : p.none))
1601
+ })
1602
+ return out.join('\n')
1603
+ }
1604
+
1605
+
1606
+ // The panel as SVG: the set sizes as bars, the intersections as a dot
1607
+ // matrix (a filled dot where the set lies in the column), the column
1608
+ // cardinalities as bars under it, and the columns' elements as text.
1609
+ function panelSvg(p: Panel, about: string): string {
1610
+ const w = widest(p.names)
1611
+ const most = p.sizes.reduce((m, n) => Math.max(m, n), 0)
1612
+ const parts: string[] = [svgText(4, 14, 'av-m', p.header)]
1613
+ const gx = w * CH + 8
1614
+ const yS = LH + 8
1615
+ p.names.forEach((n, i) => {
1616
+ const y = yS + i * LH
1617
+ parts.push(svgText(4, y + 14, 'av-t', n))
1618
+ if (p.bars) {
1619
+ parts.push(svgRect(gx, y + 3, p.sizes[i] * 10, 14, 'av-bar'))
1620
+ }
1621
+ parts.push(svgText(gx + (p.bars ? most * 10 + 8 : 0), y + 14, 'av-m',
1622
+ String(p.sizes[i])))
1623
+ })
1624
+ const yM = yS + p.names.length * LH + 8
1625
+ p.names.forEach((n, i) => {
1626
+ parts.push(svgText(4, yM + i * LH + 14, 'av-t', n))
1627
+ p.cols.forEach((c, ci) => {
1628
+ parts.push(`<circle cx="${gx + ci * 20 + 10}" cy="${yM + i * LH + 10}" r="5" ` +
1629
+ `class="${c.sig[i] ? 'av-dot' : 'av-hole'}"/>`)
1630
+ })
1631
+ })
1632
+ const yB = yM + p.names.length * LH + 4
1633
+ const tallest = p.cols.reduce((m, c) => Math.max(m, c.items.length), 0)
1634
+ parts.push(svgPath(`M${gx} ${yB}H${gx + p.cols.length * 20}`, 'av-line'))
1635
+ p.cols.forEach((c, ci) => {
1636
+ parts.push(svgRect(gx + ci * 20 + 4, yB, 12, c.items.length * 8, 'av-bar'))
1637
+ parts.push(svgText(gx + ci * 20 + 10, yB + tallest * 8 + 14, 'av-m',
1638
+ String(c.items.length), 'middle'))
1639
+ })
1640
+ const yI = yB + tallest * 8 + LH + 4
1641
+ const lines: string[] = []
1642
+ p.cols.forEach((c, i) => {
1643
+ const shown = 4 < c.items.length && !p.bars
1644
+ ? c.items.slice(0, 3).join(' ') + ' ...' : c.items.join(' ')
1645
+ lines.push(`col ${i + 1}${p.bars ? '' : ` (${c.items.length})`}: ${shown}` +
1646
+ (c.sig.some((b) => b) ? '' : p.none))
1647
+ })
1648
+ lines.forEach((l, i) => {
1649
+ parts.push(svgText(4, yI + i * LH + 14, 'av-t', l))
1650
+ })
1651
+ const width = Math.max(gx + p.cols.length * 20,
1652
+ gx + (p.bars ? most * 10 + 8 : 0) + 3 * CH,
1653
+ 4 + widest(lines) * CH, 4 + p.header.length * CH) + PAD
1654
+ return svgDoc(width, yI + lines.length * LH + PAD, about, parts)
1655
+ }
1656
+
1657
+
1658
+ // Elide the columns beyond `--max-cols`, counted. Zero means no limit,
1659
+ // in both ports.
1660
+ function elide(
1661
+ cols: Column[], maxCols: number | undefined, loss: ViewLoss[]
1662
+ ): Column[] {
1663
+ if (undefined === maxCols || 0 === maxCols || cols.length <= maxCols) {
1664
+ return cols
1665
+ }
1666
+ loss.push({ code: 'cols_elided', count: cols.length - maxCols })
1667
+ return cols.slice(0, maxCols)
1668
+ }
1669
+
1670
+
1671
+ // The generated value at a path, walked plainly: the panel reads
1672
+ // `generate()`, never the Val tree.
1673
+ function genAt(gen: any, path: string): any {
1674
+ let v = gen
1675
+ for (const part of pathParts(path)) {
1676
+ if (null == v || 'object' !== typeof v) {
1677
+ return undefined
1678
+ }
1679
+ v = v[part]
1680
+ }
1681
+ return v
1682
+ }
1683
+
1684
+
1685
+ function shapeFinding(path: string, message: string): VetFinding {
1686
+ return finding('view_sets_shape', 'reference', path, message)
1687
+ }
1688
+
1689
+
1690
+ const allStrings = (xs: any[]): boolean =>
1691
+ xs.every((x) => 'string' === typeof x)
1692
+
1693
+
1694
+ function drawSets(
1695
+ gen: any,
1696
+ o: {
1697
+ sets: string, member: string, universe?: string,
1698
+ minDegree?: number, maxCols?: number, as: ViewProfile,
1699
+ },
1700
+ max: number, loss: ViewLoss[]
1701
+ ): Figure {
1702
+ const family = genAt(gen, o.sets)
1703
+ if (null == family || 'object' !== typeof family || Array.isArray(family)) {
1704
+ return { errors: [shapeFinding(o.sets, 'The set family is not a map.')] }
1705
+ }
1706
+ const names = Object.keys(family).sort(cmpCodePoint)
1707
+ if (max < names.length) {
1708
+ return { errors: [rowsFinding(names.length, max, '--sets')] }
1709
+ }
1710
+ const members = new Map<string, Set<string>>()
1711
+ const elements = new Set<string>()
1712
+ for (const n of names) {
1713
+ const list = family[n]?.[o.member]
1714
+ if (!Array.isArray(list) || !allStrings(list)) {
1715
+ return {
1716
+ errors: [shapeFinding(`${o.sets}.${n}.${o.member}`,
1717
+ 'A set\'s members must be a list of strings.')],
1718
+ }
1719
+ }
1720
+ members.set(n, new Set(list))
1721
+ for (const x of list) {
1722
+ elements.add(x)
1723
+ }
1724
+ }
1725
+ if (undefined !== o.universe) {
1726
+ // A universe MAP names its elements by ADDRESS -- `$.permissions`
1727
+ // holds `$.permissions.admin_all` -- which is what a member written
1728
+ // `path($.permissions.admin_all)` generates, so the two meet on the
1729
+ // path; a universe list names them as it lists them.
1730
+ const u = genAt(gen, o.universe)
1731
+ const all = Array.isArray(u) ? u
1732
+ : null != u && 'object' === typeof u
1733
+ ? Object.keys(u).map((k) => o.universe + '.' + k) : undefined
1734
+ if (undefined === all || !allStrings(all)) {
1735
+ return {
1736
+ errors: [shapeFinding(o.universe,
1737
+ 'The universe must be a map or a list of strings.')],
1738
+ }
1739
+ }
1740
+ for (const x of all) {
1741
+ elements.add(x)
1742
+ }
1743
+ }
1744
+ // An element written as an address is shown by the shortest suffix
1745
+ // that tells it from every other address in the panel, as a node
1746
+ // is; one written as a plain string is shown as written.
1747
+ const addressed = [...elements].filter((x) => x.startsWith('$.')).sort(cmpCodePoint)
1748
+ const short = labelsOf(addressed)
1749
+ const shown = (x: string): string => short.get(x) ?? x
1750
+ let cols = columnsOf(names, members, [...elements], shown)
1751
+ if (undefined !== o.minDegree) {
1752
+ const least = o.minDegree
1753
+ cols = cols.filter((c) => least <= c.sig.filter((b) => b).length)
1754
+ }
1755
+ cols = elide(cols, o.maxCols, loss)
1756
+ // A set name or an element is a generated string, and a string can
1757
+ // hold a line terminator; no line of the panel can.
1758
+ const broken = [...names, ...elements].find(hasLineBreak)
1759
+ if (undefined !== broken) {
1760
+ return { errors: [lineBreakFinding(o.sets)] }
1761
+ }
1762
+ const panel: Panel = {
1763
+ header: `# upset sets=${o.sets}(${names.length}) member=${o.member}` +
1764
+ ` elements=${elements.size}` +
1765
+ (undefined === o.universe ? '' : ` universe=${o.universe}`),
1766
+ names,
1767
+ sizes: names.map((n) => (members.get(n) as Set<string>).size),
1768
+ cols,
1769
+ bars: true,
1770
+ none: ' (in no set)',
1771
+ }
1772
+ return {
1773
+ text: 'svg' === o.as
1774
+ ? panelSvg(panel, `Set panel over ${o.sets}: ${names.length} sets, ` +
1775
+ `${elements.size} elements, ${cols.length} intersections`)
1776
+ : renderPanel(panel),
1777
+ }
1778
+ }
1779
+
1780
+
1781
+ // The file a contribution names, as the panel shows it: relative to
1782
+ // the entry document's directory, the entry itself by its own name.
1783
+ function docName(file: string, entry: string | undefined): string {
1784
+ if ('' === file || file === entry) {
1785
+ return undefined === entry ? '-' : basename(entry)
1786
+ }
1787
+ return isAbsolute(file) && undefined !== entry
1788
+ ? relative(dirname(resolve(entry)), file) : file
1789
+ }
1790
+
1791
+
1792
+ function drawLayers(
1793
+ prov: Provenance, root: any, entry: string | undefined,
1794
+ o: { at?: string, minSize?: number, maxCols?: number, as: ViewProfile },
1795
+ max: number, loss: ViewLoss[]
1796
+ ): Figure {
1797
+ // Every path something met at AND THE DOCUMENT HAS A VALUE AT,
1798
+ // mapped to the documents that met there. A meet can happen at a
1799
+ // position the finished document does not have -- a template's own
1800
+ // child, folded into each key it is spread over -- and the panel is
1801
+ // about the document, so only its paths are rows. A path is shown
1802
+ // as `a.b.c`; the root as `$`.
1803
+ const members = new Map<string, Set<string>>()
1804
+ const paths: string[] = []
1805
+ const atParts = undefined === o.at ? [] : pathParts(o.at)
1806
+ for (const [key, rec] of prov.paths) {
1807
+ // A record at a position the document does not have is the Go
1808
+ // recorder's template ghost (use-cases/BUGS.md 70); this port's
1809
+ // recorder does not write one, and the two ports must skip the
1810
+ // same rows.
1811
+ if (0 === rec.conjuncts.length || null == anchorAt(root, '$.' + key)) {
1812
+ continue
1813
+ }
1814
+ const parts = '' === key ? [] : key.split('.')
1815
+ if (atParts.some((p, i) => parts[i] !== p)) {
1816
+ continue
1817
+ }
1818
+ const shown = 0 === parts.length ? '$' : parts.join('.')
1819
+ paths.push(shown)
1820
+ for (const c of rec.conjuncts) {
1821
+ const d = docName(c.site.file, entry)
1822
+ let set = members.get(d)
1823
+ if (undefined === set) {
1824
+ set = new Set()
1825
+ members.set(d, set)
1826
+ }
1827
+ set.add(shown)
1828
+ }
1829
+ }
1830
+ const names = [...members.keys()].sort(cmpCodePoint)
1831
+ if (max < names.length) {
1832
+ return { errors: [rowsFinding(names.length, max, '--at')] }
1833
+ }
1834
+ let cols = columnsOf(names, members, paths, (p) => p)
1835
+ if (undefined !== o.minSize) {
1836
+ const least = o.minSize
1837
+ cols = cols.filter((c) => least <= c.items.length)
1838
+ }
1839
+ cols = elide(cols, o.maxCols, loss)
1840
+ const panel: Panel = {
1841
+ header: `# layers file=${undefined === entry ? '-' : basename(entry)}` +
1842
+ ` documents=${names.length} paths=${paths.length}`,
1843
+ names,
1844
+ sizes: names.map((n) => (members.get(n) as Set<string>).size),
1845
+ cols,
1846
+ bars: false,
1847
+ none: '',
1848
+ }
1849
+ return {
1850
+ text: 'svg' === o.as
1851
+ ? panelSvg(panel, `Document layers: ${names.length} documents, ` +
1852
+ `${paths.length} paths, ${cols.length} intersections`)
1853
+ : renderPanel(panel),
1854
+ }
1855
+ }
1856
+
1857
+
1858
+ // ---------------------------------------------------------------------
1859
+ // The meet ladder (VIEWS-ORDER.0.md)
1860
+
1861
+ // The descent from `top` through each contribution to the resolved
1862
+ // value, one rung per conjunct. Where the contributions are ranked
1863
+ // preferences the ladder IS the arbitration: fewer stars win, so the
1864
+ // rungs read weakest-first and the winner is the last before the
1865
+ // value. `why`'s record is in source order, which is not rank order,
1866
+ // so the rungs are SORTED -- an emitter that trusted the record would
1867
+ // draw an arbitration that did not happen.
1868
+ function drawLadder(
1869
+ src: string, options: ViewOptions, as: ViewProfile, max: number
1870
+ ): Figure {
1871
+ if (undefined === options.at) {
1872
+ return {
1873
+ errors: [finding('view_at_required', 'reference', '$',
1874
+ 'The ladder needs the path to draw; name it with --at.')],
1875
+ }
1876
+ }
1877
+ const rep = why(src, options.at, { path: options.path, trust: options.trust })
1878
+ if (undefined === rep.record) {
1879
+ return { errors: rep.findings }
1880
+ }
1881
+ const rungs = rep.record.conjuncts.slice().sort((a, b) =>
1882
+ (b.rank ?? 0) - (a.rank ?? 0)
1883
+ || cmpCodePoint(a.site.file, b.site.file)
1884
+ || a.site.row - b.site.row
1885
+ || a.site.col - b.site.col)
1886
+ if (max < rungs.length) {
1887
+ return { errors: [rowsFinding(rungs.length, max, 'a narrower --at')] }
1888
+ }
1889
+ const where = (c: WhyConjunct): string =>
1890
+ `${basename(c.site.file)}:${c.site.row}:${c.site.col}`
1891
+
1892
+ const out: string[] = []
1893
+ if ('mermaid' === as) {
1894
+ const esc = (s: string): string => escape(s, MERMAID_ESC)
1895
+ out.push('graph TD', ' top(("top"))')
1896
+ rungs.forEach((c, i) => {
1897
+ out.push(` c${i}["${esc(c.canon)}<br/>${c.role} | ${esc(where(c))}"]`)
1898
+ })
1899
+ out.push(` val{{"${esc(rep.record.value)}"}}`)
1900
+ let prev = 'top'
1901
+ rungs.forEach((_c, i) => {
1902
+ out.push(` ${prev} --> c${i}`)
1903
+ prev = `c${i}`
1904
+ })
1905
+ out.push(` ${prev} --> val`)
1906
+ }
1907
+ else {
1908
+ const esc = (s: string): string => escape(s, DOT_ESC)
1909
+ out.push('digraph G {', ' rankdir=TB;', ' node [shape=box];',
1910
+ ' top [shape=circle, label="top"];')
1911
+ rungs.forEach((c, i) => {
1912
+ out.push(
1913
+ ` c${i} [label="${esc(c.canon)}\\n${c.role} | ${esc(where(c))}"];`)
1914
+ })
1915
+ out.push(` val [shape=hexagon, label="${esc(rep.record.value)}"];`)
1916
+ let prev = 'top'
1917
+ rungs.forEach((_c, i) => {
1918
+ out.push(` ${prev} -> c${i};`)
1919
+ prev = `c${i}`
1920
+ })
1921
+ out.push(` ${prev} -> val;`, '}')
1922
+ }
1923
+ return { text: out.join('\n') }
1924
+ }
1925
+
1926
+
1927
+ // ---------------------------------------------------------------------
1928
+ // The subsumption poset (VIEWS-ORDER.0.md)
1929
+
1930
+ export type ViewPosetDoc = { src: string, path?: string, label: string }
1931
+ type Doc = ViewPosetDoc
1932
+ type Cls = { members: number[], label: string }
1933
+
1934
+
1935
+ // The order over a document set, in the design's five steps: the
1936
+ // verdict matrix; the quotient by MUTUAL subsumption (two documents
1937
+ // that subsume each other are one node -- mandatory, since without it
1938
+ // the relation is not antisymmetric and the cover relation is
1939
+ // undefined); the closure, then the cover relation over the closure;
1940
+ // and a canonical order, so the result does not depend on the order the
1941
+ // files were given.
1942
+ // One pairwise comparison: does the general document admit everything
1943
+ // the specific one does? `subsume`, with the poset's anchor and
1944
+ // profile; a parameter so a test can hand the drawing a verdict matrix
1945
+ // the checker cannot be made to produce.
1946
+ export type ViewCompare = (
1947
+ general: Doc, specific: Doc, options: ViewOptions
1948
+ ) => { verdict: string, code: string }
1949
+
1950
+ const compareBySubsume: ViewCompare = (general, specific, options) => {
1951
+ const r = subsume(general.src, specific.src, {
1952
+ at: options.at, profile: options.profile,
1953
+ generalPath: general.path, specificPath: specific.path,
1954
+ trust: options.trust,
1955
+ })
1956
+ return { verdict: r.verdict, code: r.findings[0]?.code ?? 'undecided' }
1957
+ }
1958
+
1959
+ function drawPoset(
1960
+ docs: Doc[], options: ViewOptions, as: ViewProfile, max: number,
1961
+ loss: ViewLoss[], compare: ViewCompare
1962
+ ): Figure {
1963
+ const n = docs.length
1964
+ const verdict: string[][] = docs.map(() => docs.map(() => 'subsumes'))
1965
+ const code: string[][] = docs.map(() => docs.map(() => ''))
1966
+ let broken = false
1967
+ for (let a = 0; a < n; a++) {
1968
+ for (let b = 0; b < n; b++) {
1969
+ if (a === b) {
1970
+ continue
1971
+ }
1972
+ const r = compare(docs[a], docs[b], options)
1973
+ verdict[a][b] = r.verdict
1974
+ code[a][b] = r.code
1975
+ broken = broken || 'error' === r.verdict
1976
+ }
1977
+ }
1978
+ if (broken) {
1979
+ return { errors: docs.flatMap((d) => docFailure(d, options)) }
1980
+ }
1981
+ const ge = (a: number, b: number): boolean => 'subsumes' === verdict[a][b]
1982
+
1983
+ // Quotient by mutual subsumption; class labels joined by ` = `.
1984
+ const classes: Cls[] = []
1985
+ for (let i = 0; i < n; i++) {
1986
+ const found = classes.find((c) =>
1987
+ ge(i, c.members[0]) && ge(c.members[0], i))
1988
+ if (undefined === found) {
1989
+ classes.push({ members: [i], label: '' })
1990
+ }
1991
+ else {
1992
+ found.members.push(i)
1993
+ }
1994
+ }
1995
+ for (const c of classes) {
1996
+ c.members.sort((x, y) => cmpCodePoint(docs[x].label, docs[y].label))
1997
+ c.label = c.members.map((m) => docs[m].label).join(' = ')
1998
+ }
1999
+ classes.sort((x, y) => cmpCodePoint(x.label, y.label))
2000
+ if (max < classes.length) {
2001
+ return { errors: [rowsFinding(classes.length, max, 'fewer documents')] }
2002
+ }
2003
+ for (const c of classes) {
2004
+ if (hasLineBreak(c.label)) {
2005
+ return { errors: [lineBreakFinding('$')] }
2006
+ }
2007
+ }
2008
+
2009
+ // closure[lo][hi]: hi subsumes lo, directly or by transitivity.
2010
+ const k = classes.length
2011
+ const rep = (ci: number): number => classes[ci].members[0]
2012
+ const closure: boolean[][] = classes.map((_x, lo) =>
2013
+ classes.map((_y, hi) => lo !== hi && ge(rep(hi), rep(lo))))
2014
+ for (let m = 0; m < k; m++) {
2015
+ for (let i = 0; i < k; i++) {
2016
+ for (let j = 0; j < k; j++) {
2017
+ if (closure[i][m] && closure[m][j]) {
2018
+ closure[i][j] = true
2019
+ }
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ const covers: [number, number][] = []
2025
+ const intransitive: string[] = []
2026
+ for (let lo = 0; lo < k; lo++) {
2027
+ for (let hi = 0; hi < k; hi++) {
2028
+ if (!closure[lo][hi]) {
2029
+ continue
2030
+ }
2031
+ // A pair the closure implies but the checker measured as
2032
+ // `does_not_subsume` is reported rather than absorbed: the
2033
+ // measured relation is a conservative under-approximation, and
2034
+ // an under-approximation of a transitive relation need not be
2035
+ // transitive.
2036
+ if ('does_not_subsume' === verdict[rep(hi)][rep(lo)]) {
2037
+ intransitive.push(`${classes[lo].label} < ${classes[hi].label}`)
2038
+ }
2039
+ const viaMid = classes.some((_c, mid) =>
2040
+ mid !== lo && mid !== hi && closure[lo][mid] && closure[mid][hi])
2041
+ if (!viaMid) {
2042
+ covers.push([lo, hi])
2043
+ }
2044
+ }
2045
+ }
2046
+ if (0 < intransitive.length) {
2047
+ loss.push({
2048
+ code: 'order_intransitive', count: intransitive.length,
2049
+ detail: intransitive,
2050
+ })
2051
+ }
2052
+
2053
+ // An undecided pair with no proven order either way is a DASHED edge
2054
+ // in the queried direction, labelled with the reason; one proven one
2055
+ // way and undecided the other keeps its solid edge and is reported,
2056
+ // since the two may be equal and the checker cannot tell.
2057
+ const dashed: [number, number, string][] = []
2058
+ const maybeEqual: string[] = []
2059
+ for (let g = 0; g < k; g++) {
2060
+ for (let s = 0; s < k; s++) {
2061
+ if (g === s || 'undecided' !== verdict[rep(g)][rep(s)]) {
2062
+ continue
2063
+ }
2064
+ if (closure[s][g] || closure[g][s]) {
2065
+ maybeEqual.push(`${classes[s].label} ~ ${classes[g].label}`)
2066
+ }
2067
+ else {
2068
+ dashed.push([s, g, code[rep(g)][rep(s)]])
2069
+ }
2070
+ }
2071
+ }
2072
+ if (0 < dashed.length) {
2073
+ loss.push({
2074
+ code: 'order_undecided', count: dashed.length,
2075
+ detail: dashed.map(([s, g, c]) =>
2076
+ `${classes[s].label} ~ ${classes[g].label} (${c})`),
2077
+ })
2078
+ }
2079
+ if (0 < maybeEqual.length) {
2080
+ loss.push({
2081
+ code: 'order_maybe_equal', count: maybeEqual.length, detail: maybeEqual,
2082
+ })
2083
+ }
2084
+
2085
+ const head = 'aontu subsumption poset' +
2086
+ (undefined === options.at ? '' : ` at=${options.at}`) +
2087
+ ` profile=${options.profile ?? 'defaults'}` +
2088
+ ` documents=${n} nodes=${k}`
2089
+ const out: string[] = []
2090
+ if ('mermaid' === as) {
2091
+ const esc = (s: string): string => escape(s, MERMAID_ESC)
2092
+ out.push('%% ' + head, 'graph BT')
2093
+ classes.forEach((c, i) => {
2094
+ out.push(` n${i}["${esc(c.label)}"]`)
2095
+ })
2096
+ for (const [lo, hi] of covers) {
2097
+ out.push(` n${lo} --> n${hi}`)
2098
+ }
2099
+ for (const [s, g, c] of dashed) {
2100
+ out.push(` n${s} -.->|"${esc(c)}"| n${g}`)
2101
+ }
2102
+ }
2103
+ else {
2104
+ const esc = (s: string): string => escape(s, DOT_ESC)
2105
+ out.push('// ' + head, 'digraph G {', ' rankdir=BT;', ' node [shape=box];')
2106
+ classes.forEach((c, i) => {
2107
+ out.push(` n${i} [label="${esc(c.label)}"];`)
2108
+ })
2109
+ for (const [lo, hi] of covers) {
2110
+ out.push(` n${lo} -> n${hi};`)
2111
+ }
2112
+ for (const [s, g, c] of dashed) {
2113
+ out.push(` n${s} -> n${g} [style=dashed, label="${esc(c)}"];`)
2114
+ }
2115
+ out.push('}')
2116
+ }
2117
+ return { text: out.join('\n') }
2118
+ }
2119
+
2120
+
2121
+ // Why a poset could not be drawn: the documents that do not stand up
2122
+ // on their own, each with its own finding, or the anchor a document
2123
+ // lacks.
2124
+ function docFailure(d: Doc, options: ViewOptions): VetFinding[] {
2125
+ const loaded = load(d.src, d.path, options.trust, undefined)
2126
+ if (undefined !== loaded.errors) {
2127
+ return loaded.errors
2128
+ }
2129
+ if (undefined !== options.at && null == anchorAt(loaded.root, options.at)) {
2130
+ return [finding('no_path', 'reference', options.at,
2131
+ `${d.label} has no value at ${options.at}.`)]
2132
+ }
2133
+ return []
2134
+ }
2135
+
2136
+
2137
+ // ---------------------------------------------------------------------
2138
+ // The verb
2139
+
2140
+ type Loaded = { root?: any, ctx?: any, errors?: VetFinding[] }
2141
+
2142
+
2143
+ // One evaluation, parsed and unified separately so the provenance
2144
+ // recorder can stamp the parsed tree before the fixpoint runs (`why`'s
2145
+ // precedent).
2146
+ function load(
2147
+ src: string, path: string | undefined, trust: TrustOptions | undefined,
2148
+ prov: Provenance | undefined
2149
+ ): Loaded {
2150
+ const aontu = new Aontu(null == trust ? undefined : { trust })
2151
+ const ctx = aontu.ctx({ collect: true, prov })
2152
+ const parseOpts = null == path ? undefined : { path }
2153
+ const parsed: any = aontu.parse(src, parseOpts, ctx)
2154
+ if (0 < ctx.err.length || null == parsed) {
2155
+ return { errors: [failureFinding(ctx, path, parsed)] }
2156
+ }
2157
+ if (undefined !== prov) {
2158
+ prov.writtenFrom(parsed)
2159
+ }
2160
+ const root: any = aontu.unify(parsed, parseOpts, ctx)
2161
+ // A document that does not stand up has no figure: the errors it
2162
+ // already has are the answer.
2163
+ if (0 < ctx.err.length || true === root?.isNil) {
2164
+ return { errors: [failureFinding(ctx, path, root)] }
2165
+ }
2166
+ return { root, ctx }
2167
+ }
2168
+
2169
+
2170
+ // The seams a test can reach in: the poset's pairwise comparison
2171
+ // (`subsume` otherwise), and the provenance recorder the layers panel
2172
+ // reads (a fresh one otherwise).
2173
+ export type ViewHooks = {
2174
+ compare?: ViewCompare
2175
+ provenance?: () => Provenance
2176
+ }
2177
+
2178
+ // A figure of one document (or, for the poset, of a set of them).
2179
+ export function view(
2180
+ src: string, opts?: ViewOptions, hooks?: ViewHooks
2181
+ ): ViewReport {
2182
+ const options = opts ?? {}
2183
+ const compare = hooks?.compare ?? compareBySubsume
2184
+ const kind: ViewKind = options.kind ?? 'tree'
2185
+ const loss: ViewLoss[] = []
2186
+
2187
+ const done = (fig: Figure): ViewReport => {
2188
+ if (undefined !== fig.errors) {
2189
+ return { verdict: 'error', kind, loss: [], errors: fig.errors }
2190
+ }
2191
+ loss.sort((a, b) => cmpCodePoint(a.code, b.code))
2192
+ const lossy = loss.some((l) => !INFORMATIONAL.includes(l.code))
2193
+ return { verdict: lossy ? 'lossy' : 'rendered', kind, text: fig.text, loss }
2194
+ }
2195
+
2196
+ const profiles = PROFILES[kind]
2197
+ if (undefined === profiles) {
2198
+ return done({
2199
+ errors: [finding('view_kind_unknown', 'reference', '$',
2200
+ `${kind} is not a figure kind.`,
2201
+ 'kinds: ' + Object.keys(PROFILES).join(', '))],
2202
+ })
2203
+ }
2204
+ const as = options.as ?? profiles[0]
2205
+ if (!profiles.includes(as)) {
2206
+ return done({
2207
+ errors: [finding('view_profile_unknown', 'reference', '$',
2208
+ `The ${kind} figure does not render as ${as}.`,
2209
+ `profiles: ${profiles.join(', ')}`)],
2210
+ })
2211
+ }
2212
+ // Zero means the default, in both ports.
2213
+ const max = options.maxRows || DEFAULT_MAX_ROWS
2214
+
2215
+ if ('poset' === kind) {
2216
+ const docs: Doc[] = [{ src, path: options.path }, ...(options.docs ?? [])]
2217
+ .map((d: ViewDoc, i) => ({
2218
+ src: d.src, path: d.path,
2219
+ label: d.name ?? (undefined === d.path
2220
+ ? `doc${i + 1}` : basename(d.path).replace(/\.aon$/, '')),
2221
+ }))
2222
+ return done(drawPoset(docs, options, as, max, loss, compare))
2223
+ }
2224
+ if ('ladder' === kind) {
2225
+ return done(drawLadder(src, options, as, max))
2226
+ }
2227
+
2228
+ const prov = 'layers' === kind
2229
+ ? (hooks?.provenance ?? (() => new Provenance()))() : undefined
2230
+ const loaded = load(src, options.path, options.trust, prov)
2231
+ if (undefined !== loaded.errors) {
2232
+ return done({ errors: loaded.errors })
2233
+ }
2234
+ return done(drawLoaded(loaded.root, loaded.ctx, undefined, prov,
2235
+ kind, as, options, max, loss))
2236
+ }
2237
+
2238
+
2239
+ // THE KINDS THAT DRAW FROM A LOADED MODEL, so a view document can load
2240
+ // once and draw N figures from the one evaluation. `gen` is the
2241
+ // generated value where the caller already holds it -- a view document
2242
+ // reads its own declarations out of one -- and undefined where the set
2243
+ // panel must generate its own. It is a BOX rather than the value, so
2244
+ // that a document generating `undefined` is still a value the panel
2245
+ // has rather than one it must recompute.
2246
+ function drawLoaded(
2247
+ root: any, ctx: any, gen: { value: any } | undefined,
2248
+ prov: Provenance | undefined,
2249
+ kind: ViewKind, as: ViewProfile, options: ViewOptions,
2250
+ max: number, loss: ViewLoss[]
2251
+ ): Figure {
2252
+ if ('layers' === kind) {
2253
+ return drawLayers(prov as Provenance, root, options.path,
2254
+ { ...options, as }, max, loss)
2255
+ }
2256
+ if ('sets' === kind) {
2257
+ if (undefined === options.sets || undefined === options.member) {
2258
+ return {
2259
+ errors: [finding('view_sets_required', 'reference', '$',
2260
+ 'The set panel needs --sets and --member.')],
2261
+ }
2262
+ }
2263
+ let value = gen?.value
2264
+ if (undefined === gen) {
2265
+ // GENERATION CAN FAIL WHERE UNIFICATION DID NOT: the panel reads
2266
+ // generated values, so a document that is not concrete is an
2267
+ // error here, exactly as `aontu file.aon` on it is.
2268
+ const before = ctx.err.length
2269
+ value = root.gen(ctx)
2270
+ if (before < ctx.err.length) {
2271
+ const err: any = ctx.err[before]
2272
+ return {
2273
+ errors: [finding(err?.why ?? 'unify_failed', 'reference', '$',
2274
+ err?.msg ?? 'The document does not generate.')],
2275
+ }
2276
+ }
2277
+ }
2278
+ return drawSets(value, {
2279
+ sets: options.sets, member: options.member, universe: options.universe,
2280
+ minDegree: options.minDegree, maxCols: options.maxCols, as,
2281
+ }, max, loss)
2282
+ }
2283
+
2284
+ const triples = triplesOf(graphOf(root), options.at, loss)
2285
+ const decls: RelDecls = ctx._reldecls
2286
+ // An empty relation name is no relation, so both ports read it as
2287
+ // "every relation" rather than one that names nothing.
2288
+ const relation = options.relation || undefined
2289
+ if ('matrix' === kind) {
2290
+ return drawMatrix(triples, decls, {
2291
+ relation, order: options.order ?? 'canon', closure: true === options.closure,
2292
+ as,
2293
+ }, max, loss)
2294
+ }
2295
+ if ('graph' === kind) {
2296
+ return drawGraph(triples, decls, root, {
2297
+ relations: options.relations ?? [], groupBy: options.groupBy,
2298
+ label: options.label, as,
2299
+ }, max, loss)
2300
+ }
2301
+ if ('layer' === kind) {
2302
+ return drawLayer(triples, root, {
2303
+ relation, groupBy: options.groupBy, layers: options.layers ?? [],
2304
+ edges: options.edges, as,
2305
+ }, max, loss)
2306
+ }
2307
+ return drawTree(
2308
+ collapse(triples, relation), relation, options.roots ?? [], max, as)
2309
+ }
2310
+
2311
+
2312
+ // The tree view of one document: `view` with the kind fixed.
2313
+ export function viewTree(src: string, opts?: ViewOptions): ViewReport {
2314
+ return view(src, { ...(opts ?? {}), kind: 'tree' })
2315
+ }
2316
+
2317
+
2318
+ // ---------------------------------------------------------------------
2319
+ // The view document (VIEWS.0.md, "6. The view document")
2320
+ //
2321
+ // A projection that runs in CI belongs in a file. A view document is an
2322
+ // ORDINARY document that includes the model and declares its figures as
2323
+ // data; `views` is the AUTHOR's key and nothing here knows the name
2324
+ // (ADR-010), which is why `--views` names the path.
2325
+ //
2326
+ // The declaration keys ARE the library's option names, which are the
2327
+ // CLI's flag names without the dashes: one vocabulary, three doors. A
2328
+ // declaration must name its `kind` and its `out` -- a figure in a file
2329
+ // that a review reads should say what it draws and where it goes,
2330
+ // rather than inheriting a default from whoever ran the verb.
2331
+
2332
+ const DECL_TEXT = [
2333
+ 'kind', 'as', 'out', 'at', 'relation', 'order', 'groupBy', 'label',
2334
+ 'sets', 'member', 'universe', 'edges',
2335
+ ]
2336
+
2337
+ // The options whose values are a closed set. A view document is the
2338
+ // artifact CI reads, so a typo here is a refusal rather than a silent
2339
+ // fall back to the default.
2340
+ const DECL_ENUM: Record<string, string[]> = {
2341
+ order: ['canon', 'partition'],
2342
+ edges: ['upward', 'all', 'none'],
2343
+ }
2344
+ const DECL_COUNT = ['maxRows', 'maxCols', 'minDegree', 'minSize']
2345
+ const DECL_FLAG = ['closure']
2346
+ const DECL_LIST = ['roots', 'relations', 'layers']
2347
+
2348
+ const DECL_KEYS = [...DECL_TEXT, ...DECL_COUNT, ...DECL_FLAG, ...DECL_LIST]
2349
+ .sort(cmpCodePoint)
2350
+
2351
+
2352
+ function documentFinding(path: string, message: string, note?: string): VetFinding {
2353
+ return finding('view_document_shape', 'reference', path, message, note)
2354
+ }
2355
+
2356
+
2357
+ // One validated declaration: everything the drawing needs, decided
2358
+ // before any figure is drawn, so a document with three bad
2359
+ // declarations reports three faults rather than the first.
2360
+ type Plan = {
2361
+ name: string
2362
+ kind: ViewKind
2363
+ as: ViewProfile
2364
+ out: string
2365
+ max: number
2366
+ opts: ViewOptions
2367
+ }
2368
+
2369
+
2370
+ function planOf(name: string, decl: any, at: string): {
2371
+ plan?: Plan, errors: VetFinding[]
2372
+ } {
2373
+ const where = `${at}.${name}`
2374
+ const errors: VetFinding[] = []
2375
+ if (null == decl || 'object' !== typeof decl || Array.isArray(decl)) {
2376
+ return { errors: [documentFinding(where, 'A view declaration is not a map.')] }
2377
+ }
2378
+ const opts: ViewOptions = {}
2379
+ for (const key of Object.keys(decl).sort(cmpCodePoint)) {
2380
+ const value = decl[key]
2381
+ if (DECL_TEXT.includes(key)) {
2382
+ if ('string' !== typeof value) {
2383
+ errors.push(documentFinding(`${where}.${key}`, `${key} must be a string.`))
2384
+ continue
2385
+ }
2386
+ (opts as any)[key] = value
2387
+ }
2388
+ else if (DECL_COUNT.includes(key)) {
2389
+ if ('number' !== typeof value || !Number.isInteger(value) || 0 > value) {
2390
+ errors.push(documentFinding(`${where}.${key}`,
2391
+ `${key} must be a whole number, zero or more.`))
2392
+ continue
2393
+ }
2394
+ (opts as any)[key] = value
2395
+ }
2396
+ else if (DECL_FLAG.includes(key)) {
2397
+ if ('boolean' !== typeof value) {
2398
+ errors.push(documentFinding(`${where}.${key}`, `${key} must be true or false.`))
2399
+ continue
2400
+ }
2401
+ (opts as any)[key] = value
2402
+ }
2403
+ else if (DECL_LIST.includes(key)) {
2404
+ if (!Array.isArray(value) || !allStrings(value)) {
2405
+ errors.push(documentFinding(`${where}.${key}`,
2406
+ `${key} must be a list of strings.`))
2407
+ continue
2408
+ }
2409
+ (opts as any)[key] = value
2410
+ }
2411
+ else {
2412
+ errors.push(documentFinding(`${where}.${key}`,
2413
+ `${key} is not a view option.`, 'options: ' + DECL_KEYS.join(', ')))
2414
+ }
2415
+ }
2416
+
2417
+ for (const key of Object.keys(DECL_ENUM)) {
2418
+ const value = (opts as any)[key]
2419
+ if (undefined !== value && !DECL_ENUM[key].includes(value)) {
2420
+ errors.push(documentFinding(`${where}.${key}`,
2421
+ `${value} is not a ${key}.`, `${key}: ${DECL_ENUM[key].join(', ')}`))
2422
+ }
2423
+ }
2424
+
2425
+ const kind = opts.kind
2426
+ if (undefined === kind) {
2427
+ errors.push(documentFinding(where, 'A view declaration must name its kind.',
2428
+ 'kinds: ' + Object.keys(PROFILES).join(', ')))
2429
+ }
2430
+ else if (undefined === PROFILES[kind]) {
2431
+ errors.push(documentFinding(`${where}.kind`, `${kind} is not a figure kind.`,
2432
+ 'kinds: ' + Object.keys(PROFILES).join(', ')))
2433
+ }
2434
+ else if ('poset' === kind) {
2435
+ // The poset is an order over SEVERAL documents, and a view document
2436
+ // declares figures of the one it includes. `aontu view poset` draws
2437
+ // it, naming the documents on the command line.
2438
+ errors.push(documentFinding(`${where}.kind`,
2439
+ 'A view document draws figures of one document; ' +
2440
+ 'the poset compares several.'))
2441
+ }
2442
+ const profiles = undefined === kind ? undefined : PROFILES[kind]
2443
+ const as = opts.as ?? profiles?.[0]
2444
+ if (undefined !== profiles && undefined !== as && !profiles.includes(as)) {
2445
+ errors.push(documentFinding(`${where}.as`,
2446
+ `The ${kind} figure does not render as ${as}.`,
2447
+ `profiles: ${profiles.join(', ')}`))
2448
+ }
2449
+ const out = opts.out
2450
+ if (undefined === out || '' === out) {
2451
+ errors.push(documentFinding(where,
2452
+ 'A view declaration must name the file it draws into, as out.'))
2453
+ }
2454
+ else if (hasLineBreak(out)) {
2455
+ errors.push(documentFinding(`${where}.out`,
2456
+ 'A file name cannot hold a line terminator.'))
2457
+ }
2458
+ if (0 < errors.length) {
2459
+ return { errors }
2460
+ }
2461
+ return {
2462
+ plan: {
2463
+ name, kind: kind as ViewKind, as: as as ViewProfile, out: out as string,
2464
+ max: opts.maxRows || DEFAULT_MAX_ROWS, opts,
2465
+ },
2466
+ errors: [],
2467
+ }
2468
+ }
2469
+
2470
+
2471
+ // N FIGURES OF ONE DOCUMENT. The document is evaluated ONCE, with the
2472
+ // provenance recorder on, and every figure but the ladder draws from
2473
+ // that one root; the ladder re-runs `why` by construction.
2474
+ //
2475
+ // The caller writes the files, and only when the whole set rendered:
2476
+ // N figures of one model are only meaningful together, so a set whose
2477
+ // third figure refuses must not leave the first two on disk.
2478
+ export function viewSet(
2479
+ src: string, opts?: ViewOptions, hooks?: ViewHooks
2480
+ ): ViewSetReport {
2481
+ const options = opts ?? {}
2482
+ const at = options.views
2483
+ if (undefined === at || '' === at) {
2484
+ return {
2485
+ verdict: 'error', views: [],
2486
+ errors: [documentFinding('$', 'The view document needs the path of ' +
2487
+ 'the map that declares the figures; name it with --views.')],
2488
+ }
2489
+ }
2490
+ // ONE EVALUATION, and it is INSTRUMENTED: the layers panel reads the
2491
+ // provenance record, which is written during unification, so a set
2492
+ // that declares one would otherwise need a second run. Recording it
2493
+ // always costs a little and makes the one-evaluation claim true for
2494
+ // every kind but the ladder, which re-runs `why` by construction.
2495
+ const prov = (hooks?.provenance ?? (() => new Provenance()))()
2496
+ const loaded = load(src, options.path, options.trust, prov)
2497
+ if (undefined !== loaded.errors) {
2498
+ return { verdict: 'error', views: [], errors: loaded.errors }
2499
+ }
2500
+ const root = loaded.root
2501
+ const ctx = loaded.ctx
2502
+ // The declarations are part of the document, so reading them
2503
+ // generates it -- and a view document that does not generate has no
2504
+ // figures, exactly as `aontu file.aon` on it has no output.
2505
+ const before = ctx.err.length
2506
+ const value = root.gen(ctx)
2507
+ if (before < ctx.err.length) {
2508
+ const err: any = ctx.err[before]
2509
+ return {
2510
+ verdict: 'error', views: [],
2511
+ errors: [finding(err?.why ?? 'unify_failed', 'reference', '$',
2512
+ err?.msg ?? 'The document does not generate.')],
2513
+ }
2514
+ }
2515
+ const declared = genAt(value, at)
2516
+ if (null == declared || 'object' !== typeof declared || Array.isArray(declared)) {
2517
+ return {
2518
+ verdict: 'error', views: [],
2519
+ errors: [documentFinding(at, 'The view declarations are not a map.')],
2520
+ }
2521
+ }
2522
+
2523
+ const plans: Plan[] = []
2524
+ const errors: VetFinding[] = []
2525
+ for (const name of Object.keys(declared).sort(cmpCodePoint)) {
2526
+ const planned = planOf(name, declared[name], at)
2527
+ errors.push(...planned.errors)
2528
+ if (undefined !== planned.plan) {
2529
+ plans.push(planned.plan)
2530
+ }
2531
+ }
2532
+ if (0 < errors.length) {
2533
+ return { verdict: 'error', views: [], errors }
2534
+ }
2535
+
2536
+ const gen = { value }
2537
+ const views: ViewFigure[] = plans.map((plan) => {
2538
+ const loss: ViewLoss[] = []
2539
+ const each: ViewOptions = {
2540
+ ...plan.opts, path: options.path, trust: options.trust,
2541
+ }
2542
+ const fig: Figure = 'ladder' === plan.kind
2543
+ ? drawLadder(src, each, plan.as, plan.max)
2544
+ : drawLoaded(root, ctx, gen, prov, plan.kind, plan.as, each, plan.max, loss)
2545
+ if (undefined !== fig.errors) {
2546
+ return {
2547
+ name: plan.name, kind: plan.kind, out: plan.out,
2548
+ verdict: 'error' as ViewVerdict, loss: [], errors: fig.errors,
2549
+ }
2550
+ }
2551
+ loss.sort((a, b) => cmpCodePoint(a.code, b.code))
2552
+ const lossy = loss.some((l) => !INFORMATIONAL.includes(l.code))
2553
+ return {
2554
+ name: plan.name, kind: plan.kind, out: plan.out,
2555
+ verdict: (lossy ? 'lossy' : 'rendered') as ViewVerdict,
2556
+ text: fig.text, loss,
2557
+ }
2558
+ })
2559
+
2560
+ const verdict: ViewVerdict = views.some((v) => 'error' === v.verdict)
2561
+ ? 'error' : views.some((v) => 'lossy' === v.verdict) ? 'lossy' : 'rendered'
2562
+ return { verdict, views }
2563
+ }