@rse/specbook 1.2.5 → 1.2.7

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
@@ -128,17 +131,17 @@ Commands:
128
131
 
129
132
  ```bash
130
133
  $ specbook init \
131
- [-v|--verbose] \
134
+ [-v|--verbose [<level>]] \
132
135
  [-c|--config <schema-yaml-file>] \
133
136
  [-b|--basedir <spec-md-file-basedir>]
134
137
 
135
138
  $ specbook lint \
136
- [-v|--verbose] \
139
+ [-v|--verbose [<level>]] \
137
140
  [-c|--config <schema-yaml-file>] \
138
141
  [-b|--basedir <spec-md-file-basedir>]
139
142
 
140
143
  $ specbook export \
141
- [-v|--verbose] \
144
+ [-v|--verbose [<level>]] \
142
145
  [-c|--config <schema-yaml-file>] \
143
146
  [-b|--basedir <spec-md-file-basedir>] \
144
147
  [-o|--output [<format>:]<output-file>] \
@@ -146,14 +149,14 @@ $ specbook export \
146
149
  [...]
147
150
 
148
151
  $ specbook preview \
149
- [-v|--verbose] \
152
+ [-v|--verbose [<level>]] \
150
153
  [-c|--config <schema-yaml-file>] \
151
154
  [-b|--basedir <spec-md-file-basedir>] \
152
155
  [-a|--addr <ip-addr>] \
153
156
  [-p|--port <tcp-port>]
154
157
 
155
158
  $ specbook describe \
156
- [-v|--verbose] \
159
+ [-v|--verbose [<level>]] \
157
160
  [-c|--config <schema-yaml-file>] \
158
161
  [-b|--basedir <spec-md-file-basedir>] \
159
162
  [-e|--embed] \
@@ -163,13 +166,17 @@ $ specbook describe \
163
166
  [-o|--output <output-file>]
164
167
 
165
168
  $ specbook mcp \
166
- [-v|--verbose]
169
+ [-v|--verbose [<level>]]
167
170
  ```
168
171
 
169
172
  Options:
170
173
 
171
- - `-v|--verbose`:
172
- Enable verbose logging of processing information to `stderr`.
174
+ - `-v|--verbose [<level>]`:
175
+ Enable verbose logging of processing information to `stderr` at the
176
+ given verbosity level: `0` (the default) logs the notices only, `1`
177
+ (the bare flag) also the regular processing information, `2` also
178
+ the reference coverage ratios, and `3` also the unreferenced objects
179
+ of a reference coverage.
173
180
 
174
181
  - `-c|--config <schema-yaml-file>`:
175
182
  The YAML schema configuration (default: the bundled standard schema
@@ -182,7 +189,10 @@ Options:
182
189
  all diagnostics and fail on any error among them (a warning, like a
183
190
  lapse of the reference coverage a `referenced` object kind demands,
184
191
  is reported only), so a partial or invalid specification is never
185
- exported.
192
+ exported. The verbose output additionally states at verbosity level
193
+ `2` the reference coverage ratios: the ones the `referenced` object
194
+ kinds receive and the ones the `coverage` object kinds report, the
195
+ latter naming the unreferenced objects at level `3`.
186
196
  The option accepts glob patterns and can be given multiple times: the
187
197
  matching files (in the order of the patterns and alphabetically within
188
198
  a pattern, where the literal `std` names the bundled standard schema
@@ -3,8 +3,9 @@ import { type LintResult } from "./specbook-cmd-lint.js";
3
3
  import { parseOutputSpec, formats, type ExportFormat } from "./specbook-cmd-export.js";
4
4
  import { previewAddr, previewPort } from "./specbook-cmd-preview.js";
5
5
  import { describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel, type DescribeFormat, type DescribePart, type CompressLevel } from "./specbook-cmd-describe.js";
6
- import { literal, renderVerbose, type Verbose, type VerboseLevel } from "./specbook-verbose.js";
7
- export { literal, renderVerbose, type Verbose, type VerboseLevel };
6
+ import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity, type Verbose, type VerboseLevel, type Verbosity } from "./specbook-verbose.js";
7
+ export { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity };
8
+ export { type Verbose, type VerboseLevel, type Verbosity };
8
9
  export { formats, parseOutputSpec, type ExportFormat };
9
10
  export { previewAddr, previewPort };
10
11
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
@@ -14,9 +14,9 @@ import { lint } from "./specbook-cmd-lint.js";
14
14
  import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser } from "./specbook-cmd-export.js";
15
15
  import { servePreview, previewAddr, previewPort } from "./specbook-cmd-preview.js";
16
16
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel } from "./specbook-cmd-describe.js";
17
- import { literal, renderVerbose } from "./specbook-verbose.js";
17
+ import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity } from "./specbook-verbose.js";
18
18
  /* re-export the central types for API consumers */
19
- export { literal, renderVerbose };
19
+ export { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity };
20
20
  export { formats, parseOutputSpec };
21
21
  export { previewAddr, previewPort };
22
22
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
@@ -37,7 +37,7 @@ export class SpecBook {
37
37
  }
