@rse/specbook 1.1.0 → 1.2.1
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 +2 -1
- package/dst/specbook-api.js +19 -16
- package/dst/specbook-cli.js +14 -13
- package/dst/specbook-cmd-describe.js +20 -18
- package/dst/specbook-cmd-export.d.ts +3 -1
- package/dst/specbook-cmd-export.js +27 -9
- package/dst/specbook-cmd-preview.js +11 -2
- package/dst/specbook-config.js +90 -74
- package/dst/specbook-diagnostic.js +2 -1
- package/dst/specbook-diagram.d.ts +4 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-common.js +28 -14
- package/dst/specbook-export-html.d.ts +4 -2
- package/dst/specbook-export-html.js +89 -113
- package/dst/specbook-export-md.js +4 -3
- package/dst/specbook-export-pdf.d.ts +1 -1
- package/dst/specbook-export-pdf.js +11 -7
- package/dst/specbook-format.md +13 -9
- package/dst/specbook-format.yaml +1263 -676
- package/dst/specbook-link.d.ts +2 -0
- package/dst/specbook-link.js +77 -52
- package/dst/specbook-mcp.js +5 -6
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +4 -0
- package/dst/specbook-parse-semantic.js +81 -71
- package/dst/specbook-parse-syntax.js +4 -3
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-theme.d.ts +0 -1
- package/dst/specbook-theme.js +2 -3
- package/package.json +11 -9
package/dst/specbook-link.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare const plainText: (text: string) => string;
|
|
|
4
4
|
interface LinkNode {
|
|
5
5
|
object: SpecObject;
|
|
6
6
|
parent?: LinkNode;
|
|
7
|
+
pos: number;
|
|
8
|
+
name: string;
|
|
7
9
|
}
|
|
8
10
|
export type LinkIndex = LinkNode[];
|
|
9
11
|
export declare const chainOf: (index: LinkIndex, object: SpecObject) => SpecObject[];
|
package/dst/specbook-link.js
CHANGED
|
@@ -8,22 +8,37 @@ export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
|
|
|
8
8
|
/* strip the inline code markup of a name or property value
|
|
9
9
|
(preserved in the AST for rendering) for matching and labeling */
|
|
10
10
|
export const plainText = (text) => text.replace(/`/g, "");
|
|
11
|
-
/* the
|
|
12
|
-
the first scoped resolution (as the index itself stays a plain list) */
|
|
11
|
+
/* provide the lookup structures of an index, built once on first use */
|
|
13
12
|
const lookups = new WeakMap();
|
|
14
|
-
const lookup = (index
|
|
15
|
-
let
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
const lookup = (index) => {
|
|
14
|
+
let result = lookups.get(index);
|
|
15
|
+
if (result === undefined) {
|
|
16
|
+
result = { nodes: new Map(), byKey: new Map(), byName: new Map(), byKind: new Map(),
|
|
17
|
+
sets: new Map(), uniques: new Map() };
|
|
18
|
+
const add = (map, key, node) => {
|
|
19
|
+
const nodes = map.get(key);
|
|
20
|
+
if (nodes === undefined)
|
|
21
|
+
map.set(key, [node]);
|
|
22
|
+
else
|
|
23
|
+
nodes.push(node);
|
|
24
|
+
};
|
|
25
|
+
for (const node of index) {
|
|
26
|
+
result.nodes.set(node.object, node);
|
|
27
|
+
add(result.byKey, node.object.id, node);
|
|
28
|
+
if (node.object.anchor !== undefined && node.object.anchor !== node.object.id)
|
|
29
|
+
add(result.byKey, node.object.anchor, node);
|
|
30
|
+
add(result.byName, node.name, node);
|
|
31
|
+
add(result.byKind, node.object.kind, node);
|
|
32
|
+
}
|
|
33
|
+
lookups.set(index, result);
|
|
19
34
|
}
|
|
20
|
-
return
|
|
35
|
+
return result;
|
|
21
36
|
};
|
|
22
37
|
/* the ancestor-or-self chain of an object, root first
|
|
23
38
|
(empty for an object unknown to the index) */
|
|
24
39
|
export const chainOf = (index, object) => {
|
|
25
40
|
const chain = new Array();
|
|
26
|
-
for (let node = lookup(index
|
|
41
|
+
for (let node = lookup(index).nodes.get(object); node !== undefined; node = node.parent)
|
|
27
42
|
chain.unshift(node.object);
|
|
28
43
|
return chain;
|
|
29
44
|
};
|
|
@@ -31,7 +46,7 @@ export const chainOf = (index, object) => {
|
|
|
31
46
|
export const buildLinkIndex = (specification) => {
|
|
32
47
|
const index = [];
|
|
33
48
|
const walk = (object, parent) => {
|
|
34
|
-
const node = { object, parent };
|
|
49
|
+
const node = { object, parent, pos: index.length, name: plainText(object.name) };
|
|
35
50
|
index.push(node);
|
|
36
51
|
for (const child of object.childs)
|
|
37
52
|
walk(child, node);
|
|
@@ -58,40 +73,53 @@ const splitQuoted = (text, separator) => {
|
|
|
58
73
|
};
|
|
59
74
|
/* strip the optional surrounding double quotes of a part */
|
|
60
75
|
const unquote = (part) => part.replace(/^"(.*)"$/s, "$1");
|
|
61
|
-
|
|
62
|
-
const matchesPart = (object, part) => part === "*"
|
|
63
|
-
|| object.id === part
|
|
64
|
-
|| object.anchor === part
|
|
65
|
-
|| plainText(object.name) === part;
|
|
66
|
-
/* match a single reference segment ("id", "name", "KIND:name-or-id",
|
|
67
|
-
"KIND:*", or "*", each part optionally double-quoted to allow
|
|
68
|
-
spaces, dots, and colons) against a single object */
|
|
69
|
-
const matchesSegment = (object, segment) => {
|
|
76
|
+
const parseSegment = (segment) => {
|
|
70
77
|
const parts = splitQuoted(segment, ":");
|
|
71
78
|
if (parts.length >= 2)
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
return { kind: unquote(parts[0].trim()), part: unquote(parts.slice(1).join(":").trim()) };
|
|
80
|
+
return { part: unquote(segment.trim()) };
|
|
81
|
+
};
|
|
82
|
+
/* match a parsed segment against a single node (with "*" wildcard) */
|
|
83
|
+
const matchesSegment = (node, segment) => (segment.kind === undefined || node.object.kind === segment.kind)
|
|
84
|
+
&& (segment.part === "*"
|
|
85
|
+
|| node.object.id === segment.part
|
|
86
|
+
|| node.object.anchor === segment.part
|
|
87
|
+
|| node.name === segment.part);
|
|
88
|
+
/* the candidate nodes matching a parsed segment, in index order,
|
|
89
|
+
found through the lookup structures instead of an index scan */
|
|
90
|
+
const candidates = (index, segment) => {
|
|
91
|
+
const { byKey, byName, byKind } = lookup(index);
|
|
92
|
+
if (segment.part === "*")
|
|
93
|
+
return segment.kind !== undefined ? (byKind.get(segment.kind) ?? []) : index;
|
|
94
|
+
const nodes = Array.from(new Set([...(byKey.get(segment.part) ?? []), ...(byName.get(segment.part) ?? [])]))
|
|
95
|
+
.sort((a, b) => a.pos - b.pos);
|
|
96
|
+
return segment.kind !== undefined ? nodes.filter((node) => node.object.kind === segment.kind) : nodes;
|
|
75
97
|
};
|
|
76
98
|
/* split a reference into its hierarchical path segments,
|
|
77
99
|
honoring double-quoted parts */
|
|
78
100
|
const segmentsOf = (reference) => splitQuoted(reference, ".").map((segment) => segment.trim()).filter((segment) => segment !== "");
|
|
79
101
|
/* resolve a reference into the set of all matching objects: a path
|
|
80
102
|
matches object chains connected by direct parent-to-child steps,
|
|
81
|
-
with the leading segments up to the root freely omittable
|
|
103
|
+
with the leading segments up to the root freely omittable (the
|
|
104
|
+
candidates of the last segment are walked up along the parents) */
|
|
82
105
|
export const resolveSet = (index, reference) => {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
const { sets } = lookup(index);
|
|
107
|
+
let matches = sets.get(reference);
|
|
108
|
+
if (matches === undefined) {
|
|
109
|
+
const segments = segmentsOf(reference).map(parseSegment);
|
|
110
|
+
matches = segments.length === 0 ? [] :
|
|
111
|
+
candidates(index, segments[segments.length - 1]).filter((node) => {
|
|
112
|
+
let current = node.parent;
|
|
113
|
+
for (let i = segments.length - 2; i >= 0; i--) {
|
|
114
|
+
if (current === undefined || !matchesSegment(current, segments[i]))
|
|
115
|
+
return false;
|
|
116
|
+
current = current.parent;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}).map((node) => node.object);
|
|
120
|
+
sets.set(reference, matches);
|
|
121
|
+
}
|
|
122
|
+
return matches;
|
|
95
123
|
};
|
|
96
124
|
/* narrow several matches down to the ones nearest to the referencing
|
|
97
125
|
object, i.e. sharing the longest ancestor chain with it (the lexical
|
|
@@ -115,26 +143,23 @@ const nearest = (index, matches, from) => {
|
|
|
115
143
|
the first variant yielding matches decides, while a hierarchical path
|
|
116
144
|
resolves via its full match set; several matches are narrowed down to
|
|
117
145
|
the ones nearest to the referencing object (if known) and remain an
|
|
118
|
-
ambiguity only if still more than one remains
|
|
146
|
+
ambiguity only if still more than one remains (the scope-independent
|
|
147
|
+
match set is memoized per reference) */
|
|
119
148
|
export const resolveUnique = (index, reference, from) => {
|
|
120
|
-
const
|
|
121
|
-
let matches =
|
|
122
|
-
if (
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
(
|
|
128
|
-
|
|
129
|
-
];
|
|
130
|
-
for (const variant of variants) {
|
|
131
|
-
matches = index.filter((node) => variant(node.object)).map((node) => node.object);
|
|
132
|
-
if (matches.length > 0)
|
|
133
|
-
break;
|
|
149
|
+
const { byKey, byName, uniques } = lookup(index);
|
|
150
|
+
let matches = uniques.get(reference);
|
|
151
|
+
if (matches === undefined) {
|
|
152
|
+
const segments = segmentsOf(reference);
|
|
153
|
+
if (segments.length === 1) {
|
|
154
|
+
const segment = segments[0];
|
|
155
|
+
const unquoted = unquote(segment);
|
|
156
|
+
matches = (byKey.get(unquoted) ?? byName.get(unquoted) ?? candidates(index, parseSegment(segment)))
|
|
157
|
+
.map((node) => node.object);
|
|
134
158
|
}
|
|
159
|
+
else
|
|
160
|
+
matches = resolveSet(index, reference);
|
|
161
|
+
uniques.set(reference, matches);
|
|
135
162
|
}
|
|
136
|
-
else
|
|
137
|
-
matches = resolveSet(index, reference);
|
|
138
163
|
if (matches.length > 1 && from !== undefined)
|
|
139
164
|
matches = nearest(index, matches, from);
|
|
140
165
|
return { target: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
|
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, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts,
|
|
10
|
+
import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, 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);
|
|
@@ -78,7 +78,7 @@ export const serveMcp = async (verbose) => {
|
|
|
78
78
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
79
79
|
format: z.enum(formats).optional().describe("output format (default: inferred from the " +
|
|
80
80
|
"output file extension, else json)"),
|
|
81
|
-
output: z.string().optional().describe("output file path (\"-\" returns the result directly)")
|
|
81
|
+
output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)")
|
|
82
82
|
}
|
|
83
83
|
}, async (args) => {
|
|
84
84
|
try {
|
|
@@ -120,7 +120,7 @@ export const serveMcp = async (verbose) => {
|
|
|
120
120
|
basedir: z.string().optional().describe("base directory of the specification Markdown files"),
|
|
121
121
|
embed: z.boolean().optional().describe("embed the given YAML schema configuration instead " +
|
|
122
122
|
"of just referencing it (default: false; the bundled standard one is always embedded)"),
|
|
123
|
-
compress: z.
|
|
123
|
+
compress: z.literal(compressLevels).optional().describe("compression level of the emitted " +
|
|
124
124
|
"YAML schema configuration (embedded or raw): 0 for verbatim, 1 for re-emitted with 2-space " +
|
|
125
125
|
"indentation and without comments, 2 for additionally without its \"refs\" fields, or 3 for " +
|
|
126
126
|
"additionally without its \"desc\" fields (default: 1)"),
|
|
@@ -134,10 +134,9 @@ export const serveMcp = async (verbose) => {
|
|
|
134
134
|
}
|
|
135
135
|
}, async (args) => {
|
|
136
136
|
try {
|
|
137
|
-
const text = await specbook.describe(
|
|
138
|
-
compress: args.compress !== undefined ? parseCompressLevel(args.compress) : undefined });
|
|
137
|
+
const text = await specbook.describe(args);
|
|
139
138
|
if (args.output !== undefined && args.output !== "-") {
|
|
140
|
-
await fs.promises.writeFile(args.output, text
|
|
139
|
+
await fs.promises.writeFile(args.output, text);
|
|
141
140
|
return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
|
|
142
141
|
}
|
|
143
142
|
return { content: [{ type: "text", text }] };
|
|
@@ -9,6 +9,10 @@ export class ParseContext {
|
|
|
9
9
|
objectMeta = new WeakMap();
|
|
10
10
|
propMeta = new WeakMap();
|
|
11
11
|
linkIndex = [];
|
|
12
|
+
/* the synthetic properties supplied by the parenthesized name
|
|
13
|
+
tokens consumed as property values (kept outside the AST, so
|
|
14
|
+
the tokens stay plain heading markers on export) */
|
|
15
|
+
parenProps = new WeakMap();
|
|
12
16
|
/* the resolved paths of the embedded asset files, recorded even for
|
|
13
17
|
an unreadable one, as a watching consumer has to observe it, too */
|
|
14
18
|
assets = new Set();
|
|
@@ -41,8 +41,13 @@ const matchAlternatives = (ctx, object, alternatives, item) => {
|
|
|
41
41
|
}
|
|
42
42
|
return false;
|
|
43
43
|
};
|
|
44
|
-
/* find a property of an object by its key (case-sensitive)
|
|
45
|
-
|
|
44
|
+
/* find a property of an object by its key (case-sensitive), including
|
|
45
|
+
the synthetic one supplied by a consumed parenthesized name token */
|
|
46
|
+
const findProp = (ctx, object, name) => {
|
|
47
|
+
const synthetic = ctx.parenProps.get(object);
|
|
48
|
+
return object.properties.find((p) => p.key === name) ??
|
|
49
|
+
(synthetic?.key === name ? synthetic : undefined);
|
|
50
|
+
};
|
|
46
51
|
/* split a multi-valued property value into its items, reporting an
|
|
47
52
|
empty one -- stemming from an empty value or a stray comma -- once,
|
|
48
53
|
as such an item can never satisfy any constraint */
|
|
@@ -114,9 +119,50 @@ const parenProp = (object, props) => {
|
|
|
114
119
|
const paren = object.paren;
|
|
115
120
|
if (paren === undefined)
|
|
116
121
|
return undefined;
|
|
117
|
-
return props.find((prop) => prop.value !== undefined &&
|
|
122
|
+
return props.find((prop) => prop.value !== undefined && !object.properties.some((p) => p.key === prop.name)
|
|
118
123
|
&& directMatches(compileValueExpr(prop.value), paren));
|
|
119
124
|
};
|
|
125
|
+
/* check the property values flagged unique for distinctness among
|
|
126
|
+
the sibling objects of the same kind and the ones flagged present
|
|
127
|
+
for occurring on at least one of the (existing) siblings (all
|
|
128
|
+
values, or only the values matching the regexp or enum expression
|
|
129
|
+
of the flag) */
|
|
130
|
+
const checkSiblingFlags = (ctx, object, childs, meta) => {
|
|
131
|
+
const marker = (flag) => flag === undefined || flag === false ? undefined :
|
|
132
|
+
{ filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
|
|
133
|
+
for (const child of childs) {
|
|
134
|
+
const siblings = object.childs.filter((c) => c.kind === child.kind);
|
|
135
|
+
for (const prop of child.props ?? []) {
|
|
136
|
+
const unique = marker(prop.unique);
|
|
137
|
+
const present = marker(prop.present);
|
|
138
|
+
if (unique === undefined && present === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
const seen = new Map();
|
|
141
|
+
let found = false;
|
|
142
|
+
for (const sibling of siblings) {
|
|
143
|
+
const property = findProp(ctx, sibling, prop.name);
|
|
144
|
+
if (property === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
const value = plainText(property.value).trim();
|
|
147
|
+
if (present !== undefined && (present.filter === undefined || directMatches(present.filter, value)))
|
|
148
|
+
found = true;
|
|
149
|
+
if (unique === undefined || (unique.filter !== undefined && !directMatches(unique.filter, value)))
|
|
150
|
+
continue;
|
|
151
|
+
const first = seen.get(value);
|
|
152
|
+
if (first === undefined)
|
|
153
|
+
seen.set(value, sibling);
|
|
154
|
+
else {
|
|
155
|
+
const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
|
|
156
|
+
ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
|
|
157
|
+
`already used by preceding ${sibling.kind} "${first.name}"`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (present !== undefined && siblings.length > 0 && !found)
|
|
161
|
+
ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
|
|
162
|
+
(typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
120
166
|
/* validate a single object (and recursively its childs) against its schema */
|
|
121
167
|
const validateObject = (ctx, object, schema, level) => {
|
|
122
168
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
@@ -132,11 +178,11 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
132
178
|
/* check every occurrence of the configured properties (a trailing
|
|
133
179
|
parenthesized name token is accepted as the value of a still
|
|
134
180
|
missing property, and a repeated key is reported as duplicate) */
|
|
135
|
-
const consumed =
|
|
181
|
+
const consumed = ctx.parenProps.get(object);
|
|
136
182
|
for (const prop of props) {
|
|
137
183
|
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
138
184
|
const matches = object.properties.filter((p) => p.key === prop.name);
|
|
139
|
-
if (matches.length === 0 && prop !== consumed && prop.optional !== true)
|
|
185
|
+
if (matches.length === 0 && prop.name !== consumed?.key && prop.optional !== true)
|
|
140
186
|
ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
|
|
141
187
|
for (const [i, match] of matches.entries()) {
|
|
142
188
|
const line = ctx.propMeta.get(match)?.line ?? meta.line;
|
|
@@ -161,45 +207,8 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
161
207
|
}
|
|
162
208
|
validateObject(ctx, child, childSchema, level + 1);
|
|
163
209
|
}
|
|
164
|
-
/* check the property
|
|
165
|
-
|
|
166
|
-
for occurring on at least one of the (existing) siblings (all
|
|
167
|
-
values, or only the values matching the regexp or enum expression
|
|
168
|
-
of the flag) */
|
|
169
|
-
for (const child of childs) {
|
|
170
|
-
const siblings = object.childs.filter((c) => c.kind === child.kind);
|
|
171
|
-
for (const prop of child.props ?? []) {
|
|
172
|
-
const marker = (flag) => flag === undefined || flag === false ? undefined :
|
|
173
|
-
{ filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
|
|
174
|
-
const unique = marker(prop.unique);
|
|
175
|
-
const present = marker(prop.present);
|
|
176
|
-
if (unique === undefined && present === undefined)
|
|
177
|
-
continue;
|
|
178
|
-
const seen = new Map();
|
|
179
|
-
let found = false;
|
|
180
|
-
for (const sibling of siblings) {
|
|
181
|
-
const property = findProp(sibling, prop.name);
|
|
182
|
-
if (property === undefined)
|
|
183
|
-
continue;
|
|
184
|
-
const value = plainText(property.value).trim();
|
|
185
|
-
if (present !== undefined && (present.filter === undefined || directMatches(present.filter, value)))
|
|
186
|
-
found = true;
|
|
187
|
-
if (unique === undefined || (unique.filter !== undefined && !directMatches(unique.filter, value)))
|
|
188
|
-
continue;
|
|
189
|
-
const first = seen.get(value);
|
|
190
|
-
if (first === undefined)
|
|
191
|
-
seen.set(value, sibling);
|
|
192
|
-
else {
|
|
193
|
-
const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
|
|
194
|
-
ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
|
|
195
|
-
`already used by preceding ${sibling.kind} "${first.name}"`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (present !== undefined && siblings.length > 0 && !found)
|
|
199
|
-
ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
|
|
200
|
-
(typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
210
|
+
/* check the sibling-scoped property flags of the child kinds */
|
|
211
|
+
checkSiblingFlags(ctx, object, childs, meta);
|
|
203
212
|
/* report the configured child object kinds which are missing */
|
|
204
213
|
for (const child of childs)
|
|
205
214
|
if (child.optional !== true
|
|
@@ -222,7 +231,7 @@ const validateObject = (ctx, object, schema, level) => {
|
|
|
222
231
|
/* resolve a level 1 object onto its configured artifact, deliberately
|
|
223
232
|
leniently by kind and id first and by case-insensitive name second,
|
|
224
233
|
so deviations of the heading can still be precisely reported */
|
|
225
|
-
export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
234
|
+
export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || (s.id !== undefined && `${s.kind}-${s.id}` === object.id)) ??
|
|
226
235
|
config.find((s) => s.name !== undefined
|
|
227
236
|
&& s.name.toUpperCase() === plainText(object.name).toUpperCase());
|
|
228
237
|
/* map the specification objects onto their schema configuration
|
|
@@ -267,7 +276,7 @@ const checkIds = (ctx, objects) => {
|
|
|
267
276
|
distinct, in document order), leniently skipping the unresolvable
|
|
268
277
|
ones, as those are already reported by the reference pass */
|
|
269
278
|
const referencedObjects = (ctx, object, name) => {
|
|
270
|
-
const property = findProp(object, name);
|
|
279
|
+
const property = findProp(ctx, object, name);
|
|
271
280
|
if (property === undefined)
|
|
272
281
|
return [];
|
|
273
282
|
const targets = new Array();
|
|
@@ -296,7 +305,7 @@ const checkAutomata = (ctx, schemas) => {
|
|
|
296
305
|
const nodes = object.childs.filter((child) => child.kind === automaton.nodes);
|
|
297
306
|
const nodeSet = new Set(nodes);
|
|
298
307
|
const flagged = (node, name) => {
|
|
299
|
-
const property = findProp(node, name);
|
|
308
|
+
const property = findProp(ctx, node, name);
|
|
300
309
|
return property !== undefined && plainText(property.value).trim() === "true";
|
|
301
310
|
};
|
|
302
311
|
const initials = nodes.filter((node) => flagged(node, automaton.initial));
|
|
@@ -317,7 +326,7 @@ const checkAutomata = (ctx, schemas) => {
|
|
|
317
326
|
const closure = (starts, next) => {
|
|
318
327
|
const seen = new Set(starts);
|
|
319
328
|
const queue = [...starts];
|
|
320
|
-
for (
|
|
329
|
+
for (const node of queue)
|
|
321
330
|
for (const other of next.get(node) ?? [])
|
|
322
331
|
if (!seen.has(other)) {
|
|
323
332
|
seen.add(other);
|
|
@@ -349,13 +358,18 @@ const checkRelations = (ctx, schemas) => {
|
|
|
349
358
|
const groups = new Map();
|
|
350
359
|
for (const [object, schema] of schemas)
|
|
351
360
|
for (const prop of schema.props ?? [])
|
|
352
|
-
if (prop.symmetric === true || prop.acyclic === true)
|
|
353
|
-
|
|
361
|
+
if (prop.symmetric === true || prop.acyclic === true) {
|
|
362
|
+
const group = groups.get(prop);
|
|
363
|
+
if (group === undefined)
|
|
364
|
+
groups.set(prop, [object]);
|
|
365
|
+
else
|
|
366
|
+
group.push(object);
|
|
367
|
+
}
|
|
354
368
|
for (const [prop, objects] of groups) {
|
|
355
369
|
const members = new Set(objects);
|
|
356
370
|
const locate = (object) => {
|
|
357
371
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
358
|
-
const property = findProp(object, prop.name);
|
|
372
|
+
const property = findProp(ctx, object, prop.name);
|
|
359
373
|
const line = property !== undefined ? ctx.propMeta.get(property)?.line : undefined;
|
|
360
374
|
return { file: meta.file, line: line ?? meta.line };
|
|
361
375
|
};
|
|
@@ -403,11 +417,11 @@ const checkRelations = (ctx, schemas) => {
|
|
|
403
417
|
the references from within the subtree of the object itself do not
|
|
404
418
|
count (a lapse is a warning only, as the specification stays valid) */
|
|
405
419
|
const checkReferenced = (ctx, specification, schemas) => {
|
|
406
|
-
/* collect the referencing objects of every referenced object
|
|
407
|
-
|
|
420
|
+
/* collect the referencing objects of every referenced object
|
|
421
|
+
(the references from within its own subtree excluded) */
|
|
408
422
|
const referrers = new Map();
|
|
409
|
-
const
|
|
410
|
-
|
|
423
|
+
const walk = (object) => {
|
|
424
|
+
const chain = chainOf(ctx.linkIndex, object);
|
|
411
425
|
const texts = [object.name, ...object.properties.map((p) => p.value)];
|
|
412
426
|
if (object.description !== undefined)
|
|
413
427
|
texts.push(object.description.description, object.description.rationale ?? "");
|
|
@@ -417,14 +431,10 @@ const checkReferenced = (ctx, specification, schemas) => {
|
|
|
417
431
|
if (target !== undefined && !chain.includes(target))
|
|
418
432
|
referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
|
|
419
433
|
}
|
|
420
|
-
|
|
421
|
-
parents.set(child, object);
|
|
422
|
-
walk(child, [...chain, child]);
|
|
423
|
-
}
|
|
434
|
+
object.childs.forEach(walk);
|
|
424
435
|
};
|
|
425
436
|
for (const artifact of specification.artifacts)
|
|
426
|
-
|
|
427
|
-
walk(object, [object]);
|
|
437
|
+
artifact.objects.forEach(walk);
|
|
428
438
|
/* resolve the reference patterns of every flagged kind into the
|
|
429
439
|
admissible referencing objects (once per shared schema node) */
|
|
430
440
|
const sources = new Map();
|
|
@@ -442,12 +452,8 @@ const checkReferenced = (ctx, specification, schemas) => {
|
|
|
442
452
|
}
|
|
443
453
|
sources.set(schema, admissible);
|
|
444
454
|
}
|
|
445
|
-
const covered = Array.from(referrers.get(object) ?? [])
|
|
446
|
-
|
|
447
|
-
if (admissible.has(o))
|
|
448
|
-
return true;
|
|
449
|
-
return false;
|
|
450
|
-
});
|
|
455
|
+
const covered = Array.from(referrers.get(object) ?? [])
|
|
456
|
+
.some((referrer) => chainOf(ctx.linkIndex, referrer).some((o) => admissible.has(o)));
|
|
451
457
|
if (!covered) {
|
|
452
458
|
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
453
459
|
ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
|
|
@@ -469,9 +475,13 @@ export const validate = (ctx, specification, config) => {
|
|
|
469
475
|
token, so the token still acts as the id and a single
|
|
470
476
|
unresolved heading no longer cascades into unresolvable
|
|
471
477
|
references all over the corpus */
|
|
472
|
-
if (object.paren !== undefined
|
|
473
|
-
|
|
474
|
-
|
|
478
|
+
if (object.paren !== undefined) {
|
|
479
|
+
const prop = parenProp(object, schemas.get(object)?.props ?? []);
|
|
480
|
+
if (prop !== undefined)
|
|
481
|
+
ctx.parenProps.set(object, { key: prop.name, value: object.paren });
|
|
482
|
+
else if (object.anchor === undefined)
|
|
483
|
+
object.id = object.paren;
|
|
484
|
+
}
|
|
475
485
|
object.childs.forEach(promote);
|
|
476
486
|
};
|
|
477
487
|
for (const artifact of specification.artifacts)
|
|
@@ -513,7 +523,7 @@ export const validate = (ctx, specification, config) => {
|
|
|
513
523
|
its carrying object), independent of any configuration */
|
|
514
524
|
export const validateReferences = (ctx, specification) => {
|
|
515
525
|
const check = (object, text, file, line) => {
|
|
516
|
-
for (const m of text.matchAll(referenceRegex)) {
|
|
526
|
+
for (const m of plainText(text).matchAll(referenceRegex)) {
|
|
517
527
|
const ref = m[1].trim();
|
|
518
528
|
const resolution = resolveUnique(ctx.linkIndex, ref, object);
|
|
519
529
|
if (resolution.ambiguous)
|
|
@@ -27,8 +27,8 @@ const parseMarkers = (raw) => {
|
|
|
27
27
|
let primary = false;
|
|
28
28
|
for (;;) {
|
|
29
29
|
const am = text.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
30
|
-
if (am !== null && am.index !== undefined) {
|
|
31
|
-
anchor
|
|
30
|
+
if (am !== null && am.index !== undefined && anchor === undefined) {
|
|
31
|
+
anchor = am[1].trim();
|
|
32
32
|
text = text.slice(0, am.index).trim();
|
|
33
33
|
continue;
|
|
34
34
|
}
|
|
@@ -48,7 +48,8 @@ const parseMarkers = (raw) => {
|
|
|
48
48
|
}
|
|
49
49
|
return { text, anchor, paren, primary };
|
|
50
50
|
};
|
|
51
|
-
/* parse a heading text of the form
|
|
51
|
+
/* parse a heading text of the form
|
|
52
|
+
"<KIND>: <name> [(<token>)] [(*)] [{{<anchor>}}] [<a id="..."></a>]" */
|
|
52
53
|
const parseHeadingText = (raw) => {
|
|
53
54
|
let text = raw.trim();
|
|
54
55
|
let id;
|
package/dst/specbook-parse.js
CHANGED
|
@@ -33,7 +33,7 @@ export const parseSpecification = (sources, config) => {
|
|
|
33
33
|
if (!result.success)
|
|
34
34
|
for (const issue of result.issues) {
|
|
35
35
|
const path = v.getDotPath(issue) ?? "";
|
|
36
|
-
ctx.diagnose(sources[0]
|
|
36
|
+
ctx.diagnose(sources[0].file, 1, `internal AST invalid at "${path}": ${issue.message}`);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
return { specification, diagnostics: ctx.diagnostics, assets: Array.from(ctx.assets) };
|
package/dst/specbook-theme.d.ts
CHANGED
|
@@ -7,7 +7,6 @@ export type ThemeColors = {
|
|
|
7
7
|
export declare const themeColors: (tone: string) => ThemeColors;
|
|
8
8
|
export declare const themeStylesheet: (colors: ThemeColors) => string;
|
|
9
9
|
export type ThemeMapping = {
|
|
10
|
-
text: string;
|
|
11
10
|
muted: string;
|
|
12
11
|
symbol: string;
|
|
13
12
|
border: string;
|
package/dst/specbook-theme.js
CHANGED
|
@@ -26,10 +26,9 @@ export const themeStylesheet = (colors) => ":root {\n" +
|
|
|
26
26
|
aligned with the layer-2 CSS mapping of the HTML rendering */
|
|
27
27
|
export const themeMapping = (colors, style) => {
|
|
28
28
|
const idx = style === "dark" ?
|
|
29
|
-
{
|
|
30
|
-
{
|
|
29
|
+
{ muted: 22, symbol: 20, border: 16, accent: 24 } :
|
|
30
|
+
{ muted: 22, symbol: 24, border: 28, accent: 12 };
|
|
31
31
|
return {
|
|
32
|
-
text: colors.base[idx.text - 1],
|
|
33
32
|
muted: colors.base[idx.muted - 1],
|
|
34
33
|
symbol: colors.base[idx.symbol - 1],
|
|
35
34
|
border: colors.base[idx.border - 1],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rse/specbook",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"description": "Markdown-based Specification Format (API, CLI and MCP service)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"yaml": "2.9.0",
|
|
23
23
|
"json5": "2.2.3",
|
|
24
24
|
"pdf-lib": "1.17.1",
|
|
25
|
-
"subset-font": "2.
|
|
25
|
+
"subset-font": "2.7.0",
|
|
26
26
|
"@rse/mrcs": "0.9.2",
|
|
27
|
-
"@rse/gradia": "1.1.
|
|
27
|
+
"@rse/gradia": "1.1.7",
|
|
28
28
|
"@toon-format/toon": "4.1.1",
|
|
29
29
|
"valibot": "1.4.2",
|
|
30
30
|
"nunjucks": "3.2.4",
|
|
@@ -42,26 +42,28 @@
|
|
|
42
42
|
"fastify": "5.12.1",
|
|
43
43
|
"@fastify/websocket": "11.3.0",
|
|
44
44
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
45
|
-
"zod": "4.5.
|
|
45
|
+
"zod": "4.5.4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"chokidar-cli": "3.0.0",
|
|
49
49
|
"eslint": "10.9.0",
|
|
50
50
|
"@eslint/js": "10.0.1",
|
|
51
|
-
"typescript-eslint": "8.
|
|
51
|
+
"typescript-eslint": "8.69.0",
|
|
52
52
|
"eslint-plugin-promise": "7.3.0",
|
|
53
53
|
"neostandard": "0.14.0-next.1",
|
|
54
|
-
"globals": "17.
|
|
55
|
-
"
|
|
54
|
+
"globals": "17.12.0",
|
|
55
|
+
"markdownlint": "0.41.1",
|
|
56
|
+
"markdownlint-cli2": "0.23.2",
|
|
57
|
+
"@types/node": "26.4.1",
|
|
56
58
|
"@types/subset-font": "2.3.0",
|
|
57
59
|
"@types/nunjucks": "3.2.6",
|
|
58
60
|
"@types/ws": "8.18.1",
|
|
59
61
|
"typescript": "6.0.3",
|
|
60
|
-
"tsx": "4.23.
|
|
62
|
+
"tsx": "4.23.13",
|
|
61
63
|
"source-sans": "3.52.0",
|
|
62
64
|
"source-code-pro": "2.42.0",
|
|
63
65
|
"@fontsource/noto-sans-symbols": "5.3.0",
|
|
64
|
-
"postcss": "8.5.
|
|
66
|
+
"postcss": "8.5.28",
|
|
65
67
|
"postcss-cli": "11.0.1",
|
|
66
68
|
"postcss-url": "10.1.4",
|
|
67
69
|
"stylus": "0.64.0",
|