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