@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 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, a
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
 
@@ -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 */
@@ -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 expressions, an
194
- "automaton" has to name existing child kinds and properties, and the
195
- names of non-artifact objects have to be valid patterns (as they are
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 [j, entry] of (object.referenced ?? []).entries()) {
220
- /* a reference coverage entry has to be a single
221
- (usually wildcard) reference expression */
222
- let expr;
223
- try {
224
- expr = compileValueExpr(entry);
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;
@@ -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 derived edge */
44
- const renderSpec = (diagram, type, center, centerUrl, nodes, edges, index, anchors, parenProps) => {
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 node without an anchor
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 every target lifted to its nearest node and the
93
- reference count as the arity) */
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 "deep" diagram attributes the references of
98
- the descendants to their nearest node object */
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
- let target = resolveUnique(index, m[1].trim(), object).target;
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 name = label !== undefined ? plainText(expandReferences(label, (reference) => resolveUnique(index, reference, node).target?.name ?? reference)) : undefined;
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
- for (const node of index)
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])));