@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.
@@ -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 ("![alt](file)") */
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 splitItems(plainText(property.value))) {
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 splitItems(plainText(property.value)))
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
- for (const [object, schema] of schemas)
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, schema.props ?? []) === undefined)
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 if (token.type === "table")
384
- ctx.diagnose(source.file, line, "unsupported table content ignored");
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
- }).filter((part) => part !== "");
132
+ });
132
133
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",