@rse/specbook 0.9.5 → 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 +108 -328
- package/dst/specbook-api.d.ts +13 -20
- package/dst/specbook-api.js +43 -39
- package/dst/specbook-cli.js +42 -55
- package/dst/specbook-cmd-describe.d.ts +5 -2
- package/dst/specbook-cmd-describe.js +30 -91
- package/dst/specbook-cmd-export.d.ts +3 -3
- package/dst/specbook-cmd-export.js +23 -19
- package/dst/specbook-cmd-init.d.ts +3 -3
- package/dst/specbook-cmd-init.js +38 -15
- package/dst/specbook-cmd-lint.d.ts +5 -6
- package/dst/specbook-cmd-lint.js +61 -24
- package/dst/specbook-config.d.ts +2 -2
- package/dst/specbook-config.js +82 -73
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +6 -5
- package/dst/specbook-diagram.js +94 -104
- package/dst/specbook-export-ast.d.ts +3 -3
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +11 -9
- package/dst/specbook-export-common.js +34 -22
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +37 -32
- package/dst/specbook-export-html.d.ts +4 -4
- package/dst/specbook-export-html.js +153 -107
- package/dst/specbook-export-logo-dark.svg +35 -0
- package/dst/specbook-export-logo-light.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -3
- 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-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
- package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +8 -4
- package/dst/specbook-format-spec.d.ts +31 -0
- package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
- package/dst/specbook-format.md +750 -0
- package/dst/specbook-link.d.ts +3 -2
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.d.ts +2 -1
- package/dst/specbook-mcp.js +30 -62
- package/dst/specbook-parse-common.d.ts +5 -3
- package/dst/specbook-parse-common.js +8 -0
- package/dst/specbook-parse-semantic.d.ts +6 -4
- package/dst/specbook-parse-semantic.js +98 -106
- package/dst/specbook-parse-syntax.d.ts +2 -2
- package/dst/specbook-parse-syntax.js +175 -144
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +6 -3
- package/dst/specbook-parse.d.ts +4 -7
- package/dst/specbook-parse.js +28 -35
- package/dst/specbook-verbose.d.ts +2 -0
- package/dst/specbook-verbose.js +17 -0
- package/package.json +3 -7
- package/dst/specbook-cmd-edit.d.ts +0 -9
- package/dst/specbook-cmd-edit.js +0 -74
- package/dst/specbook-cmd-import.d.ts +0 -9
- package/dst/specbook-cmd-import.js +0 -78
- package/dst/specbook-export-logo.svg +0 -35
- package/dst/specbook-llm.d.ts +0 -13
- package/dst/specbook-llm.js +0 -51
- package/dst/specbook-struct-spec.d.ts +0 -31
package/dst/specbook-link.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
2
|
export declare const referenceRegex: RegExp;
|
|
3
|
+
export declare const plainText: (text: string) => string;
|
|
3
4
|
interface LinkNode {
|
|
4
5
|
object: SpecObject;
|
|
5
6
|
parent?: LinkNode;
|
|
@@ -9,7 +10,7 @@ export interface LinkTarget {
|
|
|
9
10
|
target?: SpecObject;
|
|
10
11
|
ambiguous: boolean;
|
|
11
12
|
}
|
|
12
|
-
export declare const buildLinkIndex: (specification:
|
|
13
|
+
export declare const buildLinkIndex: (specification: Spec) => LinkIndex;
|
|
13
14
|
export declare const resolveSet: (index: LinkIndex, reference: string) => SpecObject[];
|
|
14
15
|
export declare const resolveUnique: (index: LinkIndex, reference: string) => LinkTarget;
|
|
15
16
|
export declare const anchorPaths: (index: LinkIndex) => Map<SpecObject, string>;
|
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.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { type VerboseSink } from "./specbook-api.js";
|
|
2
|
+
export declare const serveMcp: (verbose: VerboseSink) => Promise<void>;
|
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);
|
|
@@ -43,10 +43,10 @@ export const serveMcp = async (verbose) => {
|
|
|
43
43
|
});
|
|
44
44
|
server.registerTool("specbook_lint", {
|
|
45
45
|
title: "Lint Specification",
|
|
46
|
-
description: "Lint the specification Markdown files
|
|
47
|
-
"
|
|
46
|
+
description: "Lint the specification Markdown files the YAML schema configuration references " +
|
|
47
|
+
"below the base directory against this configuration and return all diagnostics.",
|
|
48
48
|
inputSchema: {
|
|
49
|
-
config: z.string().
|
|
49
|
+
config: z.string().describe("YAML schema configuration file"),
|
|
50
50
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
|
|
51
51
|
}
|
|
52
52
|
}, async (args) => {
|
|
@@ -63,24 +63,32 @@ export const serveMcp = async (verbose) => {
|
|
|
63
63
|
});
|
|
64
64
|
server.registerTool("specbook_export", {
|
|
65
65
|
title: "Export Specification",
|
|
66
|
-
description: "Export the specification Markdown files
|
|
66
|
+
description: "Export the specification Markdown files the YAML schema configuration references " +
|
|
67
|
+
"below the base directory as JSON, JSON5, " +
|
|
67
68
|
"YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
|
|
68
69
|
"if an output path is given, else it is returned directly (PDF as a base64-encoded resource).",
|
|
69
70
|
inputSchema: {
|
|
70
|
-
config: z.string().
|
|
71
|
+
config: z.string().describe("YAML schema configuration file"),
|
|
71
72
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
72
|
-
format: z.enum(formats).optional().describe("output format (default:
|
|
73
|
-
|
|
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)")
|
|
74
76
|
}
|
|
75
77
|
}, async (args) => {
|
|
76
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);
|
|
77
85
|
const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
|
|
78
|
-
formats: [
|
|
79
|
-
if (
|
|
80
|
-
await fs.promises.writeFile(
|
|
81
|
-
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)` }] };
|
|
82
90
|
}
|
|
83
|
-
else if (
|
|
91
|
+
else if (spec.format === "pdf")
|
|
84
92
|
return { content: [{
|
|
85
93
|
type: "resource",
|
|
86
94
|
resource: { uri: "specbook:export.pdf", mimeType: "application/pdf", blob: data.toString("base64") }
|
|
@@ -94,16 +102,20 @@ export const serveMcp = async (verbose) => {
|
|
|
94
102
|
});
|
|
95
103
|
server.registerTool("specbook_describe", {
|
|
96
104
|
title: "Describe Specification Format",
|
|
97
|
-
description: "Describe the
|
|
98
|
-
"
|
|
105
|
+
description: "Describe the SpecBook models and formats as a Markdown document. If the YAML " +
|
|
106
|
+
"schema configuration or the base directory is given, the description additionally points " +
|
|
107
|
+
"to the artifacts of that particular project.",
|
|
99
108
|
inputSchema: {
|
|
100
|
-
config: z.string().describe("YAML schema configuration file"),
|
|
101
|
-
|
|
109
|
+
config: z.string().optional().describe("YAML schema configuration file"),
|
|
110
|
+
basedir: z.string().optional().describe("base directory of the specification Markdown files"),
|
|
111
|
+
embed: z.boolean().optional().describe("embed the YAML schema configuration instead of " +
|
|
112
|
+
"just referencing it (default: false)"),
|
|
113
|
+
output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)")
|
|
102
114
|
}
|
|
103
115
|
}, async (args) => {
|
|
104
116
|
try {
|
|
105
117
|
const text = await specbook.describe(args);
|
|
106
|
-
if (args.output !== undefined) {
|
|
118
|
+
if (args.output !== undefined && args.output !== "-") {
|
|
107
119
|
await fs.promises.writeFile(args.output, text, "utf8");
|
|
108
120
|
return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
|
|
109
121
|
}
|
|
@@ -113,49 +125,5 @@ export const serveMcp = async (verbose) => {
|
|
|
113
125
|
return errorResult(err);
|
|
114
126
|
}
|
|
115
127
|
});
|
|
116
|
-
server.registerTool("specbook_import", {
|
|
117
|
-
title: "Import Foreign Sources",
|
|
118
|
-
description: "Import the information of foreign source files into the configured specification " +
|
|
119
|
-
"artifact files below the base directory, using an LLM.",
|
|
120
|
-
inputSchema: {
|
|
121
|
-
config: z.string().describe("YAML schema configuration file"),
|
|
122
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
123
|
-
inputs: z.array(z.string()).describe("foreign source files to import"),
|
|
124
|
-
provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
|
|
125
|
-
model: z.string().optional().describe("AI model")
|
|
126
|
-
}
|
|
127
|
-
}, async (args) => {
|
|
128
|
-
try {
|
|
129
|
-
const written = await specbook.import(args);
|
|
130
|
-
return { content: [{ type: "text", text: written.length > 0 ?
|
|
131
|
-
`imported into artifact file(s): ${written.join(", ")}` :
|
|
132
|
-
"no artifact files were changed" }] };
|
|
133
|
-
}
|
|
134
|
-
catch (err) {
|
|
135
|
-
return errorResult(err);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
server.registerTool("specbook_edit", {
|
|
139
|
-
title: "Edit Specification",
|
|
140
|
-
description: "Apply a free-text edit request to the configured specification artifact files " +
|
|
141
|
-
"below the base directory, using an LLM.",
|
|
142
|
-
inputSchema: {
|
|
143
|
-
config: z.string().describe("YAML schema configuration file"),
|
|
144
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
145
|
-
query: z.string().describe("free-text edit request"),
|
|
146
|
-
provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
|
|
147
|
-
model: z.string().optional().describe("AI model")
|
|
148
|
-
}
|
|
149
|
-
}, async (args) => {
|
|
150
|
-
try {
|
|
151
|
-
const written = await specbook.edit(args);
|
|
152
|
-
return { content: [{ type: "text", text: written.length > 0 ?
|
|
153
|
-
`edited artifact file(s): ${written.join(", ")}` :
|
|
154
|
-
"no artifact files were changed" }] };
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
return errorResult(err);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
128
|
await server.connect(new StdioServerTransport());
|
|
161
129
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
|
|
2
2
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
3
3
|
import { type LinkIndex } from "./specbook-link.js";
|
|
4
4
|
export interface SourceFile {
|
|
@@ -6,7 +6,7 @@ export interface SourceFile {
|
|
|
6
6
|
text: string;
|
|
7
7
|
}
|
|
8
8
|
export interface ParseResult {
|
|
9
|
-
specification:
|
|
9
|
+
specification: Spec;
|
|
10
10
|
diagnostics: Diagnostic[];
|
|
11
11
|
}
|
|
12
12
|
export interface ObjectMeta {
|
|
@@ -16,11 +16,13 @@ export interface ObjectMeta {
|
|
|
16
16
|
export declare class ParseContext {
|
|
17
17
|
diagnostics: Diagnostic[];
|
|
18
18
|
objectMeta: WeakMap<SpecObject, ObjectMeta>;
|
|
19
|
-
propMeta: WeakMap<
|
|
19
|
+
propMeta: WeakMap<SpecProperty, {
|
|
20
20
|
line: number;
|
|
21
21
|
}>;
|
|
22
22
|
linkIndex: LinkIndex;
|
|
23
23
|
diagnose(file: string, line: number, message: string): void;
|
|
24
24
|
}
|
|
25
25
|
export declare const embeddingRegex: RegExp;
|
|
26
|
+
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
27
|
+
export declare const embeddingVariants: (reference: string) => string[];
|
|
26
28
|
export declare const embeddingMimeType: (reference: string) => string | undefined;
|
|
@@ -16,6 +16,14 @@ export class ParseContext {
|
|
|
16
16
|
}
|
|
17
17
|
/* the Markdown image embedding syntax ("") */
|
|
18
18
|
export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
|
|
19
|
+
/* the theme variants of a theme-aware image embedding */
|
|
20
|
+
export const embeddingThemes = ["light", "dark"];
|
|
21
|
+
/* expand an image embedding reference into its variants: a reference
|
|
22
|
+
carrying the "{theme}" placeholder yields one variant per theme,
|
|
23
|
+
while every other reference stays its own single variant */
|
|
24
|
+
export const embeddingVariants = (reference) => reference.includes("{theme}") ?
|
|
25
|
+
embeddingThemes.map((theme) => reference.replace(/\{theme\}/g, theme)) :
|
|
26
|
+
[reference];
|
|
19
27
|
/* the embeddable file types and their MIME types */
|
|
20
28
|
const embeddingTypes = {
|
|
21
29
|
svg: "image/svg+xml",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type
|
|
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
|
|
5
|
-
export declare const
|
|
4
|
+
export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
|
|
5
|
+
export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
|
|
6
|
+
export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
|
|
7
|
+
export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
|
|
@@ -3,35 +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 plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
11
|
-
/* strip the inline code markup of a name or property value
|
|
12
|
-
(preserved in the AST for rendering) for matching purposes */
|
|
13
|
-
const plainText = (text) => text.replace(/`/g, "");
|
|
14
|
-
/* compile a configured pattern into an anchored regular expression */
|
|
15
|
-
const anchored = (pattern, flags = "") => new RegExp(`^(?:${pattern})$`, flags);
|
|
16
|
-
/* match a single inline token against a value expression
|
|
17
|
-
(case-insensitively, as inline tokens are decorations) */
|
|
18
|
-
const tokenMatches = (expr, token) => expr.kind === "regex" ?
|
|
19
|
-
anchored(expr.source, "i").test(token) :
|
|
20
|
-
expr.kind === "enum" ?
|
|
21
|
-
expr.members.some((member) => member.toLowerCase() === token.toLowerCase()) :
|
|
22
|
-
false;
|
|
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 = /^\[\[([^[\]]+)\]\]$/;
|
|
23
10
|
/* match a full property value directly against a value expression
|
|
24
11
|
(defined for the regex and enum kinds only) */
|
|
25
|
-
const directMatches = (expr, text) => expr.kind === "regex"
|
|
26
|
-
expr.
|
|
27
|
-
expr.kind === "enum" ?
|
|
28
|
-
expr.members.includes(text) :
|
|
29
|
-
false;
|
|
12
|
+
const directMatches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
|
|
13
|
+
|| (expr.kind === "enum" && expr.members.includes(text));
|
|
30
14
|
/* match a single list item against the alternatives of a list
|
|
31
15
|
constraint (a reference item resolves leniently, as unresolvable
|
|
32
16
|
and ambiguous ones are already reported by the reference pass) */
|
|
33
17
|
const matchAlternatives = (ctx, alternatives, item) => {
|
|
34
|
-
const rm = item.match(
|
|
18
|
+
const rm = item.match(singleReferenceRegex);
|
|
35
19
|
for (const alternative of alternatives) {
|
|
36
20
|
if (alternative.kind === "reference") {
|
|
37
21
|
if (rm === null)
|
|
@@ -42,75 +26,27 @@ const matchAlternatives = (ctx, alternatives, item) => {
|
|
|
42
26
|
return true;
|
|
43
27
|
}
|
|
44
28
|
else if (alternative.kind === "tags") {
|
|
45
|
-
if (alternative.members.includes(
|
|
29
|
+
if (alternative.members.includes(item))
|
|
46
30
|
return true;
|
|
47
31
|
}
|
|
48
|
-
else if (directMatches(alternative,
|
|
32
|
+
else if (directMatches(alternative, item))
|
|
49
33
|
return true;
|
|
50
34
|
}
|
|
51
35
|
return false;
|
|
52
36
|
};
|
|
53
37
|
/* find a property of an object by its key (case-sensitive) */
|
|
54
|
-
const findProp = (object, name) => object.properties.find((p) =>
|
|
55
|
-
/* distribute the inline tokens split off the multi-token property values
|
|
56
|
-
of an object across its configured properties and report the
|
|
57
|
-
unassignable ones (the properties assigned a token are returned) */
|
|
58
|
-
const assignInlineTokens = (ctx, object, props, meta) => {
|
|
59
|
-
const tokens = new Array();
|
|
60
|
-
const tokenAssigned = new Set();
|
|
61
|
-
/* explode a multi-token property value whose direct pattern check
|
|
62
|
-
fails: keep the token matching the own pattern and distribute
|
|
63
|
-
the remaining tokens across the sibling properties */
|
|
64
|
-
for (const prop of props) {
|
|
65
|
-
if (prop.value === undefined)
|
|
66
|
-
continue;
|
|
67
|
-
const expr = compileValueExpr(prop.value);
|
|
68
|
-
if (expr.kind !== "regex" && expr.kind !== "enum")
|
|
69
|
-
continue;
|
|
70
|
-
const match = findProp(object, prop.name);
|
|
71
|
-
if (match === undefined || directMatches(expr, plainText(match.value)))
|
|
72
|
-
continue;
|
|
73
|
-
const parts = plainText(match.value).split(/\s+/);
|
|
74
|
-
if (parts.length < 2)
|
|
75
|
-
continue;
|
|
76
|
-
const idx = parts.findIndex((part) => tokenMatches(expr, part));
|
|
77
|
-
if (idx < 0)
|
|
78
|
-
continue;
|
|
79
|
-
match.value = parts[idx];
|
|
80
|
-
tokenAssigned.add(prop.name);
|
|
81
|
-
parts.splice(idx, 1);
|
|
82
|
-
tokens.push(...parts);
|
|
83
|
-
}
|
|
84
|
-
/* assign the collected tokens to the still unset configured
|
|
85
|
-
properties whose pattern they match */
|
|
86
|
-
for (const prop of props) {
|
|
87
|
-
if (prop.value === undefined || findProp(object, prop.name) !== undefined)
|
|
88
|
-
continue;
|
|
89
|
-
const expr = compileValueExpr(prop.value);
|
|
90
|
-
if (expr.kind !== "regex" && expr.kind !== "enum")
|
|
91
|
-
continue;
|
|
92
|
-
const idx = tokens.findIndex((token) => tokenMatches(expr, token));
|
|
93
|
-
if (idx >= 0) {
|
|
94
|
-
object.properties.push({ key: prop.name, value: tokens[idx] });
|
|
95
|
-
tokenAssigned.add(prop.name);
|
|
96
|
-
tokens.splice(idx, 1);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
/* report the tokens which no configured property accepts */
|
|
100
|
-
for (const token of tokens)
|
|
101
|
-
ctx.diagnose(meta.file, meta.line, `unassignable inline token "${token}" on ${object.kind} "${object.name}"`);
|
|
102
|
-
return tokenAssigned;
|
|
103
|
-
};
|
|
38
|
+
const findProp = (object, name) => object.properties.find((p) => p.key === name);
|
|
104
39
|
/* check a property value against the compiled value expression of its
|
|
105
40
|
configured schema property */
|
|
106
41
|
const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
107
42
|
if (expr.kind === "reference") {
|
|
108
43
|
/* a reference constraint: the value has to be exactly one
|
|
109
44
|
reference resolving into the constraint's wildcard match set */
|
|
110
|
-
|
|
45
|
+
const rm = plainText(property.value).trim().match(singleReferenceRegex);
|
|
46
|
+
if (rm === null)
|
|
111
47
|
ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
|
|
112
48
|
else {
|
|
113
|
-
const ref =
|
|
49
|
+
const ref = rm[1].trim();
|
|
114
50
|
const target = resolveUnique(ctx.linkIndex, ref).target;
|
|
115
51
|
if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
|
|
116
52
|
ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
|
|
@@ -131,7 +67,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
131
67
|
else if (expr.kind === "list") {
|
|
132
68
|
/* a list constraint: the value is a comma-separated list
|
|
133
69
|
of items, each matching at least one alternative */
|
|
134
|
-
for (const item of splitItems(property.value))
|
|
70
|
+
for (const item of splitItems(plainText(property.value)))
|
|
135
71
|
if (!matchAlternatives(ctx, expr.alternatives, item))
|
|
136
72
|
ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
|
|
137
73
|
}
|
|
@@ -140,6 +76,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
140
76
|
`property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
|
|
141
77
|
`property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
|
|
142
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
|
+
};
|
|
143
89
|
/* validate a single object (and recursively its childs) against its schema */
|
|
144
90
|
const validateObject = (ctx, object, schema, level) => {
|
|
145
91
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
@@ -152,35 +98,27 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
152
98
|
input, via either "{{<id>}}" or "(<id>)" */
|
|
153
99
|
if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
|
|
154
100
|
ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
|
|
155
|
-
/*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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);
|
|
159
105
|
for (const prop of props) {
|
|
160
106
|
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (prop.optional !== true)
|
|
171
|
-
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 });
|
|
172
116
|
}
|
|
173
|
-
else if (expr !== undefined && !tokenAssigned.has(prop.name))
|
|
174
|
-
checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
|
|
175
117
|
}
|
|
176
118
|
/* report the properties not configured by the schema */
|
|
177
119
|
for (const property of object.properties)
|
|
178
|
-
if (!props.some((p) => p.name ===
|
|
120
|
+
if (!props.some((p) => p.name === property.key))
|
|
179
121
|
ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
|
|
180
|
-
/* a parenthesized token not consumed as a property value acts as
|
|
181
|
-
the implicit anchor (an explicit "{{...}}" anchor takes precedence) */
|
|
182
|
-
if (object.paren !== undefined && !parenConsumed && object.anchor === undefined)
|
|
183
|
-
object.id = object.paren;
|
|
184
122
|
/* check the configured child objects */
|
|
185
123
|
const childs = schema.childs ?? [];
|
|
186
124
|
for (const child of object.childs) {
|
|
@@ -206,27 +144,81 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
206
144
|
};
|
|
207
145
|
object.childs.sort((a, b) => kindPos(a.kind) - kindPos(b.kind));
|
|
208
146
|
const propPos = (key) => {
|
|
209
|
-
const i = props.findIndex((p) => p.name ===
|
|
147
|
+
const i = props.findIndex((p) => p.name === key);
|
|
210
148
|
return i >= 0 ? i : props.length;
|
|
211
149
|
};
|
|
212
150
|
object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
|
|
213
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
|
+
};
|
|
214
196
|
/* validate the parsed specification against the configuration */
|
|
215
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 */
|
|
216
211
|
const position = new Map();
|
|
217
212
|
for (const artifact of specification.artifacts) {
|
|
218
213
|
for (const object of artifact.objects) {
|
|
219
214
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
220
|
-
const schema =
|
|
221
|
-
config.find((s) => s.name !== undefined
|
|
222
|
-
&& s.name.toUpperCase() === plainText(object.name).toUpperCase());
|
|
215
|
+
const schema = schemas.get(object);
|
|
223
216
|
if (schema === undefined) {
|
|
224
217
|
ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
|
|
225
218
|
continue;
|
|
226
219
|
}
|
|
227
220
|
/* the artifact heading must carry the configured kind and
|
|
228
|
-
name verbatim (the resolution
|
|
229
|
-
lenient, so deviations are still precisely reported) */
|
|
221
|
+
name verbatim (the resolution is lenient on purpose) */
|
|
230
222
|
if (object.kind !== schema.kind)
|
|
231
223
|
ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
|
|
232
224
|
if (schema.name !== undefined && plainText(object.name) !== schema.name)
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type SpecArtifact } from "./specbook-format-spec.js";
|
|
2
2
|
import { ParseContext, type SourceFile } from "./specbook-parse-common.js";
|
|
3
|
-
export declare const parseFile: (ctx: ParseContext, source: SourceFile) =>
|
|
3
|
+
export declare const parseFile: (ctx: ParseContext, source: SourceFile) => SpecArtifact[];
|