@rse/specbook 0.9.6 → 0.9.8
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 +65 -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 +24 -17
- 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 +90 -71
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +8 -3
- package/dst/specbook-diagram.js +101 -105
- 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 +101 -83
- package/dst/specbook-export-md.d.ts +1 -1
- package/dst/specbook-export-md.js +56 -23
- 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 +47 -20
- 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 +2 -1
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.d.ts +4 -2
- package/dst/specbook-parse-semantic.js +114 -43
- package/dst/specbook-parse-syntax.js +179 -137
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +9 -5
- 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
|
@@ -8,9 +8,12 @@ import { Gradia } from "@rse/gradia";
|
|
|
8
8
|
/* ==== Schema ==== */
|
|
9
9
|
/* the Gradia rendering options, derived from the configuration
|
|
10
10
|
defaults of Gradia: the option names constrain the allowed keys and
|
|
11
|
-
the types of the default values constrain the allowed value types
|
|
11
|
+
the types of the default values constrain the allowed value types
|
|
12
|
+
(with the numbers restricted to the finite, non-negative values
|
|
13
|
+
Gradia honors, as it silently drops all others) */
|
|
12
14
|
const GradiaConfigSchemas = Object.fromEntries(Object.entries(Gradia.config).map(([key, value]) => [key,
|
|
13
|
-
typeof value === "boolean" ? v.boolean() :
|
|
15
|
+
typeof value === "boolean" ? v.boolean() :
|
|
16
|
+
typeof value === "number" ? v.pipe(v.number(), v.finite(), v.minValue(0)) : v.string()]));
|
|
14
17
|
const SchemaDiagramConfig = v.partial(v.strictObject(GradiaConfigSchemas, (issue) => issue.expected === "never" ? "unknown Gradia rendering option" :
|
|
15
18
|
"expected a map of Gradia rendering options"));
|
|
16
19
|
const SchemaProperty = v.strictObject({
|
|
@@ -37,7 +40,7 @@ const SchemaDiagram = v.strictObject({
|
|
|
37
40
|
});
|
|
38
41
|
const SchemaFormat = v.strictObject({
|
|
39
42
|
type: v.optional(v.picklist(["auto", "complex", "concise"])),
|
|
40
|
-
maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(
|
|
43
|
+
maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
|
|
41
44
|
withUnusedProps: v.optional(v.boolean())
|
|
42
45
|
});
|
|
43
46
|
const SchemaObject = v.strictObject({
|
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
|
|
|
@@ -159,7 +160,8 @@ type SchemaGradiaConfig = Partial<{
|
|
|
159
160
|
BECAUSE artifacts and their content follow one recursive structure
|
|
160
161
|
|
|
161
162
|
- `SchemaObject.kind: string`:
|
|
162
|
-
kind name of the object (e.g. `SPEC`, `REQUIREMENT`),
|
|
163
|
+
kind name of the object (e.g. `SPEC`, `REQUIREMENT`), unique among
|
|
164
|
+
its siblings, on level 1 together with its `id`,
|
|
163
165
|
BECAUSE it discriminates objects and anchors `[[xxx]]` references
|
|
164
166
|
|
|
165
167
|
- `SchemaObject.name?: string`:
|
|
@@ -180,7 +182,8 @@ type SchemaGradiaConfig = Partial<{
|
|
|
180
182
|
BECAUSE it guides the authors and instructs the AI
|
|
181
183
|
|
|
182
184
|
- `SchemaObject.optional?: boolean`:
|
|
183
|
-
whether the kind may be absent below its parent
|
|
185
|
+
whether the kind may be absent below its parent or, for a level 1
|
|
186
|
+
artifact, from its file (default: `false`),
|
|
184
187
|
BECAUSE not every kind is mandatory below every parent object
|
|
185
188
|
|
|
186
189
|
- `SchemaObject.diagram?: SchemaDiagram`:
|
|
@@ -502,6 +505,7 @@ type SpecProperty = {
|
|
|
502
505
|
|
|
503
506
|
- `SpecDescription.embedding?: string[]`:
|
|
504
507
|
image files embedded via ``, inlined at parse time,
|
|
508
|
+
one entry per file in markup order (empty for an unreadable file),
|
|
505
509
|
BECAUSE the exports have to stand alone, without the image files
|
|
506
510
|
|
|
507
511
|
- `SpecProperty`:
|
|
@@ -518,6 +522,7 @@ type SpecProperty = {
|
|
|
518
522
|
|
|
519
523
|
- `SpecProperty.embedding?: string[]`:
|
|
520
524
|
image files embedded in the value, inlined at parse time,
|
|
525
|
+
one entry per file in markup order (empty for an unreadable file),
|
|
521
526
|
BECAUSE the exports have to stand alone, without the image files
|
|
522
527
|
|
|
523
528
|
SpecBook SCHEMA Model
|
|
@@ -553,15 +558,21 @@ follow each other on level 1, below the single frontmatter block of that
|
|
|
553
558
|
file. Both ATX headings (`# <text/>`, `## <text/>`, ...) and Setext
|
|
554
559
|
headings (`<text/>` underlined with `===` or `---`) are supported.
|
|
555
560
|
|
|
561
|
+
As the timestamps are a property of the *file* and not of the individual
|
|
562
|
+
artifact, the normalized Markdown export -- which merges the entire
|
|
563
|
+
corpus into a single document -- carries the earliest `Created:` and the
|
|
564
|
+
latest `Modified:` timestamp of all artifacts in its single frontmatter
|
|
565
|
+
block, so the per-artifact timestamps do not survive this round-trip.
|
|
566
|
+
|
|
556
567
|
### Object Model
|
|
557
568
|
|
|
558
569
|
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.
|
|
570
|
+
*kind* (e.g. `ENTITY`), a mandatory *name*, an anchor *id* (explicitly
|
|
571
|
+
given or derived from the name, and unique among the sibling objects of
|
|
572
|
+
its kind), an optional *primary* marker, optional *properties* (key/value
|
|
573
|
+
pairs), an optional *description* statement with an optional rationale,
|
|
574
|
+
and optional *child* objects. Which kinds, properties, and nestings are
|
|
575
|
+
allowed is defined by the schema.
|
|
565
576
|
|
|
566
577
|
### Format Variant: Complex Format
|
|
567
578
|
|
|
@@ -599,11 +610,14 @@ multiple (indented) lines:
|
|
|
599
610
|
|
|
600
611
|
A list item is recognized as a Concise Format object (instead of a
|
|
601
612
|
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
|
-
|
|
613
|
+
first line. Every segment of the form `<key/>: <value/>` (with a
|
|
614
|
+
possibly empty value) becomes a property, unless it contains a
|
|
615
|
+
` BECAUSE ` or is the last segment of a `.`-terminated item (as a
|
|
616
|
+
description like `Note: xxx` has to survive the round-trip through
|
|
617
|
+
exported Markdown); all remaining segments are joined into the
|
|
618
|
+
description. Child objects nest as indented list items below their
|
|
619
|
+
parent item, where key/value items become properties and concise items
|
|
620
|
+
become child objects of that parent:
|
|
607
621
|
|
|
608
622
|
```
|
|
609
623
|
- <kind/>: <name/>; <key/>: <value/>;
|
|
@@ -637,8 +651,14 @@ the item would look like a key/value pair) -- backquote it in this case.
|
|
|
637
651
|
|
|
638
652
|
When **SpecBook** itself generates specification Markdown, it emits
|
|
639
653
|
the Complex Format on levels 1-3 and the Concise Format from level 4
|
|
640
|
-
upwards
|
|
641
|
-
|
|
654
|
+
upwards -- unless a descendant of an object carries a description which
|
|
655
|
+
no single list item can carry: a multi-line one (a fenced code block, an
|
|
656
|
+
ordered list, a blockquote, or multiple paragraphs) or one carrying a
|
|
657
|
+
`;` (which a re-parse would split into a spurious property plus a
|
|
658
|
+
truncated description). All child objects of such an object then stay in
|
|
659
|
+
the Complex Format (as sibling objects have to share the format), with
|
|
660
|
+
headings reaching down to level 6. When **SpecBook** edits existing files, it mirrors the format each
|
|
661
|
+
existing object already uses.
|
|
642
662
|
|
|
643
663
|
### Names, Anchors, and Ids
|
|
644
664
|
|
|
@@ -666,9 +686,14 @@ explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
|
|
|
666
686
|
In the Complex Format, each property is an unordered list item of the
|
|
667
687
|
form `<key/>: <value/>`, whose value may continue on the following
|
|
668
688
|
indented lines of the item (joined with spaces). In the Concise Format,
|
|
669
|
-
each property is a `<key/>: <value/>` segment.
|
|
670
|
-
|
|
671
|
-
|
|
689
|
+
each property is a `<key/>: <value/>` segment. A value carrying a `;`
|
|
690
|
+
or a ` BECAUSE ` cannot be a segment and has to become a key/value item
|
|
691
|
+
nested below the item instead -- a `;` value additionally starting on a
|
|
692
|
+
continuation line of that item, as a `;` on the first line of a list
|
|
693
|
+
item marks a Concise Format object. The key is matched
|
|
694
|
+
against the schema exactly as written and may occur only once per
|
|
695
|
+
object, and the value has to satisfy the configured constraint as a
|
|
696
|
+
whole.
|
|
672
697
|
|
|
673
698
|
### Descriptions and Rationales
|
|
674
699
|
|
|
@@ -679,7 +704,9 @@ fenced code blocks. A fenced code block
|
|
|
679
704
|
of language `gradia` is skipped, as it is the derived diagram which
|
|
680
705
|
**SpecBook** itself emits into exported Markdown and which must not
|
|
681
706
|
become authored content on a re-parse. In the Concise Format, the
|
|
682
|
-
description is formed by the non-property segments of the item
|
|
707
|
+
description is formed by the non-property segments of the item, so a
|
|
708
|
+
description carrying a `;` cannot be a segment either and forces its
|
|
709
|
+
object into the Complex Format.
|
|
683
710
|
|
|
684
711
|
A description is split into its *statement* and *rationale* at the first
|
|
685
712
|
`, BECAUSE ` (or `, **BECAUSE** `) occurrence:
|
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
|
}
|
|
@@ -22,7 +22,8 @@ export declare class ParseContext {
|
|
|
22
22
|
linkIndex: LinkIndex;
|
|
23
23
|
diagnose(file: string, line: number, message: string): void;
|
|
24
24
|
}
|
|
25
|
+
export declare const becauseRegex: RegExp;
|
|
25
26
|
export declare const embeddingRegex: RegExp;
|
|
26
|
-
export declare const embeddingThemes:
|
|
27
|
+
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
27
28
|
export declare const embeddingVariants: (reference: string) => string[];
|
|
28
29
|
export declare const embeddingMimeType: (reference: string) => string | undefined;
|
|
@@ -14,6 +14,8 @@ export class ParseContext {
|
|
|
14
14
|
this.diagnostics.push({ file, line, column: 1, message });
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
/* the marker separating a statement from its rationale */
|
|
18
|
+
export const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
|
|
17
19
|
/* the Markdown image embedding syntax ("") */
|
|
18
20
|
export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
|
|
19
21
|
/* the theme variants of a theme-aware image embedding */
|
|
@@ -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,26 +26,36 @@ 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;
|
|
42
36
|
};
|
|
43
37
|
/* find a property of an object by its key (case-sensitive) */
|
|
44
38
|
const findProp = (object, name) => object.properties.find((p) => p.key === name);
|
|
39
|
+
/* split a multi-valued property value into its items, reporting an
|
|
40
|
+
empty one -- stemming from an empty value or a stray comma -- once,
|
|
41
|
+
as such an item can never satisfy any constraint */
|
|
42
|
+
const splitPropItems = (ctx, prop, property, meta) => {
|
|
43
|
+
const items = splitItems(plainText(property.value));
|
|
44
|
+
if (items.some((item) => item === ""))
|
|
45
|
+
ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" contains an empty item`);
|
|
46
|
+
return items.filter((item) => item !== "");
|
|
47
|
+
};
|
|
45
48
|
/* check a property value against the compiled value expression of its
|
|
46
49
|
configured schema property */
|
|
47
50
|
const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
48
51
|
if (expr.kind === "reference") {
|
|
49
52
|
/* a reference constraint: the value has to be exactly one
|
|
50
53
|
reference resolving into the constraint's wildcard match set */
|
|
51
|
-
|
|
54
|
+
const rm = plainText(property.value).trim().match(singleReferenceRegex);
|
|
55
|
+
if (rm === null)
|
|
52
56
|
ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
|
|
53
57
|
else {
|
|
54
|
-
const ref =
|
|
58
|
+
const ref = rm[1].trim();
|
|
55
59
|
const target = resolveUnique(ctx.linkIndex, ref).target;
|
|
56
60
|
if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
|
|
57
61
|
ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
|
|
@@ -61,7 +65,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
61
65
|
/* a tags constraint: the value is a comma-separated set of
|
|
62
66
|
configured tags, each occurring at most once */
|
|
63
67
|
const seen = new Set();
|
|
64
|
-
for (const item of
|
|
68
|
+
for (const item of splitPropItems(ctx, prop, property, meta)) {
|
|
65
69
|
if (!expr.members.includes(item))
|
|
66
70
|
ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
|
|
67
71
|
else if (seen.has(item))
|
|
@@ -72,7 +76,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
72
76
|
else if (expr.kind === "list") {
|
|
73
77
|
/* a list constraint: the value is a comma-separated list
|
|
74
78
|
of items, each matching at least one alternative */
|
|
75
|
-
for (const item of
|
|
79
|
+
for (const item of splitPropItems(ctx, prop, property, meta))
|
|
76
80
|
if (!matchAlternatives(ctx, expr.alternatives, item))
|
|
77
81
|
ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
|
|
78
82
|
}
|
|
@@ -81,6 +85,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
81
85
|
`property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
|
|
82
86
|
`property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
|
|
83
87
|
};
|
|
88
|
+
/* find the configured property whose value the trailing parenthesized
|
|
89
|
+
name token of an object is accepted for: the first still missing
|
|
90
|
+
property whose pattern the token matches */
|
|
91
|
+
const parenProp = (object, props) => {
|
|
92
|
+
const paren = object.paren;
|
|
93
|
+
if (paren === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
return props.find((prop) => prop.value !== undefined && findProp(object, prop.name) === undefined
|
|
96
|
+
&& directMatches(compileValueExpr(prop.value), paren));
|
|
97
|
+
};
|
|
84
98
|
/* validate a single object (and recursively its childs) against its schema */
|
|
85
99
|
const validateObject = (ctx, object, schema, level) => {
|
|
86
100
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
@@ -93,33 +107,27 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
93
107
|
input, via either "{{<id>}}" or "(<id>)" */
|
|
94
108
|
if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
|
|
95
109
|
ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
|
|
96
|
-
/* check the configured properties
|
|
97
|
-
|
|
110
|
+
/* check every occurrence of the configured properties (a trailing
|
|
111
|
+
parenthesized name token is accepted as the value of a still
|
|
112
|
+
missing property, and a repeated key is reported as duplicate) */
|
|
113
|
+
const consumed = parenProp(object, props);
|
|
98
114
|
for (const prop of props) {
|
|
99
115
|
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}"`);
|
|
116
|
+
const matches = object.properties.filter((p) => p.key === prop.name);
|
|
117
|
+
if (matches.length === 0 && prop !== consumed && prop.optional !== true)
|
|
118
|
+
ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
|
|
119
|
+
for (const [i, match] of matches.entries()) {
|
|
120
|
+
const line = ctx.propMeta.get(match)?.line ?? meta.line;
|
|
121
|
+
if (i > 0)
|
|
122
|
+
ctx.diagnose(meta.file, line, `duplicate property "${prop.name}" on ${object.kind} "${object.name}"`);
|
|
123
|
+
if (expr !== undefined)
|
|
124
|
+
checkPropValue(ctx, prop, expr, match, { file: meta.file, line });
|
|
111
125
|
}
|
|
112
|
-
else if (expr !== undefined)
|
|
113
|
-
checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
|
|
114
126
|
}
|
|
115
127
|
/* report the properties not configured by the schema */
|
|
116
128
|
for (const property of object.properties)
|
|
117
129
|
if (!props.some((p) => p.name === property.key))
|
|
118
130
|
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
131
|
/* check the configured child objects */
|
|
124
132
|
const childs = schema.childs ?? [];
|
|
125
133
|
for (const child of object.childs) {
|
|
@@ -150,22 +158,85 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
150
158
|
};
|
|
151
159
|
object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
|
|
152
160
|
};
|
|
161
|
+
/* resolve a level 1 object onto its configured artifact, deliberately
|
|
162
|
+
leniently by kind and id first and by case-insensitive name second,
|
|
163
|
+
so deviations of the heading can still be precisely reported */
|
|
164
|
+
export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
165
|
+
config.find((s) => s.name !== undefined
|
|
166
|
+
&& s.name.toUpperCase() === plainText(object.name).toUpperCase());
|
|
167
|
+
/* map the specification objects onto their schema configuration
|
|
168
|
+
nodes (the artifact resolution is shared with the validation) */
|
|
169
|
+
export const collectSchemas = (specification, config) => {
|
|
170
|
+
const schemas = new Map();
|
|
171
|
+
const walk = (object, schema) => {
|
|
172
|
+
schemas.set(object, schema);
|
|
173
|
+
for (const child of object.childs) {
|
|
174
|
+
const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
|
|
175
|
+
if (childSchema !== undefined)
|
|
176
|
+
walk(child, childSchema);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
for (const artifact of specification.artifacts) {
|
|
180
|
+
for (const object of artifact.objects) {
|
|
181
|
+
const schema = resolveArtifact(config, object);
|
|
182
|
+
if (schema !== undefined)
|
|
183
|
+
walk(object, schema);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return schemas;
|
|
187
|
+
};
|
|
188
|
+
/* report the objects colliding on kind and id among their siblings
|
|
189
|
+
(recursively from the artifacts down), as such objects derive
|
|
190
|
+
identical anchor paths and hence cannot be addressed distinctly */
|
|
191
|
+
const checkIds = (ctx, objects) => {
|
|
192
|
+
const seen = new Map();
|
|
193
|
+
for (const object of objects) {
|
|
194
|
+
const key = `${object.kind}:${object.id}`;
|
|
195
|
+
const first = seen.get(key);
|
|
196
|
+
if (first === undefined)
|
|
197
|
+
seen.set(key, object);
|
|
198
|
+
else {
|
|
199
|
+
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
200
|
+
ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
|
|
201
|
+
}
|
|
202
|
+
checkIds(ctx, object.childs);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
153
205
|
/* validate the parsed specification against the configuration */
|
|
154
206
|
export const validate = (ctx, specification, config) => {
|
|
207
|
+
/* map all objects onto their schema nodes and assign the implicit
|
|
208
|
+
ids up-front: a parenthesized token not consumed as a property
|
|
209
|
+
value acts as the id (an explicit "{{...}}" anchor takes
|
|
210
|
+
precedence), which has to happen before any property check, as
|
|
211
|
+
those resolve references by id across all artifacts */
|
|
212
|
+
const schemas = collectSchemas(specification, config);
|
|
213
|
+
const promote = (object) => {
|
|
214
|
+
/* an object below an unresolved artifact or an unknown kind
|
|
215
|
+
carries no schema and hence no property able to consume the
|
|
216
|
+
token, so the token still acts as the id and a single
|
|
217
|
+
unresolved heading no longer cascades into unresolvable
|
|
218
|
+
references all over the corpus */
|
|
219
|
+
if (object.paren !== undefined && object.anchor === undefined
|
|
220
|
+
&& parenProp(object, schemas.get(object)?.props ?? []) === undefined)
|
|
221
|
+
object.id = object.paren;
|
|
222
|
+
object.childs.forEach(promote);
|
|
223
|
+
};
|
|
224
|
+
for (const artifact of specification.artifacts)
|
|
225
|
+
artifact.objects.forEach(promote);
|
|
226
|
+
/* check the ids for local uniqueness, now that all of them are final */
|
|
227
|
+
checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
|
|
228
|
+
/* validate every level 1 object against its artifact schema */
|
|
155
229
|
const position = new Map();
|
|
156
230
|
for (const artifact of specification.artifacts) {
|
|
157
231
|
for (const object of artifact.objects) {
|
|
158
232
|
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());
|
|
233
|
+
const schema = schemas.get(object);
|
|
162
234
|
if (schema === undefined) {
|
|
163
235
|
ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
|
|
164
236
|
continue;
|
|
165
237
|
}
|
|
166
238
|
/* the artifact heading must carry the configured kind and
|
|
167
|
-
name verbatim (the resolution
|
|
168
|
-
lenient, so deviations are still precisely reported) */
|
|
239
|
+
name verbatim (the resolution is lenient on purpose) */
|
|
169
240
|
if (object.kind !== schema.kind)
|
|
170
241
|
ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
|
|
171
242
|
if (schema.name !== undefined && plainText(object.name) !== schema.name)
|