@rse/specbook 1.2.5 → 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
@@ -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;
@@ -340,7 +340,9 @@ const deriveCenter = (object, diagram, nodes, nodeSet, index, anchors, parenProp
340
340
  direction property of the node objects: the "inbound" value
341
341
  maps onto a node-to-center edge, the "outbound" value onto a
342
342
  center-to-node edge, and the "both" value onto both (which
343
- 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>") */
344
346
  const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
345
347
  const edges = new Array();
346
348
  for (const node of nodes) {
@@ -356,9 +358,17 @@ const deriveCenterEdges = (cfg, nodes, center, index, parenProps, errors) => {
356
358
  `carries the unmapped diagram "centerEdges" value "${value}"` });
357
359
  continue;
358
360
  }
361
+ const expand = (text) => plainText(expandReferences(text, (reference) => resolveUnique(index, reference, node).target?.name ?? reference));
359
362
  const label = cfg.labeled !== undefined ?
360
363
  propValue(parenProps, node, cfg.labeled) : undefined;
361
- 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;
362
372
  if (inbound || both)
363
373
  edges.push({ source: node, target: center, name });
364
374
  if (outbound || both)
@@ -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])));
@@ -486,7 +486,7 @@ div.fold-switch div.fold-controls svg {
486
486
  /* the folding of the diagrams and the over-long table cells
487
487
  (screen-only): a diagram sits in a fold container carrying a chevron
488
488
  mark in the muted symbol color of the anchors at its top left corner
489
- -- in the page margin beside the content, and in the flow left of
489
+ -- in the page margin beside the content, and in a flex row left of
490
490
  the diagram inside a table cell, where no margin is left -- while an
491
491
  over-long cell carries its own chevron in a rounded grey box behind
492
492
  the last still shown word; a folded diagram leaves the icon of its
@@ -543,19 +543,25 @@ span.fold-label svg {
543
543
  height: 1.1rem;
544
544
  }
545
545
  @media screen {
546
+ td div.fold {
547
+ display: flex;
548
+ align-items: flex-start;
549
+ }
546
550
  td div.fold > span.fold-mark {
547
551
  position: static;
548
- display: inline-block;
549
- vertical-align: middle;
550
- margin-right: 0.3rem;
552
+ flex: 0 0 auto;
553
+ margin: 0.3rem 0.3rem 0 0;
554
+ }
555
+ td div.fold > div.diagram {
556
+ flex: 1 1 auto;
557
+ min-width: 0;
551
558
  }
552
559
  body:not(.searching) div.fold.folded > span.fold-label {
553
560
  display: block;
554
561
  }
555
562
  body:not(.searching) td div.fold.folded > span.fold-label {
556
- display: inline-block;
557
- vertical-align: middle;
558
- margin-bottom: 0;
563
+ display: block;
564
+ margin: 0.15rem 0 0 0;
559
565
  }
560
566
  body:not(.searching) div.fold.folded > div.diagram,
561
567
  body:not(.searching) td.folded .fold-rest {
@@ -1103,6 +1109,58 @@ table.objects td section {
1103
1109
  table.objects td section h6 {
1104
1110
  margin: 0.5rem 0 0.25rem 0;
1105
1111
  }
1112
+ /* the reference coverage tables of the coverage-configured objects:
1113
+ the headers like the "Properties" and "Diagram" headers of the
1114
+ compact tables (as they name neither object kinds nor properties),
1115
+ the counts in narrow columns, the ratio as a bar in the accent
1116
+ color over the remaining width, followed by its percentage */
1117
+ table.coverage {
1118
+ border-collapse: collapse;
1119
+ margin: 1rem 0;
1120
+ width: 100%;
1121
+ }
1122
+ table.coverage th,
1123
+ table.coverage td {
1124
+ border: 0;
1125
+ border-left: 1px solid var(--theme-color-specbook-border);
1126
+ padding: 0.3rem 0.6rem;
1127
+ text-align: left;
1128
+ vertical-align: top;
1129
+ white-space: nowrap;
1130
+ }
1131
+ table.coverage th:first-child,
1132
+ table.coverage td:first-child {
1133
+ border-left: 0;
1134
+ }
1135
+ table.coverage td:first-child {
1136
+ font-weight: 600;
1137
+ }
1138
+ table.coverage th {
1139
+ background-color: var(--theme-color-specbook-tint);
1140
+ font-weight: normal;
1141
+ color: var(--theme-color-specbook-accent);
1142
+ }
1143
+ table.coverage tbody tr.even td {
1144
+ background-color: var(--theme-color-specbook-stripe);
1145
+ }
1146
+ table.coverage th.ratio,
1147
+ table.coverage td.ratio {
1148
+ width: 100%;
1149
+ }
1150
+ table.coverage span.bar {
1151
+ display: inline-block;
1152
+ vertical-align: middle;
1153
+ width: 12rem;
1154
+ height: 0.6rem;
1155
+ border-radius: 0.3rem;
1156
+ background-color: var(--theme-color-specbook-tint-deep);
1157
+ overflow: hidden;
1158
+ }
1159
+ table.coverage span.bar > span {
1160
+ display: block;
1161
+ height: 100%;
1162
+ background-color: var(--theme-color-specbook-accent);
1163
+ }
1106
1164
  /* the object descriptions and their embedded images */
1107
1165
  p.description,
1108
1166
  div.description {
@@ -15,6 +15,7 @@ import { embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants }
15
15
  import { escapeHtml, stylesheet, searchScript, fallbackLogo, isTitleObject, titleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
16
16
  import { collectSchemas } from "./specbook-parse-semantic.js";
17
17
  import { renderDiagrams } from "./specbook-diagram.js";
18
+ import { specCoverage, coverageRatio } from "./specbook-coverage.js";
18
19
  /* ==== Templates ==== */
19
20
  /* the built-in Nunjucks templates for the HTML export */
20
21
  const templates = {
@@ -154,9 +155,23 @@ const templates = {
154
155
  {{ Object.diagram }}
155
156
  {{ Object.properties }}
156
157
  {{ Object.description }}
158
+ {{ Object.coverage }}
157
159
  {{ Object.children }}
158
160
  </section>
159
161
  `,
162
+ /* <Coverage/> */
163
+ "Coverage": textframe `
164
+ <table class="coverage">
165
+ <thead>
166
+ <tr><th>Coverage</th><th>Covered</th><th>Total</th><th class="ratio">Ratio</th></tr>
167
+ </thead>
168
+ <tbody>
169
+ {% for entry in Coverage %}
170
+ <tr{% if entry.even %} class="even"{% endif %}><td>{{ entry.label }}</td><td>{{ entry.covered }}</td><td>{{ entry.total }}</td><td class="ratio"><span class="bar"><span style="width: {{ entry.ratio }}%"></span></span> {{ entry.ratio }}%</td></tr>
171
+ {% endfor %}
172
+ </tbody>
173
+ </table>
174
+ `,
160
175
  /* <Properties/> */
161
176
  "Properties": textframe `
162
177
  <table class="props"{% if Fold %} data-fold-height="{{ Fold }}"{% endif %}>
@@ -979,14 +994,15 @@ const render = (name, context) => {
979
994
  };
980
995
  /* the active per-document reference expander, fully-qualified
981
996
  anchor paths, member-carrying property value constraints, object
982
- schema nodes, pre-rendered diagram SVGs, description popup keys
983
- of the schema nodes, and object parents of the description popup
984
- title paths (all set during HTML rendering) */
997
+ schema nodes, pre-rendered diagram SVGs, reference coverages,
998
+ description popup keys of the schema nodes, and object parents of
999
+ the description popup title paths (all set during HTML rendering) */
985
1000
  let linker = null;
986
1001
  let anchors = null;
987
1002
  let members = null;
988
1003
  let schemas = null;
989
1004
  let diagrams = null;
1005
+ let coverages = null;
990
1006
  let infoKeys = null;
991
1007
  let infoParents = null;
992
1008
  /* the object whose texts are currently rendered, scoping the
@@ -1292,6 +1308,30 @@ const diagramOf = (object) => {
1292
1308
  const diagram = diagrams?.get(object);
1293
1309
  return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
1294
1310
  };
1311
+ /* the reference coverage of an object as a table: one row per
1312
+ configured pattern, labeled by the kinds of the matching objects,
1313
+ marked as object kinds and carrying the description popup of the
1314
+ first matching object of each kind (by the bare pattern, if none
1315
+ matches), with the counts and the ratio as a bar (empty for an
1316
+ object without a configured coverage) */
1317
+ const coverageOf = (object) => {
1318
+ const coverage = coverages?.get(object);
1319
+ if (coverage === undefined)
1320
+ return "";
1321
+ const kindLabel = (entry, kind) => {
1322
+ const first = [...entry.covered, ...entry.uncovered].find((target) => target.kind === kind);
1323
+ return `<span class="object-kind"${first !== undefined ? infoAttr(first) : ""}>${escapeHtml(kind)}</span>`;
1324
+ };
1325
+ return safe(render("Coverage", { Coverage: coverage.map((entry, i) => ({
1326
+ label: entry.kinds.length > 0 ?
1327
+ safe(entry.kinds.map((kind) => kindLabel(entry, kind)).join(", ")) :
1328
+ entry.pattern.replace(/^\[\[(.*)\]\]$/s, "$1"),
1329
+ covered: entry.covered.length,
1330
+ total: entry.covered.length + entry.uncovered.length,
1331
+ ratio: coverageRatio(entry.covered.length, entry.covered.length + entry.uncovered.length),
1332
+ even: i % 2 === 1
1333
+ })) }));
1334
+ };
1295
1335
  /* render a single-kind group of children into one compact table:
1296
1336
  the name first, then the property columns, then the description;
1297
1337
  a group wider than maxColumns, or carrying diagrams, instead chunks
@@ -1386,6 +1426,7 @@ const renderObject = (object, level, concise) => {
1386
1426
  Fold: formatOf(object)?.maxCellHeight })) : "",
1387
1427
  description: object.description !== undefined ?
1388
1428
  safe(renderDescription(object.description)) : "",
1429
+ coverage: coverageOf(object),
1389
1430
  children: safe(renderChildren(object, level + 1, concise))
1390
1431
  } }));
1391
1432
  };
@@ -1572,6 +1613,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1572
1613
  members = config !== undefined ? collectMembers(config, new Map()) : null;
1573
1614
  schemas = config !== undefined ? collectSchemas(specification, config) : null;
1574
1615
  diagrams = rendered;
1616
+ coverages = schemas !== null ? specCoverage(index, schemas) : null;
1575
1617
  /* collect the schema descriptions for the description popups,
1576
1618
  plus the object parents composing their title paths */
1577
1619
  let info = null;
@@ -1638,6 +1680,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1638
1680
  members = null;
1639
1681
  schemas = null;
1640
1682
  diagrams = null;
1683
+ coverages = null;
1641
1684
  infoKeys = null;
1642
1685
  infoParents = null;
1643
1686
  }
@@ -10,6 +10,7 @@ export type SchemaObject = {
10
10
  refs?: string;
11
11
  optional?: boolean;
12
12
  referenced?: string[];
13
+ coverage?: string[];
13
14
  automaton?: SchemaAutomaton;
14
15
  diagram?: SchemaDiagram;
15
16
  format?: SchemaFormat;
@@ -36,6 +37,7 @@ export type SchemaDiagramCenterEdges = {
36
37
  outbound?: string;
37
38
  both?: string;
38
39
  labeled?: string;
40
+ via?: string;
39
41
  };
40
42
  export type SchemaDiagramNest = {
41
43
  properties?: string[];
@@ -48,7 +48,8 @@ const SchemaDiagramCenterEdges = v.strictObject({
48
48
  inbound: v.optional(v.string()),
49
49
  outbound: v.optional(v.string()),
50
50
  both: v.optional(v.string()),
51
- labeled: v.optional(v.string())
51
+ labeled: v.optional(v.string()),
52
+ via: v.optional(v.string())
52
53
  });
53
54
  const SchemaDiagramNest = v.strictObject({
54
55
  properties: v.optional(v.array(v.string())),
@@ -93,6 +94,7 @@ const SchemaObject = v.strictObject({
93
94
  refs: v.optional(v.string()),
94
95
  optional: v.optional(v.boolean()),
95
96
  referenced: v.optional(v.pipe(v.array(v.string()), v.minLength(1))),
97
+ coverage: v.optional(v.pipe(v.array(v.string()), v.minLength(1))),
96
98
  automaton: v.optional(SchemaAutomaton),
97
99
  diagram: v.optional(SchemaDiagram),
98
100
  format: v.optional(SchemaFormat),
@@ -74,6 +74,7 @@ type SchemaObject = {
74
74
  refs?: string
75
75
  optional?: boolean
76
76
  referenced?: string[]
77
+ coverage?: string[]
77
78
  automaton?: SchemaAutomaton
78
79
  diagram?: SchemaDiagram
79
80
  format?: SchemaFormat
@@ -100,6 +101,7 @@ type SchemaDiagramCenterEdges = {
100
101
  outbound?: string
101
102
  both?: string
102
103
  labeled?: string
104
+ via?: string
103
105
  }
104
106
  type SchemaDiagram = {
105
107
  type?: "graph" | "hub" | "grid"
@@ -235,6 +237,13 @@ type SchemaGradiaConfig = Partial<{
235
237
  at least once, where a lapse is reported as a warning only,
236
238
  BECAUSE a term nobody uses or a requirement no use case exercises is dead weight
237
239
 
240
+ - `SchemaObject.coverage?: string[]`:
241
+ wildcard references (e.g. `[[SCENARIO:*]]` for the use case scenarios)
242
+ matching the objects whose reference coverage every object of this kind
243
+ reports: the share of them referenced -- themselves or through their
244
+ descendants -- from the object or its descendants (see below),
245
+ BECAUSE a test artifact has to state how much of the specification it verifies
246
+
238
247
  - `SchemaObject.automaton?: SchemaAutomaton`:
239
248
  finite state machine the child objects of every object of this kind form,
240
249
  BECAUSE a lifecycle is only trustworthy when its dead-ends and
@@ -319,9 +328,11 @@ type SchemaGradiaConfig = Partial<{
319
328
  objects whose `inbound` value maps onto a node-to-center edge, whose
320
329
  `outbound` value onto a center-to-node edge, and whose `both` value
321
330
  onto both edges (the second placement rendered as a "ghost" node),
322
- each optionally named by the value of the `labeled` property,
323
- BECAUSE the nodes carry no `[[xxx]]` reference to a synthetic center,
324
- so their relation to it lives in a direction property instead
331
+ each optionally named by the value of the `labeled` property and/or
332
+ by the mediating object referenced by the `via` property (as `via
333
+ <name>`), BECAUSE the nodes carry no `[[xxx]]` reference to a
334
+ synthetic center, so their relation to it lives in a direction
335
+ property instead
325
336
 
326
337
  - `SchemaDiagram.links?: "props" | "all"`:
327
338
  edge source: property values (`props`) or also texts (`all`),
@@ -595,6 +606,24 @@ afterwards merge into one with their arities summed, so the edges
595
606
  inside a container stay node-to-node while the bundles of references
596
607
  into a container become single edges onto it.
597
608
 
609
+ ### Coverage
610
+
611
+ An object kind can carry a `coverage` field, a list of `[[xxx]]`
612
+ reference patterns, which makes every object of that kind report how
613
+ much of the objects matching each pattern it references: the *coverage*
614
+ is the share of the matching objects (those below the reporting object
615
+ itself excluded) which are referenced from the reporting object or one
616
+ of its descendants, where a matching object also counts as referenced
617
+ through one of its descendants, so a use case is covered as soon as one
618
+ of its scenarios is. While the `referenced` field demands the coverage
619
+ of an object kind and warns about every lapse, the `coverage` field
620
+ merely measures it: the HTML/PDF export renders a coverage table below
621
+ the description of the reporting object (one row per pattern, labeled
622
+ by the kinds of the matching objects, with the covered and total counts
623
+ and the ratio as a bar), the AST exports attach the counts as the
624
+ `coverage` field of the object, and the verbose output of `lint` and
625
+ `export` names the unreferenced objects.
626
+
598
627
  ### Format
599
628
 
600
629
  An object kind can furthermore carry a `format` field, which controls
@@ -908,7 +937,7 @@ In all formats, the `<name/>` may carry trailing decorations, in any
908
937
  order:
909
938
 
910
939
  - `{{<id/>}}`: the explicit Wiki-style anchor, setting the object id
911
- (e.g. `## ENTITY: Attendee Browser {{attendee}}`).
940
+ (e.g. `## ENTITY: Attendee {{attendee}}`).
912
941
 
913
942
  - `(<token/>)`: a parenthesized token with three possible roles: on
914
943
  level 1 it becomes the artifact id (e.g. `# DATA: Data Model (DM)`);
@@ -967,7 +996,7 @@ exports. A reference is a `.`-separated path of segments, where each
967
996
  segment matches a single object as:
968
997
 
969
998
  - `<id/>`: the object id or explicit anchor (e.g. `[[attendee]]`)
970
- - `<name/>`: the object name (e.g. `[[Attendee Browser]]`)
999
+ - `<name/>`: the object name (e.g. `[[Attendee]]`)
971
1000
  - `<kind/>:<name-or-id/>`: kind-qualified (e.g. `[[ENTITY:Event]]`)
972
1001
  - `<kind/>:*` or `*`: wildcards (for match sets, see below)
973
1002
 
@@ -711,6 +711,28 @@
711
711
  several requirements otherwise) which is verifiable against its `METRIC`, written
712
712
  with exactly one keyword `MUST`, `SHOULD`, or `COULD` matching its `PRIORITY`, plus
713
713
  a 1-sentence rationale ("why").
714
+
715
+ The requirement is answered by the architecture, never the other way round: a
716
+ Quality Perspectives tactic addresses it through its mechanism, a Design Decisions
717
+ decision is driven by it as a force, and an Operations View concern fulfills it
718
+ through its procedure, so every requirement is referenced from at least one of
719
+ them (a `WONT` requirement, which the architecture rightly ignores, is the tolerated
720
+ exception the lint warning cannot tell apart) and its hub diagram shows what
721
+ answers it.
722
+ referenced:
723
+ - "[[TACTIC:*]]"
724
+ - "[[DECISION:*]]"
725
+ - "[[CONCERN:*]]"
726
+ diagram:
727
+ type: hub
728
+ center: self
729
+ nodes: "[[NR.REQUIREMENT:*]], [[FR.REQUIREMENT:*]], [[PREMISE:*]], [[RULE:*]],
730
+ [[TACTIC:*]], [[DECISION:*]], [[CONCERN:*]], [[TEST-CASE:*]]"
731
+ labeled: true
732
+ qualified: true
733
+ config:
734
+ size-node-width-max: 440
735
+ size-node-height-scale: 1.75
714
736
  props:
715
737
  - name: PRIORITY
716
738
  desc: >
@@ -1077,7 +1099,8 @@
1077
1099
  type: hub
1078
1100
  center: self
1079
1101
  nodes: "[[USE-CASE:*]], [[ROLE:*]], [[PERSONA:*]], [[STEP:*]], [[ACTIVITY:*]],
1080
- [[FR.REQUIREMENT:*]], [[RULE:*]], [[TRANSITION:*]], [[PERMISSION:*]]"
1102
+ [[FR.REQUIREMENT:*]], [[RULE:*]], [[TRANSITION:*]], [[PERMISSION:*]],
1103
+ [[PRINCIPLE:*]], [[CV.ENTITY:*]]"
1081
1104
  labeled: true
1082
1105
  qualified: true
1083
1106
  config:
@@ -2133,7 +2156,7 @@
2133
2156
  diagram:
2134
2157
  type: hub
2135
2158
  center: self
2136
- nodes: "[[PRINCIPLE:*]], [[PERSONA:*]], [[STEP:*]], [[PATTERN:*]]"
2159
+ nodes: "[[PRINCIPLE:*]], [[PERSONA:*]], [[STEP:*]], [[USE-CASE:*]], [[PATTERN:*]]"
2137
2160
  labeled: true
2138
2161
  qualified: true
2139
2162
  config:
@@ -2151,16 +2174,32 @@
2151
2174
  desc: >
2152
2175
  Comma-separated references to the User Personas objects whose experience
2153
2176
  the principle governs, omitted for a principle governing the experience of
2154
- all users alike.
2177
+ all users alike; this scopes the principle only, its origin is the
2178
+ `MOTIVATED-BY` and its realization the `USE-CASES`.
2155
2179
  value: list([[PERSONA:*]])
2156
2180
  optional: true
2157
2181
  - name: MOTIVATED-BY
2158
2182
  desc: >
2159
2183
  Comma-separated references to the User Journey step objects whose
2160
2184
  `PAIN-POINT` or `OPPORTUNITY` the principle answers, so that a principle is
2161
- grounded in the observed experience rather than in taste alone.
2185
+ grounded in the observed experience rather than in taste alone; this is the
2186
+ origin of the principle (why it exists), while the `USE-CASES` are its
2187
+ realization (where it can be checked).
2162
2188
  value: list([[STEP:*]])
2163
2189
  optional: true
2190
+ - name: USE-CASES
2191
+ desc: >
2192
+ Comma-separated references to the USE-CASES objects whose scenario steps
2193
+ exhibit the behavior the principle prescribes (e.g. a `Moderation-Gated
2194
+ Visibility` surfaces in the step where the submitted message is acknowledged
2195
+ but not yet public), or, where not every scenario of a use case does so, to
2196
+ the very `SCENARIO` objects doing so, tracing the principle into the
2197
+ step-by-step flows against which its realization is checked, while the
2198
+ concrete user interface realizing it stays with the Dialog Patterns and the
2199
+ Dialog Storyboard; omitted for a principle governing no use case flow in
2200
+ particular.
2201
+ value: list([[USE-CASE:*]], [[SCENARIO:*]])
2202
+ optional: true
2164
2203
  - name: TRADE-OFF
2165
2204
  desc: >
2166
2205
  The competing concern the principle deliberately subordinates, stated so
@@ -2595,6 +2634,7 @@
2595
2634
  inbound: Inbound
2596
2635
  outbound: Outbound
2597
2636
  both: Bidirectional
2637
+ via: VIA
2598
2638
  qualified: true
2599
2639
  properties:
2600
2640
  - KIND
@@ -2633,8 +2673,20 @@
2633
2673
  sides initiate), independent of the direction the exchanged information
2634
2674
  flows in, which the `DATA` property describes (e.g. a datastore the
2635
2675
  solution reads from is `Outbound`, as the solution initiates, while the
2636
- data flows in).
2676
+ data flows in). For a mediated entity (see `VIA`), where the mediator
2677
+ initiates every interaction, the direction of the data flow instead.
2637
2678
  value: enum(Inbound,Outbound,Bidirectional)
2679
+ - name: VIA
2680
+ desc: >
2681
+ Reference to the entity mediating the exchange with this one, when the
2682
+ solution and the entity have no direct technical interaction at all,
2683
+ but a person or system in between carries the data (e.g. a manager
2684
+ uploading the file exported by an external system), so the context
2685
+ diagram marks the edge as `via` that entity instead of suggesting an
2686
+ online integration; omitted for a directly interacting entity.
2687
+ value: "[[CV.ENTITY:*]]"
2688
+ optional: true
2689
+ acyclic: true
2638
2690
  - name: TERMS
2639
2691
  desc: >
2640
2692
  Comma-separated references to the GLOSSARY terms the entity appears as in
@@ -2661,6 +2713,15 @@
2661
2713
  re-stating them; omitted when no modeled role stands behind the entity.
2662
2714
  value: list([[ROLE:*]])
2663
2715
  optional: true
2716
+ - name: USE-CASES
2717
+ desc: >
2718
+ Comma-separated references to the USE-CASES objects whose interaction
2719
+ crosses the boundary at this entity (the use cases a `Person` entity
2720
+ performs, or the ones exchanging data with a `System` or `Service`
2721
+ entity), so the use case responsible for an entity is found without a
2722
+ text search; omitted when no modeled use case touches the entity.
2723
+ value: list([[USE-CASE:*]])
2724
+ optional: true
2664
2725
  - name: PREMISES
2665
2726
  desc: >
2666
2727
  Comma-separated references to the SOLUTION-PREMISES objects (usually of
@@ -2714,7 +2775,8 @@
2714
2775
  diagram:
2715
2776
  type: hub
2716
2777
  center: self
2717
- nodes: "[[FV.COMPONENT:*]], [[FR.REQUIREMENT:*]], [[INTERFACE:*]], [[DM.ENTITY:*]]"
2778
+ nodes: "[[FV.COMPONENT:*]], [[FR.REQUIREMENT:*]], [[INTERFACE:*]], [[DM.ENTITY:*]],
2779
+ [[TACTIC:*]], [[DECISION:*]]"
2718
2780
  labeled: true
2719
2781
  qualified: true
2720
2782
  properties:
@@ -2853,7 +2915,8 @@
2853
2915
  diagram:
2854
2916
  type: hub
2855
2917
  center: self
2856
- nodes: "[[TS.TIER:*.COMPONENT:*]], [[FV.COMPONENT:*]], [[DECISION:*]]"
2918
+ nodes: "[[TS.TIER:*.COMPONENT:*]], [[FV.COMPONENT:*]], [[TACTIC:*]],
2919
+ [[DECISION:*]]"
2857
2920
  labeled: true
2858
2921
  qualified: true
2859
2922
  properties:
@@ -3176,7 +3239,22 @@
3176
3239
  states the product choice), does not restate the runtime structure (a Functionality
3177
3240
  View `Module` or `Subsystem` groups the logic), does not cover production
3178
3241
  procedures (an Operations View concern does), and the trade-off that produced a
3179
- rule is a Design Decisions decision referencing the aspect through `AFFECTS`.
3242
+ rule is a Design Decisions decision referencing the aspect through `AFFECTS`,
3243
+ while the quality the rule protects (usually a `Maintainability` requirement) is
3244
+ addressed by a Quality Perspectives tactic referencing the aspect through
3245
+ `AFFECTS`, too, so the hub diagram of an aspect shows the decision and the tactic
3246
+ behind it.
3247
+ diagram:
3248
+ type: hub
3249
+ center: self
3250
+ nodes: "[[ASPECT:*]], [[TACTIC:*]], [[DECISION:*]]"
3251
+ labeled: true
3252
+ qualified: true
3253
+ properties:
3254
+ - CATEGORY
3255
+ config:
3256
+ size-node-width-max: 440
3257
+ size-node-height-scale: 1.75
3180
3258
  props:
3181
3259
  - name: CATEGORY
3182
3260
  desc: >
@@ -3229,7 +3307,7 @@
3229
3307
  diagram:
3230
3308
  type: hub
3231
3309
  center: self
3232
- nodes: "[[UNIT:*]], [[FV.COMPONENT:*]], [[NODE:*]]"
3310
+ nodes: "[[UNIT:*]], [[FV.COMPONENT:*]], [[NODE:*]], [[TACTIC:*]], [[DECISION:*]]"
3233
3311
  labeled: true
3234
3312
  qualified: true
3235
3313
  properties:
@@ -3330,7 +3408,7 @@
3330
3408
  diagram:
3331
3409
  type: hub
3332
3410
  center: self
3333
- nodes: "[[DV.TIER:*]], [[NODE:*]]"
3411
+ nodes: "[[DV.TIER:*]], [[NODE:*]], [[TACTIC:*]], [[DECISION:*]]"
3334
3412
  labeled: true
3335
3413
  qualified: true
3336
3414
  properties:
@@ -3380,7 +3458,7 @@
3380
3458
  diagram:
3381
3459
  type: hub
3382
3460
  center: self
3383
- nodes: "[[AREA:*]], [[NODE:*]]"
3461
+ nodes: "[[AREA:*]], [[NODE:*]], [[TACTIC:*]], [[DECISION:*]]"
3384
3462
  labeled: true
3385
3463
  qualified: true
3386
3464
  properties:
@@ -3407,7 +3485,7 @@
3407
3485
  type: hub
3408
3486
  center: self
3409
3487
  nodes: "[[DV.TIER:*]], [[AREA:*]], [[NODE:*]], [[NETWORK:*]], [[FV.COMPONENT:*]],
3410
- [[UNIT:*]]"
3488
+ [[UNIT:*]], [[TACTIC:*]], [[DECISION:*]]"
3411
3489
  labeled: true
3412
3490
  qualified: true
3413
3491
  properties:
@@ -3504,7 +3582,7 @@
3504
3582
  diagram:
3505
3583
  type: hub
3506
3584
  center: self
3507
- nodes: "[[NETWORK:*]], [[NODE:*]]"
3585
+ nodes: "[[NETWORK:*]], [[NODE:*]], [[TACTIC:*]], [[DECISION:*]]"
3508
3586
  labeled: true
3509
3587
  qualified: true
3510
3588
  properties:
@@ -3886,10 +3964,11 @@
3886
3964
  Comma-separated references to the NON-FUNCTIONAL-REQUIREMENTS or
3887
3965
  SOLUTION-PREMISES objects forcing the decision (e.g. the compliance
3888
3966
  obligation, the budget constraint, the dependency taken as given), anchoring
3889
- the forces `WHEN` describes in prose; omitted when no modeled requirement or
3890
- premise drives the decision.
3967
+ the forces `WHEN` describes in prose: as a decision is the response to
3968
+ forces, at least one is always named, usually a Non-Functional Requirement
3969
+ and only exceptionally a premise alone, and a decision finding no
3970
+ requirement to name reveals a missing one rather than a needless reference.
3891
3971
  value: list([[NR.*]], [[PREMISE:*]])
3892
- optional: true
3893
3972
  - name: AFFECTS
3894
3973
  desc: >
3895
3974
  Comma-separated references to the objects of the DATA, APIS, UXUI, or ARCH
@@ -4014,6 +4093,11 @@
4014
4093
  or fail, and many test cases (e.g. of Non-Functional Requirements or of cross-session rules)
4015
4094
  have no use case counterpart at all.
4016
4095
  optional: true
4096
+ coverage:
4097
+ - "[[USE-CASE:*]]"
4098
+ - "[[SCENARIO:*]]"
4099
+ - "[[REQUIREMENT:*]]"
4100
+ - "[[RULE:*]]"
4017
4101
  refs: >
4018
4102
  - ISO/IEC/IEEE 29119-3:2021: Software and systems engineering -- Software testing -- Part 3: Test documentation (2021),
4019
4103
  https://www.iso.org/standard/79429.html
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText, assignId } from "./specbook-link.js";
7
7
  import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
8
+ import { referencedCoverage } from "./specbook-coverage.js";
8
9
  /* the syntax of a value consisting of exactly one Wiki-style reference */
9
10
  const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
10
11
  /* match a full property value directly against a value expression
@@ -431,50 +432,13 @@ const checkRelations = (ctx, schemas) => {
431
432
  of the flag's reference patterns, itself or through an ancestor, while
432
433
  the references from within the subtree of the object itself do not
433
434
  count (a lapse is a warning only, as the specification stays valid) */
434
- const checkReferenced = (ctx, specification, schemas) => {
435
- /* collect the referencing objects of every referenced object
436
- (the references from within its own subtree excluded) */
437
- const referrers = new Map();
438
- const walk = (object) => {
439
- const chain = chainOf(ctx.linkIndex, object);
440
- const texts = [object.name, ...object.properties.map((p) => p.value)];
441
- if (object.description !== undefined)
442
- texts.push(object.description.description, object.description.rationale ?? "");
443
- for (const text of texts)
444
- for (const m of plainText(text).matchAll(referenceRegex)) {
445
- const target = resolveUnique(ctx.linkIndex, m[1].trim(), object).target;
446
- if (target !== undefined && !chain.includes(target))
447
- referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
448
- }
449
- object.children.forEach(walk);
450
- };
451
- for (const artifact of specification.artifacts)
452
- artifact.objects.forEach(walk);
453
- /* resolve the reference patterns of every flagged kind into the
454
- admissible referencing objects (once per shared schema node) */
455
- const sources = new Map();
456
- for (const [object, schema] of schemas) {
457
- if (schema.referenced === undefined)
458
- continue;
459
- let admissible = sources.get(schema);
460
- if (admissible === undefined) {
461
- admissible = new Set();
462
- for (const entry of schema.referenced) {
463
- const expr = compileValueExpr(entry);
464
- if (expr.kind === "reference")
465
- for (const source of resolveSet(ctx.linkIndex, expr.pattern))
466
- admissible.add(source);
467
- }
468
- sources.set(schema, admissible);
469
- }
470
- const covered = Array.from(referrers.get(object) ?? [])
471
- .some((referrer) => chainOf(ctx.linkIndex, referrer).some((o) => admissible.has(o)));
472
- if (!covered) {
435
+ const checkReferenced = (ctx, schemas) => {
436
+ for (const { schema, uncovered } of referencedCoverage(ctx.linkIndex, schemas))
437
+ for (const object of uncovered) {
473
438
  const meta = ctx.metaOf(object);
474
439
  ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
475
- schema.referenced.map((entry) => `"${entry}"`).join(" or "), "warning");
440
+ (schema.referenced ?? []).map((entry) => `"${entry}"`).join(" or "), "warning");
476
441
  }
477
- }
478
442
  };
479
443
  /* validate the parsed specification against the configuration */
480
444
  export const validate = (ctx, specification, config) => {
@@ -527,7 +491,7 @@ export const validate = (ctx, specification, config) => {
527
491
  been validated on its own */
528
492
  checkRelations(ctx, schemas);
529
493
  checkAutomata(ctx, schemas);
530
- checkReferenced(ctx, specification, schemas);
494
+ checkReferenced(ctx, schemas);
531
495
  /* order the artifacts exactly along the schema definition */
532
496
  specification.artifacts.sort((a, b) => (position.get(a) ?? config.length) - (position.get(b) ?? config.length));
533
497
  };
@@ -2,7 +2,7 @@ import { Spec } from "./specbook-format-spec.js";
2
2
  import { type Schema } from "./specbook-format-schema.js";
3
3
  import { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
4
4
  export { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
5
- export { resolveArtifact } from "./specbook-parse-semantic.js";
5
+ export { resolveArtifact, collectSchemas } from "./specbook-parse-semantic.js";
6
6
  export declare const parseSpecification: (sources: SourceFile[], config?: Schema) => ParseResult;
7
7
  export declare const specStatistics: (specification: Spec) => {
8
8
  objects: number;
@@ -10,7 +10,7 @@ import { ParseContext } from "./specbook-parse-common.js";
10
10
  import { parseFile } from "./specbook-parse-syntax.js";
11
11
  import { validate, validateReferences } from "./specbook-parse-semantic.js";
12
12
  import { validateDiagrams } from "./specbook-diagram.js";
13
- export { resolveArtifact } from "./specbook-parse-semantic.js";
13
+ export { resolveArtifact, collectSchemas } from "./specbook-parse-semantic.js";
14
14
  /* parse a set of specification Markdown files into the specification
15
15
  AST and optionally validate the result against a configuration */
16
16
  export const parseSpecification = (sources, config) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",