@rse/specbook 1.0.2 → 1.0.4

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 (44) hide show
  1. package/README.md +106 -19
  2. package/dst/specbook-api.d.ts +32 -12
  3. package/dst/specbook-api.js +114 -33
  4. package/dst/specbook-cli.js +92 -31
  5. package/dst/specbook-cmd-describe.d.ts +7 -1
  6. package/dst/specbook-cmd-describe.js +56 -11
  7. package/dst/specbook-cmd-export.d.ts +3 -1
  8. package/dst/specbook-cmd-export.js +46 -3
  9. package/dst/specbook-cmd-init.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.d.ts +4 -2
  11. package/dst/specbook-cmd-lint.js +12 -8
  12. package/dst/specbook-cmd-preview.d.ts +12 -0
  13. package/dst/specbook-cmd-preview.js +57 -0
  14. package/dst/specbook-config.d.ts +1 -1
  15. package/dst/specbook-config.js +187 -37
  16. package/dst/specbook-diagnostic.d.ts +2 -0
  17. package/dst/specbook-diagnostic.js +4 -2
  18. package/dst/specbook-diagram.d.ts +1 -0
  19. package/dst/specbook-diagram.js +0 -0
  20. package/dst/specbook-export-ast.js +4 -2
  21. package/dst/specbook-export-common.js +5 -3
  22. package/dst/specbook-export-html-search.js +118 -30
  23. package/dst/specbook-export-html.css +1140 -233
  24. package/dst/specbook-export-html.d.ts +1 -1
  25. package/dst/specbook-export-html.js +764 -120
  26. package/dst/specbook-export-md.js +4 -2
  27. package/dst/specbook-export-pdf.d.ts +4 -1
  28. package/dst/specbook-export-pdf.js +46 -16
  29. package/dst/specbook-format-schema.d.ts +33 -1
  30. package/dst/specbook-format-schema.js +34 -2
  31. package/dst/specbook-format.md +193 -25
  32. package/dst/specbook-format.yaml +2291 -299
  33. package/dst/specbook-link.d.ts +2 -1
  34. package/dst/specbook-link.js +43 -3
  35. package/dst/specbook-mcp.js +20 -11
  36. package/dst/specbook-parse-common.d.ts +4 -2
  37. package/dst/specbook-parse-common.js +6 -3
  38. package/dst/specbook-parse-semantic.js +290 -30
  39. package/dst/specbook-parse-syntax.js +3 -1
  40. package/dst/specbook-parse-value.d.ts +1 -0
  41. package/dst/specbook-parse-value.js +17 -3
  42. package/dst/specbook-parse.js +1 -1
  43. package/dst/specbook-verbose.d.ts +2 -0
  44. package/package.json +14 -6
@@ -6,13 +6,14 @@ interface LinkNode {
6
6
  parent?: LinkNode;
7
7
  }
8
8
  export type LinkIndex = LinkNode[];
9
+ export declare const chainOf: (index: LinkIndex, object: SpecObject) => SpecObject[];
9
10
  export interface LinkTarget {
10
11
  target?: SpecObject;
11
12
  ambiguous: boolean;
12
13
  }
13
14
  export declare const buildLinkIndex: (specification: Spec) => LinkIndex;
14
15
  export declare const resolveSet: (index: LinkIndex, reference: string) => SpecObject[];
15
- export declare const resolveUnique: (index: LinkIndex, reference: string) => LinkTarget;
16
+ export declare const resolveUnique: (index: LinkIndex, reference: string, from?: SpecObject) => LinkTarget;
16
17
  export declare const anchorPaths: (index: LinkIndex) => Map<SpecObject, string>;
17
18
  export declare const expandReferences: (text: string, replace: (reference: string) => string) => string;
18
19
  export {};
@@ -8,6 +8,25 @@ export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
8
8
  /* strip the inline code markup of a name or property value
9
9
  (preserved in the AST for rendering) for matching and labeling */
