@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.
Files changed (63) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +288 -43
  3. package/dst/specbook-api.d.ts +23 -3
  4. package/dst/specbook-api.js +133 -59
  5. package/dst/specbook-cli.js +93 -26
  6. package/dst/specbook-cmd-describe.js +4 -4
  7. package/dst/specbook-cmd-export-schema.d.ts +8 -0
  8. package/dst/specbook-cmd-export-schema.js +441 -0
  9. package/dst/specbook-cmd-export.js +23 -6
  10. package/dst/specbook-cmd-init-sample.d.ts +4 -0
  11. package/dst/specbook-cmd-init-sample.js +322 -0
  12. package/dst/specbook-cmd-init.d.ts +1 -0
  13. package/dst/specbook-cmd-init.js +42 -10
  14. package/dst/specbook-cmd-lint.js +46 -48
  15. package/dst/specbook-cmd-query.d.ts +30 -0
  16. package/dst/specbook-cmd-query.js +165 -0
  17. package/dst/specbook-config.d.ts +9 -1
  18. package/dst/specbook-config.js +135 -10
  19. package/dst/specbook-coverage.js +2 -2
  20. package/dst/specbook-diagram.d.ts +7 -5
  21. package/dst/specbook-diagram.js +546 -143
  22. package/dst/specbook-export-ast.d.ts +1 -1
  23. package/dst/specbook-export-ast.js +11 -8
  24. package/dst/specbook-export-code.js +3 -3
  25. package/dst/specbook-export-common.d.ts +1 -1
  26. package/dst/specbook-export-common.js +3 -2
  27. package/dst/specbook-export-diagram.d.ts +1 -1
  28. package/dst/specbook-export-diagram.js +25 -7
  29. package/dst/specbook-export-html-fold.d.ts +16 -6
  30. package/dst/specbook-export-html-fold.js +65 -44
  31. package/dst/specbook-export-html-render.js +107 -26
  32. package/dst/specbook-export-html-search.js +18 -20
  33. package/dst/specbook-export-html-template.js +38 -9
  34. package/dst/specbook-export-html-widget.d.ts +2 -0
  35. package/dst/specbook-export-html-widget.js +219 -19
  36. package/dst/specbook-export-html.css +208 -38
  37. package/dst/specbook-export-html.js +96 -53
  38. package/dst/specbook-export-md.d.ts +2 -1
  39. package/dst/specbook-export-md.js +7 -8
  40. package/dst/specbook-export-pdf.js +1 -1
  41. package/dst/specbook-files.d.ts +2 -0
  42. package/dst/specbook-files.js +41 -0
  43. package/dst/specbook-format-meta.yaml +401 -0
  44. package/dst/specbook-format-pm.yaml +1240 -0
  45. package/dst/specbook-format-schema.d.ts +33 -3
  46. package/dst/specbook-format-schema.js +49 -3
  47. package/dst/specbook-format.md +427 -47
  48. package/dst/specbook-format.yaml +524 -228
  49. package/dst/specbook-ignore.js +2 -2
  50. package/dst/specbook-link.d.ts +4 -0
  51. package/dst/specbook-link.js +61 -6
  52. package/dst/specbook-mcp.js +103 -19
  53. package/dst/specbook-parse-semantic.d.ts +1 -0
  54. package/dst/specbook-parse-semantic.js +11 -4
  55. package/dst/specbook-parse-syntax.d.ts +1 -0
  56. package/dst/specbook-parse-syntax.js +1 -1
  57. package/dst/specbook-parse.d.ts +1 -1
  58. package/dst/specbook-parse.js +3 -3
  59. package/dst/specbook-project.d.ts +2 -0
  60. package/dst/specbook-project.js +15 -5
  61. package/dst/specbook-remote.d.ts +28 -0
  62. package/dst/specbook-remote.js +429 -0
  63. 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
+ };
@@ -3,6 +3,7 @@ import { type Schema } from "./specbook-format-schema.js";
3
3
  export interface InitOptions {
4
4
  config: Schema;
5
5
  basedir: string;
6
+ sample: boolean;
6
7
  verbose: Verbose;
7
8
  }
8
9
  export declare const initSpecification: (options: InitOptions) => string[];
@@ -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 { formatTimestamp } from "./specbook-export-md.js";
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), where
26
- all artifacts configured onto the same file reside in it side by
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
  };
@@ -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
- /* collect the distinct artifact files the schema configuration
15
- references, in their declaration order, each mapped onto whether
16
- all of its artifacts are optional and hence the file may be absent */
17
- const schemaFiles = (config) => {
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 artifact files the configuration references, resolved
68
- against the base directory and tolerating an absent file if all
69
- its artifacts are optional */
70
- const files = config !== undefined ? schemaFiles(config) : new Map();
71
- if (config !== undefined && files.size === 0)
72
- diagnostics.push({ file: options.config[0], line: 1, column: 1, severity: "error",
73
- message: "no artifact files configured" });
74
- /* an artifact file Git excludes from its project is treated exactly
75
- like an absent one -- it is no part of the project and hence no
76
- part of its specification */
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 present = new Set();
80
- const watched = new Array();
81
- for (const [name, optional] of files) {
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 non-optional artifacts absent from the specification,
120
- against their loaded artifact file (an absent or unreadable file
121
- is already reported above) or else against the configuration */
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.optional === true || found.has(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
+ }