@rse/specbook 1.2.4 → 1.2.6
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/README.md +21 -3
- package/dst/specbook-cmd-lint.js +25 -1
- package/dst/specbook-config.js +18 -17
- package/dst/specbook-coverage.d.ts +17 -0
- package/dst/specbook-coverage.js +106 -0
- package/dst/specbook-diagram.js +163 -21
- package/dst/specbook-export-ast.js +11 -1
- package/dst/specbook-export-html.css +236 -5
- package/dst/specbook-export-html.js +312 -7
- package/dst/specbook-export-pdf.js +30 -6
- package/dst/specbook-format-schema.d.ts +12 -0
- package/dst/specbook-format-schema.js +13 -1
- package/dst/specbook-format.md +110 -10
- package/dst/specbook-format.yaml +354 -173
- package/dst/specbook-parse-semantic.js +6 -42
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -72,14 +72,17 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
72
72
|
- **AST Exports for AI/LLMs**:
|
|
73
73
|
The parsed specification Abstract Syntax Tree (AST) can be exported in
|
|
74
74
|
JSON, JSON5, YAML, or TOON format for machine consumption, with the
|
|
75
|
-
derived diagram of an object attached to it as a textual Gradia spec
|
|
75
|
+
derived diagram of an object attached to it as a textual Gradia spec
|
|
76
|
+
and the reference coverage it reports attached as its counts.
|
|
76
77
|
Together with the `describe` command, which explains the models and
|
|
77
78
|
formats, this enables LLMs to both read and write specifications.
|
|
78
79
|
|
|
79
80
|
- **HTML Export for Developers**:
|
|
80
81
|
The HTML export is a self-contained single document with a title page,
|
|
81
82
|
a table-of-contents (also as a slide-in side panel for quick navigation),
|
|
82
|
-
a diagram-of-contents, a fuzzy full-text search, embedded images,
|
|
83
|
+
a diagram-of-contents, a fuzzy full-text search, embedded images,
|
|
84
|
+
reference coverage tables (e.g. the share of the use cases, scenarios,
|
|
85
|
+
requirements, and rules the test cases verify), a
|
|
83
86
|
scroll progress meter (which also scrolls back to the top on click),
|
|
84
87
|
description popups (which show the schema and corpus description of
|
|
85
88
|
an object kind, property, or object instance on hover), and a
|
|
@@ -182,7 +185,10 @@ Options:
|
|
|
182
185
|
all diagnostics and fail on any error among them (a warning, like a
|
|
183
186
|
lapse of the reference coverage a `referenced` object kind demands,
|
|
184
187
|
is reported only), so a partial or invalid specification is never
|
|
185
|
-
exported.
|
|
188
|
+
exported. The verbose output additionally states the reference
|
|
189
|
+
coverage ratios: the ones the `referenced` object kinds receive and
|
|
190
|
+
the ones the `coverage` object kinds report, the latter naming the
|
|
191
|
+
unreferenced objects.
|
|
186
192
|
The option accepts glob patterns and can be given multiple times: the
|
|
187
193
|
matching files (in the order of the patterns and alphabetically within
|
|
188
194
|
a pattern, where the literal `std` names the bundled standard schema
|
|
@@ -273,6 +279,18 @@ explicitly supplied option always wins. As `-c|--config` is repeatable,
|
|
|
273
279
|
`SPECBOOK_CONFIG` carries a list of patterns separated by the path
|
|
274
280
|
delimiter of the platform (`:` on Unix, `;` on Windows).
|
|
275
281
|
|
|
282
|
+
Beyond those, the option-less `SPECBOOK_BROWSER` selects the browser
|
|
283
|
+
printing the PDF export: a value carrying a path separator is taken as
|
|
284
|
+
an executable path and any other one as a Playwright channel name
|
|
285
|
+
(`chromium`, `chromium-headless-shell`, `chrome`, `chrome-beta`,
|
|
286
|
+
`chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`,
|
|
287
|
+
or `msedge-canary`). The variable itself has no default value: when it
|
|
288
|
+
is unset, the downloaded Playwright Chromium is used (the equivalent of
|
|
289
|
+
`chromium`) and only if that one is absent a system-installed *Google
|
|
290
|
+
Chrome* (the equivalent of `chrome`). An explicitly configured browser
|
|
291
|
+
failing to launch fails the export instead of falling back onto another
|
|
292
|
+
browser.
|
|
293
|
+
|
|
276
294
|
Example: Simple
|
|
277
295
|
---------------
|
|
278
296
|
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -7,7 +7,9 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
9
9
|
import { literal } from "./specbook-verbose.js";
|
|
10
|
-
import { parseSpecification, specStatistics, resolveArtifact } from "./specbook-parse.js";
|
|
10
|
+
import { parseSpecification, specStatistics, resolveArtifact, collectSchemas } from "./specbook-parse.js";
|
|
11
|
+
import { buildLinkIndex, chainOf, plainText } from "./specbook-link.js";
|
|
12
|
+
import { referencedCoverage, specCoverage, coverageRatio } from "./specbook-coverage.js";
|
|
11
13
|
/* collect the distinct artifact files the schema configuration
|
|
12
14
|
references, in their declaration order, each mapped onto whether
|
|
13
15
|
all of its artifacts are optional and hence the file may be absent */
|
|
@@ -72,6 +74,28 @@ export const lint = (options) => {
|
|
|
72
74
|
const stats = specStatistics(result.specification);
|
|
73
75
|
options.verbose(`parsed specification defining ${literal(stats.objects)} object(s) ` +
|
|
74
76
|
`and ${literal(stats.links)} link relationship(s)`);
|
|
77
|
+
/* report the reference coverage the "referenced"-flagged object
|
|
78
|
+
kinds receive and the "coverage"-configured objects report, the
|
|
79
|
+
unreferenced objects of the latter by name (the ones of the
|
|
80
|
+
former are already reported as warnings) */
|
|
81
|
+
if (config !== undefined && result.specification.artifacts.length > 0) {
|
|
82
|
+
const index = buildLinkIndex(result.specification);
|
|
83
|
+
const schemas = collectSchemas(result.specification, config);
|
|
84
|
+
const label = (object) => `${object.kind} "${literal(plainText(object.name))}"`;
|
|
85
|
+
const ratio = (covered, uncovered) => `${literal(covered.length)} of ${literal(covered.length + uncovered.length)} ` +
|
|
86
|
+
`(${literal(coverageRatio(covered.length, covered.length + uncovered.length))}%)`;
|
|
87
|
+
for (const { schema, covered, uncovered } of referencedCoverage(index, schemas)) {
|
|
88
|
+
const chain = chainOf(index, covered[0] ?? uncovered[0]);
|
|
89
|
+
options.verbose((schema.referenced ?? []).map((entry) => `"${literal(entry)}"`).join(" or ") +
|
|
90
|
+
` references ${ratio(covered, uncovered)} ${schema.kind} object(s)` +
|
|
91
|
+
(chain.length > 1 ? ` below ${label(chain[0])}` : ""));
|
|
92
|
+
}
|
|
93
|
+
for (const [object, entries] of specCoverage(index, schemas))
|
|
94
|
+
for (const { pattern, covered, uncovered } of entries)
|
|
95
|
+
options.verbose(`${label(object)} references ${ratio(covered, uncovered)} ` +
|
|
96
|
+
`"${literal(pattern)}" object(s)` + (uncovered.length > 0 ?
|
|
97
|
+
`, unreferenced: ${uncovered.map(label).join(", ")}` : ""));
|
|
98
|
+
}
|
|
75
99
|
/* report the non-optional artifacts absent from the specification,
|
|
76
100
|
against their loaded artifact file (an absent or unreadable file
|
|
77
101
|
is already reported above) or else against the configuration */
|
package/dst/specbook-config.js
CHANGED
|
@@ -190,10 +190,10 @@ const checkAutomaton = (object, at, diagnose) => {
|
|
|
190
190
|
are beyond its schema: sibling objects have to stay distinctly
|
|
191
191
|
resolvable, "file" fields are allowed on the first (artifact) level
|
|
192
192
|
only, property value and marker expressions have to be syntactically
|
|
193
|
-
valid, "referenced" entries have to be reference
|
|
194
|
-
"automaton" has to name existing child kinds and
|
|
195
|
-
names of non-artifact objects have to be valid
|
|
196
|
-
compiled into regular expressions) */
|
|
193
|
+
valid, "referenced" and "coverage" entries have to be reference
|
|
194
|
+
expressions, an "automaton" has to name existing child kinds and
|
|
195
|
+
properties, and the names of non-artifact objects have to be valid
|
|
196
|
+
patterns (as they are compiled into regular expressions) */
|
|
197
197
|
const checkConstraints = (config, posOfPath) => {
|
|
198
198
|
const diagnostics = new Array();
|
|
199
199
|
const diagnose = (path, message) => {
|
|
@@ -216,20 +216,21 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
216
216
|
diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
|
|
217
217
|
checkProperties(object.props ?? [], at, diagnose);
|
|
218
218
|
checkAutomaton(object, at, diagnose);
|
|
219
|
-
for (const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
for (const field of ["referenced", "coverage"])
|
|
220
|
+
for (const [j, entry] of (object[field] ?? []).entries()) {
|
|
221
|
+
/* a reference coverage entry has to be a single
|
|
222
|
+
(usually wildcard) reference expression */
|
|
223
|
+
let expr;
|
|
224
|
+
try {
|
|
225
|
+
expr = compileValueExpr(entry);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
diagnose([...at, field, j], `invalid "${field}" entry "${entry}": ` +
|
|
229
|
+
(err instanceof Error ? err.message : String(err)));
|
|
230
|
+
}
|
|
231
|
+
if (expr !== undefined && expr.kind !== "reference")
|
|
232
|
+
diagnose([...at, field, j], `"${field}" entry "${entry}" is not a single "[[xxx]]" reference`);
|
|
225
233
|
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
diagnose([...at, "referenced", j], `invalid "referenced" entry "${entry}": ` +
|
|
228
|
-
(err instanceof Error ? err.message : String(err)));
|
|
229
|
-
}
|
|
230
|
-
if (expr !== undefined && expr.kind !== "reference")
|
|
231
|
-
diagnose([...at, "referenced", j], `"referenced" entry "${entry}" is not a single "[[xxx]]" reference`);
|
|
232
|
-
}
|
|
233
234
|
if (depth > 1 && object.name !== undefined) {
|
|
234
235
|
try {
|
|
235
236
|
anchored(object.name);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import { type SchemaObject } from "./specbook-format-schema.js";
|
|
3
|
+
import { type LinkIndex } from "./specbook-link.js";
|
|
4
|
+
export interface ReferencedCoverage {
|
|
5
|
+
schema: SchemaObject;
|
|
6
|
+
covered: SpecObject[];
|
|
7
|
+
uncovered: SpecObject[];
|
|
8
|
+
}
|
|
9
|
+
export declare const referencedCoverage: (index: LinkIndex, schemas: Map<SpecObject, SchemaObject>) => ReferencedCoverage[];
|
|
10
|
+
export interface Coverage {
|
|
11
|
+
pattern: string;
|
|
12
|
+
kinds: string[];
|
|
13
|
+
covered: SpecObject[];
|
|
14
|
+
uncovered: SpecObject[];
|
|
15
|
+
}
|
|
16
|
+
export declare const specCoverage: (index: LinkIndex, schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, Coverage[]>;
|
|
17
|
+
export declare const coverageRatio: (covered: number, total: number) => number;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText } from "./specbook-link.js";
|
|
7
|
+
import { compileValueExpr } from "./specbook-parse-value.js";
|
|
8
|
+
/* the referencing objects of every referenced object, resolved from
|
|
9
|
+
the names, property values, descriptions, and rationales of all
|
|
10
|
+
objects, where the references from within the subtree of the
|
|
11
|
+
referenced object itself do not count (an object cannot cover
|
|
12
|
+
itself), leniently skipping the unresolvable references, as those
|
|
13
|
+
are already reported by the reference pass */
|
|
14
|
+
const referrerIndex = (index) => {
|
|
15
|
+
const referrers = new Map();
|
|
16
|
+
for (const { object } of index) {
|
|
17
|
+
const chain = chainOf(index, object);
|
|
18
|
+
const texts = [object.name, ...object.properties.map((p) => p.value)];
|
|
19
|
+
if (object.description !== undefined)
|
|
20
|
+
texts.push(object.description.description, object.description.rationale ?? "");
|
|
21
|
+
for (const text of texts)
|
|
22
|
+
for (const m of plainText(text).matchAll(referenceRegex)) {
|
|
23
|
+
const target = resolveUnique(index, m[1].trim(), object).target;
|
|
24
|
+
if (target !== undefined && !chain.includes(target))
|
|
25
|
+
referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return referrers;
|
|
29
|
+
};
|
|
30
|
+
/* whether an object is covered by a set of source objects: it is
|
|
31
|
+
referenced from an object lying below one of the sources (itself
|
|
32
|
+
or through an ancestor) or, when lifting, so is one of its
|
|
33
|
+
descendants */
|
|
34
|
+
const isCovered = (index, referrers, sources, object, lifting) => {
|
|
35
|
+
for (const referrer of referrers.get(object) ?? [])
|
|
36
|
+
if (chainOf(index, referrer).some((o) => sources.has(o)))
|
|
37
|
+
return true;
|
|
38
|
+
return lifting && object.children.some((child) => isCovered(index, referrers, sources, child, true));
|
|
39
|
+
};
|
|
40
|
+
/* the objects matching the reference patterns of a schema field */
|
|
41
|
+
const matchingObjects = (index, patterns) => {
|
|
42
|
+
const objects = new Array();
|
|
43
|
+
for (const pattern of patterns) {
|
|
44
|
+
const expr = compileValueExpr(pattern);
|
|
45
|
+
if (expr.kind === "reference")
|
|
46
|
+
for (const object of resolveSet(index, expr.pattern))
|
|
47
|
+
if (!objects.includes(object))
|
|
48
|
+
objects.push(object);
|
|
49
|
+
}
|
|
50
|
+
return objects;
|
|
51
|
+
};
|
|
52
|
+
/* determine the coverage the objects of every "referenced"-flagged
|
|
53
|
+
kind receive, one entry per flagged schema node (in the order of
|
|
54
|
+
the first objects of the nodes), as the semantic validation
|
|
55
|
+
reports every uncovered object and the verbose output the ratio */
|
|
56
|
+
export const referencedCoverage = (index, schemas) => {
|
|
57
|
+
const referrers = referrerIndex(index);
|
|
58
|
+
const results = new Map();
|
|
59
|
+
for (const [object, schema] of schemas) {
|
|
60
|
+
if (schema.referenced === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
let result = results.get(schema);
|
|
63
|
+
if (result === undefined) {
|
|
64
|
+
result = { schema, sources: new Set(matchingObjects(index, schema.referenced)),
|
|
65
|
+
covered: [], uncovered: [] };
|
|
66
|
+
results.set(schema, result);
|
|
67
|
+
}
|
|
68
|
+
const bucket = isCovered(index, referrers, result.sources, object, false) ?
|
|
69
|
+
result.covered : result.uncovered;
|
|
70
|
+
bucket.push(object);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(results.values()).map(({ schema, covered, uncovered }) => ({ schema, covered, uncovered }));
|
|
73
|
+
};
|
|
74
|
+
/* determine the coverage the objects of every "coverage"-configured
|
|
75
|
+
kind report, one entry per configured pattern: the objects matching
|
|
76
|
+
the pattern (those below the reporting object itself excluded) count
|
|
77
|
+
as covered once referenced -- themselves or through one of their
|
|
78
|
+
descendants, so a use case is covered through its scenarios -- from
|
|
79
|
+
the reporting object or one of its descendants */
|
|
80
|
+
export const specCoverage = (index, schemas) => {
|
|
81
|
+
const referrers = referrerIndex(index);
|
|
82
|
+
const results = new Map();
|
|
83
|
+
for (const [object, schema] of schemas) {
|
|
84
|
+
if (schema.coverage === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
const sources = new Set([object]);
|
|
87
|
+
const entries = new Array();
|
|
88
|
+
for (const pattern of schema.coverage) {
|
|
89
|
+
const targets = matchingObjects(index, [pattern])
|
|
90
|
+
.filter((target) => !chainOf(index, target).includes(object));
|
|
91
|
+
const entry = { pattern, kinds: [], covered: [], uncovered: [] };
|
|
92
|
+
for (const target of targets) {
|
|
93
|
+
if (!entry.kinds.includes(target.kind))
|
|
94
|
+
entry.kinds.push(target.kind);
|
|
95
|
+
const bucket = isCovered(index, referrers, sources, target, true) ?
|
|
96
|
+
entry.covered : entry.uncovered;
|
|
97
|
+
bucket.push(target);
|
|
98
|
+
}
|
|
99
|
+
entries.push(entry);
|
|
100
|
+
}
|
|
101
|
+
results.set(object, entries);
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
};
|
|
105
|
+
/* the percentage of a coverage, rounded to the integer */
|
|
106
|
+
export const coverageRatio = (covered, total) => total > 0 ? Math.round(covered * 100 / total) : 0;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -40,26 +40,47 @@ const propValue = (parenProps, object, key) => propsOf(parenProps, object).find(
|
|
|
40
40
|
"#type" directive and the configured "#config" directives so every
|
|
41
41
|
embedded spec is self-contained: one node statement per object (the
|
|
42
42
|
unique anchor path as the node id, the object name as the displayed
|
|
43
|
-
label) and one edge statement per
|
|
44
|
-
|
|
43
|
+
label, the container node as the parent) and one edge statement per
|
|
44
|
+
derived edge */
|
|
45
|
+
const renderSpec = (diagram, type, center, centerUrl, nodes, edges, nesting, index, anchors, positions, parenProps) => {
|
|
45
46
|
const lines = [`#type ${type}`];
|
|
46
47
|
const config = diagram.config ?? {};
|
|
47
48
|
for (const [key, value] of Object.entries(config))
|
|
48
49
|
if (value !== undefined)
|
|
49
50
|
lines.push(`#config ${key} ${configValue(value)}`);
|
|
50
51
|
lines.push("");
|
|
52
|
+
/* the explicit top-down order of an "ordered" diagram: the members
|
|
53
|
+
of a container share one row (which Gradia wraps), while the
|
|
54
|
+
top-level nodes stack in document order, a synthetic container
|
|
55
|
+
taking the position of its first member */
|
|
56
|
+
const orderOf = (node) => {
|
|
57
|
+
if (nesting.parentOf.has(node))
|
|
58
|
+
return 0;
|
|
59
|
+
let order = positions.get(node) ?? Number.MAX_SAFE_INTEGER;
|
|
60
|
+
for (const [member, container] of nesting.parentOf)
|
|
61
|
+
if (container === node)
|
|
62
|
+
order = Math.min(order, positions.get(member) ?? order);
|
|
63
|
+
return order;
|
|
64
|
+
};
|
|
51
65
|
for (const node of nodes) {
|
|
52
66
|
const anchor = anchors.get(node);
|
|
53
67
|
const attrs = new Array();
|
|
54
|
-
/* a synthetic center node (the only
|
|
55
|
-
path) links to its source object, or nowhere at all */
|
|
56
|
-
const url = anchor !== undefined ? `#${anchor}` : centerUrl;
|
|
68
|
+
/* a synthetic center or container node (the only nodes without
|
|
69
|
+
an anchor path) links to its source object, or nowhere at all */
|
|
70
|
+
const url = anchor !== undefined ? `#${anchor}` : (node === center ? centerUrl : undefined);
|
|
57
71
|
if (url !== undefined)
|
|
58
72
|
attrs.push(`url: ${atom(url)}`);
|
|
59
73
|
if (diagram.qualified === true && node.kind !== "")
|
|
60
74
|
attrs.push(`type: ${atom(node.kind)}`);
|
|
61
75
|
if (type === "hub" ? node === center : node.primary === true)
|
|
62
76
|
attrs.push("primary: true");
|
|
77
|
+
const container = nesting.parentOf.get(node);
|
|
78
|
+
if (container !== undefined)
|
|
79
|
+
attrs.push(`parent: ${atom(anchors.get(container) ?? container.id)}`);
|
|
80
|
+
if (diagram.nest?.layout !== undefined && nesting.containers.has(node))
|
|
81
|
+
attrs.push(`container: ${diagram.nest.layout}`);
|
|
82
|
+
if (diagram.ordered === true)
|
|
83
|
+
attrs.push(`order: ${orderOf(node)}`);
|
|
63
84
|
/* attach the values of the configured properties as Gradia
|
|
64
85
|
key/value attributes (a node lacking a property is fine, as
|
|
65
86
|
the node set can mix objects of different kinds), with every
|
|
@@ -89,13 +110,13 @@ const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, ancho
|
|
|
89
110
|
/* derive the reference edges of a diagram from the "[[...]]" references
|
|
90
111
|
of the node objects (per the "links" selection, named with the
|
|
91
112
|
property key when "labeled", and for a "deep" diagram also of their
|
|
92
|
-
descendants, with
|
|
93
|
-
|
|
113
|
+
descendants, with the reference count as the arity), with every
|
|
114
|
+
target lifted to its nearest node */
|
|
94
115
|
const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents, parenProps) => {
|
|
95
116
|
const edges = new Array();
|
|
96
117
|
/* lift an object to its nearest ancestor-or-self within the
|
|
97
|
-
node set, as a
|
|
98
|
-
the
|
|
118
|
+
node set, as a reference onto a descendant of a node object
|
|
119
|
+
(like the scenario of a use case) is a reference onto the node */
|
|
99
120
|
const lift = (object) => {
|
|
100
121
|
while (object !== undefined && !nodeSet.has(object))
|
|
101
122
|
object = parents.get(object);
|
|
@@ -127,9 +148,7 @@ const deriveReferenceEdges = (diagram, nodes, nodeSet, index, anchors, parents,
|
|
|
127
148
|
}
|
|
128
149
|
for (const { text, name } of texts)
|
|
129
150
|
for (const m of text.matchAll(referenceRegex)) {
|
|
130
|
-
|
|
131
|
-
if (diagram.deep === true)
|
|
132
|
-
target = lift(target);
|
|
151
|
+
const target = lift(resolveUnique(index, m[1].trim(), object).target);
|
|
133
152
|
if (target !== undefined && target !== node && nodeSet.has(target)) {
|
|
134
153
|
const key = `${anchors.get(target) ?? target.id}\u0000${name ?? ""}`;
|
|
135
154
|
const entry = counts.get(key) ?? { target, name, count: 0 };
|
|
@@ -321,7 +340,9 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProp
|
|
|
321
340
|
direction property of the node objects: the "inbound" value
|
|
322
341
|
maps onto a node-to-center edge, the "outbound" value onto a
|
|
323
342
|
center-to-node edge, and the "both" value onto both (which
|
|
324
|
-
Gradia renders as an input node plus an output "ghost" node)
|
|
343
|
+
Gradia renders as an input node plus an output "ghost" node),
|
|
344
|
+
named by the "labeled" property and/or by the mediating object
|
|
345
|
+
the "via" property references (as "via <name>") */
|
|
325
346
|
const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
326
347
|
const edges = new Array();
|
|
327
348
|
for (const node of nodes) {
|
|
@@ -337,9 +358,17 @@ const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
|
337
358
|
`carries the unmapped diagram "centerEdges" value "${value}"` });
|
|
338
359
|
continue;
|
|
339
360
|
}
|
|
361
|
+
const expand = (text) => plainText(expandReferences(text, (reference) => resolveUnique(index, reference, node).target?.name ?? reference));
|
|
340
362
|
const label = cfg.labeled !== undefined ?
|
|
341
363
|
propValue(parenProps, node, cfg.labeled) : undefined;
|
|
342
|
-
const
|
|
364
|
+
const via = cfg.via !== undefined ?
|
|
365
|
+
propValue(parenProps, node, cfg.via) : undefined;
|
|
366
|
+
const names = new Array();
|
|
367
|
+
if (label !== undefined)
|
|
368
|
+
names.push(expand(label));
|
|
369
|
+
if (via !== undefined)
|
|
370
|
+
names.push(`via ${expand(via)}`);
|
|
371
|
+
const name = names.length > 0 ? names.join(" ") : undefined;
|
|
343
372
|
if (inbound || both)
|
|
344
373
|
edges.push({ source: node, target: center, name });
|
|
345
374
|
if (outbound || both)
|
|
@@ -347,10 +376,104 @@ const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
|
|
|
347
376
|
}
|
|
348
377
|
return edges;
|
|
349
378
|
};
|
|
379
|
+
/* derive the nesting of the nodes into container nodes: per node the
|
|
380
|
+
first configured property carrying exactly one resolvable reference
|
|
381
|
+
names its container (added to the node set when absent, and nested
|
|
382
|
+
on its own in turn), else its parent object when part of the node
|
|
383
|
+
set, else a synthetic container per object kind; a node nested into
|
|
384
|
+
itself or into a cycle stays un-nested and is reported */
|
|
385
|
+
const deriveNesting = (nest, nodes, nodeSet, index, parents, parenProps, errors) => {
|
|
386
|
+
const parentOf = new Map();
|
|
387
|
+
const byKind = new Map();
|
|
388
|
+
/* the iteration also visits the containers appended underway */
|
|
389
|
+
for (const node of nodes) {
|
|
390
|
+
let container;
|
|
391
|
+
for (const key of nest.properties ?? []) {
|
|
392
|
+
const value = propValue(parenProps, node, key);
|
|
393
|
+
const refs = value !== undefined ? Array.from(value.matchAll(referenceRegex)) : [];
|
|
394
|
+
if (refs.length !== 1)
|
|
395
|
+
continue;
|
|
396
|
+
container = resolveUnique(index, refs[0][1].trim(), node).target;
|
|
397
|
+
if (container !== undefined)
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
if (container === undefined && nest.parent === true) {
|
|
401
|
+
const parent = parents.get(node);
|
|
402
|
+
if (parent !== undefined && nodeSet.has(parent))
|
|
403
|
+
container = parent;
|
|
404
|
+
}
|
|
405
|
+
if (container === undefined && nest.kind === true && node.kind !== "") {
|
|
406
|
+
container = byKind.get(node.kind);
|
|
407
|
+
if (container === undefined) {
|
|
408
|
+
container = { kind: "", id: `@kind-${node.kind}`, name: node.kind, properties: [], children: [] };
|
|
409
|
+
byKind.set(node.kind, container);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (container === undefined)
|
|
413
|
+
continue;
|
|
414
|
+
/* a container chain leading back to the node would close a
|
|
415
|
+
cycle, which Gradia rejects exactly like a self-nesting */
|
|
416
|
+
let ancestor = container;
|
|
417
|
+
while (ancestor !== undefined && ancestor !== node)
|
|
418
|
+
ancestor = parentOf.get(ancestor);
|
|
419
|
+
if (ancestor === node) {
|
|
420
|
+
errors.push({ reason: `node "${plainText(node.name)}" is nested into itself` });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
parentOf.set(node, container);
|
|
424
|
+
if (!nodeSet.has(container)) {
|
|
425
|
+
nodes.push(container);
|
|
426
|
+
nodeSet.add(container);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { parentOf, containers: new Set(parentOf.values()) };
|
|
430
|
+
};
|
|
431
|
+
/* lift the edges crossing a container boundary onto the containers:
|
|
432
|
+
an end is replaced by its outermost ancestor container not enclosing
|
|
433
|
+
the other end as well (the target end only, or both ends), and the
|
|
434
|
+
edges coinciding afterwards merge into one, with their arities summed
|
|
435
|
+
where numeric, so a bundle of references into a container becomes a
|
|
436
|
+
single edge onto it */
|
|
437
|
+
const liftEdges = (edges, parentOf, mode, anchors) => {
|
|
438
|
+
const ancestors = (object) => {
|
|
439
|
+
const chain = new Array();
|
|
440
|
+
for (let p = parentOf.get(object); p !== undefined; p = parentOf.get(p))
|
|
441
|
+
chain.push(p);
|
|
442
|
+
return chain;
|
|
443
|
+
};
|
|
444
|
+
const lift = (end, other) => {
|
|
445
|
+
const enclosing = new Set([other, ...ancestors(other)]);
|
|
446
|
+
let lifted = end;
|
|
447
|
+
for (const ancestor of ancestors(end)) {
|
|
448
|
+
if (enclosing.has(ancestor))
|
|
449
|
+
break;
|
|
450
|
+
lifted = ancestor;
|
|
451
|
+
}
|
|
452
|
+
return lifted;
|
|
453
|
+
};
|
|
454
|
+
const merged = new Map();
|
|
455
|
+
for (const edge of edges) {
|
|
456
|
+
const target = lift(edge.target, edge.source);
|
|
457
|
+
const source = mode === "both" ? lift(edge.source, edge.target) : edge.source;
|
|
458
|
+
if (source === target)
|
|
459
|
+
continue;
|
|
460
|
+
const key = `${anchors.get(source) ?? source.id}${anchors.get(target) ?? target.id}` +
|
|
461
|
+
`${edge.name ?? ""}`;
|
|
462
|
+
const known = merged.get(key);
|
|
463
|
+
if (known === undefined)
|
|
464
|
+
merged.set(key, { source, target, name: edge.name, arity: edge.arity });
|
|
465
|
+
else if (known.arity !== undefined && edge.arity !== undefined
|
|
466
|
+
&& /^\d+$/.test(known.arity) && /^\d+$/.test(edge.arity))
|
|
467
|
+
known.arity = String(Number(known.arity) + Number(edge.arity));
|
|
468
|
+
else if (known.arity !== edge.arity)
|
|
469
|
+
known.arity = undefined;
|
|
470
|
+
}
|
|
471
|
+
return Array.from(merged.values());
|
|
472
|
+
};
|
|
350
473
|
/* derive the Gradia spec of a single object from its "diagram:"
|
|
351
474
|
schema configuration (the returned spec is absent whenever the
|
|
352
475
|
configured diagram situation is invalid) */
|
|
353
|
-
const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) => {
|
|
476
|
+
const deriveDiagram = (object, diagram, index, anchors, positions, parents, parenProps) => {
|
|
354
477
|
const type = diagram.type ?? "graph";
|
|
355
478
|
const errors = new Array();
|
|
356
479
|
/* determine the node objects (default: the current object plus
|
|
@@ -418,15 +541,32 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
|
|
|
418
541
|
(edges.some((edge) => edge.source === center) ? 1 : 0);
|
|
419
542
|
}
|
|
420
543
|
}
|
|
544
|
+
/* nest the nodes into container nodes (except in a "hub" diagram,
|
|
545
|
+
where every container would need a primary node of its own),
|
|
546
|
+
dropping the edges between a container and its own members,
|
|
547
|
+
which Gradia rejects, as the nesting already shows them */
|
|
548
|
+
let nesting = { parentOf: new Map(), containers: new Set() };
|
|
549
|
+
if (diagram.nest !== undefined) {
|
|
550
|
+
if (type === "hub")
|
|
551
|
+
errors.push({ reason: "\"hub\" diagram cannot carry a \"nest\" configuration" });
|
|
552
|
+
else {
|
|
553
|
+
nesting = deriveNesting(diagram.nest, nodes, nodeSet, index, parents, parenProps, errors);
|
|
554
|
+
if (diagram.nest.crossing !== undefined && diagram.nest.crossing !== "nodes")
|
|
555
|
+
edges = liftEdges(edges, nesting.parentOf, diagram.nest.crossing, anchors);
|
|
556
|
+
edges = edges.filter((edge) => nesting.parentOf.get(edge.source) !== edge.target
|
|
557
|
+
&& nesting.parentOf.get(edge.target) !== edge.source);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
421
560
|
/* the "onlyConnected" filtering keeps only the nodes with at
|
|
422
|
-
least one incident edge (sensible for "graph" diagrams only)
|
|
561
|
+
least one incident edge (sensible for "graph" diagrams only),
|
|
562
|
+
and the container nodes, whose members connect them */
|
|
423
563
|
if (diagram.onlyConnected === true && type === "graph") {
|
|
424
564
|
const connected = new Set();
|
|
425
565
|
for (const edge of edges) {
|
|
426
566
|
connected.add(edge.source);
|
|
427
567
|
connected.add(edge.target);
|
|
428
568
|
}
|
|
429
|
-
nodes = nodes.filter((node) => connected.has(node));
|
|
569
|
+
nodes = nodes.filter((node) => connected.has(node) || nesting.containers.has(node));
|
|
430
570
|
}
|
|
431
571
|
if (errors.length > 0)
|
|
432
572
|
return { errors };
|
|
@@ -438,8 +578,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
|
|
|
438
578
|
if (nodes.length === 0
|
|
439
579
|
|| (diagram.collapse !== false && nodes.length === 1 && edges.length === 0))
|
|
440
580
|
return { errors };
|
|
441
|
-
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, index, anchors, parenProps),
|
|
442
|
-
config: diagram.config, columns, errors };
|
|
581
|
+
return { spec: renderSpec(diagram, type, center, centerUrl, nodes, edges, nesting, index, anchors, positions, parenProps), config: diagram.config, columns, errors };
|
|
443
582
|
};
|
|
444
583
|
/* the memoized diagram derivations, keyed by specification, as the
|
|
445
584
|
lint validation and every renderer of an export derive the very
|
|
@@ -454,8 +593,11 @@ export const specDiagrams = (specification, config) => {
|
|
|
454
593
|
const index = buildLinkIndex(specification);
|
|
455
594
|
const anchors = anchorPaths(index);
|
|
456
595
|
const parents = new Map();
|
|
457
|
-
|
|
596
|
+
const positions = new Map();
|
|
597
|
+
index.forEach((node, position) => {
|
|
458
598
|
parents.set(node.object, node.parent?.object);
|
|
599
|
+
positions.set(node.object, position);
|
|
600
|
+
});
|
|
459
601
|
const schemas = collectSchemas(specification, config);
|
|
460
602
|
/* re-derive the synthetic properties of the consumed parenthesized
|
|
461
603
|
name tokens, as the derivation sees the AST alone, while the
|
|
@@ -464,7 +606,7 @@ export const specDiagrams = (specification, config) => {
|
|
|
464
606
|
const results = new Map();
|
|
465
607
|
for (const [object, schema] of schemas)
|
|
466
608
|
if (schema.diagram !== undefined)
|
|
467
|
-
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents, parenProps));
|
|
609
|
+
results.set(object, deriveDiagram(object, schema.diagram, index, anchors, positions, parents, parenProps));
|
|
468
610
|
derivations.set(specification, { config, results });
|
|
469
611
|
return results;
|
|
470
612
|
};
|
|
@@ -7,6 +7,9 @@ import JSON5 from "json5";
|
|
|
7
7
|
import { stringify as stringifyYaml } from "yaml";
|
|
8
8
|
import { encode as encodeToon } from "@toon-format/toon";
|
|
9
9
|
import { specDiagrams } from "./specbook-diagram.js";
|
|
10
|
+
import { specCoverage } from "./specbook-coverage.js";
|
|
11
|
+
import { buildLinkIndex } from "./specbook-link.js";
|
|
12
|
+
import { collectSchemas } from "./specbook-parse-semantic.js";
|
|
10
13
|
import { isTitleObject } from "./specbook-export-common.js";
|
|
11
14
|
/* render the specification AST into a serialization format */
|
|
12
15
|
export const renderAst = (specification, format, config) => {
|
|
@@ -16,13 +19,20 @@ export const renderAst = (specification, format, config) => {
|
|
|
16
19
|
"diagram" fields onto the corresponding plain object nodes
|
|
17
20
|
(an invalid diagram situation omits the diagram, as it is
|
|
18
21
|
already reported as a lint diagnostic, and the diagram of
|
|
19
|
-
the title object is reserved for the HTML/PDF export)
|
|
22
|
+
the title object is reserved for the HTML/PDF export), and
|
|
23
|
+
the reference coverage of the coverage-configured objects as
|
|
24
|
+
"coverage" fields (the counts only, as the export is intended
|
|
25
|
+
for machine consumption) */
|
|
20
26
|
if (config !== undefined) {
|
|
21
27
|
const diagrams = specDiagrams(specification, config);
|
|
28
|
+
const coverages = specCoverage(buildLinkIndex(specification), collectSchemas(specification, config));
|
|
22
29
|
const walk = (object, node) => {
|
|
23
30
|
const result = diagrams.get(object);
|
|
24
31
|
if (result?.spec !== undefined && !isTitleObject(object))
|
|
25
32
|
node.diagram = result.spec;
|
|
33
|
+
const coverage = coverages.get(object);
|
|
34
|
+
if (coverage !== undefined)
|
|
35
|
+
node.coverage = coverage.map(({ pattern, covered, uncovered }) => ({ pattern, covered: covered.length, total: covered.length + uncovered.length }));
|
|
26
36
|
object.children.forEach((child, i) => walk(child, node.children[i]));
|
|
27
37
|
};
|
|
28
38
|
specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
|