@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,441 @@
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 * as fs from "node:fs";
7
+ import { loadConfig, configStatistics, originKey } from "./specbook-config.js";
8
+ import { metaConfig, version, reportOverlaps } from "./specbook-remote.js";
9
+ import { literal } from "./specbook-verbose.js";
10
+ import { renderDiagnostic } from "./specbook-diagnostic.js";
11
+ import { parseSpecification, specStatistics } from "./specbook-parse.js";
12
+ import { compileValueExpr } from "./specbook-parse-value.js";
13
+ import { buildLinkIndex, resolveSet, referenceTo, outsideCode } from "./specbook-link.js";
14
+ import { slugify } from "./specbook-parse-syntax.js";
15
+ import { renderObjectMd, formatTimestamp } from "./specbook-export-md.js";
16
+ /* escape the Markdown-significant characters of a prose text (a
17
+ configuration file label), with its whitespace collapsed, so it
18
+ renders literally */
19
+ const escapeMd = (text) => text.replace(/\s+/g, " ").trim().replace(/[\\`*_[\]<>|]/g, "\\$&");
20
+ /* render a verbatim text (a value expression, a name pattern, a file
21
+ name, a configuration) as an inline code span, with its whitespace
22
+ collapsed, which renders it literally and in which its "[[...]]"
23
+ is plain code instead of a reference (the fence outgrows every
24
+ backtick run inside, padded where the text touches a backtick) */
25
+ const codeMd = (text) => {
26
+ const code = text.replace(/\s+/g, " ").trim();
27
+ const fence = "`".repeat(Math.max(0, ...Array.from(code.matchAll(/`+/g), (m) => m[0].length)) + 1);
28
+ const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";
29
+ return `${fence}${pad}${code}${pad}${fence}`;
30
+ };
31
+ /* escape a description text taken from the schema configuration for
32
+ the generated corpus, outside of its fenced code blocks and inline
33
+ code spans (in which a "[[" is plain code): every "[[" and "![" is
34
+ escaped, a line-leading "#" would open a heading, and the unordered
35
+ list items become ordered ones, as the former would be taken for
36
+ properties, while an inline code span carrying an "![" becomes
37
+ escaped plain text, as the parser loads an embedding even there */
38
+ const escapeDesc = (text) => outsideCode(text.trim().replace(/`([^`\n]*!\[[^`\n]*)`/g, (_, code) => escapeMd(code)), (part) => {
39
+ let counter = 0;
40
+ return part
41
+ .replace(/\[\[/g, "\\[\\[")
42
+ .replace(/!\[/g, "!\\[")
43
+ .split("\n").map((line) => {
44
+ const item = line.match(/^(\s*)[-*+]\s+(.*)$/);
45
+ if (item !== null)
46
+ return `${item[1]}${++counter}. ${item[2]}`;
47
+ if ((/^\S/).test(line))
48
+ counter = 0;
49
+ return line.replace(/^(\s*)#/, "$1\\#");
50
+ }).join("\n");
51
+ });
52
+ /* flatten a nested configuration value (an automaton, badge, multiple,
53
+ diagram, or format) into "key=value" pairs, with the nested keys dotted, the
54
+ lists of scalars bracketed, and the elements of the lists of objects
55
+ (the diagrams) indexed */
56
+ const flatten = (value, prefix = "") => {
57
+ if (Array.isArray(value) && value.some((item) => typeof item === "object" && item !== null))
58
+ return value.flatMap((item, i) => flatten(item, `${prefix}[${i}]`));
59
+ else if (Array.isArray(value))
60
+ return [`${prefix}=[${value.map((item) => String(item)).join(", ")}]`];
61
+ else if (typeof value === "object" && value !== null)
62
+ return Object.entries(value)
63
+ .filter(([, val]) => val !== undefined)
64
+ .flatMap(([key, val]) => flatten(val, prefix !== "" ? `${prefix}.${key}` : key));
65
+ else
66
+ return [`${prefix}=${String(value)}`];
67
+ };
68
+ /* the "[[...]]" reference patterns a property value expression carries */
69
+ const patternsOf = (prop) => {
70
+ if (prop.value === undefined)
71
+ return [];
72
+ const expr = compileValueExpr(prop.value);
73
+ return (expr.kind === "list" ? expr.alternatives : [expr])
74
+ .flatMap((alternative) => alternative.kind === "reference" ? [alternative.pattern] : []);
75
+ };
76
+ /* the cardinality of a reference-valued property: a single reference
77
+ or a "list" of them, required or optional */
78
+ const arityOf = (prop) => {
79
+ const many = prop.value !== undefined && compileValueExpr(prop.value).kind === "list";
80
+ return `${prop.optional === true ? "0.." : many ? "1.." : ""}${many ? "n" : "1"}`;
81
+ };
82
+ /* the value of a sibling marker flag: "true", or its expression */
83
+ const markerOf = (flag) => flag === true ? "true" : typeof flag === "string" ? codeMd(flag) : undefined;
84
+ /* a count with its noun */
85
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
86
+ /* render a text as a Mermaid class diagram identifier (in the dotted
87
+ notation of the qualified references) */
88
+ const classId = (text) => text.replace(/[^A-Za-z0-9_.-]/g, "_");
89
+ /* render a non-reference property as a class attribute, typed by the
90
+ kind of its value constraint plus its multiplicity */
91
+ const classAttribute = (prop) => {
92
+ const kind = prop.value !== undefined ? compileValueExpr(prop.value).kind : "text";
93
+ return `${kind === "reference" ? "ref" : kind}[${prop.optional === true ? "0..1" : "1"}] ${classId(prop.name)}`;
94
+ };
95
+ /* generate the Markdown sources of the specification describing a
96
+ schema configuration (against the meta schema configuration): the
97
+ title artifact and one (recurring) ARTIFACT-KIND artifact per
98
+ artifact kind, holding one ARTIFACT object per artifact, the
99
+ OBJECT-KIND objects flat below their artifact (in
100
+ pre-order, named after their kind, qualified by the parent kind on
101
+ a collision), and the PROPERTY/REFERENCE objects below the artifacts
102
+ and object kinds, where the reference patterns of the configuration
103
+ are resolved at the kind level against a skeleton corpus */
104
+ const generateSources = (config, meta, files, labels, origins) => {
105
+ const now = new Date();
106
+ /* the skeleton corpus standing in for the configuration: one object
107
+ per schema node, the artifacts carrying their ids and names, so
108
+ the reference patterns resolve onto the kinds through the
109
+ regular resolution */
110
+ const chains = new Map();
111
+ const skeletons = new Map();
112
+ const skeletonOf = (schema, chain) => {
113
+ const artifact = chain.length === 1;
114
+ const object = { kind: schema.kind,
115
+ id: artifact ? (schema.id ?? slugify(schema.name ?? schema.kind)) : "",
116
+ name: artifact ? (schema.name ?? "") : "", properties: [], children: [] };
117
+ if (artifact && schema.id !== undefined)
118
+ object.paren = schema.id;
119
+ chains.set(schema, chain);
120
+ skeletons.set(object, schema);
121
+ object.children = (schema.children ?? []).map((child) => skeletonOf(child, [...chain, child]));
122
+ return object;
123
+ };
124
+ const skeleton = { artifacts: config.map((schema) => ({ created: now, modified: now, objects: [skeletonOf(schema, [schema])] })) };
125
+ const skeletonIndex = buildLinkIndex(skeleton);
126
+ /* the schema nodes a reference pattern matches at the kind level:
127
+ every name part is generalized to the wildcard, as the skeleton
128
+ knows the kinds only, and a pattern matching every node (like
129
+ "[[*]]") names no particular target */
130
+ const targetsOf = (pattern) => {
131
+ const generalized = pattern.replace(/:("(?:[^"\\]|\\.)*"|[^.":]+)/g, ":*");
132
+ const matches = resolveSet(skeletonIndex, generalized).flatMap((object) => skeletons.get(object) ?? []);
133
+ return matches.length === skeletons.size ? [] : matches;
134
+ };
135
+ const targetsOfAll = (patterns) => Array.from(new Set(patterns.flatMap(targetsOf)));
136
+ /* the meta objects: the title, the schema, the artifact kinds, the
137
+ artifacts, the object kinds, and the properties, where a property
138
+ whose value constraint references at least one kind is a REFERENCE */
139
+ const metaObject = (kind, name, paren) => {
140
+ const o = { kind, id: paren ?? slugify(name), name, properties: [], children: [] };
141
+ if (paren !== undefined)
142
+ o.paren = paren;
143
+ return o;
144
+ };
145
+ const title = metaObject("META", "Title", "TI");
146
+ const kinds = new Map();
147
+ const metas = new Map();
148
+ const props = new Map();
149
+ const propertyObjects = (owner, chain) => {
150
+ for (const prop of chain[chain.length - 1].props ?? []) {
151
+ const targets = targetsOfAll(patternsOf(prop));
152
+ const o = metaObject(targets.length > 0 ? "REFERENCE" : "PROPERTY", prop.name);
153
+ props.set(o, { chain, prop, targets });
154
+ owner.children.push(o);
155
+ }
156
+ };
157
+ for (const schema of config) {
158
+ let kind = kinds.get(schema.kind);
159
+ if (kind === undefined) {
160
+ kind = metaObject("ARTIFACT-KIND", schema.kind);
161
+ kinds.set(schema.kind, kind);
162
+ }
163
+ const artifact = metaObject("ARTIFACT", schema.name ?? schema.id ?? schema.kind, schema.id);
164
+ metas.set(schema, artifact);
165
+ kind.children.push(artifact);
166
+ propertyObjects(artifact, [schema]);
167
+ /* the object kinds, flat below their artifact in pre-order */
168
+ const names = new Set();
169
+ const walk = (parent) => {
170
+ for (const child of parent.children ?? []) {
171
+ const name = names.has(child.kind) ? `${parent.kind}/${child.kind}` : child.kind;
172
+ names.add(name);
173
+ const o = metaObject("OBJECT-KIND", name);
174
+ metas.set(child, o);
175
+ artifact.children.push(o);
176
+ propertyObjects(o, chains.get(child) ?? [child]);
177
+ walk(child);
178
+ }
179
+ };
180
+ walk(schema);
181
+ }
182
+ /* the references among the meta objects, shortest unique from
183
+ their referencing object (resolved through the final index) */
184
+ const spec = { artifacts: [title, ...kinds.values()].map((o) => ({ created: now, modified: now, objects: [o] })) };
185
+ const index = buildLinkIndex(spec);
186
+ const ref = (target, from) => `[[${referenceTo(index, target, from)}]]`;
187
+ const refs = (targets, from) => targets.flatMap((target) => metas.get(target) ?? []).map((target) => ref(target, from)).join(", ");
188
+ /* the display labels of the configuration files, and the origin of
189
+ a node (its contributing files, shown when several files are merged) */
190
+ const labelOf = (file) => labels.get(file) ?? file;
191
+ const labelsMd = files.map((file) => escapeMd(labelOf(file))).join(", ");
192
+ const filesOf = (chain, prop) => origins.get(originKey(chain, prop)) ?? [];
193
+ const originOf = (origin) => files.length > 1 && origin.length > 0 ? origin.map((file) => codeMd(labelOf(file))).join(", ") : undefined;
194
+ /* the property values of a meta object (the absent ones left out) */
195
+ const push = (o, entries) => {
196
+ for (const [key, value] of entries)
197
+ if (value !== undefined)
198
+ o.properties.push({ key, value });
199
+ };
200
+ const patternsMd = (patterns) => patterns !== undefined ? codeMd(patterns.join(", ")) : undefined;
201
+ const configMd = (value) => value !== undefined ? codeMd(flatten(value).join(", ")) : undefined;
202
+ /* the title: the document properties and the abstract */
203
+ const stats = configStatistics(config);
204
+ push(title, [
205
+ ["TITLE", "Schema Configuration"],
206
+ ["SUBTITLE", labelsMd],
207
+ ["VERSION", `SpecBook ${version}`],
208
+ ["LANG", "en"]
209
+ ]);
210
+ title.description = {
211
+ description: `The schema configuration ${labelsMd} specifies ${plural(kinds.size, "artifact kind")} ` +
212
+ `with ${plural(config.length, "artifact")}, ${plural(stats.objects - config.length, "object kind")}, ` +
213
+ `and ${plural(stats.links, "reference relationship")}.`,
214
+ elaboration: "Every artifact and object kind lists its properties (`PROPERTY`) and its " +
215
+ "reference-valued properties (`REFERENCE`, the edges of the diagrams, leading to the " +
216
+ "artifacts and object kinds the value constraint references), and every object kind " +
217
+ "names the artifact or object kind it is nested directly below (`PARENT`). The value " +
218
+ "constraints, name patterns, and configurations are quoted verbatim from the schema " +
219
+ "configuration.\n\n" +
220
+ "The UML class diagram of an artifact shows the artifact (stereotyped `artifact`) and " +
221
+ "its object kinds as classes with their properties as attributes (typed by the kind of " +
222
+ "their value constraint and their multiplicity), their nesting as compositions, and " +
223
+ "their reference-valued properties as associations labeled with the property and its " +
224
+ "arity, where a referenced artifact or object kind of another artifact appears as a bare class."
225
+ };
226
+ /* the UML class diagram of an artifact and its object kinds -- the
227
+ artifact stereotyped, the non-reference properties as typed
228
+ attributes, the nesting as compositions, and the reference-valued
229
+ properties as labeled associations with their arity (none for a
230
+ degenerate diagram without any relationship, exactly like the
231
+ collapsed Gradia diagrams) */
232
+ const membersOf = (artifacts) => {
233
+ const members = new Array();
234
+ const collect = (schema) => {
235
+ members.push(schema);
236
+ for (const child of schema.children ?? [])
237
+ collect(child);
238
+ };
239
+ artifacts.forEach(collect);
240
+ return members;
241
+ };
242
+ const classDiagramOf = (artifacts) => {
243
+ const members = membersOf(artifacts);
244
+ /* the class names in the notation of the references: an artifact
245
+ as "<kind>.<id>", an object kind by its kind, qualified by the
246
+ id of its artifact (and then by its parent kind) only where
247
+ the name collides among the classes of the diagram */
248
+ const shown = new Set([...members,
249
+ ...members.flatMap((schema) => (schema.props ?? []).flatMap((prop) => targetsOfAll(patternsOf(prop))))]);
250
+ const names = new Map();
251
+ const unique = (candidate, nodes) => {
252
+ const counts = new Map();
253
+ for (const schema of nodes)
254
+ counts.set(candidate(schema), (counts.get(candidate(schema)) ?? 0) + 1);
255
+ return nodes.filter((schema) => {
256
+ if (counts.get(candidate(schema)) !== 1)
257
+ return true;
258
+ names.set(schema, classId(candidate(schema)));
259
+ return false;
260
+ });
261
+ };
262
+ const artifactLabel = (schema) => schema.id ?? slugify(schema.name ?? schema.kind);
263
+ for (const schema of shown) {
264
+ const chain = chains.get(schema) ?? [schema];
265
+ if (chain.length === 1)
266
+ names.set(schema, classId(`${schema.kind}.${artifactLabel(schema)}`));
267
+ }
268
+ const qualified = (schema) => `${artifactLabel((chains.get(schema) ?? [schema])[0])}.${schema.kind}`;
269
+ const nested = (schema) => `${artifactLabel((chains.get(schema) ?? [schema])[0])}.` +
270
+ (metas.get(schema)?.name ?? schema.kind).replace(/\//g, ".");
271
+ let rest = Array.from(shown).filter((schema) => !names.has(schema));
272
+ rest = unique((schema) => schema.kind, rest);
273
+ rest = unique(qualified, rest);
274
+ for (const schema of rest)
275
+ names.set(schema, classId(nested(schema)));
276
+ const classOf = (schema) => names.get(schema) ?? classId(schema.kind);
277
+ const relations = new Array();
278
+ for (const schema of members) {
279
+ for (const child of schema.children ?? [])
280
+ relations.push(` ${classOf(schema)} *-- "${child.optional === true ? "0..n" : "1..n"}" ` +
281
+ classOf(child));
282
+ for (const prop of schema.props ?? [])
283
+ for (const target of targetsOfAll(patternsOf(prop)))
284
+ relations.push(` ${classOf(schema)} --> "${arityOf(prop)}" ${classOf(target)} : ${prop.name}`);
285
+ }
286
+ if (relations.length === 0)
287
+ return undefined;
288
+ const lines = ["classDiagram"];
289
+ for (const schema of members) {
290
+ lines.push(` class ${classOf(schema)} {`);
291
+ if ((chains.get(schema) ?? [schema]).length === 1)
292
+ lines.push(" <<artifact>>");
293
+ for (const prop of schema.props ?? [])
294
+ if (targetsOfAll(patternsOf(prop)).length === 0)
295
+ lines.push(` ${classAttribute(prop)}`);
296
+ lines.push(" }");
297
+ }
298
+ return [...lines, ...relations].join("\n") + "\n";
299
+ };
300
+ /* the artifact kinds: their artifacts */
301
+ for (const [name, o] of kinds) {
302
+ const artifacts = config.filter((schema) => schema.kind === name);
303
+ push(o, [["ORIGIN", originOf(Array.from(new Set(artifacts.flatMap((schema) => filesOf([schema])))))]]);
304
+ o.description = {
305
+ description: `The artifact kind **${escapeMd(name)}** comprises ${plural(artifacts.length, "artifact")}: ` +
306
+ `${refs(artifacts, o)}.`
307
+ };
308
+ }
309
+ /* the class diagram of an artifact, introduced by its figures: its
310
+ object kinds and associations, plus the other artifacts the
311
+ associations reach into */
312
+ const classDiagramMd = (schema, o) => {
313
+ const uml = classDiagramOf([schema]);
314
+ if (uml === undefined)
315
+ return undefined;
316
+ const members = membersOf([schema]);
317
+ const targets = members.flatMap((member) => (member.props ?? []).flatMap((prop) => targetsOfAll(patternsOf(prop))));
318
+ const foreign = Array.from(new Set(targets.map((target) => (chains.get(target) ?? [target])[0])))
319
+ .filter((artifact) => artifact !== schema);
320
+ return `The class diagram shows ${plural(members.length - 1, "object kind")} of the artifact` +
321
+ (targets.length > 0 ? ` with ${plural(targets.length, "association")}` : "") +
322
+ (foreign.length > 0 ? `, reaching into ${refs(foreign, o)}` : "") +
323
+ ".\n\n```mermaid\n" + uml + "```";
324
+ };
325
+ /* the artifacts and object kinds: their configuration, their
326
+ description, and the prose of their relationships */
327
+ const kindsMd = (patterns, o) => {
328
+ const inner = patterns.map((pattern) => compileValueExpr(pattern))
329
+ .flatMap((expr) => expr.kind === "reference" ? [expr.pattern] : []);
330
+ const targets = targetsOfAll(inner);
331
+ return targets.length > 0 ? `the ${refs(targets, o)} objects` :
332
+ inner.includes("*") ? "any object" : `the objects matching ${codeMd(patterns.join(", "))}`;
333
+ };
334
+ for (const [schema, o] of metas) {
335
+ const chain = chains.get(schema) ?? [schema];
336
+ const parent = chain.length >= 2 ? metas.get(chain[chain.length - 2]) : undefined;
337
+ push(o, [
338
+ ["ORIGIN", originOf(filesOf(chain))],
339
+ ["PARENT", parent !== undefined ? ref(parent, o) : undefined],
340
+ ["FILE", chain.length === 1 && schema.file !== undefined ? codeMd(schema.file) : undefined],
341
+ ["NAME", chain.length > 1 && schema.name !== undefined ? codeMd(schema.name) : undefined],
342
+ ["OPTIONAL", schema.optional === true ? "true" : "false"],
343
+ ["REFERENCED", patternsMd(schema.referenced)],
344
+ ["COVERAGE", patternsMd(schema.coverage)],
345
+ ["AUTOMATON", configMd(schema.automaton)],
346
+ ["BADGE", typeof schema.badge === "string" ? codeMd(schema.badge) : configMd(schema.badge)],
347
+ ["MULTIPLE", configMd(schema.multiple)],
348
+ ["DIAGRAM", configMd(schema.diagram)],
349
+ ["FORMAT", configMd(schema.format)]
350
+ ]);
351
+ const parts = new Array();
352
+ const subject = `Every object of the kind **${escapeMd(schema.kind)}**`;
353
+ if (schema.referenced !== undefined)
354
+ parts.push(`${subject} has to be referenced from ${kindsMd(schema.referenced, o)}.`);
355
+ if (schema.coverage !== undefined)
356
+ parts.push(`${subject} reports the reference coverage of ${kindsMd(schema.coverage, o)}.`);
357
+ if (schema.automaton !== undefined) {
358
+ const automaton = schema.automaton;
359
+ const childMd = (kind) => {
360
+ const child = (schema.children ?? []).find((c) => c.kind === kind);
361
+ const target = child !== undefined ? metas.get(child) : undefined;
362
+ return target !== undefined ? ref(target, o) : `\`${kind}\``;
363
+ };
364
+ parts.push(`The ${childMd(automaton.nodes)} children form a finite state machine, whose ` +
365
+ `${childMd(automaton.edges)} children lead from the node their \`${automaton.source}\` names ` +
366
+ `to the node their \`${automaton.target}\` names, where \`${automaton.initial}\` flags the ` +
367
+ `initial and \`${automaton.final}\` the final nodes.`);
368
+ }
369
+ if (schema.refs !== undefined)
370
+ parts.push(`References:\n\n${escapeDesc(schema.refs)}`);
371
+ const uml = chain.length === 1 ? classDiagramMd(schema, o) : undefined;
372
+ if (uml !== undefined)
373
+ parts.push(uml);
374
+ o.description = { description: escapeDesc(schema.desc ?? `The objects of the kind \`${schema.kind}\`.`),
375
+ elaboration: parts.length > 0 ? parts.join("\n\n") : undefined };
376
+ }
377
+ /* the properties: their constraint, flags, and targets */
378
+ for (const [o, { chain, prop, targets }] of props) {
379
+ push(o, [
380
+ ["ORIGIN", originOf(filesOf(chain, prop))],
381
+ ["OPTIONAL", prop.optional === true ? "true" : "false"],
382
+ ["VALUE", prop.value !== undefined ? codeMd(prop.value) : undefined],
383
+ ["TARGET", targets.length > 0 ? refs(targets, o) : undefined],
384
+ ["ARITY", targets.length > 0 ? arityOf(prop) : undefined],
385
+ ["UNIQUE", markerOf(prop.unique)],
386
+ ["PRESENT", markerOf(prop.present)],
387
+ ["LOCAL", prop.local === true ? "true" : undefined],
388
+ ["SYMMETRIC", prop.symmetric === true ? "true" : undefined],
389
+ ["ACYCLIC", prop.acyclic === true ? "true" : undefined]
390
+ ]);
391
+ o.description = { description: escapeDesc(prop.desc ?? `The property \`${prop.name}\`.`) };
392
+ }
393
+ /* render the title artifact and the (recurring) artifact kind
394
+ artifacts into the Markdown sources of their configured files,
395
+ timestamped with the oldest and the latest configuration file */
396
+ const times = files.map((file) => fs.statSync(file).mtime.getTime());
397
+ const created = formatTimestamp(new Date(Math.min(...times)));
398
+ const modified = formatTimestamp(new Date(Math.max(...times)));
399
+ const source = (objects) => ({
400
+ file: meta.find((schema) => schema.kind === objects[0].kind)?.file ?? `${objects[0].kind}.md`,
401
+ text: `---\nCreated: ${created}\nModified: ${modified}\n---\n\n` +
402
+ objects.map((o) => renderObjectMd(o, 1)).join("\n\n") + "\n"
403
+ });
404
+ return [source([title]), ...(kinds.size > 0 ? [source(Array.from(kinds.values()))] : [])];
405
+ };
406
+ /* generate the specification describing the effective schema
407
+ configuration and parse it against the bundled meta schema
408
+ configuration, exactly like "lint" parses the specification files,
409
+ so the result feeds the very same exports (the generated sources
410
+ carry no files to observe beyond the configuration ones) */
411
+ export const lintSchema = (options) => {
412
+ const { verbose } = options;
413
+ const diagnostics = new Array();
414
+ /* load the schema configuration to describe, merged out of its files */
415
+ verbose("loading schema configuration " +
416
+ options.config.map((file) => `"${literal(file)}"`).join(", "));
417
+ const loaded = loadConfig(options.config);
418
+ diagnostics.push(...loaded.diagnostics);
419
+ reportOverlaps(loaded.overlaps, verbose);
420
+ const config = loaded.config;
421
+ if (config === undefined)
422
+ return { specification: { artifacts: [] }, diagnostics, files: [], origins: new Map() };
423
+ const stats = configStatistics(config);
424
+ verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
425
+ `and ${literal(stats.links)} link relationship(s)`);
426
+ /* load the bundled meta schema configuration the generated specification follows */
427
+ const meta = loadConfig([metaConfig]);
428
+ if (meta.config === undefined)
429
+ throw new Error("invalid meta schema configuration:\n" +
430
+ meta.diagnostics.map(renderDiagnostic).join("\n"));
431
+ /* generate and parse the specification describing the schema configuration */
432
+ const sources = generateSources(config, meta.config, options.config, options.labels, loaded.origins);
433
+ verbose(`parsing ${literal(sources.length)} generated specification file(s) describing the schema configuration`);
434
+ const result = parseSpecification(sources, meta.config);
435
+ diagnostics.push(...result.diagnostics);
436
+ const specStats = specStatistics(result.specification);
437
+ verbose(`parsed specification defining ${literal(specStats.objects)} object(s) ` +
438
+ `and ${literal(specStats.links)} link relationship(s)`);
439
+ return { specification: result.specification, diagnostics, config: meta.config,
440
+ files: [], origins: result.origins };
441
+ };
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import * as fs from "node:fs";
7
+ import * as path from "node:path";
7
8
  /* Chokidar is aliased in "package.json", as Nunjucks declares the
8
9
  (unused) Chokidar 3 as an optional peer dependency and the current
9
10
  Chokidar under its plain name would raise a peer warning on install */
@@ -16,6 +17,7 @@ import { renderMarkdown } from "./specbook-export-md.js";
16
17
  import { renderHtml, htmlOutline, titlePageObject } from "./specbook-export-html.js";
17
18
  import { htmlToPdf, requireBrowser } from "./specbook-export-pdf.js";
18
19
  import { literal } from "./specbook-verbose.js";
20
+ import { specFiles } from "./specbook-files.js";
19
21
  /* re-export the browser pre-check of the PDF renderer, so the API
20
22
  facade reaches the renderers through this command module only */
21
23
  export { requireBrowser };
@@ -66,12 +68,16 @@ const watchDelay = 1000;
66
68
  const scanDelay = 30 * 1000;
67
69
  /* take the modification time snapshot of the observed files, where an
68
70
  absent file (a still missing artifact) is remembered as absent, too,
69
- so its creation and removal are noticed like a modification */
71
+ so its creation and removal are noticed like a modification, while a
72
+ directory is represented by the number of its specification files,
73
+ as its own modification time also changes with every unrelated file
74
+ created in it (like an editor swap file) */
70
75
  const snapshot = (files) => {
71
76
  const times = new Map();
72
77
  for (const file of files) {
73
78
  try {
74
- times.set(file, fs.statSync(file).mtimeMs);
79
+ const stats = fs.statSync(file);
80
+ times.set(file, stats.isDirectory() ? specFiles(file).length : stats.mtimeMs);
75
81
  }
76
82
  catch {
77
83
  times.set(file, 0);
@@ -92,7 +98,15 @@ export const watchSpecification = async (run, verbose) => {
92
98
  const started = Date.now();
93
99
  let observed = await run();
94
100
  let times = snapshot(observed);
95
- const watcher = watch(observed, { ignoreInitial: true });
101
+ /* the observed set carries the base directory as a whole (so the
102
+ added and removed specification files are noticed, too), below
103
+ which only the Markdown files and the explicitly observed files
104
+ count, as any other file written into it (like an export output)
105
+ would re-trigger the export endlessly, and whose "."-prefixed
106
+ directories (like ".git") the specification never spans */
107
+ const watcher = watch(observed, { ignoreInitial: true,
108
+ ignored: (file, stats) => stats !== undefined && !observed.includes(file)
109
+ && (stats.isDirectory() ? path.basename(file).startsWith(".") : !file.endsWith(".md")) });
96
110
  verbose(`observing ${literal(observed.length)} specification file(s) for changes`);
97
111
  /* report the failures of the observation itself, as an unhandled
98
112
  "error" event would otherwise terminate the process */
@@ -100,13 +114,16 @@ export const watchSpecification = async (run, verbose) => {
100
114
  verbose("observing failed: " +
101
115
  (err instanceof Error ? err.message : String(err)), "none");
102
116
  });
103
- /* perform a re-export and re-synchronize the observed files */
117
+ /* perform a re-export and re-synchronize the observed files (the
118
+ observed set is replaced before the watcher is fed, as its filter
119
+ above consults it) */
104
120
  const cycle = async () => {
105
121
  const files = await run();
106
- watcher.unwatch(observed.filter((file) => !files.includes(file)));
107
- watcher.add(files.filter((file) => !observed.includes(file)));
122
+ const previous = observed;
108
123
  observed = files;
109
124
  times = snapshot(files);
125
+ watcher.unwatch(previous.filter((file) => !files.includes(file)));
126
+ watcher.add(files.filter((file) => !previous.includes(file)));
110
127
  };
111
128
  /* restart the quiet period on every change, so a burst collapses
112
129
  into a single re-export, and chain the runs onto each other, so a
@@ -0,0 +1,4 @@
1
+ import { type SchemaObject } from "./specbook-format-schema.js";
2
+ import { type SpecObject } from "./specbook-format-spec.js";
3
+ export declare const wrapSample: (text: string) => string;
4
+ export declare const sampleArtifacts: (artifacts: SchemaObject[]) => Map<SchemaObject, SpecObject>;