@rse/specbook 0.9.6 → 0.9.7
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 +60 -10
- package/dst/specbook-api.js +19 -18
- package/dst/specbook-cli.js +9 -6
- package/dst/specbook-cmd-describe.js +1 -3
- package/dst/specbook-cmd-export.js +19 -16
- package/dst/specbook-cmd-init.d.ts +1 -1
- package/dst/specbook-cmd-init.js +5 -4
- package/dst/specbook-cmd-lint.d.ts +0 -1
- package/dst/specbook-cmd-lint.js +29 -5
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +80 -71
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +3 -2
- package/dst/specbook-diagram.js +75 -91
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +2 -1
- package/dst/specbook-export-common.js +12 -14
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +24 -29
- package/dst/specbook-export-html.js +100 -83
- package/dst/specbook-export-md.d.ts +1 -1
- package/dst/specbook-export-md.js +50 -24
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/specbook-format-schema.js +6 -3
- package/dst/specbook-format.md +34 -18
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.js +17 -10
- package/dst/specbook-parse-common.d.ts +1 -1
- package/dst/specbook-parse-semantic.d.ts +4 -2
- package/dst/specbook-parse-semantic.js +95 -42
- package/dst/specbook-parse-syntax.js +163 -136
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +6 -3
- package/dst/specbook-parse.d.ts +2 -5
- package/dst/specbook-parse.js +27 -34
- package/dst/specbook-verbose.js +2 -1
- package/package.json +1 -1
package/dst/specbook-format.md
CHANGED
|
@@ -56,7 +56,8 @@ SpecBook SCHEMA Meta Model
|
|
|
56
56
|
This meta model defines which domain-specific objects are allowed in
|
|
57
57
|
a *Schema*: the object kinds with their `file` mapping on the first
|
|
58
58
|
level, the object kinds of the deeper levels, and their properties
|
|
59
|
-
with optional value constraints: regular expressions `/xxx
|
|
59
|
+
with optional value constraints: regular expressions `/xxx/` (implicitly
|
|
60
|
+
anchored, so they have to match the value as a whole),
|
|
60
61
|
object references `[[xxx]]`, enumerations `enum(xxx,yyy)`, tag sets
|
|
61
62
|
`tags(xxx,yyy)`, and lists `list(xxx[, ...])` of such alternatives.
|
|
62
63
|
|
|
@@ -180,7 +181,8 @@ type SchemaGradiaConfig = Partial<{
|
|
|
180
181
|
BECAUSE it guides the authors and instructs the AI
|
|
181
182
|
|
|
182
183
|
- `SchemaObject.optional?: boolean`:
|
|
183
|
-
whether the kind may be absent below its parent
|
|
184
|
+
whether the kind may be absent below its parent or, for a level 1
|
|
185
|
+
artifact, from its file (default: `false`),
|
|
184
186
|
BECAUSE not every kind is mandatory below every parent object
|
|
185
187
|
|
|
186
188
|
- `SchemaObject.diagram?: SchemaDiagram`:
|
|
@@ -502,6 +504,7 @@ type SpecProperty = {
|
|
|
502
504
|
|
|
503
505
|
- `SpecDescription.embedding?: string[]`:
|
|
504
506
|
image files embedded via ``, inlined at parse time,
|
|
507
|
+
one entry per file in markup order (empty for an unreadable file),
|
|
505
508
|
BECAUSE the exports have to stand alone, without the image files
|
|
506
509
|
|
|
507
510
|
- `SpecProperty`:
|
|
@@ -518,6 +521,7 @@ type SpecProperty = {
|
|
|
518
521
|
|
|
519
522
|
- `SpecProperty.embedding?: string[]`:
|
|
520
523
|
image files embedded in the value, inlined at parse time,
|
|
524
|
+
one entry per file in markup order (empty for an unreadable file),
|
|
521
525
|
BECAUSE the exports have to stand alone, without the image files
|
|
522
526
|
|
|
523
527
|
SpecBook SCHEMA Model
|
|
@@ -556,12 +560,12 @@ headings (`<text/>` underlined with `===` or `---`) are supported.
|
|
|
556
560
|
### Object Model
|
|
557
561
|
|
|
558
562
|
An artifact carries a tree of *objects*. Every object has a mandatory
|
|
559
|
-
*kind* (e.g. `ENTITY`), a mandatory *name*,
|
|
560
|
-
|
|
561
|
-
marker, optional *properties* (key/value
|
|
562
|
-
*description* statement with an optional rationale,
|
|
563
|
-
objects. Which kinds, properties, and nestings are
|
|
564
|
-
the schema.
|
|
563
|
+
*kind* (e.g. `ENTITY`), a mandatory *name*, an anchor *id* (explicitly
|
|
564
|
+
given or derived from the name, and unique among the sibling objects of
|
|
565
|
+
its kind), an optional *primary* marker, optional *properties* (key/value
|
|
566
|
+
pairs), an optional *description* statement with an optional rationale,
|
|
567
|
+
and optional *child* objects. Which kinds, properties, and nestings are
|
|
568
|
+
allowed is defined by the schema.
|
|
565
569
|
|
|
566
570
|
### Format Variant: Complex Format
|
|
567
571
|
|
|
@@ -599,11 +603,14 @@ multiple (indented) lines:
|
|
|
599
603
|
|
|
600
604
|
A list item is recognized as a Concise Format object (instead of a
|
|
601
605
|
property of the enclosing object) by carrying at least one `;` on its
|
|
602
|
-
first line. Every segment of the form `<key/>: <value/>`
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
606
|
+
first line. Every segment of the form `<key/>: <value/>` (with a
|
|
607
|
+
possibly empty value) becomes a property, unless it contains a
|
|
608
|
+
` BECAUSE ` or is the last segment of a `.`-terminated item (as a
|
|
609
|
+
description like `Note: xxx` has to survive the round-trip through
|
|
610
|
+
exported Markdown); all remaining segments are joined into the
|
|
611
|
+
description. Child objects nest as indented list items below their
|
|
612
|
+
parent item, where key/value items become properties and concise items
|
|
613
|
+
become child objects of that parent:
|
|
607
614
|
|
|
608
615
|
```
|
|
609
616
|
- <kind/>: <name/>; <key/>: <value/>;
|
|
@@ -637,8 +644,13 @@ the item would look like a key/value pair) -- backquote it in this case.
|
|
|
637
644
|
|
|
638
645
|
When **SpecBook** itself generates specification Markdown, it emits
|
|
639
646
|
the Complex Format on levels 1-3 and the Concise Format from level 4
|
|
640
|
-
upwards
|
|
641
|
-
|
|
647
|
+
upwards -- unless a descendant of an object carries a multi-line
|
|
648
|
+
description (a fenced code block, an ordered list, a blockquote, or
|
|
649
|
+
multiple paragraphs), which no single list item can carry: all child
|
|
650
|
+
objects of such an object then stay in the Complex Format (as sibling
|
|
651
|
+
objects have to share the format), with headings reaching down to level
|
|
652
|
+
6. When **SpecBook** edits existing files, it mirrors the format each
|
|
653
|
+
existing object already uses.
|
|
642
654
|
|
|
643
655
|
### Names, Anchors, and Ids
|
|
644
656
|
|
|
@@ -666,9 +678,13 @@ explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
|
|
|
666
678
|
In the Complex Format, each property is an unordered list item of the
|
|
667
679
|
form `<key/>: <value/>`, whose value may continue on the following
|
|
668
680
|
indented lines of the item (joined with spaces). In the Concise Format,
|
|
669
|
-
each property is a `<key/>: <value/>` segment.
|
|
670
|
-
|
|
671
|
-
|
|
681
|
+
each property is a `<key/>: <value/>` segment. A value carrying a `;`
|
|
682
|
+
cannot be a segment and has to start on a continuation line of a
|
|
683
|
+
key/value item, as a `;` on the first line of a list item marks a
|
|
684
|
+
Concise Format object. The key is matched
|
|
685
|
+
against the schema exactly as written and may occur only once per
|
|
686
|
+
object, and the value has to satisfy the configured constraint as a
|
|
687
|
+
whole.
|
|
672
688
|
|
|
673
689
|
### Descriptions and Rationales
|
|
674
690
|
|
package/dst/specbook-link.d.ts
CHANGED
package/dst/specbook-link.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/* the Wiki-style reference syntax ("[[xxx]]") */
|
|
7
7
|
export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
|
|
8
|
+
/* strip the inline code markup of a name or property value
|
|
9
|
+
(preserved in the AST for rendering) for matching and labeling */
|
|
10
|
+
export const plainText = (text) => text.replace(/`/g, "");
|
|
8
11
|
/* build the resolution index over all objects of a specification */
|
|
9
12
|
export const buildLinkIndex = (specification) => {
|
|
10
13
|
const index = [];
|
|
@@ -40,7 +43,7 @@ const unquote = (part) => part.replace(/^"(.*)"$/s, "$1");
|
|
|
40
43
|
const matchesPart = (object, part) => part === "*"
|
|
41
44
|
|| object.id === part
|
|
42
45
|
|| object.anchor === part
|
|
43
|
-
|| object.name
|
|
46
|
+
|| plainText(object.name) === part;
|
|
44
47
|
/* match a single reference segment ("id", "name", "KIND:name-or-id",
|
|
45
48
|
"KIND:*", or "*", each part optionally double-quoted to allow
|
|
46
49
|
spaces, dots, and colons) against a single object */
|
|
@@ -80,10 +83,10 @@ export const resolveUnique = (index, reference) => {
|
|
|
80
83
|
let matches = [];
|
|
81
84
|
if (segments.length === 1) {
|
|
82
85
|
const segment = segments[0];
|
|
83
|
-
const
|
|
86
|
+
const unquoted = unquote(segment);
|
|
84
87
|
const variants = [
|
|
85
|
-
(object) => object.id ===
|
|
86
|
-
(object) => object.name
|
|
88
|
+
(object) => object.id === unquoted || object.anchor === unquoted,
|
|
89
|
+
(object) => plainText(object.name) === unquoted,
|
|
87
90
|
(object) => matchesSegment(object, segment)
|
|
88
91
|
];
|
|
89
92
|
for (const variant of variants) {
|
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, version } from "./specbook-api.js";
|
|
10
|
+
import { SpecBook, renderDiagnostic, formats, parseOutputSpec, 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);
|
|
@@ -70,18 +70,25 @@ export const serveMcp = async (verbose) => {
|
|
|
70
70
|
inputSchema: {
|
|
71
71
|
config: z.string().describe("YAML schema configuration file"),
|
|
72
72
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
73
|
-
format: z.enum(formats).optional().describe("output format (default:
|
|
74
|
-
|
|
73
|
+
format: z.enum(formats).optional().describe("output format (default: inferred from the " +
|
|
74
|
+
"output file extension, else json)"),
|
|
75
|
+
output: z.string().optional().describe("output file path (\"-\" returns the result directly)")
|
|
75
76
|
}
|
|
76
77
|
}, async (args) => {
|
|
77
78
|
try {
|
|
79
|
+
/* an explicit format takes the output as a plain file path, while
|
|
80
|
+
otherwise the output is an "[<format>:]<file>" specification
|
|
81
|
+
(the "-" stdout sentinel returns the result directly) */
|
|
82
|
+
const spec = args.format !== undefined || args.output === undefined ?
|
|
83
|
+
{ format: args.format ?? "json", output: args.output } :
|
|
84
|
+
parseOutputSpec(args.output);
|
|
78
85
|
const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
|
|
79
|
-
formats: [
|
|
80
|
-
if (
|
|
81
|
-
await fs.promises.writeFile(
|
|
82
|
-
return { content: [{ type: "text", text: `exported specification into "${
|
|
86
|
+
formats: [spec.format] });
|
|
87
|
+
if (spec.output !== undefined && spec.output !== "-") {
|
|
88
|
+
await fs.promises.writeFile(spec.output, data);
|
|
89
|
+
return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
|
|
83
90
|
}
|
|
84
|
-
else if (
|
|
91
|
+
else if (spec.format === "pdf")
|
|
85
92
|
return { content: [{
|
|
86
93
|
type: "resource",
|
|
87
94
|
resource: { uri: "specbook:export.pdf", mimeType: "application/pdf", blob: data.toString("base64") }
|
|
@@ -103,12 +110,12 @@ export const serveMcp = async (verbose) => {
|
|
|
103
110
|
basedir: z.string().optional().describe("base directory of the specification Markdown files"),
|
|
104
111
|
embed: z.boolean().optional().describe("embed the YAML schema configuration instead of " +
|
|
105
112
|
"just referencing it (default: false)"),
|
|
106
|
-
output: z.string().optional().describe("output file path (
|
|
113
|
+
output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)")
|
|
107
114
|
}
|
|
108
115
|
}, async (args) => {
|
|
109
116
|
try {
|
|
110
117
|
const text = await specbook.describe(args);
|
|
111
|
-
if (args.output !== undefined) {
|
|
118
|
+
if (args.output !== undefined && args.output !== "-") {
|
|
112
119
|
await fs.promises.writeFile(args.output, text, "utf8");
|
|
113
120
|
return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
|
|
114
121
|
}
|
|
@@ -23,6 +23,6 @@ export declare class ParseContext {
|
|
|
23
23
|
diagnose(file: string, line: number, message: string): void;
|
|
24
24
|
}
|
|
25
25
|
export declare const embeddingRegex: RegExp;
|
|
26
|
-
export declare const embeddingThemes:
|
|
26
|
+
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
27
27
|
export declare const embeddingVariants: (reference: string) => string[];
|
|
28
28
|
export declare const embeddingMimeType: (reference: string) => string | undefined;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type Spec } from "./specbook-format-spec.js";
|
|
2
|
-
import { type Schema } from "./specbook-format-schema.js";
|
|
1
|
+
import { type Spec, type SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
|
|
3
3
|
import { ParseContext } from "./specbook-parse-common.js";
|
|
4
|
+
export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
|
|
5
|
+
export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
|
|
4
6
|
export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
|
5
7
|
export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
|
|
@@ -3,25 +3,19 @@
|
|
|
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 } from "./specbook-link.js";
|
|
7
|
-
import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
|
|
8
|
-
/*
|
|
9
|
-
|
|
10
|
-
const plainText = (text) => text.replace(/`/g, "");
|
|
11
|
-
/* compile a configured pattern into an anchored regular expression */
|
|
12
|
-
const anchored = (pattern, flags = "") => new RegExp(`^(?:${pattern})$`, flags);
|
|
6
|
+
import { referenceRegex, resolveUnique, resolveSet, plainText } from "./specbook-link.js";
|
|
7
|
+
import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
|
|
8
|
+
/* the syntax of a value consisting of exactly one Wiki-style reference */
|
|
9
|
+
const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
|
|
13
10
|
/* match a full property value directly against a value expression
|
|
14
11
|
(defined for the regex and enum kinds only) */
|
|
15
|
-
const directMatches = (expr, text) => expr.kind === "regex"
|
|
16
|
-
expr.
|
|
17
|
-
expr.kind === "enum" ?
|
|
18
|
-
expr.members.includes(text) :
|
|
19
|
-
false;
|
|
12
|
+
const directMatches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
|
|
13
|
+
|| (expr.kind === "enum" && expr.members.includes(text));
|
|
20
14
|
/* match a single list item against the alternatives of a list
|
|
21
15
|
constraint (a reference item resolves leniently, as unresolvable
|
|
22
16
|
and ambiguous ones are already reported by the reference pass) */
|
|
23
17
|
const matchAlternatives = (ctx, alternatives, item) => {
|
|
24
|
-
const rm = item.match(
|
|
18
|
+
const rm = item.match(singleReferenceRegex);
|
|
25
19
|
for (const alternative of alternatives) {
|
|
26
20
|
if (alternative.kind === "reference") {
|
|
27
21
|
if (rm === null)
|
|
@@ -32,10 +26,10 @@ const matchAlternatives = (ctx, alternatives, item) => {
|
|
|
32
26
|
return true;
|
|
33
27
|
}
|
|
34
28
|
else if (alternative.kind === "tags") {
|
|
35
|
-
if (alternative.members.includes(
|
|
29
|
+
if (alternative.members.includes(item))
|
|
36
30
|
return true;
|
|
37
31
|
}
|
|
38
|
-
else if (directMatches(alternative,
|
|
32
|
+
else if (directMatches(alternative, item))
|
|
39
33
|
return true;
|
|
40
34
|
}
|
|
41
35
|
return false;
|
|
@@ -48,10 +42,11 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
48
42
|
if (expr.kind === "reference") {
|
|
49
43
|
/* a reference constraint: the value has to be exactly one
|
|
50
44
|
reference resolving into the constraint's wildcard match set */
|
|
51
|
-
|
|
45
|
+
const rm = plainText(property.value).trim().match(singleReferenceRegex);
|
|
46
|
+
if (rm === null)
|
|
52
47
|
ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
|
|
53
48
|
else {
|
|
54
|
-
const ref =
|
|
49
|
+
const ref = rm[1].trim();
|
|
55
50
|
const target = resolveUnique(ctx.linkIndex, ref).target;
|
|
56
51
|
if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
|
|
57
52
|
ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
|
|
@@ -72,7 +67,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
72
67
|
else if (expr.kind === "list") {
|
|
73
68
|
/* a list constraint: the value is a comma-separated list
|
|
74
69
|
of items, each matching at least one alternative */
|
|
75
|
-
for (const item of splitItems(property.value))
|
|
70
|
+
for (const item of splitItems(plainText(property.value)))
|
|
76
71
|
if (!matchAlternatives(ctx, expr.alternatives, item))
|
|
77
72
|
ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
|
|
78
73
|
}
|
|
@@ -81,6 +76,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
81
76
|
`property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
|
|
82
77
|
`property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
|
|
83
78
|
};
|
|
79
|
+
/* find the configured property whose value the trailing parenthesized
|
|
80
|
+
name token of an object is accepted for: the first still missing
|
|
81
|
+
property whose pattern the token matches */
|
|
82
|
+
const parenProp = (object, props) => {
|
|
83
|
+
const paren = object.paren;
|
|
84
|
+
if (paren === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
return props.find((prop) => prop.value !== undefined && findProp(object, prop.name) === undefined
|
|
87
|
+
&& directMatches(compileValueExpr(prop.value), paren));
|
|
88
|
+
};
|
|
84
89
|
/* validate a single object (and recursively its childs) against its schema */
|
|
85
90
|
const validateObject = (ctx, object, schema, level) => {
|
|
86
91
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
@@ -93,33 +98,27 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
93
98
|
input, via either "{{<id>}}" or "(<id>)" */
|
|
94
99
|
if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
|
|
95
100
|
ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
|
|
96
|
-
/* check the configured properties
|
|
97
|
-
|
|
101
|
+
/* check every occurrence of the configured properties (a trailing
|
|
102
|
+
parenthesized name token is accepted as the value of a still
|
|
103
|
+
missing property, and a repeated key is reported as duplicate) */
|
|
104
|
+
const consumed = parenProp(object, props);
|
|
98
105
|
for (const prop of props) {
|
|
99
106
|
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (prop.optional !== true)
|
|
110
|
-
ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
|
|
107
|
+
const matches = object.properties.filter((p) => p.key === prop.name);
|
|
108
|
+
if (matches.length === 0 && prop !== consumed && prop.optional !== true)
|
|
109
|
+
ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
|
|
110
|
+
for (const [i, match] of matches.entries()) {
|
|
111
|
+
const line = ctx.propMeta.get(match)?.line ?? meta.line;
|
|
112
|
+
if (i > 0)
|
|
113
|
+
ctx.diagnose(meta.file, line, `duplicate property "${prop.name}" on ${object.kind} "${object.name}"`);
|
|
114
|
+
if (expr !== undefined)
|
|
115
|
+
checkPropValue(ctx, prop, expr, match, { file: meta.file, line });
|
|
111
116
|
}
|
|
112
|
-
else if (expr !== undefined)
|
|
113
|
-
checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
|
|
114
117
|
}
|
|
115
118
|
/* report the properties not configured by the schema */
|
|
116
119
|
for (const property of object.properties)
|
|
117
120
|
if (!props.some((p) => p.name === property.key))
|
|
118
121
|
ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
|
|
119
|
-
/* a parenthesized token not consumed as a property value acts as
|
|
120
|
-
the implicit anchor (an explicit "{{...}}" anchor takes precedence) */
|
|
121
|
-
if (object.paren !== undefined && !parenConsumed && object.anchor === undefined)
|
|
122
|
-
object.id = object.paren;
|
|
123
122
|
/* check the configured child objects */
|
|
124
123
|
const childs = schema.childs ?? [];
|
|
125
124
|
for (const child of object.childs) {
|
|
@@ -150,22 +149,76 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
150
149
|
};
|
|
151
150
|
object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
|
|
152
151
|
};
|
|
152
|
+
/* resolve a level 1 object onto its configured artifact, deliberately
|
|
153
|
+
leniently by kind and id first and by case-insensitive name second,
|
|
154
|
+
so deviations of the heading can still be precisely reported */
|
|
155
|
+
export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
156
|
+
config.find((s) => s.name !== undefined
|
|
157
|
+
&& s.name.toUpperCase() === plainText(object.name).toUpperCase());
|
|
158
|
+
/* map the specification objects onto their schema configuration
|
|
159
|
+
nodes (the artifact resolution is shared with the validation) */
|
|
160
|
+
export const collectSchemas = (specification, config) => {
|
|
161
|
+
const schemas = new Map();
|
|
162
|
+
const walk = (object, schema) => {
|
|
163
|
+
schemas.set(object, schema);
|
|
164
|
+
for (const child of object.childs) {
|
|
165
|
+
const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
|
|
166
|
+
if (childSchema !== undefined)
|
|
167
|
+
walk(child, childSchema);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
for (const artifact of specification.artifacts) {
|
|
171
|
+
for (const object of artifact.objects) {
|
|
172
|
+
const schema = resolveArtifact(config, object);
|
|
173
|
+
if (schema !== undefined)
|
|
174
|
+
walk(object, schema);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return schemas;
|
|
178
|
+
};
|
|
179
|
+
/* report the objects colliding on kind and id among their siblings
|
|
180
|
+
(recursively from the artifacts down), as such objects derive
|
|
181
|
+
identical anchor paths and hence cannot be addressed distinctly */
|
|
182
|
+
const checkIds = (ctx, objects) => {
|
|
183
|
+
const seen = new Map();
|
|
184
|
+
for (const object of objects) {
|
|
185
|
+
const key = `${object.kind}:${object.id}`;
|
|
186
|
+
const first = seen.get(key);
|
|
187
|
+
if (first === undefined)
|
|
188
|
+
seen.set(key, object);
|
|
189
|
+
else {
|
|
190
|
+
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
191
|
+
ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
|
|
192
|
+
}
|
|
193
|
+
checkIds(ctx, object.childs);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
153
196
|
/* validate the parsed specification against the configuration */
|
|
154
197
|
export const validate = (ctx, specification, config) => {
|
|
198
|
+
/* map all objects onto their schema nodes and assign the implicit
|
|
199
|
+
ids up-front: a parenthesized token not consumed as a property
|
|
200
|
+
value acts as the id (an explicit "{{...}}" anchor takes
|
|
201
|
+
precedence), which has to happen before any property check, as
|
|
202
|
+
those resolve references by id across all artifacts */
|
|
203
|
+
const schemas = collectSchemas(specification, config);
|
|
204
|
+
for (const [object, schema] of schemas)
|
|
205
|
+
if (object.paren !== undefined && object.anchor === undefined
|
|
206
|
+
&& parenProp(object, schema.props ?? []) === undefined)
|
|
207
|
+
object.id = object.paren;
|
|
208
|
+
/* check the ids for local uniqueness, now that all of them are final */
|
|
209
|
+
checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
|
|
210
|
+
/* validate every level 1 object against its artifact schema */
|
|
155
211
|
const position = new Map();
|
|
156
212
|
for (const artifact of specification.artifacts) {
|
|
157
213
|
for (const object of artifact.objects) {
|
|
158
214
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
159
|
-
const schema =
|
|
160
|
-
config.find((s) => s.name !== undefined
|
|
161
|
-
&& s.name.toUpperCase() === plainText(object.name).toUpperCase());
|
|
215
|
+
const schema = schemas.get(object);
|
|
162
216
|
if (schema === undefined) {
|
|
163
217
|
ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
|
|
164
218
|
continue;
|
|
165
219
|
}
|
|
166
220
|
/* the artifact heading must carry the configured kind and
|
|
167
|
-
name verbatim (the resolution
|
|
168
|
-
lenient, so deviations are still precisely reported) */
|
|
221
|
+
name verbatim (the resolution is lenient on purpose) */
|
|
169
222
|
if (object.kind !== schema.kind)
|
|
170
223
|
ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
|
|
171
224
|
if (schema.name !== undefined && plainText(object.name) !== schema.name)
|