@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 +21 -11
- package/dst/specbook-api.d.ts +3 -2
- package/dst/specbook-api.js +5 -5
- package/dst/specbook-cli.js +21 -13
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-cmd-lint.js +30 -1
- package/dst/specbook-cmd-preview.js +5 -5
- package/dst/specbook-config.js +18 -17
- package/dst/specbook-coverage.d.ts +17 -0
- package/dst/specbook-coverage.js +106 -0
- package/dst/specbook-diagram.js +12 -2
- package/dst/specbook-export-ast.js +11 -1
- package/dst/specbook-export-html.css +65 -7
- package/dst/specbook-export-html.js +46 -3
- package/dst/specbook-export-pdf.js +2 -2
- package/dst/specbook-format-schema.d.ts +2 -0
- package/dst/specbook-format-schema.js +3 -1
- package/dst/specbook-format.md +35 -5
- package/dst/specbook-format.yaml +100 -16
- package/dst/specbook-parse-semantic.js +6 -42
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-verbose.d.ts +5 -1
- package/dst/specbook-verbose.js +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,14 +72,17 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
72
72
|
- **AST Exports for AI/LLMs**:
|
|
73
73
|
The parsed specification Abstract Syntax Tree (AST) can be exported in
|
|
74
74
|
JSON, JSON5, YAML, or TOON format for machine consumption, with the
|
|
75
|
-
derived diagram of an object attached to it as a textual Gradia spec
|
|
75
|
+
derived diagram of an object attached to it as a textual Gradia spec
|
|
76
|
+
and the reference coverage it reports attached as its counts.
|
|
76
77
|
Together with the `describe` command, which explains the models and
|
|
77
78
|
formats, this enables LLMs to both read and write specifications.
|
|
78
79
|
|
|
79
80
|
- **HTML Export for Developers**:
|
|
80
81
|
The HTML export is a self-contained single document with a title page,
|
|
81
82
|
a table-of-contents (also as a slide-in side panel for quick navigation),
|
|
82
|
-
a diagram-of-contents, a fuzzy full-text search, embedded images,
|
|
83
|
+
a diagram-of-contents, a fuzzy full-text search, embedded images,
|
|
84
|
+
reference coverage tables (e.g. the share of the use cases, scenarios,
|
|
85
|
+
requirements, and rules the test cases verify), a
|
|
83
86
|
scroll progress meter (which also scrolls back to the top on click),
|
|
84
87
|
description popups (which show the schema and corpus description of
|
|
85
88
|
an object kind, property, or object instance on hover), and a
|
|
@@ -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
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -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,
|
|
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 };
|
package/dst/specbook-api.js
CHANGED
|
@@ -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 = "
|
|
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), "
|
|
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)), "
|
|
152
|
+
(err instanceof Error ? err.message : String(err)), "none");
|
|
153
153
|
}
|
|
154
154
|
return files;
|
|
155
155
|
}, verbose);
|
package/dst/specbook-cli.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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)`, "
|
|
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
|
|
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", "
|
|
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", "
|
|
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)), "
|
|
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)), "
|
|
98
|
+
(err instanceof Error ? err.message : String(err)), "none");
|
|
99
99
|
});
|
|
100
100
|
}, watchDelay);
|
|
101
101
|
});
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -7,7 +7,9 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
9
9
|
import { literal } from "./specbook-verbose.js";
|
|
10
|
-
import { parseSpecification, specStatistics, resolveArtifact } from "./specbook-parse.js";
|
|
10
|
+
import { parseSpecification, specStatistics, resolveArtifact, collectSchemas } from "./specbook-parse.js";
|
|
11
|
+
import { buildLinkIndex, chainOf, plainText } from "./specbook-link.js";
|
|
12
|
+
import { referencedCoverage, specCoverage, coverageRatio } from "./specbook-coverage.js";
|
|
11
13
|
/* collect the distinct artifact files the schema configuration
|
|
12
14
|
references, in their declaration order, each mapped onto whether
|
|
13
15
|
all of its artifacts are optional and hence the file may be absent */
|
|
@@ -72,6 +74,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`, "
|
|
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`, "
|
|
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}`, "
|
|
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)}`, "
|
|
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)}`, "
|
|
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)
|
package/dst/specbook-config.js
CHANGED
|
@@ -190,10 +190,10 @@ const checkAutomaton = (object, at, diagnose) => {
|
|
|
190
190
|
are beyond its schema: sibling objects have to stay distinctly
|
|
191
191
|
resolvable, "file" fields are allowed on the first (artifact) level
|
|
192
192
|
only, property value and marker expressions have to be syntactically
|
|
193
|
-
valid, "referenced" entries have to be reference
|
|
194
|
-
"automaton" has to name existing child kinds and
|
|
195
|
-
names of non-artifact objects have to be valid
|
|
196
|
-
compiled into regular expressions) */
|
|
193
|
+
valid, "referenced" and "coverage" entries have to be reference
|
|
194
|
+
expressions, an "automaton" has to name existing child kinds and
|
|
195
|
+
properties, and the names of non-artifact objects have to be valid
|
|
196
|
+
patterns (as they are compiled into regular expressions) */
|
|
197
197
|
const checkConstraints = (config, posOfPath) => {
|
|
198
198
|
const diagnostics = new Array();
|
|
199
199
|
const diagnose = (path, message) => {
|
|
@@ -216,20 +216,21 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
216
216
|
diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
|
|
217
217
|
checkProperties(object.props ?? [], at, diagnose);
|
|
218
218
|
checkAutomaton(object, at, diagnose);
|
|
219
|
-
for (const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
for (const field of ["referenced", "coverage"])
|
|
220
|
+
for (const [j, entry] of (object[field] ?? []).entries()) {
|
|
221
|
+
/* a reference coverage entry has to be a single
|
|
222
|
+
(usually wildcard) reference expression */
|
|
223
|
+
let expr;
|
|
224
|
+
try {
|
|
225
|
+
expr = compileValueExpr(entry);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
diagnose([...at, field, j], `invalid "${field}" entry "${entry}": ` +
|
|
229
|
+
(err instanceof Error ? err.message : String(err)));
|
|
230
|
+
}
|
|
231
|
+
if (expr !== undefined && expr.kind !== "reference")
|
|
232
|
+
diagnose([...at, field, j], `"${field}" entry "${entry}" is not a single "[[xxx]]" reference`);
|
|
225
233
|
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
diagnose([...at, "referenced", j], `invalid "referenced" entry "${entry}": ` +
|
|
228
|
-
(err instanceof Error ? err.message : String(err)));
|
|
229
|
-
}
|
|
230
|
-
if (expr !== undefined && expr.kind !== "reference")
|
|
231
|
-
diagnose([...at, "referenced", j], `"referenced" entry "${entry}" is not a single "[[xxx]]" reference`);
|
|
232
|
-
}
|
|
233
234
|
if (depth > 1 && object.name !== undefined) {
|
|
234
235
|
try {
|
|
235
236
|
anchored(object.name);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import { type SchemaObject } from "./specbook-format-schema.js";
|
|
3
|
+
import { type LinkIndex } from "./specbook-link.js";
|
|
4
|
+
export interface ReferencedCoverage {
|
|
5
|
+
schema: SchemaObject;
|
|
6
|
+
covered: SpecObject[];
|
|
7
|
+
uncovered: SpecObject[];
|
|
8
|
+
}
|
|
9
|
+
export declare const referencedCoverage: (index: LinkIndex, schemas: Map<SpecObject, SchemaObject>) => ReferencedCoverage[];
|
|
10
|
+
export interface Coverage {
|
|
11
|
+
pattern: string;
|
|
12
|
+
kinds: string[];
|
|
13
|
+
covered: SpecObject[];
|
|
14
|
+
uncovered: SpecObject[];
|
|
15
|
+
}
|
|
16
|
+
export declare const specCoverage: (index: LinkIndex, schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, Coverage[]>;
|
|
17
|
+
export declare const coverageRatio: (covered: number, total: number) => number;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText } from "./specbook-link.js";
|
|
7
|
+
import { compileValueExpr } from "./specbook-parse-value.js";
|
|
8
|
+
/* the referencing objects of every referenced object, resolved from
|
|
9
|
+
the names, property values, descriptions, and rationales of all
|
|
10
|
+
objects, where the references from within the subtree of the
|
|
11
|
+
referenced object itself do not count (an object cannot cover
|
|
12
|
+
itself), leniently skipping the unresolvable references, as those
|
|
13
|
+
are already reported by the reference pass */
|
|
14
|
+
const referrerIndex = (index) => {
|
|
15
|
+
const referrers = new Map();
|
|
16
|
+
for (const { object } of index) {
|
|
17
|
+
const chain = chainOf(index, object);
|
|
18
|
+
const texts = [object.name, ...object.properties.map((p) => p.value)];
|
|
19
|
+
if (object.description !== undefined)
|
|
20
|
+
texts.push(object.description.description, object.description.rationale ?? "");
|
|
21
|
+
for (const text of texts)
|
|
22
|
+
for (const m of plainText(text).matchAll(referenceRegex)) {
|
|
23
|
+
const target = resolveUnique(index, m[1].trim(), object).target;
|
|
24
|
+
if (target !== undefined && !chain.includes(target))
|
|
25
|
+
referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return referrers;
|
|
29
|
+
};
|
|
30
|
+
/* whether an object is covered by a set of source objects: it is
|
|
31
|
+
referenced from an object lying below one of the sources (itself
|
|
32
|
+
or through an ancestor) or, when lifting, so is one of its
|
|
33
|
+
descendants */
|
|
34
|
+
const isCovered = (index, referrers, sources, object, lifting) => {
|
|
35
|
+
for (const referrer of referrers.get(object) ?? [])
|
|
36
|
+
if (chainOf(index, referrer).some((o) => sources.has(o)))
|
|
37
|
+
return true;
|
|
38
|
+
return lifting && object.children.some((child) => isCovered(index, referrers, sources, child, true));
|
|
39
|
+
};
|
|
40
|
+
/* the objects matching the reference patterns of a schema field */
|
|
41
|
+
const matchingObjects = (index, patterns) => {
|
|
42
|
+
const objects = new Array();
|
|
43
|
+
for (const pattern of patterns) {
|
|
44
|
+
const expr = compileValueExpr(pattern);
|
|
45
|
+
if (expr.kind === "reference")
|
|
46
|
+
for (const object of resolveSet(index, expr.pattern))
|
|
47
|
+
if (!objects.includes(object))
|
|
48
|
+
objects.push(object);
|
|
49
|
+
}
|
|
50
|
+
return objects;
|
|
51
|
+
};
|
|
52
|
+
/* determine the coverage the objects of every "referenced"-flagged
|
|
53
|
+
kind receive, one entry per flagged schema node (in the order of
|
|
54
|
+
the first objects of the nodes), as the semantic validation
|
|
55
|
+
reports every uncovered object and the verbose output the ratio */
|
|
56
|
+
export const referencedCoverage = (index, schemas) => {
|
|
57
|
+
const referrers = referrerIndex(index);
|
|
58
|
+
const results = new Map();
|
|
59
|
+
for (const [object, schema] of schemas) {
|
|
60
|
+
if (schema.referenced === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
let result = results.get(schema);
|
|
63
|
+
if (result === undefined) {
|
|
64
|
+
result = { schema, sources: new Set(matchingObjects(index, schema.referenced)),
|
|
65
|
+
covered: [], uncovered: [] };
|
|
66
|
+
results.set(schema, result);
|
|
67
|
+
}
|
|
68
|
+
const bucket = isCovered(index, referrers, result.sources, object, false) ?
|
|
69
|
+
result.covered : result.uncovered;
|
|
70
|
+
bucket.push(object);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(results.values()).map(({ schema, covered, uncovered }) => ({ schema, covered, uncovered }));
|
|
73
|
+
};
|
|
74
|
+
/* determine the coverage the objects of every "coverage"-configured
|
|
75
|
+
kind report, one entry per configured pattern: the objects matching
|
|
76
|
+
the pattern (those below the reporting object itself excluded) count
|
|
77
|
+
as covered once referenced -- themselves or through one of their
|
|
78
|
+
descendants, so a use case is covered through its scenarios -- from
|
|
79
|
+
the reporting object or one of its descendants */
|
|
80
|
+
export const specCoverage = (index, schemas) => {
|
|
81
|
+
const referrers = referrerIndex(index);
|
|
82
|
+
const results = new Map();
|
|
83
|
+
for (const [object, schema] of schemas) {
|
|
84
|
+
if (schema.coverage === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
const sources = new Set([object]);
|
|
87
|
+
const entries = new Array();
|
|
88
|
+
for (const pattern of schema.coverage) {
|
|
89
|
+
const targets = matchingObjects(index, [pattern])
|
|
90
|
+
.filter((target) => !chainOf(index, target).includes(object));
|
|
91
|
+
const entry = { pattern, kinds: [], covered: [], uncovered: [] };
|
|
92
|
+
for (const target of targets) {
|
|
93
|
+
if (!entry.kinds.includes(target.kind))
|
|
94
|
+
entry.kinds.push(target.kind);
|
|
95
|
+
const bucket = isCovered(index, referrers, sources, target, true) ?
|
|
96
|
+
entry.covered : entry.uncovered;
|
|
97
|
+
bucket.push(target);
|
|
98
|
+
}
|
|
99
|
+
entries.push(entry);
|
|
100
|
+
}
|
|
101
|
+
results.set(object, entries);
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
};
|
|
105
|
+
/* the percentage of a coverage, rounded to the integer */
|
|
106
|
+
export const coverageRatio = (covered, total) => total > 0 ? Math.round(covered * 100 / total) : 0;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -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
|
|
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])));
|