38
38
  /* bind the verbose sink to a particular command */
39
39
  verboseOf(cmd) {
40
- return (msg, level = "debug") => this.verbose(cmd, msg, level);
40
+ return (msg, level = "notice") => this.verbose(cmd, msg, level);
41
41
  }
42
42
  /* determine the files of the YAML schema configuration out of the
43
43
  glob patterns, in the order of the patterns and alphabetically
@@ -96,7 +96,7 @@ export class SpecBook {
96
96
  throw new Error("invalid specification:\n" +
97
97
  result.diagnostics.map(renderDiagnostic).join("\n"));
98
98
  for (const diagnostic of result.diagnostics)
99
- verbose(renderDiagnostic(diagnostic), "notice");
99
+ verbose(renderDiagnostic(diagnostic), "none");
100
100
  if (result.specification.artifacts.length === 0)
101
101
  throw new Error("unexportable specification: no artifacts found");
102
102
  const buffers = new Array();
@@ -149,7 +149,7 @@ export class SpecBook {
149
149
  }
150
150
  catch (err) {
151
151
  verbose("export failed: " +
152
- (err instanceof Error ? err.message : String(err)), "notice");
152
+ (err instanceof Error ? err.message : String(err)), "none");
153
153
  }
154
154
  return files;
155
155
  }, verbose);
@@ -8,19 +8,23 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { Command, CommanderError } from "commander";
10
10
  import chalk from "chalk";
11
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, version } from "./specbook-api.js";
12
12
  import { serveMcp } from "./specbook-mcp.js";
13
13
  /* route verbose messages to stderr, keeping stdout reserved for the
14
14
  command outputs and the MCP protocol, and qualify every message with
15
15
  the tool name plus the scope path of the emitting command, where
16
16
  Chalk styles the output only if the terminal supports colors; the
17
- "notice" messages pass regardless of the verbose option, as they
17
+ verbosity of the verbose option gates the messages by their level,
18
+ so the "none" messages pass regardless of the option, as they
18
19
  report environment problems the user has to see */
19
- const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
20
- if (opts.verbose || level === "notice") {
21
- const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
22
- process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
23
- }
20
+ const verboseOf = (opts, ...scope) => {
21
+ const verbosity = parseVerbosity(opts.verbose);
22
+ return (cmd, msg, level) => {
23
+ if (verbosityOf[level] <= verbosity) {
24
+ const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
25
+ process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
26
+ }
27
+ };
24
28
  };
25
29
  /* write a buffer to a standard stream, awaiting the write callback
26
30
  which only fires once the data has been flushed to the underlying
@@ -41,7 +45,7 @@ const writeOutput = async (output, data, cmd, verbose) => {
41
45
  await writeStdout(data);
42
46
  else {
43
47
  await fs.promises.writeFile(output, data);
44
- verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`, "debug");
48
+ verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`, "notice");
45
49
  }
46
50
  };
47
51
  /* determine an option default value, overridable via a
@@ -56,9 +60,13 @@ const envDefaultFlag = (name, fallback) => {
56
60
  const value = process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`];
57
61
  return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
58
62
  };
59
- /* provide the verbose option of all sub-commands */
63
+ /* provide the verbose option of all sub-commands, whose optional level
64
+ selects the verbosity (where the environment default carries a level
65
+ or a boolean word) */
60
66
  const withVerboseOption = (command) => command
61
- .option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false));
67
+ .option("-v, --verbose [level]", "print verbose processing information to stderr " +
68
+ "(0: none, 1: notices, 2: also the details like the coverage ratios, " +
69
+ "3: also the traces like the unreferenced objects; default: 0, bare flag: 1)", envDefault("verbose", "0"));
62
70
  /* provide the repeatable schema configuration option, whose files or
63
71
  glob patterns are merged in order (with "std" naming the bundled
64
72
  standard one), and determine its value, where the environment default
@@ -96,7 +104,7 @@ program.name("specbook")
96
104
  withVerboseOption(program.command("mcp"))
97
105
  .description("run as MCP stdio server")
98
106
  .action(async (opts) => {
99
- verboseOf(opts)("mcp", "starting MCP server on stdio", "debug");
107
+ verboseOf(opts)("mcp", "starting MCP server on stdio", "notice");
100
108
  await serveMcp(verboseOf(opts, "mcp"));
101
109
  });
102
110
  /* the init command creates the configured artifact files */
@@ -116,13 +124,13 @@ withCommonOptions(program.command("lint"))
116
124
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
117
125
  const result = await specbook.lint({ config: configOf(opts), basedir: opts.basedir });
118
126
  for (const diagnostic of result.diagnostics)
119
- await writeStdout(opts.verbose ?
127
+ await writeStdout(parseVerbosity(opts.verbose) > 0 ?
120
128
  renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
121
129
  `${renderDiagnostic(diagnostic)}\n`);
122
130
  if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
123
131
  process.exitCode = 1;
124
132
  else
125
- verboseOf(opts)("lint", "specification valid", "debug");
133
+ verboseOf(opts)("lint", "specification valid", "notice");
126
134
  });
