@rse/specbook 1.5.2 → 1.6.1
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/LICENSE.txt +201 -0
- package/README.md +288 -43
- package/dst/specbook-api.d.ts +23 -3
- package/dst/specbook-api.js +133 -59
- package/dst/specbook-cli.js +93 -26
- package/dst/specbook-cmd-describe.js +4 -4
- package/dst/specbook-cmd-export-schema.d.ts +8 -0
- package/dst/specbook-cmd-export-schema.js +441 -0
- package/dst/specbook-cmd-export.js +23 -6
- package/dst/specbook-cmd-init-sample.d.ts +4 -0
- package/dst/specbook-cmd-init-sample.js +322 -0
- package/dst/specbook-cmd-init.d.ts +1 -0
- package/dst/specbook-cmd-init.js +42 -10
- package/dst/specbook-cmd-lint.js +46 -48
- package/dst/specbook-cmd-query.d.ts +30 -0
- package/dst/specbook-cmd-query.js +165 -0
- package/dst/specbook-config.d.ts +9 -1
- package/dst/specbook-config.js +135 -10
- package/dst/specbook-coverage.js +2 -2
- package/dst/specbook-diagram.d.ts +7 -5
- package/dst/specbook-diagram.js +546 -143
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +11 -8
- package/dst/specbook-export-code.js +3 -3
- package/dst/specbook-export-common.d.ts +1 -1
- package/dst/specbook-export-common.js +3 -2
- package/dst/specbook-export-diagram.d.ts +1 -1
- package/dst/specbook-export-diagram.js +25 -7
- package/dst/specbook-export-html-fold.d.ts +16 -6
- package/dst/specbook-export-html-fold.js +65 -44
- package/dst/specbook-export-html-render.js +107 -26
- package/dst/specbook-export-html-search.js +18 -20
- package/dst/specbook-export-html-template.js +38 -9
- package/dst/specbook-export-html-widget.d.ts +2 -0
- package/dst/specbook-export-html-widget.js +219 -19
- package/dst/specbook-export-html.css +208 -38
- package/dst/specbook-export-html.js +96 -53
- package/dst/specbook-export-md.d.ts +2 -1
- package/dst/specbook-export-md.js +7 -8
- package/dst/specbook-export-pdf.js +1 -1
- package/dst/specbook-files.d.ts +2 -0
- package/dst/specbook-files.js +41 -0
- package/dst/specbook-format-meta.yaml +401 -0
- package/dst/specbook-format-pm.yaml +1240 -0
- package/dst/specbook-format-schema.d.ts +33 -3
- package/dst/specbook-format-schema.js +49 -3
- package/dst/specbook-format.md +427 -47
- package/dst/specbook-format.yaml +524 -228
- package/dst/specbook-ignore.js +2 -2
- package/dst/specbook-link.d.ts +4 -0
- package/dst/specbook-link.js +61 -6
- package/dst/specbook-mcp.js +103 -19
- package/dst/specbook-parse-semantic.d.ts +1 -0
- package/dst/specbook-parse-semantic.js +11 -4
- package/dst/specbook-parse-syntax.d.ts +1 -0
- package/dst/specbook-parse-syntax.js +1 -1
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +3 -3
- package/dst/specbook-project.d.ts +2 -0
- package/dst/specbook-project.js +15 -5
- package/dst/specbook-remote.d.ts +28 -0
- package/dst/specbook-remote.js +429 -0
- package/package.json +32 -20
|
@@ -0,0 +1,322 @@
|
|
|
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 RandExpModule from "randexp";
|
|
7
|
+
import wrapAnsi from "wrap-ansi";
|
|
8
|
+
import { compileValueExpr, anchored } from "./specbook-parse-value.js";
|
|
9
|
+
import { slugify } from "./specbook-parse-syntax.js";
|
|
10
|
+
import { buildLinkIndex, resolveSet, referenceTo, chainOf } from "./specbook-link.js";
|
|
11
|
+
import { titleProperties, isTitleObject } from "./specbook-export-common.js";
|
|
12
|
+
import { referencedCoverage } from "./specbook-coverage.js";
|
|
13
|
+
const RandExp = RandExpModule;
|
|
14
|
+
/* the fixed rationale of every sample description, which explains the
|
|
15
|
+
statement/rationale split of a description by example */
|
|
16
|
+
const rationale = "the sample shows that a description states what holds and, behind the keyword, why it holds.";
|
|
17
|
+
/* the capitalized words of a kind or property name
|
|
18
|
+
("USE-CASE" yields "Use" and "Case") */
|
|
19
|
+
const wordsOf = (name) => name.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")
|
|
20
|
+
.map((word) => word[0].toUpperCase() + word.slice(1).toLowerCase());
|
|
21
|
+
/* the leading word of a sample object or value: the first object of a
|
|
22
|
+
kind is the "Sample ..." one, its partner the "Other ..." one */
|
|
23
|
+
const ordinalWord = (ordinal) => ordinal === 0 ? "Sample" : "Other";
|
|
24
|
+
/* the casing variants of a word list, the title case first */
|
|
25
|
+
const casings = (words) => {
|
|
26
|
+
const lower = words.map((word) => word.toLowerCase());
|
|
27
|
+
const upper = words.map((word) => word.toUpperCase());
|
|
28
|
+
return [words.join(" "), words.join(""), lower[0] + words.slice(1).join(""),
|
|
29
|
+
lower.join("-"), lower.join("_"), upper.join("-"), upper.join("_"),
|
|
30
|
+
lower.join(" "), upper.join(" "), lower.join(""), upper.join("")];
|
|
31
|
+
};
|
|
32
|
+
/* generate a text matching a regular expression deterministically (the
|
|
33
|
+
random choices of RandExp replaced by the lowest option, shifted by
|
|
34
|
+
the ordinal, so a partner object receives a distinct text, and its
|
|
35
|
+
free characters restricted to letters and digits, as the Markdown
|
|
36
|
+
parsing would strip a leading or trailing whitespace), or none if
|
|
37
|
+
the expression is beyond RandExp */
|
|
38
|
+
const generate = (regex, ordinal) => {
|
|
39
|
+
try {
|
|
40
|
+
const randexp = new RandExp(regex);
|
|
41
|
+
randexp.randInt = (a, b) => Math.min(a + ordinal, b);
|
|
42
|
+
for (const [a, b] of [[0, 47], [58, 64], [91, 96], [123, 126]])
|
|
43
|
+
randexp.defaultRange.subtract(a, b);
|
|
44
|
+
const text = randexp.gen();
|
|
45
|
+
return regex.test(text) && text.trim() === text ? text : undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/* derive the name of a sample object: the first casing variant of
|
|
52
|
+
"Sample <Kind>", else of the single word "Sample", matching the
|
|
53
|
+
configured name pattern, else a generated match, else the plain
|
|
54
|
+
title case (which the check reports) */
|
|
55
|
+
const sampleName = (schema, ordinal) => {
|
|
56
|
+
const words = [ordinalWord(ordinal), ...wordsOf(schema.kind)];
|
|
57
|
+
if (schema.name === undefined)
|
|
58
|
+
return words.join(" ");
|
|
59
|
+
const regex = anchored(schema.name);
|
|
60
|
+
const name = [...casings(words), ...casings([ordinalWord(ordinal)])].find((text) => regex.test(text));
|
|
61
|
+
return name ?? generate(regex, ordinal) ?? words.join(" ");
|
|
62
|
+
};
|
|
63
|
+
/* the first sentence of a text (its first paragraph up to the first
|
|
64
|
+
sentence-ending punctuation not closing an abbreviation), without
|
|
65
|
+
its trailing punctuation */
|
|
66
|
+
const firstSentence = (text) => {
|
|
67
|
+
const paragraph = text.trim().split(/\n\s*\n/)[0].replace(/\s+/g, " ");
|
|
68
|
+
const sentence = paragraph.match(/^.*?(?<!\b(?:e\.g|i\.e|etc|vs|cf|resp))[.!?](?=\s|$)/)?.[0] ?? paragraph;
|
|
69
|
+
return sentence.replace(/[.!?]$/, "");
|
|
70
|
+
};
|
|
71
|
+
/* the number of sample objects of a child kind: two where a relation
|
|
72
|
+
needs a partner -- the node kind of the automaton of the parent and
|
|
73
|
+
a kind carrying a symmetric or acyclic property -- and one otherwise */
|
|
74
|
+
const countOf = (parent, child) => (parent.automaton?.nodes === child.kind
|
|
75
|
+
|| (child.props ?? []).some((prop) => prop.symmetric === true || prop.acyclic === true)) ? 2 : 1;
|
|
76
|
+
/* instantiate the sample objects of a schema node (recursively) with
|
|
77
|
+
their names and ids only, as the property values need the whole
|
|
78
|
+
corpus, where a recurring artifact (configured without a name) is
|
|
79
|
+
named like a nested object */
|
|
80
|
+
const instantiate = (schema, ordinal, count, samples, parent) => {
|
|
81
|
+
const name = parent === undefined && schema.name !== undefined ? schema.name : sampleName(schema, ordinal);
|
|
82
|
+
const object = { kind: schema.kind, id: schema.id ?? slugify(name), name, properties: [], children: [] };
|
|
83
|
+
if (schema.id !== undefined)
|
|
84
|
+
object.paren = schema.id;
|
|
85
|
+
const sample = { object, schema, ordinal, count, parent };
|
|
86
|
+
samples.push(sample);
|
|
87
|
+
for (const child of schema.children ?? []) {
|
|
88
|
+
const n = countOf(schema, child);
|
|
89
|
+
for (let i = 0; i < n; i++)
|
|
90
|
+
object.children.push(instantiate(child, i, n, samples, sample));
|
|
91
|
+
}
|
|
92
|
+
return object;
|
|
93
|
+
};
|
|
94
|
+
/* match a text directly against a regex or enum expression */
|
|
95
|
+
const matches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
|
|
96
|
+
|| (expr.kind === "enum" && expr.members.includes(text));
|
|
97
|
+
/* the objects a reference pattern matches, the ones nearest to the
|
|
98
|
+
referencing object (sharing the longest ancestor chain with it)
|
|
99
|
+
first and the referencing object itself last, as a self-reference
|
|
100
|
+
is the last resort only */
|
|
101
|
+
const targetsOf = (index, pattern, from) => {
|
|
102
|
+
const chain = chainOf(index, from);
|
|
103
|
+
const proximity = (object) => {
|
|
104
|
+
if (object === from)
|
|
105
|
+
return -1;
|
|
106
|
+
const other = chainOf(index, object);
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < chain.length && i < other.length && chain[i] === other[i])
|
|
109
|
+
i++;
|
|
110
|
+
return i;
|
|
111
|
+
};
|
|
112
|
+
return resolveSet(index, pattern)
|
|
113
|
+
.map((object, pos) => ({ object, pos, proximity: proximity(object) }))
|
|
114
|
+
.sort((a, b) => b.proximity - a.proximity || a.pos - b.pos)
|
|
115
|
+
.map((entry) => entry.object);
|
|
116
|
+
};
|
|
117
|
+
/* the candidate values of a property, in order of preference: the
|
|
118
|
+
members of an enum/tags, the references onto the nearest objects of
|
|
119
|
+
a reference pattern, the alternatives of a list in order, and the
|
|
120
|
+
placeholders matching a regex -- "Sample <Name>" first, then the
|
|
121
|
+
common literals, then a generated match -- or, without a value
|
|
122
|
+
constraint, the placeholder alone */
|
|
123
|
+
const candidates = (prop, expr, index, from, ordinal) => {
|
|
124
|
+
const placeholder = `${ordinalWord(ordinal)} ${wordsOf(prop.name).join(" ")}`;
|
|
125
|
+
if (expr === undefined)
|
|
126
|
+
return [placeholder];
|
|
127
|
+
else if (expr.kind === "enum" || expr.kind === "tags")
|
|
128
|
+
return [...expr.members];
|
|
129
|
+
else if (expr.kind === "reference")
|
|
130
|
+
return targetsOf(index, expr.pattern, from).map((target) => `[[${referenceTo(index, target, from)}]]`);
|
|
131
|
+
else if (expr.kind === "list")
|
|
132
|
+
return expr.alternatives.flatMap((alternative) => candidates(prop, alternative, index, from, ordinal));
|
|
133
|
+
const literals = [placeholder, "true", "false", "1", "2", "0"].filter((text) => expr.regex.test(text));
|
|
134
|
+
if (literals.length > 0)
|
|
135
|
+
return literals;
|
|
136
|
+
const generated = generate(expr.regex, ordinal);
|
|
137
|
+
return generated !== undefined ? [generated] : [];
|
|
138
|
+
};
|
|
139
|
+
/* the value of a property on a sample object: the first candidate,
|
|
140
|
+
where a "present" expression prefers a candidate matching it on the
|
|
141
|
+
first object of the kind and a "unique" flag skips the candidates
|
|
142
|
+
the sibling objects of the kind already carry (all of them, or the
|
|
143
|
+
ones matching its expression), or none if no candidate exists */
|
|
144
|
+
const valueOf = (sample, prop, index) => {
|
|
145
|
+
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
146
|
+
const compile = (flag) => typeof flag === "string" ? compileValueExpr(flag) : undefined;
|
|
147
|
+
const present = compile(prop.present);
|
|
148
|
+
const unique = compile(prop.unique);
|
|
149
|
+
const list = candidates(prop, expr, index, sample.object, sample.ordinal);
|
|
150
|
+
if (sample.ordinal === 0 && present !== undefined)
|
|
151
|
+
list.sort((a, b) => Number(matches(present, b)) - Number(matches(present, a)));
|
|
152
|
+
const taken = (sample.parent?.object.children ?? [])
|
|
153
|
+
.filter((child) => child.kind === sample.object.kind && child !== sample.object)
|
|
154
|
+
.flatMap((child) => child.properties.filter((p) => p.key === prop.name).map((p) => p.value));
|
|
155
|
+
const applies = (value) => prop.unique === true || (unique !== undefined && matches(unique, value));
|
|
156
|
+
return list.find((value) => !applies(value) || !taken.includes(value)) ?? list[0];
|
|
157
|
+
};
|
|
158
|
+
/* fill in the property values of a sample object: the title object
|
|
159
|
+
keeps the properties the exports interpret at their rendering
|
|
160
|
+
defaults, the automaton of the parent dictates the flags of a node
|
|
161
|
+
(the first node is the initial one, the last node the final one) and
|
|
162
|
+
the ends of an edge (from the first node to the last one), an
|
|
163
|
+
acyclic property is carried by the first object of its kind only,
|
|
164
|
+
and an optional property no candidate satisfies is left out, while
|
|
165
|
+
a required one stays empty (which the check reports) */
|
|
166
|
+
const fill = (sample, index) => {
|
|
167
|
+
const automaton = sample.parent?.schema.automaton;
|
|
168
|
+
const title = isTitleObject(sample.object);
|
|
169
|
+
const siblings = (kind) => sample.parent?.object.children.filter((child) => child.kind === kind) ?? [];
|
|
170
|
+
for (const prop of sample.schema.props ?? []) {
|
|
171
|
+
let value;
|
|
172
|
+
const seeded = title ? titleProperties.find((p) => p.name === prop.name) : undefined;
|
|
173
|
+
if (seeded !== undefined)
|
|
174
|
+
value = seeded.init;
|
|
175
|
+
else if (automaton !== undefined && sample.object.kind === automaton.nodes
|
|
176
|
+
&& (prop.name === automaton.initial || prop.name === automaton.final)) {
|
|
177
|
+
const flagged = prop.name === automaton.initial ?
|
|
178
|
+
sample.ordinal === 0 : sample.ordinal === sample.count - 1;
|
|
179
|
+
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
180
|
+
value = flagged ? "true" : candidates(prop, expr, index, sample.object, sample.ordinal)
|
|
181
|
+
.find((candidate) => candidate !== "true");
|
|
182
|
+
}
|
|
183
|
+
else if (automaton !== undefined && sample.object.kind === automaton.edges
|
|
184
|
+
&& (prop.name === automaton.source || prop.name === automaton.target)) {
|
|
185
|
+
const nodes = siblings(automaton.nodes);
|
|
186
|
+
const node = nodes[prop.name === automaton.source ? 0 : nodes.length - 1];
|
|
187
|
+
value = node !== undefined ? `[[${referenceTo(index, node, sample.object)}]]` : undefined;
|
|
188
|
+
}
|
|
189
|
+
else if (prop.acyclic === true && sample.ordinal > 0 && prop.optional === true)
|
|
190
|
+
continue;
|
|
191
|
+
else
|
|
192
|
+
value = valueOf(sample, prop, index);
|
|
193
|
+
if (value === undefined && prop.optional === true)
|
|
194
|
+
continue;
|
|
195
|
+
sample.object.properties.push({ key: prop.name, value: value ?? "" });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/* add the references the "referenced"-flagged kinds demand: an object
|
|
199
|
+
lacking its coverage gets referenced from an object matching one of
|
|
200
|
+
the patterns of its kind (or from below one, but never from within
|
|
201
|
+
its own subtree), preferably by extending a list-valued property
|
|
202
|
+
admitting it -- one without a uniqueness or relation flag, whose
|
|
203
|
+
shape the extension could break, and keeping a "local" one local --
|
|
204
|
+
and else through the elaboration of the description of the first
|
|
205
|
+
such object, as every reference of a description counts, too */
|
|
206
|
+
const cover = (samples, index) => {
|
|
207
|
+
const schemas = new Map(samples.map((sample) => [sample.object, sample.schema]));
|
|
208
|
+
const fallbacks = new Map();
|
|
209
|
+
for (const { schema, uncovered } of referencedCoverage(index, schemas)) {
|
|
210
|
+
const sources = new Set((schema.referenced ?? []).flatMap((pattern) => {
|
|
211
|
+
const expr = compileValueExpr(pattern);
|
|
212
|
+
return expr.kind === "reference" ? resolveSet(index, expr.pattern) : [];
|
|
213
|
+
}));
|
|
214
|
+
for (const object of uncovered) {
|
|
215
|
+
const referrers = samples.filter((sample) => {
|
|
216
|
+
const chain = chainOf(index, sample.object);
|
|
217
|
+
return chain.some((o) => sources.has(o)) && !chain.includes(object);
|
|
218
|
+
});
|
|
219
|
+
const admits = (sample, prop) => {
|
|
220
|
+
if (prop.value === undefined || (prop.unique ?? false) !== false
|
|
221
|
+
|| prop.symmetric === true || prop.acyclic === true)
|
|
222
|
+
return false;
|
|
223
|
+
const expr = compileValueExpr(prop.value);
|
|
224
|
+
if (expr.kind !== "list" || !expr.alternatives.some((alternative) => alternative.kind === "reference" && resolveSet(index, alternative.pattern).includes(object)))
|
|
225
|
+
return false;
|
|
226
|
+
const chain = chainOf(index, sample.object);
|
|
227
|
+
return prop.local !== true || chain.length < 2
|
|
228
|
+
|| chainOf(index, object).includes(chain[chain.length - 2]);
|
|
229
|
+
};
|
|
230
|
+
const target = referrers.map((sample) => ({ referrer: sample, prop: (sample.schema.props ?? []).find((p) => admits(sample, p)) }))
|
|
231
|
+
.find((entry) => entry.prop !== undefined);
|
|
232
|
+
if (target?.prop !== undefined) {
|
|
233
|
+
/* extend (or add) the first admitting property, keeping
|
|
234
|
+
the properties in the order of the schema */
|
|
235
|
+
const { referrer, prop } = target;
|
|
236
|
+
const props = referrer.schema.props ?? [];
|
|
237
|
+
const reference = `[[${referenceTo(index, object, referrer.object)}]]`;
|
|
238
|
+
const property = referrer.object.properties.find((p) => p.key === prop.name);
|
|
239
|
+
if (property === undefined) {
|
|
240
|
+
referrer.object.properties.push({ key: prop.name, value: reference });
|
|
241
|
+
referrer.object.properties.sort((a, b) => props.findIndex((p) => p.name === a.key) - props.findIndex((p) => p.name === b.key));
|
|
242
|
+
}
|
|
243
|
+
else
|
|
244
|
+
property.value = property.value !== "" ? `${property.value}, ${reference}` : reference;
|
|
245
|
+
}
|
|
246
|
+
else if (referrers.length > 0) {
|
|
247
|
+
const from = referrers[0].object;
|
|
248
|
+
fallbacks.set(from, [...(fallbacks.get(from) ?? []), `[[${referenceTo(index, object, from)}]]`]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const [object, references] of fallbacks)
|
|
253
|
+
if (object.description !== undefined)
|
|
254
|
+
object.description.elaboration = `The sample references ${references.join(", ")}, ` +
|
|
255
|
+
`as the schema configuration demands ${references.length === 1 ? "it" : "them"} ` +
|
|
256
|
+
"to be referenced from here.";
|
|
257
|
+
};
|
|
258
|
+
/* the maximum line width of the rendered sample, and the placeholder
|
|
259
|
+
protecting a space from becoming a line break (the ones inside the
|
|
260
|
+
references and code spans, and the ones of the kept line prefix) */
|
|
261
|
+
const lineWidth = 100;
|
|
262
|
+
const space = "\u{E000}";
|
|
263
|
+
/* wrap the text parts of the rendered sample -- the descriptions and the
|
|
264
|
+
property values -- onto lines of at most 100 characters through
|
|
265
|
+
wrap-ansi, which breaks just the text part of an overlong line and
|
|
266
|
+
never the Markdown structure in front of it: the line prefix
|
|
267
|
+
(indentation, list marker, property key, or the leading segments of a
|
|
268
|
+
Concise Format item) stays on the first line as an unbreakable word,
|
|
269
|
+
and the continuation lines are indented onto the text column of the
|
|
270
|
+
line, while a line whose wrapping would start a continuation line with
|
|
271
|
+
a block marker stays unwrapped */
|
|
272
|
+
export const wrapSample = (text) => text.split("\n").flatMap((line) => {
|
|
273
|
+
if (line.length <= lineWidth || line.startsWith("#"))
|
|
274
|
+
return [line];
|
|
275
|
+
/* the kept prefix: the indentation of a plain line, else the part
|
|
276
|
+
of a list item up to its description (behind the last segment
|
|
277
|
+
separator of a Concise Format item) or up to its property value */
|
|
278
|
+
const item = line.match(/^(\s*)- {3}/);
|
|
279
|
+
const indent = item !== null ? item[1].length + 4 : (line.match(/^\s*/)?.[0].length ?? 0);
|
|
280
|
+
const prefix = item === null ? line.slice(0, indent) :
|
|
281
|
+
line.includes("; ") ? line.slice(0, line.lastIndexOf("; ") + 2) :
|
|
282
|
+
(line.match(/^\s*- {3}[^:]*:\s*/)?.[0] ?? item[0]);
|
|
283
|
+
/* wrap the line behind its continuation indentation, with the
|
|
284
|
+
spaces of the prefix (but its last one) and of the atoms protected */
|
|
285
|
+
const head = prefix.slice(indent);
|
|
286
|
+
const input = head.replace(/ (?=.)/g, space) + line.slice(prefix.length)
|
|
287
|
+
.replace(/\[\[[^\]]*\]\]|`[^`]*`/g, (atom) => atom.replaceAll(" ", space));
|
|
288
|
+
const output = wrapAnsi(input, lineWidth - indent, { hard: false, trim: true })
|
|
289
|
+
.replaceAll(space, " ").split("\n");
|
|
290
|
+
if (output.slice(1).some((cont) => /^(?:[-+*>#]|\d+[.)])(?:\s|$)/.test(cont)))
|
|
291
|
+
return [line];
|
|
292
|
+
return [`${line.slice(0, indent)}${output[0]}`,
|
|
293
|
+
...output.slice(1).map((cont) => `${" ".repeat(indent)}${cont}`)];
|
|
294
|
+
}).join("\n");
|
|
295
|
+
/* generate the sample objects of the given artifacts (all at once, as
|
|
296
|
+
the references of the sample reach across the artifacts): one object
|
|
297
|
+
per allowed kind (two where a relation needs a partner), carrying
|
|
298
|
+
every configured property with a placeholder value satisfying its
|
|
299
|
+
constraint and the first sentence of the schema description of its
|
|
300
|
+
kind as its statement, mapped per artifact onto its level 1 object */
|
|
301
|
+
export const sampleArtifacts = (artifacts) => {
|
|
302
|
+
const samples = new Array();
|
|
303
|
+
const roots = new Map();
|
|
304
|
+
for (const artifact of artifacts)
|
|
305
|
+
roots.set(artifact, instantiate(artifact, 0, 1, samples));
|
|
306
|
+
/* the property values are filled in once all objects exist, as the
|
|
307
|
+
reference-valued ones target the nearest sample object of their
|
|
308
|
+
pattern through the link index of the whole sample */
|
|
309
|
+
const now = new Date();
|
|
310
|
+
const spec = { artifacts: Array.from(roots.values()).map((object) => ({ created: now, modified: now, objects: [object] })) };
|
|
311
|
+
const index = buildLinkIndex(spec);
|
|
312
|
+
for (const sample of samples) {
|
|
313
|
+
fill(sample, index);
|
|
314
|
+
sample.object.description = {
|
|
315
|
+
description: sample.schema.desc !== undefined ?
|
|
316
|
+
firstSentence(sample.schema.desc) : `The sample ${sample.schema.kind} object`,
|
|
317
|
+
rationale
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
cover(samples, index);
|
|
321
|
+
return roots;
|
|
322
|
+
};
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { literal } from "./specbook-verbose.js";
|
|
9
|
-
import {
|
|
9
|
+
import { renderDiagnostic } from "./specbook-diagnostic.js";
|
|
10
|
+
import { parseSpecification, specStatistics, isRecurring } from "./specbook-parse.js";
|
|
11
|
+
import { formatTimestamp, renderObjectMd } from "./specbook-export-md.js";
|
|
10
12
|
import { titleProperties } from "./specbook-export-common.js";
|
|
13
|
+
import { sampleArtifacts, wrapSample } from "./specbook-cmd-init-sample.js";
|
|
11
14
|
/* render the property list of the title artifact, seeding the properties
|
|
12
15
|
the exports interpret themselves with their rendering defaults (as far
|
|
13
16
|
as the schema configures them, as any other is reported as unknown) */
|
|
@@ -22,15 +25,16 @@ const titleList = (artifact) => {
|
|
|
22
25
|
};
|
|
23
26
|
/* initialize the configured specification artifact files below the
|
|
24
27
|
base directory with their frontmatter and artifact headings (plus
|
|
25
|
-
the title properties),
|
|
26
|
-
all artifacts configured onto the same
|
|
27
|
-
side, following each other on level 1 */
|
|
28
|
+
the title properties), or, under "sample", with a sample object per
|
|
29
|
+
allowed object kind, where all artifacts configured onto the same
|
|
30
|
+
file reside in it side by side, following each other on level 1 */
|
|
28
31
|
export const initSpecification = (options) => {
|
|
29
32
|
fs.mkdirSync(options.basedir, { recursive: true });
|
|
30
33
|
const now = formatTimestamp(new Date());
|
|
31
34
|
const created = new Array();
|
|
32
35
|
/* group the configured artifacts by their file, preserving the
|
|
33
|
-
schema order both of the files and of the artifacts within
|
|
36
|
+
schema order both of the files and of the artifacts within,
|
|
37
|
+
and skipping the already existing files */
|
|
34
38
|
const groups = new Map();
|
|
35
39
|
for (const artifact of options.config) {
|
|
36
40
|
if (artifact.file === undefined)
|
|
@@ -41,15 +45,29 @@ export const initSpecification = (options) => {
|
|
|
41
45
|
else
|
|
42
46
|
group.push(artifact);
|
|
43
47
|
}
|
|
48
|
+
for (const file of groups.keys())
|
|
49
|
+
if (fs.existsSync(path.join(options.basedir, file))) {
|
|
50
|
+
options.verbose(`skipping existing artifact file "${literal(file)}"`);
|
|
51
|
+
groups.delete(file);
|
|
52
|
+
}
|
|
53
|
+
/* generate the sample objects over the artifacts of all files to
|
|
54
|
+
be created at once, as the references of the sample reach
|
|
55
|
+
across the files */
|
|
56
|
+
const samples = options.sample ?
|
|
57
|
+
sampleArtifacts(Array.from(groups.values()).flat()) : undefined;
|
|
44
58
|
/* create each absent artifact file with its frontmatter and
|
|
45
|
-
the level 1 headings of its artifacts
|
|
59
|
+
the level 1 headings of its artifacts (or their sample objects),
|
|
60
|
+
where a recurring artifact receives no heading, as its objects
|
|
61
|
+
are named by the authors */
|
|
62
|
+
const sources = new Array();
|
|
46
63
|
for (const [file, artifacts] of groups) {
|
|
47
64
|
const target = path.join(options.basedir, file);
|
|
48
|
-
if (fs.existsSync(target)) {
|
|
49
|
-
options.verbose(`skipping existing artifact file "${literal(file)}"`);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
65
|
const headings = artifacts.map((artifact) => {
|
|
66
|
+
const sample = samples?.get(artifact);
|
|
67
|
+
if (sample !== undefined)
|
|
68
|
+
return `${wrapSample(renderObjectMd(sample, 1))}\n\n`;
|
|
69
|
+
if (isRecurring(artifact))
|
|
70
|
+
return "";
|
|
53
71
|
const name = artifact.name ?? "";
|
|
54
72
|
const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
|
|
55
73
|
return `# ${artifact.kind}: ${name}${paren}\n\n` + titleList(artifact);
|
|
@@ -64,7 +82,21 @@ export const initSpecification = (options) => {
|
|
|
64
82
|
fs.writeFileSync(target, text, "utf8");
|
|
65
83
|
options.verbose(`created artifact file "${literal(file)}" ` +
|
|
66
84
|
`with ${literal(artifacts.length)} artifact(s)`);
|
|
85
|
+
sources.push({ file: target, text });
|
|
67
86
|
created.push(file);
|
|
68
87
|
}
|
|
88
|
+
/* check the generated sample, as the generation satisfies the
|
|
89
|
+
constraints of the schema configuration on a best-effort basis
|
|
90
|
+
only, reporting the errors unconditionally (the files stay, as
|
|
91
|
+
they are the starting point to fix) and the warnings (like the
|
|
92
|
+
coverage lapses) as regular notices */
|
|
93
|
+
if (samples !== undefined && sources.length > 0) {
|
|
94
|
+
const result = parseSpecification(sources, options.config);
|
|
95
|
+
const stats = specStatistics(result.specification);
|
|
96
|
+
options.verbose(`generated sample defining ${literal(stats.objects)} object(s) ` +
|
|
97
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
98
|
+
for (const diagnostic of result.diagnostics)
|
|
99
|
+
options.verbose(renderDiagnostic(diagnostic), diagnostic.severity === "error" ? "none" : "notice");
|
|
100
|
+
}
|
|
69
101
|
return created;
|
|
70
102
|
};
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -6,23 +6,16 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
9
|
+
import { reportOverlaps } from "./specbook-remote.js";
|
|
9
10
|
import { literal } from "./specbook-verbose.js";
|
|
10
|
-
import { parseSpecification, specStatistics, resolveArtifact, collectSchemas } from "./specbook-parse.js";
|
|
11
|
+
import { parseSpecification, specStatistics, resolveArtifact, isRecurring, collectSchemas } from "./specbook-parse.js";
|
|
11
12
|
import { buildLinkIndex, chainOf, plainText } from "./specbook-link.js";
|
|
12
13
|
import { referencedCoverage, specCoverage, coverageRatio } from "./specbook-coverage.js";
|
|
13
14
|
import { excluder } from "./specbook-ignore.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const files = new Map();
|
|
19
|
-
for (const artifact of config) {
|
|
20
|
-
if (artifact.file === undefined)
|
|
21
|
-
continue;
|
|
22
|
-
files.set(artifact.file, (files.get(artifact.file) ?? true) && artifact.optional === true);
|
|
23
|
-
}
|
|
24
|
-
return files;
|
|
25
|
-
};
|
|
15
|
+
import { specFiles } from "./specbook-files.js";
|
|
16
|
+
/* whether a configured artifact is demanded: neither optional nor
|
|
17
|
+
recurring (the latter being optional by definition) */
|
|
18
|
+
const isDemanded = (schema) => schema !== undefined && schema.optional !== true && !isRecurring(schema);
|
|
26
19
|
/* report the reference coverage the "referenced"-flagged object kinds
|
|
27
20
|
receive and the "coverage"-configured objects report as additional
|
|
28
21
|
figures, the unreferenced objects of the latter by name as a lengthy
|
|
@@ -58,52 +51,57 @@ export const lint = (options) => {
|
|
|
58
51
|
options.config.map((file) => `"${literal(file)}"`).join(", "));
|
|
59
52
|
const loaded = loadConfig(options.config);
|
|
60
53
|
diagnostics.push(...loaded.diagnostics);
|
|
54
|
+
reportOverlaps(loaded.overlaps, options.verbose);
|
|
61
55
|
const config = loaded.config;
|
|
62
56
|
if (config !== undefined) {
|
|
63
57
|
const stats = configStatistics(config);
|
|
64
58
|
options.verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
65
59
|
`and ${literal(stats.links)} link relationship(s)`);
|
|
66
60
|
}
|
|
67
|
-
/* read the
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
61
|
+
/* read the specification Markdown files below the base directory
|
|
62
|
+
(every one of them is taken for a SpecBook one, the "file"
|
|
63
|
+
fields of the configuration drive "init" only), where the base
|
|
64
|
+
directory itself heads the observed paths, so a watching
|
|
65
|
+
consumer notices added and removed files, too */
|
|
66
|
+
const files = specFiles(options.basedir);
|
|
67
|
+
if (files.length === 0)
|
|
68
|
+
diagnostics.push({ file: options.basedir, line: 1, column: 1, severity: "error",
|
|
69
|
+
message: "no specification files found" });
|
|
70
|
+
/* a file Git excludes from its project is no part of the project
|
|
71
|
+
and hence no part of its specification, so it is set aside */
|
|
77
72
|
const excluded = options.gitignore ? excluder(options.basedir) : () => false;
|
|
78
73
|
const sources = new Array();
|
|
79
|
-
const
|
|
80
|
-
const watched =
|
|
81
|
-
for (const
|
|
82
|
-
const file = path.join(options.basedir, name);
|
|
74
|
+
const pruned = new Array();
|
|
75
|
+
const watched = [path.resolve(options.basedir)];
|
|
76
|
+
for (const file of files) {
|
|
83
77
|
watched.push(path.resolve(file));
|
|
84
|
-
if (excluded(file)) {
|
|
85
|
-
if (optional)
|
|
86
|
-
options.verbose(`skipping artifact file "${literal(file)}" excluded by Git`);
|
|
87
|
-
else
|
|
88
|
-
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
89
|
-
message: "artifact file excluded by Git" });
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (!fs.existsSync(file)) {
|
|
93
|
-
if (!optional)
|
|
94
|
-
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
95
|
-
message: "missing artifact file" });
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
78
|
try {
|
|
99
|
-
sources.push({ file, text: fs.readFileSync(file, "utf8") });
|
|
100
|
-
present.add(name);
|
|
79
|
+
(excluded(file) ? pruned : sources).push({ file, text: fs.readFileSync(file, "utf8") });
|
|
101
80
|
}
|
|
102
81
|
catch (err) {
|
|
103
82
|
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
104
83
|
message: `unreadable file: ${err instanceof Error ? err.message : String(err)}` });
|
|
105
84
|
}
|
|
106
85
|
}
|
|
86
|
+
/* an excluded file is an error unless all of its artifacts are
|
|
87
|
+
optional, which only its own (separate, as it is dropped) parsing
|
|
88
|
+
tells, whose diagnostics are irrelevant for the very same reason */
|
|
89
|
+
if (pruned.length > 0) {
|
|
90
|
+
const demanded = new Set();
|
|
91
|
+
if (config !== undefined) {
|
|
92
|
+
const dropped = parseSpecification(pruned, config);
|
|
93
|
+
for (const artifact of dropped.specification.artifacts)
|
|
94
|
+
if (artifact.objects.some((object) => isDemanded(resolveArtifact(config, object))))
|
|
95
|
+
demanded.add(dropped.origins.get(artifact) ?? "");
|
|
96
|
+
}
|
|
97
|
+
for (const { file } of pruned) {
|
|
98
|
+
if (demanded.has(file))
|
|
99
|
+
diagnostics.push({ file, line: 1, column: 1, severity: "error",
|
|
100
|
+
message: "artifact file excluded by Git" });
|
|
101
|
+
else
|
|
102
|
+
options.verbose(`skipping specification file "${literal(file)}" excluded by Git`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
107
105
|
/* parse and validate the artifact files against the configuration */
|
|
108
106
|
options.verbose(`parsing ${literal(sources.length)} specification file(s) ` +
|
|
109
107
|
`below "${literal(options.basedir)}"`);
|
|
@@ -116,9 +114,10 @@ export const lint = (options) => {
|
|
|
116
114
|
/* report the reference coverage as additional figures */
|
|
117
115
|
if (config !== undefined && result.specification.artifacts.length > 0)
|
|
118
116
|
reportCoverage(result.specification, config, options.verbose);
|
|
119
|
-
/* report the
|
|
120
|
-
against
|
|
121
|
-
|
|
117
|
+
/* report the demanded artifacts absent from the specification,
|
|
118
|
+
against the artifact file configured for them below the base
|
|
119
|
+
directory (where "init" would create them, present or not) or
|
|
120
|
+
else against the configuration */
|
|
122
121
|
if (config !== undefined) {
|
|
123
122
|
const found = new Set();
|
|
124
123
|
for (const artifact of result.specification.artifacts)
|
|
@@ -128,8 +127,7 @@ export const lint = (options) => {
|
|
|
128
127
|
found.add(schema);
|
|
129
128
|
}
|
|
130
129
|
for (const schema of config) {
|
|
131
|
-
if (schema
|
|
132
|
-
|| (schema.file !== undefined && !present.has(schema.file)))
|
|
130
|
+
if (!isDemanded(schema) || found.has(schema))
|
|
133
131
|
continue;
|
|
134
132
|
const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config[0];
|
|
135
133
|
const paren = schema.id !== undefined ? ` (${schema.id})` : "";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import ASTQ from "astq";
|
|
2
|
+
import type { Spec, SpecArtifact, SpecObject } from "./specbook-format-spec.js";
|
|
3
|
+
import type { Schema } from "./specbook-format-schema.js";
|
|
4
|
+
import type { Diagnostic } from "./specbook-diagnostic.js";
|
|
5
|
+
export type SpecNode = Spec | SpecArtifact | SpecObject;
|
|
6
|
+
export type SpecQueryAdapter = ASTQ.Adapter<SpecNode>;
|
|
7
|
+
export type SpecQueryFunc = (adapter: SpecQueryAdapter, node: SpecNode, ...args: unknown[]) => unknown;
|
|
8
|
+
export declare const queryFormats: readonly ["json", "json5", "yaml", "toon"];
|
|
9
|
+
export type QueryFormat = typeof queryFormats[number];
|
|
10
|
+
export declare const parseQueryOutput: (output: string, format?: string) => {
|
|
11
|
+
format: QueryFormat;
|
|
12
|
+
output: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const renderNodes: (nodes: SpecNode[], format?: QueryFormat) => string;
|
|
15
|
+
export declare class SpecBookDocument {
|
|
16
|
+
readonly specification: Spec;
|
|
17
|
+
readonly config: Schema;
|
|
18
|
+
readonly diagnostics: Diagnostic[];
|
|
19
|
+
private astq;
|
|
20
|
+
private parents;
|
|
21
|
+
private attrs;
|
|
22
|
+
private index;
|
|
23
|
+
constructor(specification: Spec, config: Schema, diagnostics: Diagnostic[], origins: Map<SpecArtifact, string>);
|
|
24
|
+
query(selector: string, params?: Record<string, unknown>): SpecNode[];
|
|
25
|
+
parent(node: SpecNode): SpecNode | undefined;
|
|
26
|
+
func(name: string, fn: SpecQueryFunc): this;
|
|
27
|
+
private expand;
|
|
28
|
+
plain(text: string, from?: SpecObject): string;
|
|
29
|
+
html(text: string, from?: SpecObject): string;
|
|
30
|
+
}
|