10
10
  export const plainText = (text) => text.replace(/`/g, "");
11
+ /* the per-index lookup of the nodes by their objects, built lazily on
12
+ the first scoped resolution (as the index itself stays a plain list) */
13
+ const lookups = new WeakMap();
14
+ const lookup = (index, object) => {
15
+ let nodes = lookups.get(index);
16
+ if (nodes === undefined) {
17
+ nodes = new Map(index.map((node) => [node.object, node]));
18
+ lookups.set(index, nodes);
19
+ }
20
+ return nodes.get(object);
21
+ };
22
+ /* the ancestor-or-self chain of an object, root first
23
+ (empty for an object unknown to the index) */
24
+ export const chainOf = (index, object) => {
25
+ const chain = new Array();
26
+ for (let node = lookup(index, object); node !== undefined; node = node.parent)
27
+ chain.unshift(node.object);
28
+ return chain;
29
+ };
11
30
  /* build the resolution index over all objects of a specification */
12
31
  export const buildLinkIndex = (specification) => {
13
32
  const index = [];
@@ -74,11 +93,30 @@ export const resolveSet = (index, reference) => {
74
93
  return true;
75
94
  }).map((node) => node.object);
76
95
  };
96
+ /* narrow several matches down to the ones nearest to the referencing
97
+ object, i.e. sharing the longest ancestor chain with it (the lexical
98
+ scoping rule: a match within the same parent beats one in a sibling
99
+ subtree, which beats one in another artifact) */
100
+ const nearest = (index, matches, from) => {
101
+ const chain = chainOf(index, from);
102
+ const distance = (object) => {
103
+ const other = chainOf(index, object);
104
+ let i = 0;
105
+ while (i < chain.length && i < other.length && chain[i] === other[i])
106
+ i++;
107
+ return i;
108
+ };
109
+ const distances = matches.map((object) => distance(object));
110
+ const best = Math.max(...distances);
111
+ return matches.filter((_, i) => distances[i] === best);
112
+ };
77
113
  /* resolve a reference into a unique target: a single segment tries the
78
114
  ordered variants (1) id/anchor, (2) name, (3) "KIND:name-or-id", where
79
- the first variant yielding matches decides (several matches are an
80
- ambiguity), while a hierarchical path resolves via its full match set */
81
- export const resolveUnique = (index, reference) => {
115
+ the first variant yielding matches decides, while a hierarchical path
116
+ resolves via its full match set; several matches are narrowed down to
117
+ the ones nearest to the referencing object (if known) and remain an
118
+ ambiguity only if still more than one remains */
119
+ export const resolveUnique = (index, reference, from) => {
82
120
  const segments = segmentsOf(reference);
83
121
  let matches = [];
84
122
  if (segments.length === 1) {
@@ -97,6 +135,8 @@ export const resolveUnique = (index, reference) => {
97
135
  }
98
136
  else
99
137
  matches = resolveSet(index, reference);
138
+ if (matches.length > 1 && from !== undefined)
139
+ matches = nearest(index, matches, from);
100
140
  return { target: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
101
141
  };
102
142
  /* derive the fully-qualified anchor path of every object: the
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
- import { SpecBook, renderDiagnostic, formats, parseOutputSpec, describeFormats, describeParts, version } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, parseCompressLevel, version } from "./specbook-api.js";
11
11
  /* render an error with its cause chain into a tool error result */
12
12
  const errorResult = (err) => {
13
13
  let msg = err instanceof Error ? err.message : String(err);
@@ -15,7 +15,7 @@ const errorResult = (err) => {
15
15
  msg += `: ${cause.message}`;
16
16
  return {
17
17
  isError: true,
18
- content: [{ type: "text", text: `ERROR: ${msg}` }]
18
+ content: [{ type: "text", text: `ERROR: ${renderVerbose(msg)}` }]
19
19
  };
20
20
  };
21
21
  /* serve the SpecBook functionality as "specbook_<cmd>" MCP tools over stdio */
@@ -27,8 +27,9 @@ export const serveMcp = async (verbose) => {
27
27
  description: "Initialize the configured specification artifact files below the base directory " +
28
28
  "with their frontmatter and artifact heading, skipping already existing files.",
29
29
  inputSchema: {
30
- config: z.string().optional().describe("YAML schema configuration file " +
31
- "(default: the bundled standard schema configuration)"),
30
+ config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
31
+ "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
32
+ "default: the bundled standard schema configuration)"),
32
33
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
33
34
  }
34
35
  }, async (args) => {
@@ -47,8 +48,9 @@ export const serveMcp = async (verbose) => {
47
48
  description: "Lint the specification Markdown files the YAML schema configuration references " +
48
49
  "below the base directory against this configuration and return all diagnostics.",
49
50
  inputSchema: {
50
- config: z.string().optional().describe("YAML schema configuration file " +
51
- "(default: the bundled standard schema configuration)"),
51
+ config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
52
+ "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
53
+ "default: the bundled standard schema configuration)"),
52
54
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
53
55
  }
54
56
  }, async (args) => {
@@ -70,8 +72,9 @@ export const serveMcp = async (verbose) => {
70
72
  "YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
71
73
  "if an output path is given, else it is returned directly (PDF as a base64-encoded resource).",
72
74
  inputSchema: {
73
- config: z.string().optional().describe("YAML schema configuration file " +
74
- "(default: the bundled standard schema configuration)"),
75
+ config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
76
+ "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
77
+ "default: the bundled standard schema configuration)"),
75
78
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
76
79
  format: z.enum(formats).optional().describe("output format (default: inferred from the " +
77
80
  "output file extension, else json)"),
@@ -111,11 +114,16 @@ export const serveMcp = async (verbose) => {
111
114
  "points to the artifacts of that particular project. The description can be reduced to a " +
112
115
  "single part and emitted as the raw original file content instead of Markdown.",
113
116
  inputSchema: {
114
- config: z.string().optional().describe("YAML schema configuration file " +
115
- "(default: the bundled standard schema configuration, embedded verbatim)"),
117
+ config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
118
+ "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
119
+ "default: the bundled standard schema configuration, embedded)"),
116
120
  basedir: z.string().optional().describe("base directory of the specification Markdown files"),
117
121
  embed: z.boolean().optional().describe("embed the given YAML schema configuration instead " +
118
122
  "of just referencing it (default: false; the bundled standard one is always embedded)"),
123
+ compress: z.number().int().min(0).max(3).optional().describe("compression level of the emitted " +
124
+ "YAML schema configuration (embedded or raw): 0 for verbatim, 1 for re-emitted with 2-space " +
125
+ "indentation and without comments, 2 for additionally without its \"refs\" fields, or 3 for " +
126
+ "additionally without its \"desc\" fields (default: 1)"),
119
127
  format: z.enum(describeFormats).optional().describe("output format: \"md\" for Markdown or " +
120
128
  "\"raw\" for the raw original file content of the part (default: \"md\")"),
121
129
  part: z.enum(describeParts).optional().describe("document part: \"all\" for the entire " +
@@ -126,7 +134,8 @@ export const serveMcp = async (verbose) => {
126
134
  }
127
135
  }, async (args) => {
128
136
  try {
129
- const text = await specbook.describe(args);
137
+ const text = await specbook.describe({ ...args,
138
+ compress: args.compress !== undefined ? parseCompressLevel(args.compress) : undefined });
130
139
  if (args.output !== undefined && args.output !== "-") {
131
140
  await fs.promises.writeFile(args.output, text, "utf8");
132
141
  return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
@@ -1,5 +1,5 @@
1
1
  import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
2
- import { type Diagnostic } from "./specbook-diagnostic.js";
2
+ import { type Diagnostic, type DiagnosticSeverity } from "./specbook-diagnostic.js";
3
3
  import { type LinkIndex } from "./specbook-link.js";
4
4
  export interface SourceFile {
5
5
  file: string;
@@ -8,6 +8,7 @@ export interface SourceFile {
8
8
  export interface ParseResult {
9
9
  specification: Spec;
10
10
  diagnostics: Diagnostic[];
11
+ assets: string[];
11
12
  }
12
13
  export interface ObjectMeta {
13
14
  file: string;
@@ -20,7 +21,8 @@ export declare class ParseContext {
20
21
  line: number;
21
22
  }>;
22
23
  linkIndex: LinkIndex;
23
- diagnose(file: string, line: number, message: string): void;
24
+ assets: Set<string>;
25
+ diagnose(file: string, line: number, message: string, severity?: DiagnosticSeverity): void;
24
26
  }
25
27
  export declare const becauseRegex: RegExp;
26
28
  export declare const embeddingRegex: RegExp;
@@ -9,9 +9,12 @@ export class ParseContext {
9
9
  objectMeta = new WeakMap();
10
10
  propMeta = new WeakMap();
11
11
  linkIndex = [];
12
- /* record a single diagnostic */
13
- diagnose(file, line, message) {
14
- this.diagnostics.push({ file, line, column: 1, message });
12
+ /* the resolved paths of the embedded asset files, recorded even for
13
+ an unreadable one, as a watching consumer has to observe it, too */
14
+ assets = new Set();
15
+ /* record a single diagnostic (an error, unless flagged otherwise) */
16
+ diagnose(file, line, message, severity = "error") {
17
+ this.diagnostics.push({ file, line, column: 1, severity, message });
15
18
  }
16
19
  }
17
20
  /* the marker separating a statement from its rationale */
@@ -3,7 +3,7 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { referenceRegex, resolveUnique, resolveSet, plainText } from "./specbook-link.js";
6
+ import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText } from "./specbook-link.js";
7
7
  import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
8
8
  /* the syntax of a value consisting of exactly one Wiki-style reference */
9
9
  const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
@@ -11,18 +11,25 @@ const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
11
11
  (defined for the regex and enum kinds only) */
12
12
  const directMatches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
13
13
  || (expr.kind === "enum" && expr.members.includes(text));
14
- /* match a single list item against the alternatives of a list
15
- constraint (a reference item resolves leniently, as unresolvable
16
- and ambiguous ones are already reported by the reference pass) */
17
- const matchAlternatives = (ctx, alternatives, item) => {
18
- const rm = item.match(singleReferenceRegex);
14
+ /* resolve a text consisting of exactly one Wiki-style reference from
15
+ its carrying object (leniently, as unresolvable and ambiguous
16
+ references are already reported by the reference pass) */
17
+ const singleTarget = (ctx, object, text) => {
18
+ const rm = text.match(singleReferenceRegex);
19
+ if (rm === null)
20
+ return undefined;
21
+ const ref = rm[1].trim();
22
+ return { ref, target: resolveUnique(ctx.linkIndex, ref, object).target };
23
+ };
24
+ /* match a single list item against the alternatives of a list constraint */
25
+ const matchAlternatives = (ctx, object, alternatives, item) => {
26
+ const single = singleTarget(ctx, object, item);
19
27
  for (const alternative of alternatives) {
20
28
  if (alternative.kind === "reference") {
21
- if (rm === null)
29
+ if (single === undefined)
22
30
  continue;
23
- const target = resolveUnique(ctx.linkIndex, rm[1].trim()).target;
24
- if (target === undefined
25
- || resolveSet(ctx.linkIndex, alternative.pattern).includes(target))
31
+ if (single.target === undefined
32
+ || resolveSet(ctx.linkIndex, alternative.pattern).includes(single.target))
26
33
  return true;
27
34
  }
28
35
  else if (alternative.kind === "tags") {
@@ -45,20 +52,32 @@ const splitPropItems = (ctx, prop, property, meta) => {
45
52
  ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" contains an empty item`);