127
135
  /* the export command parses the input once and writes every output */
128
136
  withCommonOptions(program.command("export"))
@@ -72,7 +72,7 @@ export const watchSpecification = async (run, verbose) => {
72
72
  "error" event would otherwise terminate the process */
73
73
  watcher.on("error", (err) => {
74
74
  verbose("observing failed: " +
75
- (err instanceof Error ? err.message : String(err)), "notice");
75
+ (err instanceof Error ? err.message : String(err)), "none");
76
76
  });
77
77
  /* perform a re-export and re-synchronize the observed files */
78
78
  const cycle = async () => {
@@ -95,7 +95,7 @@ export const watchSpecification = async (run, verbose) => {
95
95
  timer = undefined;
96
96
  chain = chain.then(cycle).catch((err) => {
97
97
  verbose("re-export failed: " +
98
- (err instanceof Error ? err.message : String(err)), "notice");
98
+ (err instanceof Error ? err.message : String(err)), "none");
99
99
  });
100
100
  }, watchDelay);
101
101
  });
@@ -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,33 @@ 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 as
79
+ additional figures, the unreferenced objects of the latter by
80
+ name as a lengthy detail (the ones of the former are already
81
+ reported as warnings) */
82
+ if (config !== undefined && result.specification.artifacts.length > 0) {
83
+ const index = buildLinkIndex(result.specification);
84
+ const schemas = collectSchemas(result.specification, config);
85
+ const label = (object) => `${object.kind} "${literal(plainText(object.name))}"`;
86
+ const ratio = (covered, uncovered) => `${literal(covered.length)} of ${literal(covered.length + uncovered.length)} ` +
87
+ `(${literal(coverageRatio(covered.length, covered.length + uncovered.length))}%)`;
88
+ for (const { schema, covered, uncovered } of referencedCoverage(index, schemas)) {
89
+ const chain = chainOf(index, covered[0] ?? uncovered[0]);
90
+ options.verbose((schema.referenced ?? []).map((entry) => `"${literal(entry)}"`).join(" or ") +
91
+ ` references ${ratio(covered, uncovered)} ${schema.kind} object(s)` +
92
+ (chain.length > 1 ? ` below ${label(chain[0])}` : ""), "detail");
93
+ }
94
+ for (const [object, entries] of specCoverage(index, schemas))
95
+ for (const { pattern, covered, uncovered } of entries) {
96
+ options.verbose(`${label(object)} references ${ratio(covered, uncovered)} ` +
97
+ `"${literal(pattern)}" object(s)`, "detail");
98
+ if (uncovered.length > 0)
99
+ options.verbose(`${label(object)} leaves ${literal(uncovered.length)} ` +
100
+ `"${literal(pattern)}" object(s) unreferenced: ` +
101
+ uncovered.map(label).join(", "), "trace");
102
+ }
103
+ }
75
104
  /* report the non-optional artifacts absent from the specification,
76
105
  against their loaded artifact file (an absent or unreadable file
77
106
  is already reported above) or else against the configuration */
@@ -36,16 +36,16 @@ export const servePreview = async (options) => {
36
36
  /* identify the client by its remote address and port */
37
37
  const addr = request.socket.remoteAddress ?? "unknown";
38
38
  const client = `${addr.includes(":") ? `[${addr}]` : addr}:${request.socket.remotePort ?? 0}`;
39
- options.verbose(`client ${literal(client)} connected`, "notice");
39
+ options.verbose(`client ${literal(client)} connected`, "none");
40
40
  clients.add(socket);
41
41
  socket.on("close", () => {
42
42
  clients.delete(socket);
43
- options.verbose(`client ${literal(client)} disconnected`, "notice");
43
+ options.verbose(`client ${literal(client)} disconnected`, "none");
44
44
  });
45
45
  /* report a failing client, as an unhandled "error" event
46
46
  would otherwise terminate the process */
47
47
  socket.on("error", (err) => {
48
- options.verbose(`client ${literal(client)} failed: ${err.message}`, "notice");
48
+ options.verbose(`client ${literal(client)} failed: ${err.message}`, "none");
49
49
  });
50
50
  }
51
51
  });
@@ -53,12 +53,12 @@ export const servePreview = async (options) => {
53
53
  await fastify.listen({ host: options.addr, port: options.port });
54
54
  const host = options.addr.includes(":") ? `[${options.addr}]` : options.addr;
55
55
  const url = `http://${host}:${options.port}/`;
56
- options.verbose(`listening on ${literal(url)}`, "notice");
56
+ options.verbose(`listening on ${literal(url)}`, "none");
57
57
  return {
58
58
  update: (buffer) => {
59
59
  /* the first export turns the 503 placeholder into the document */
60
60
  if (html === undefined)
61
- options.verbose(`serving ${literal(url)}`, "notice");
61
+ options.verbose(`serving ${literal(url)}`, "none");
62
62
  html = buffer;
63
63
  options.verbose(`notifying ${literal(clients.size)} preview client(s)`);
64
64
  for (const client of clients)
@@ -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])));