@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.
- package/README.md +106 -19
- package/dst/specbook-api.d.ts +32 -12
- package/dst/specbook-api.js +114 -33
- package/dst/specbook-cli.js +92 -31
- package/dst/specbook-cmd-describe.d.ts +7 -1
- package/dst/specbook-cmd-describe.js +56 -11
- package/dst/specbook-cmd-export.d.ts +3 -1
- package/dst/specbook-cmd-export.js +46 -3
- package/dst/specbook-cmd-init.d.ts +2 -1
- package/dst/specbook-cmd-lint.d.ts +4 -2
- package/dst/specbook-cmd-lint.js +12 -8
- package/dst/specbook-cmd-preview.d.ts +12 -0
- package/dst/specbook-cmd-preview.js +57 -0
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +187 -37
- package/dst/specbook-diagnostic.d.ts +2 -0
- package/dst/specbook-diagnostic.js +4 -2
- package/dst/specbook-diagram.d.ts +1 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-ast.js +4 -2
- package/dst/specbook-export-common.js +5 -3
- package/dst/specbook-export-html-search.js +118 -30
- package/dst/specbook-export-html.css +1140 -233
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +764 -120
- package/dst/specbook-export-md.js +4 -2
- package/dst/specbook-export-pdf.d.ts +4 -1
- package/dst/specbook-export-pdf.js +46 -16
- package/dst/specbook-format-schema.d.ts +33 -1
- package/dst/specbook-format-schema.js +34 -2
- package/dst/specbook-format.md +193 -25
- package/dst/specbook-format.yaml +2291 -299
- package/dst/specbook-link.d.ts +2 -1
- package/dst/specbook-link.js +43 -3
- package/dst/specbook-mcp.js +20 -11
- package/dst/specbook-parse-common.d.ts +4 -2
- package/dst/specbook-parse-common.js +6 -3
- package/dst/specbook-parse-semantic.js +290 -30
- package/dst/specbook-parse-syntax.js +3 -1
- package/dst/specbook-parse-value.d.ts +1 -0
- package/dst/specbook-parse-value.js +17 -3
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-verbose.d.ts +2 -0
- package/package.json +14 -6
package/dst/specbook-link.d.ts
CHANGED
|
@@ -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 {};
|
package/dst/specbook-link.js
CHANGED
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
package/dst/specbook-mcp.js
CHANGED
|
@@ -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
|
|
31
|
-
"(
|
|
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
|
|
51
|
-
"(
|
|
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
|
|
74
|
-
"(
|
|
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
|
|
115
|
-
"(
|
|
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
|
-
|
|
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
|
-
/*
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const rm =
|
|
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 (
|
|
29
|
+
if (single === undefined)
|
|
22
30
|
continue;
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
if (
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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[];
|