46
53
  return items.filter((item) => item !== "");
47
54
  };
48
- /* check a property value against the compiled value expression of its
49
- configured schema property */
50
- const checkPropValue = (ctx, prop, expr, property, meta) => {
55
+ /* check a resolved reference of a property flagged "local": the
56
+ referenced object has to lie below the parent object of the
57
+ referencing object (a top-level object has no parent, and hence
58
+ no locality to keep) */
59
+ const checkLocal = (ctx, object, prop, single, meta) => {
60
+ if (prop.local !== true || single?.target === undefined)
61
+ return;
62
+ const chain = chainOf(ctx.linkIndex, object);
63
+ const parent = chain.length >= 2 ? chain[chain.length - 2] : undefined;
64
+ if (parent !== undefined && !chainOf(ctx.linkIndex, single.target).includes(parent))
65
+ ctx.diagnose(meta.file, meta.line, `link reference "[[${single.ref}]]" of local property "${prop.name}" ` +
66
+ `leaves the enclosing ${parent.kind} "${parent.name}"`);
67
+ };
68
+ /* check a property value of an object against the compiled value
69
+ expression of its configured schema property */
70
+ const checkPropValue = (ctx, object, prop, expr, property, meta) => {
51
71
  if (expr.kind === "reference") {
52
72
  /* a reference constraint: the value has to be exactly one
53
73
  reference resolving into the constraint's wildcard match set */
54
- const rm = plainText(property.value).trim().match(singleReferenceRegex);
55
- if (rm === null)
74
+ const single = singleTarget(ctx, object, plainText(property.value).trim());
75
+ if (single === undefined)
56
76
  ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
57
77
  else {
58
- const ref = rm[1].trim();
59
- const target = resolveUnique(ctx.linkIndex, ref).target;
60
- if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
61
- ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
78
+ if (single.target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(single.target))
79
+ ctx.diagnose(meta.file, meta.line, `link reference "[[${single.ref}]]" does not match constraint "${prop.value}"`);
80
+ checkLocal(ctx, object, prop, single, meta);
62
81
  }
63
82
  }
64
83
  else if (expr.kind === "tags") {
@@ -76,9 +95,12 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
76
95
  else if (expr.kind === "list") {
77
96
  /* a list constraint: the value is a comma-separated list
78
97
  of items, each matching at least one alternative */
79
- for (const item of splitPropItems(ctx, prop, property, meta))
80
- if (!matchAlternatives(ctx, expr.alternatives, item))
98
+ for (const item of splitPropItems(ctx, prop, property, meta)) {
99
+ if (!matchAlternatives(ctx, object, expr.alternatives, item))
81
100
  ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
101
+ else
102
+ checkLocal(ctx, object, prop, singleTarget(ctx, object, item), meta);
103
+ }
82
104
  }
83
105
  else if (!directMatches(expr, plainText(property.value)))
84
106
  ctx.diagnose(meta.file, meta.line, expr.kind === "enum" ?
@@ -121,7 +143,7 @@ const validateObject = (ctx, object, schema, level) => {
121
143
  if (i > 0)
122
144
  ctx.diagnose(meta.file, line, `duplicate property "${prop.name}" on ${object.kind} "${object.name}"`);
123
145
  if (expr !== undefined)
124
- checkPropValue(ctx, prop, expr, match, { file: meta.file, line });
146
+ checkPropValue(ctx, object, prop, expr, match, { file: meta.file, line });
125
147
  }
126
148
  }
127
149
  /* report the properties not configured by the schema */
@@ -139,6 +161,45 @@ const validateObject = (ctx, object, schema, level) => {
139
161
  }
140
162
  validateObject(ctx, child, childSchema, level + 1);
141
163
  }
164
+ /* check the property values flagged unique for distinctness among
165
+ the sibling objects of the same kind and the ones flagged present
166
+ for occurring on at least one of the (existing) siblings (all
167
+ values, or only the values matching the regexp or enum expression
168
+ of the flag) */
169
+ for (const child of childs) {
170
+ const siblings = object.childs.filter((c) => c.kind === child.kind);
171
+ for (const prop of child.props ?? []) {
172
+ const marker = (flag) => flag === undefined || flag === false ? undefined :
173
+ { filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
174
+ const unique = marker(prop.unique);
175
+ const present = marker(prop.present);
176
+ if (unique === undefined && present === undefined)
177
+ continue;
178
+ const seen = new Map();
179
+ let found = false;
180
+ for (const sibling of siblings) {
181
+ const property = findProp(sibling, prop.name);
182
+ if (property === undefined)
183
+ continue;
184
+ const value = plainText(property.value).trim();
185
+ if (present !== undefined && (present.filter === undefined || directMatches(present.filter, value)))
186
+ found = true;
187
+ if (unique === undefined || (unique.filter !== undefined && !directMatches(unique.filter, value)))
188
+ continue;
189
+ const first = seen.get(value);
190
+ if (first === undefined)
191
+ seen.set(value, sibling);
192
+ else {
193
+ const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
194
+ ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
195
+ `already used by preceding ${sibling.kind} "${first.name}"`);
196
+ }
197
+ }
198
+ if (present !== undefined && siblings.length > 0 && !found)
199
+ ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
200
+ (typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
201
+ }
202
+ }
142
203
  /* report the configured child object kinds which are missing */
143
204
  for (const child of childs)
144
205
  if (child.optional !== true
@@ -202,6 +263,198 @@ const checkIds = (ctx, objects) => {
202
263
  checkIds(ctx, object.childs);
203
264
  }
204
265
  };
266
+ /* the objects a property of an object references (resolved and
267
+ distinct, in document order), leniently skipping the unresolvable
268
+ ones, as those are already reported by the reference pass */
269
+ const referencedObjects = (ctx, object, name) => {
270
+ const property = findProp(object, name);
271
+ if (property === undefined)
272
+ return [];
273
+ const targets = new Array();
274
+ for (const m of plainText(property.value).matchAll(referenceRegex)) {
275
+ const target = resolveUnique(ctx.linkIndex, m[1].trim(), object).target;
276
+ if (target !== undefined && !targets.includes(target))
277
+ targets.push(target);
278
+ }
279
+ return targets;
280
+ };
281
+ /* check the object kinds configured with an "automaton": the child
282
+ objects of the node kind form a finite state machine through the
283
+ child objects of the edge kind (each connecting the nodes its source
284
+ and target properties reference), in which every node has to be
285
+ reachable from an initial node, every node without outgoing edge has
286
+ to be a final node (else it is a dead-end), and a final node has to
287
+ be reachable from every node (else it is a livelock), where the
288
+ initial and final nodes carry the value "true" in the respective flag
289
+ property (a machine lacking initial or final nodes altogether skips
290
+ the reachability checks, as the "present" flag reports the lack) */
291
+ const checkAutomata = (ctx, schemas) => {
292
+ for (const [object, schema] of schemas) {
293
+ const automaton = schema.automaton;
294
+ if (automaton === undefined)
295
+ continue;
296
+ const nodes = object.childs.filter((child) => child.kind === automaton.nodes);
297
+ const nodeSet = new Set(nodes);
298
+ const flagged = (node, name) => {
299
+ const property = findProp(node, name);
300
+ return property !== undefined && plainText(property.value).trim() === "true";
301
+ };
302
+ const initials = nodes.filter((node) => flagged(node, automaton.initial));
303
+ const finals = nodes.filter((node) => flagged(node, automaton.final));
304
+ /* the successors and predecessors of every node (an edge leaving
305
+ the node set is skipped, as the property checks report it) */
306
+ const succs = new Map(nodes.map((node) => [node, new Set()]));
307
+ const preds = new Map(nodes.map((node) => [node, new Set()]));
308
+ for (const edge of object.childs.filter((child) => child.kind === automaton.edges)) {
309
+ const source = referencedObjects(ctx, edge, automaton.source)[0];
310
+ const target = referencedObjects(ctx, edge, automaton.target)[0];
311
+ if (source !== undefined && target !== undefined && nodeSet.has(source) && nodeSet.has(target)) {
312
+ succs.get(source)?.add(target);
313
+ preds.get(target)?.add(source);
314
+ }
315
+ }
316
+ /* the transitive closure of a node set along a neighbor map */
317
+ const closure = (starts, next) => {
318
+ const seen = new Set(starts);
319
+ const queue = [...starts];
320
+ for (let node = queue.shift(); node !== undefined; node = queue.shift())
321
+ for (const other of next.get(node) ?? [])
322
+ if (!seen.has(other)) {
323
+ seen.add(other);
324
+ queue.push(other);
325
+ }
326
+ return seen;
327
+ };
328
+ const reachable = closure(initials, succs);
329
+ const coreachable = closure(finals, preds);
330
+ for (const node of nodes) {
331
+ const meta = ctx.objectMeta.get(node) ?? { file: "", line: 1 };
332
+ const name = `${node.kind} "${node.name}" of ${object.kind} "${object.name}"`;
333
+ if (initials.length > 0 && !reachable.has(node))
334
+ ctx.diagnose(meta.file, meta.line, `${name} is not reachable from the initial ${node.kind} through any ${automaton.edges}`);
335
+ if ((succs.get(node)?.size ?? 0) === 0 && !finals.includes(node))
336
+ ctx.diagnose(meta.file, meta.line, `${name} has no outgoing ${automaton.edges} but is not flagged "${automaton.final}" (dead-end)`);
337
+ else if (finals.length > 0 && !coreachable.has(node))
338
+ ctx.diagnose(meta.file, meta.line, `${name} reaches no final ${node.kind} through any ${automaton.edges} (livelock)`);
339
+ }
340
+ }
341
+ };
342
+ /* check the reference properties flagged symmetric or acyclic across
343
+ all objects of their kind: a symmetric property has to be referenced
344
+ back by every referenced object through the same property, while an
345
+ acyclic property must never lead from an object back to itself */
346
+ const checkRelations = (ctx, schemas) => {
347
+ /* group the objects by their flagged schema properties (a schema
348
+ property is shared by all objects of its kind) */
349
+ const groups = new Map();
350
+ for (const [object, schema] of schemas)
351
+ for (const prop of schema.props ?? [])
352
+ if (prop.symmetric === true || prop.acyclic === true)
353
+ groups.set(prop, [...(groups.get(prop) ?? []), object]);
354
+ for (const [prop, objects] of groups) {
355
+ const members = new Set(objects);
356
+ const locate = (object) => {
357
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
358
+ const property = findProp(object, prop.name);
359
+ const line = property !== undefined ? ctx.propMeta.get(property)?.line : undefined;
360
+ return { file: meta.file, line: line ?? meta.line };
361
+ };
362
+ if (prop.symmetric === true)
363
+ for (const object of objects)
364
+ for (const target of referencedObjects(ctx, object, prop.name)) {
365
+ const { file, line } = locate(object);
366
+ if (target === object)
367
+ ctx.diagnose(file, line, `symmetric property "${prop.name}" on ${object.kind} "${object.name}" references itself`);
368
+ else if (!referencedObjects(ctx, target, prop.name).includes(object))
369
+ ctx.diagnose(file, line, `symmetric property "${prop.name}" on ${object.kind} "${object.name}" references ` +
370
+ `${target.kind} "${target.name}", which does not reference it back`);
371
+ }
372
+ if (prop.acyclic === true) {
373
+ /* depth-first search along the references: an edge onto an
374
+ object still on the current path closes a cycle, which is
375
+ reported once, at the object closing it, with its path
376
+ (objects outside the group act as leaves) */
377
+ const done = new Set();
378
+ const path = new Array();
379
+ const walk = (object) => {
380
+ path.push(object);
381
+ for (const target of referencedObjects(ctx, object, prop.name)) {
382
+ const at = path.indexOf(target);
383
+ if (at >= 0) {
384
+ const { file, line } = locate(object);
385
+ const cycle = [...path.slice(at), target].map((o) => plainText(o.name)).join(" -> ");
386
+ ctx.diagnose(file, line, `acyclic property "${prop.name}" on ${object.kind} "${object.name}" forms a cycle: ${cycle}`);
387
+ }
388
+ else if (members.has(target) && !done.has(target))
389
+ walk(target);
390
+ }
391
+ path.pop();
392
+ done.add(object);
393
+ };
394
+ for (const object of objects)
395
+ if (!done.has(object))
396
+ walk(object);
397
+ }
398
+ }
399
+ };
400
+ /* check the object kinds flagged "referenced": every object of such a
401
+ kind has to be referenced from at least one object which matches one
402
+ of the flag's reference patterns, itself or through an ancestor, while
403
+ the references from within the subtree of the object itself do not
404
+ count (a lapse is a warning only, as the specification stays valid) */
405
+ const checkReferenced = (ctx, specification, schemas) => {
406
+ /* collect the referencing objects of every referenced object,
407
+ together with the parent of every object for the ancestor walk */
408
+ const referrers = new Map();
409
+ const parents = new Map();
410
+ const walk = (object, chain) => {
411
+ const texts = [object.name, ...object.properties.map((p) => p.value)];
412
+ if (object.description !== undefined)
413
+ texts.push(object.description.description, object.description.rationale ?? "");
414
+ for (const text of texts)
415
+ for (const m of plainText(text).matchAll(referenceRegex)) {
416
+ const target = resolveUnique(ctx.linkIndex, m[1].trim(), object).target;
417
+ if (target !== undefined && !chain.includes(target))
418
+ referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
419
+ }
420
+ for (const child of object.childs) {
421
+ parents.set(child, object);
422
+ walk(child, [...chain, child]);
423
+ }
424
+ };
425
+ for (const artifact of specification.artifacts)
426
+ for (const object of artifact.objects)
427
+ walk(object, [object]);
428
+ /* resolve the reference patterns of every flagged kind into the
429
+ admissible referencing objects (once per shared schema node) */
430
+ const sources = new Map();
431
+ for (const [object, schema] of schemas) {
432
+ if (schema.referenced === undefined)
433
+ continue;
434
+ let admissible = sources.get(schema);
435
+ if (admissible === undefined) {
436
+ admissible = new Set();
437
+ for (const entry of schema.referenced) {
438
+ const expr = compileValueExpr(entry);
439
+ if (expr.kind === "reference")
440
+ for (const source of resolveSet(ctx.linkIndex, expr.pattern))
441
+ admissible.add(source);
442
+ }
443
+ sources.set(schema, admissible);
444
+ }
445
+ const covered = Array.from(referrers.get(object) ?? []).some((referrer) => {
446
+ for (let o = referrer; o !== undefined; o = parents.get(o))
447
+ if (admissible.has(o))
448
+ return true;
449
+ return false;
450
+ });
451
+ if (!covered) {
452
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
453
+ ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
454
+ schema.referenced.map((entry) => `"${entry}"`).join(" or "), "warning");
455
+ }
456
+ }
457
+ };
205
458
  /* validate the parsed specification against the configuration */
206
459
  export const validate = (ctx, specification, config) => {
207
460
  /* map all objects onto their schema nodes and assign the implicit
@@ -246,16 +499,23 @@ export const validate = (ctx, specification, config) => {
246
499
  validateObject(ctx, object, schema, 1);
247
500
  }
248
501
  }
502
+ /* check the relation shapes of the flagged reference properties, the
503
+ state machines of the configured object kinds, and the reference
504
+ coverage of the flagged object kinds, now that every object has
505
+ been validated on its own */
506
+ checkRelations(ctx, schemas);
507
+ checkAutomata(ctx, schemas);
508
+ checkReferenced(ctx, specification, schemas);
249
509
  /* order the artifacts exactly along the schema definition */
250
510
  specification.artifacts.sort((a, b) => (position.get(a) ?? config.length) - (position.get(b) ?? config.length));
251
511
  };
252
- /* validate every Wiki-style reference for unique resolvability,
253
- independent of any configuration */
512
+ /* validate every Wiki-style reference for unique resolvability (from
513
+ its carrying object), independent of any configuration */
254
514
  export const validateReferences = (ctx, specification) => {
255
- const check = (text, file, line) => {
515
+ const check = (object, text, file, line) => {
256
516
  for (const m of text.matchAll(referenceRegex)) {
257
517
  const ref = m[1].trim();
258
- const resolution = resolveUnique(ctx.linkIndex, ref);
518
+ const resolution = resolveUnique(ctx.linkIndex, ref, object);
259
519
  if (resolution.ambiguous)
260
520
  ctx.diagnose(file, line, `ambiguous link reference "[[${ref}]]"`);
261
521
  else if (resolution.target === undefined)
@@ -264,13 +524,13 @@ export const validateReferences = (ctx, specification) => {
264
524
  };
265
525
  const walk = (object) => {
266
526
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
267
- check(object.name, meta.file, meta.line);
527
+ check(object, object.name, meta.file, meta.line);
268
528
  for (const property of object.properties)
269
- check(property.value, meta.file, ctx.propMeta.get(property)?.line ?? meta.line);
529
+ check(object, property.value, meta.file, ctx.propMeta.get(property)?.line ?? meta.line);
270
530
  if (object.description !== undefined) {
271
- check(object.description.description, meta.file, meta.line);
531
+ check(object, object.description.description, meta.file, meta.line);
272
532
  if (object.description.rationale !== undefined)
273
- check(object.description.rationale, meta.file, meta.line);
533
+ check(object, object.description.rationale, meta.file, meta.line);
274
534
  }
275
535
  object.childs.forEach(walk);
276
536
  };
@@ -129,8 +129,10 @@ const embed = (ctx, object, file) => {
129
129
  continue;
130
130
  for (const variant of embeddingVariants(reference)) {
131
131
  target.embedding ??= [];
132
+ const asset = path.resolve(path.dirname(file), variant);
133
+ ctx.assets.add(asset);
132
134
  try {
133
- const data = fs.readFileSync(path.resolve(path.dirname(file), variant));
135
+ const data = fs.readFileSync(asset);
134
136
  target.embedding.push(type === "image/svg+xml" ?
135
137
  data.toString("utf8") : `data:${type};base64,${data.toString("base64")}`);
136
138
  }
@@ -16,4 +16,5 @@ export type ValueExpr = {
16
16
  };
17
17
  export declare const anchored: (pattern: string) => RegExp;
18
18
  export declare const compileValueExpr: (source: string) => ValueExpr;
19
+ export declare const admitsReferences: (expr: ValueExpr) => boolean;
19
20
  export declare const splitItems: (text: string) => string[];