@rse/specbook 0.9.7 → 0.9.9
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 +19 -13
- package/dst/specbook-api.d.ts +5 -4
- package/dst/specbook-api.js +14 -10
- package/dst/specbook-cli.js +3 -2
- package/dst/specbook-cmd-describe.js +3 -6
- package/dst/specbook-cmd-export.js +7 -3
- package/dst/specbook-cmd-lint.js +1 -1
- package/dst/specbook-config.js +14 -4
- package/dst/specbook-diagram.d.ts +5 -1
- package/dst/specbook-diagram.js +31 -19
- package/dst/specbook-export-html.js +2 -1
- package/dst/specbook-export-md.js +21 -14
- package/dst/specbook-format.md +28 -15
- package/dst/specbook-format.yaml +1484 -0
- package/dst/specbook-mcp.js +6 -3
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.js +22 -4
- package/dst/specbook-parse-syntax.js +22 -7
- package/dst/specbook-parse-value.js +3 -2
- package/package.json +1 -1
package/dst/specbook-mcp.js
CHANGED
|
@@ -27,7 +27,8 @@ export const serveMcp = async (verbose) => {
|
|
|
27
27
|
description: "Initialize the configured specification artifact files below the base directory " +
|
|
28
28
|
"with their frontmatter and artifact heading, skipping already existing files.",
|
|
29
29
|
inputSchema: {
|
|
30
|
-
config: z.string().describe("YAML schema configuration file"
|
|
30
|
+
config: z.string().optional().describe("YAML schema configuration file " +
|
|
31
|
+
"(default: the bundled standard schema configuration)"),
|
|
31
32
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
|
|
32
33
|
}
|
|
33
34
|
}, async (args) => {
|
|
@@ -46,7 +47,8 @@ export const serveMcp = async (verbose) => {
|
|
|
46
47
|
description: "Lint the specification Markdown files the YAML schema configuration references " +
|
|
47
48
|
"below the base directory against this configuration and return all diagnostics.",
|
|
48
49
|
inputSchema: {
|
|
49
|
-
config: z.string().describe("YAML schema configuration file"
|
|
50
|
+
config: z.string().optional().describe("YAML schema configuration file " +
|
|
51
|
+
"(default: the bundled standard schema configuration)"),
|
|
50
52
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
|
|
51
53
|
}
|
|
52
54
|
}, async (args) => {
|
|
@@ -68,7 +70,8 @@ export const serveMcp = async (verbose) => {
|
|
|
68
70
|
"YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
|
|
69
71
|
"if an output path is given, else it is returned directly (PDF as a base64-encoded resource).",
|
|
70
72
|
inputSchema: {
|
|
71
|
-
config: z.string().describe("YAML schema configuration file"
|
|
73
|
+
config: z.string().optional().describe("YAML schema configuration file " +
|
|
74
|
+
"(default: the bundled standard schema configuration)"),
|
|
72
75
|
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
73
76
|
format: z.enum(formats).optional().describe("output format (default: inferred from the " +
|
|
74
77
|
"output file extension, else json)"),
|
|
@@ -22,6 +22,7 @@ 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
27
|
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
27
28
|
export declare const embeddingVariants: (reference: string) => string[];
|
|
@@ -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 */
|
|
@@ -36,6 +36,15 @@ const matchAlternatives = (ctx, alternatives, item) => {
|
|
|
36
36
|
};
|
|
37
37
|
/* find a property of an object by its key (case-sensitive) */
|
|
38
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
|
+
};
|
|
39
48
|
/* check a property value against the compiled value expression of its
|
|
40
49
|
configured schema property */
|
|
41
50
|
const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
@@ -56,7 +65,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
56
65
|
/* a tags constraint: the value is a comma-separated set of
|
|
57
66
|
configured tags, each occurring at most once */
|
|
58
67
|
const seen = new Set();
|
|
59
|
-
for (const item of
|
|
68
|
+
for (const item of splitPropItems(ctx, prop, property, meta)) {
|
|
60
69
|
if (!expr.members.includes(item))
|
|
61
70
|
ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
|
|
62
71
|
else if (seen.has(item))
|
|
@@ -67,7 +76,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
|
|
|
67
76
|
else if (expr.kind === "list") {
|
|
68
77
|
/* a list constraint: the value is a comma-separated list
|
|
69
78
|
of items, each matching at least one alternative */
|
|
70
|
-
for (const item of
|
|
79
|
+
for (const item of splitPropItems(ctx, prop, property, meta))
|
|
71
80
|
if (!matchAlternatives(ctx, expr.alternatives, item))
|
|
72
81
|
ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
|
|
73
82
|
}
|
|
@@ -201,10 +210,19 @@ export const validate = (ctx, specification, config) => {
|
|
|
201
210
|
precedence), which has to happen before any property check, as
|
|
202
211
|
those resolve references by id across all artifacts */
|
|
203
212
|
const schemas = collectSchemas(specification, config);
|
|
204
|
-
|
|
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 */
|
|
205
219
|
if (object.paren !== undefined && object.anchor === undefined
|
|
206
|
-
&& parenProp(object,
|
|
220
|
+
&& parenProp(object, schemas.get(object)?.props ?? []) === undefined)
|
|
207
221
|
object.id = object.paren;
|
|
222
|
+
object.childs.forEach(promote);
|
|
223
|
+
};
|
|
224
|
+
for (const artifact of specification.artifacts)
|
|
225
|
+
artifact.objects.forEach(promote);
|
|
208
226
|
/* check the ids for local uniqueness, now that all of them are final */
|
|
209
227
|
checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
|
|
210
228
|
/* validate every level 1 object against its artifact schema */
|
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { marked } from "marked";
|
|
9
|
-
import { embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
10
|
-
/* the marker separating a statement from its rationale */
|
|
11
|
-
const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
|
|
9
|
+
import { becauseRegex, embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
12
10
|
/* split a description text into statement and optional rationale */
|
|
13
11
|
const splitDescription = (text) => {
|
|
14
12
|
const m = text.match(becauseRegex);
|
|
@@ -268,7 +266,9 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
268
266
|
};
|
|
269
267
|
/* parse a heading token into either a grouping container (e.g. "### STATE",
|
|
270
268
|
collecting objects of its kind below the parent object) or a new object
|
|
271
|
-
(an artifact on level 1, a child of the enclosing object below)
|
|
269
|
+
(an artifact on level 1, a child of the enclosing object below); a heading
|
|
270
|
+
without a parent object detaches the current object, so the content below
|
|
271
|
+
it is reported, too, instead of being absorbed by the previous object */
|
|
272
272
|
const parseHeading = (ctx, token, state, stamps, file, line) => {
|
|
273
273
|
const { depth, text } = token;
|
|
274
274
|
const heading = parseHeadingText(text);
|
|
@@ -277,8 +277,10 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
|
|
|
277
277
|
ctx.diagnose(file, line, `malformed anchor "${heading.malformed}" in heading`);
|
|
278
278
|
if (heading.name === "" && depth > 1) {
|
|
279
279
|
const parent = state.stack[depth - 2];
|
|
280
|
-
if (parent === undefined)
|
|
280
|
+
if (parent === undefined) {
|
|
281
281
|
ctx.diagnose(file, line, `heading level ${depth} without parent object`);
|
|
282
|
+
state.current = null;
|
|
283
|
+
}
|
|
282
284
|
else {
|
|
283
285
|
state.group = { parent, kind: heading.kind };
|
|
284
286
|
state.current = parent;
|
|
@@ -306,6 +308,7 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
|
|
|
306
308
|
const parent = state.stack[depth - 2];
|
|
307
309
|
if (parent === undefined) {
|
|
308
310
|
ctx.diagnose(file, line, `heading level ${depth} without parent object`);
|
|
311
|
+
state.current = null;
|
|
309
312
|
return;
|
|
310
313
|
}
|
|
311
314
|
parent.childs.push(object);
|
|
@@ -314,6 +317,15 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
|
|
|
314
317
|
state.stack[depth - 1] = object;
|
|
315
318
|
state.current = object;
|
|
316
319
|
};
|
|
320
|
+
/* the block-level token types which carry content no description can
|
|
321
|
+
hold, mapped onto their diagnostic name (the only other unhandled
|
|
322
|
+
type is "space", which carries no content at all) */
|
|
323
|
+
const unsupportedTokens = {
|
|
324
|
+
table: "table",
|
|
325
|
+
html: "raw HTML",
|
|
326
|
+
hr: "horizontal rule",
|
|
327
|
+
def: "link definition"
|
|
328
|
+
};
|
|
317
329
|
/* parse a single source file into its artifacts */
|
|
318
330
|
export const parseFile = (ctx, source) => {
|
|
319
331
|
const { present, created, modified, body, offset } = parseFrontmatter(source.text);
|
|
@@ -380,8 +392,11 @@ export const parseFile = (ctx, source) => {
|
|
|
380
392
|
else
|
|
381
393
|
collect(token.raw, line);
|
|
382
394
|
}
|
|
383
|
-
else
|
|
384
|
-
|
|
395
|
+
else {
|
|
396
|
+
const unsupported = unsupportedTokens[token.type];
|
|
397
|
+
if (unsupported !== undefined)
|
|
398
|
+
ctx.diagnose(source.file, line, `unsupported ${unsupported} content ignored`);
|
|
399
|
+
}
|
|
385
400
|
line += (token.raw.match(/\n/g) ?? []).length;
|
|
386
401
|
}
|
|
387
402
|
flush();
|
|
@@ -98,7 +98,8 @@ export const compileValueExpr = (source) => {
|
|
|
98
98
|
return expr;
|
|
99
99
|
};
|
|
100
100
|
/* split a property value at top-level commas, honoring double-quoted
|
|
101
|
-
sections and "[[...]]" reference bracketing
|
|
101
|
+
sections and "[[...]]" reference bracketing (empty items are kept,
|
|
102
|
+
as only the caller can decide whether to report or skip them) */
|
|
102
103
|
export const splitItems = (text) => {
|
|
103
104
|
const parts = [""];
|
|
104
105
|
let quoted = false;
|
|
@@ -128,5 +129,5 @@ export const splitItems = (text) => {
|
|
|
128
129
|
const item = part.trim();
|
|
129
130
|
const m = item.match(/^"((?:\\"|[^"])*)"$/);
|
|
130
131
|
return m !== null ? m[1].replace(/\\"/g, "\"") : item;
|
|
131
|
-
})
|
|
132
|
+
});
|
|
132
133
|
};
|