@rse/specbook 0.9.2 → 0.9.4

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.
@@ -50,22 +50,12 @@ const matchAlternatives = (ctx, alternatives, item) => {
50
50
  }
51
51
  return false;
52
52
  };
53
- /* validate a single object (and recursively its childs) against its schema */
54
- const validateObject = (ctx, object, schema, level) => {
55
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
56
- const props = schema.props ?? [];
57
- /* check the name convention (the configured name of non-artifact
58
- objects is a regular expression pattern) */
59
- if (level > 1 && schema.name !== undefined && !anchored(schema.name).test(plainText(object.name)))
60
- ctx.diagnose(meta.file, meta.line, `${object.kind} name "${object.name}" does not match pattern "${schema.name}"`);
61
- /* a configured id has to be explicitly specified 1:1 in the
62
- input, via either "{{<id>}}" or "(<id>)" */
63
- if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
64
- ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
65
- /* find a property by its key (case-sensitive) */
66
- const findProp = (name) => object.properties.find((p) => plainKey(p.key) === name);
67
- /* the tokens split off multi-token property values below, assigned
68
- to the still unset configured properties by matching their patterns */
53
+ /* find a property of an object by its key (case-sensitive) */
54
+ const findProp = (object, name) => object.properties.find((p) => plainKey(p.key) === name);
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) => {
69
59
  const tokens = new Array();
70
60
  const tokenAssigned = new Set();
71
61
  /* explode a multi-token property value whose direct pattern check
@@ -77,7 +67,7 @@ const validateObject = (ctx, object, schema, level) => {
77
67
  const expr = compileValueExpr(prop.value);
78
68
  if (expr.kind !== "regex" && expr.kind !== "enum")
79
69
  continue;
80
- const match = findProp(prop.name);
70
+ const match = findProp(object, prop.name);
81
71
  if (match === undefined || directMatches(expr, plainText(match.value)))
82
72
  continue;
83
73
  const parts = plainText(match.value).split(/\s+/);
@@ -91,8 +81,10 @@ const validateObject = (ctx, object, schema, level) => {
91
81
  parts.splice(idx, 1);
92
82
  tokens.push(...parts);
93
83
  }
84
+ /* assign the collected tokens to the still unset configured
85
+ properties whose pattern they match */
94
86
  for (const prop of props) {
95
- if (prop.value === undefined || findProp(prop.name) !== undefined)
87
+ if (prop.value === undefined || findProp(object, prop.name) !== undefined)
96
88
  continue;
97
89
  const expr = compileValueExpr(prop.value);
98
90
  if (expr.kind !== "regex" && expr.kind !== "enum")
@@ -104,13 +96,69 @@ const validateObject = (ctx, object, schema, level) => {
104
96
  tokens.splice(idx, 1);
105
97
  }
106
98
  }
99
+ /* report the tokens which no configured property accepts */
107
100
  for (const token of tokens)
108
101
  ctx.diagnose(meta.file, meta.line, `unassignable inline token "${token}" on ${object.kind} "${object.name}"`);
102
+ return tokenAssigned;
103
+ };
104
+ /* check a property value against the compiled value expression of its
105
+ configured schema property */
106
+ const checkPropValue = (ctx, prop, expr, property, meta) => {
107
+ if (expr.kind === "reference") {
108
+ /* a reference constraint: the value has to be exactly one
109
+ reference resolving into the constraint's wildcard match set */
110
+ if (!/^\[\[[^[\]]+\]\]$/.test(property.value.trim()))
111
+ ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
112
+ else {
113
+ const ref = property.value.trim().slice(2, -2).trim();
114
+ const target = resolveUnique(ctx.linkIndex, ref).target;
115
+ if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
116
+ ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
117
+ }
118
+ }
119
+ else if (expr.kind === "tags") {
120
+ /* a tags constraint: the value is a comma-separated set of
121
+ configured tags, each occurring at most once */
122
+ const seen = new Set();
123
+ for (const item of splitItems(plainText(property.value))) {
124
+ if (!expr.members.includes(item))
125
+ ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
126
+ else if (seen.has(item))
127
+ ctx.diagnose(meta.file, meta.line, `duplicate tag "${item}" on property "${prop.name}"`);
128
+ seen.add(item);
129
+ }
130
+ }
131
+ else if (expr.kind === "list") {
132
+ /* a list constraint: the value is a comma-separated list
133
+ of items, each matching at least one alternative */
134
+ for (const item of splitItems(property.value))
135
+ if (!matchAlternatives(ctx, expr.alternatives, item))
136
+ ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
137
+ }
138
+ else if (!directMatches(expr, plainText(property.value)))
139
+ ctx.diagnose(meta.file, meta.line, expr.kind === "enum" ?
140
+ `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
141
+ `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
142
+ };
143
+ /* validate a single object (and recursively its childs) against its schema */
144
+ const validateObject = (ctx, object, schema, level) => {
145
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
146
+ const props = schema.props ?? [];
147
+ /* check the name convention (the configured name of non-artifact
148
+ objects is a regular expression pattern) */
149
+ if (level > 1 && schema.name !== undefined && !anchored(schema.name).test(plainText(object.name)))
150
+ ctx.diagnose(meta.file, meta.line, `${object.kind} name "${object.name}" does not match pattern "${schema.name}"`);
151
+ /* a configured id has to be explicitly specified 1:1 in the
152
+ input, via either "{{<id>}}" or "(<id>)" */
153
+ if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
154
+ ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
155
+ /* distribute the inline tokens split off the multi-token property values */
156
+ const tokenAssigned = assignInlineTokens(ctx, object, props, meta);
109
157
  /* check the configured properties */
110
158
  let parenConsumed = false;
111
159
  for (const prop of props) {
112
160
  const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
113
- const match = findProp(prop.name);
161
+ const match = findProp(object, prop.name);
114
162
  if (match === undefined) {
115
163
  /* accept a trailing parenthesized name token as the value of a
116
164
  still missing property when it matches the property pattern */
@@ -122,45 +170,10 @@ const validateObject = (ctx, object, schema, level) => {
122
170
  if (prop.optional !== true)
123
171
  ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
124
172
  }
125
- else if (expr !== undefined) {
126
- const line = ctx.propMeta.get(match)?.line ?? meta.line;
127
- if (expr.kind === "reference") {
128
- /* a reference constraint: the value has to be exactly one
129
- reference resolving into the constraint's wildcard match set */
130
- if (!/^\[\[[^[\]]+\]\]$/.test(match.value.trim()))
131
- ctx.diagnose(meta.file, line, `property "${prop.name}" value "${match.value}" is not a single link reference`);
132
- else {
133
- const ref = match.value.trim().slice(2, -2).trim();
134
- const target = resolveUnique(ctx.linkIndex, ref).target;
135
- if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
136
- ctx.diagnose(meta.file, line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
137
- }
138
- }
139
- else if (expr.kind === "tags") {
140
- /* a tags constraint: the value is a comma-separated set of
141
- configured tags, each occurring at most once */
142
- const seen = new Set();
143
- for (const item of splitItems(plainText(match.value))) {
144
- if (!expr.members.includes(item))
145
- ctx.diagnose(meta.file, line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
146
- else if (seen.has(item))
147
- ctx.diagnose(meta.file, line, `duplicate tag "${item}" on property "${prop.name}"`);
148
- seen.add(item);
149
- }
150
- }
151
- else if (expr.kind === "list") {
152
- /* a list constraint: the value is a comma-separated list
153
- of items, each matching at least one alternative */
154
- for (const item of splitItems(match.value))
155
- if (!matchAlternatives(ctx, expr.alternatives, item))
156
- ctx.diagnose(meta.file, line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
157
- }
158
- else if (!tokenAssigned.has(prop.name) && !directMatches(expr, plainText(match.value)))
159
- ctx.diagnose(meta.file, line, expr.kind === "enum" ?
160
- `property "${prop.name}" value "${match.value}" is not a member of "${prop.value}"` :
161
- `property "${prop.name}" value "${match.value}" does not match pattern "${prop.value}"`);
162
- }
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 });
163
175
  }
176
+ /* report the properties not configured by the schema */
164
177
  for (const property of object.properties)
165
178
  if (!props.some((p) => p.name === plainKey(property.key)))
166
179
  ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
@@ -179,6 +192,7 @@ const validateObject = (ctx, object, schema, level) => {
179
192
  }
180
193
  validateObject(ctx, child, childSchema, level + 1);
181
194
  }
195
+ /* report the configured child object kinds which are missing */
182
196
  for (const child of childs)
183
197
  if (child.optional !== true
184
198
  && !object.childs.some((c) => c.kind === child.kind))
@@ -204,7 +218,8 @@ export const validate = (ctx, specification, config) => {
204
218
  for (const object of artifact.objects) {
205
219
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
206
220
  const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
207
- config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
221
+ config.find((s) => s.name !== undefined
222
+ && s.name.toUpperCase() === plainText(object.name).toUpperCase());
208
223
  if (schema === undefined) {
209
224
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
210
225
  continue;
@@ -3,8 +3,8 @@
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 fs from "node:fs";
7
- import path from "node:path";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
8
  import { marked } from "marked";
9
9
  import { embeddingRegex, embeddingMimeType } from "./specbook-parse-common.js";
10
10
  /* split a description text into statement and optional rationale */
@@ -89,7 +89,7 @@ const parseFrontmatter = (text) => {
89
89
  const line = 2 + (m[1].slice(0, km.index).match(/\n/g) ?? []).length;
90
90
  const value = km[1].trim();
91
91
  const date = new Date(value);
92
- return { date: isNaN(date.getTime()) ? null : date, line, value };
92
+ return { date: Number.isNaN(date.getTime()) ? null : date, line, value };
93
93
  };
94
94
  return {
95
95
  present: true,
@@ -137,8 +137,7 @@ const parseList = (ctx, list, object, file, line) => {
137
137
  /* a concise-format item carries a further segment on its first
138
138
  line, or ends it with the segment terminator (the case of an
139
139
  object without properties, whose description follows below) */
140
- const cm = first.match(/^([^:;]+):\s+(.+;.*)$/);
141
- if (cm !== null)
140
+ if (/^[^:;]+:\s+.+;.*$/.test(first))
142
141
  parseConcise(ctx, item, object, file, line);
143
142
  else {
144
143
  const km = first.match(/^([^:;]+):\s*(.*)$/);
@@ -234,7 +233,7 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
234
233
  const statements = new Array();
235
234
  for (const segment of segments) {
236
235
  const km = segment.match(/^([^:;]+):\s+(.+)$/);
237
- if (km !== null && !(/\sBECAUSE\s/.test(segment))) {
236
+ if (km !== null && !(/\s(?:\*\*BECAUSE\*\*|BECAUSE)\s/.test(segment))) {
238
237
  const property = { key: km[1].trim(), value: km[2].trim() };
239
238
  ctx.propMeta.set(property, { line });
240
239
  object.properties.push(property);
@@ -261,6 +260,7 @@ export const parseFile = (ctx, source) => {
261
260
  ctx.diagnose(source.file, info.line, info.value === null ?
262
261
  `missing "${key}:" frontmatter key` :
263
262
  `invalid "${key}:" frontmatter timestamp "${info.value}"`);
263
+ /* the state tracked while walking the Markdown tokens */
264
264
  const artifacts = new Array();
265
265
  const stack = new Array();
266
266
  let current = null;
@@ -278,15 +278,16 @@ export const parseFile = (ctx, source) => {
278
278
  if (token.type === "heading") {
279
279
  flush();
280
280
  group = null;
281
- const heading = parseHeadingText(token.text);
281
+ const { depth, text } = token;
282
+ const heading = parseHeadingText(text);
282
283
  if (heading.malformed !== undefined)
283
284
  ctx.diagnose(source.file, line, `malformed anchor "${heading.malformed}" in heading`);
284
- if (heading.name === "" && token.depth > 1) {
285
+ if (heading.name === "" && depth > 1) {
285
286
  /* a grouping container heading (e.g. "### STATES") collects
286
287
  objects of its singular kind below the parent object */
287
- const parent = stack[token.depth - 2];
288
+ const parent = stack[depth - 2];
288
289
  if (parent === undefined)
289
- ctx.diagnose(source.file, line, `heading level ${token.depth} without parent object`);
290
+ ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
290
291
  else {
291
292
  group = { parent, kind: heading.kind.replace(/S$/, "") };
292
293
  current = parent;
@@ -295,7 +296,7 @@ export const parseFile = (ctx, source) => {
295
296
  else {
296
297
  const object = {
297
298
  kind: heading.kind,
298
- id: heading.id ?? (token.depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
299
+ id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
299
300
  name: heading.name,
300
301
  properties: [],
301
302
  childs: []
@@ -307,7 +308,7 @@ export const parseFile = (ctx, source) => {
307
308
  if (heading.primary)
308
309
  object.primary = true;
309
310
  ctx.objectMeta.set(object, { file: source.file, line });
310
- if (token.depth === 1) {
311
+ if (depth === 1) {
311
312
  artifacts.push({
312
313
  created: created.date ?? new Date(),
313
314
  modified: modified.date ?? new Date(),
@@ -316,16 +317,16 @@ export const parseFile = (ctx, source) => {
316
317
  stack.length = 0;
317
318
  }
318
319
  else {
319
- const parent = stack[token.depth - 2];
320
+ const parent = stack[depth - 2];
320
321
  if (parent === undefined) {
321
- ctx.diagnose(source.file, line, `heading level ${token.depth} without parent object`);
322
+ ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
322
323
  line += (token.raw.match(/\n/g) ?? []).length;
323
324
  continue;
324
325
  }
325
326
  parent.childs.push(object);
326
327
  }
327
- stack.length = token.depth - 1;
328
- stack[token.depth - 1] = object;
328
+ stack.length = depth - 1;
329
+ stack[depth - 1] = object;
329
330
  current = object;
330
331
  }
331
332
  }
@@ -119,5 +119,11 @@ export const splitItems = (text) => {
119
119
  else
120
120
  parts[parts.length - 1] += char;
121
121
  }
122
- return parts.map((part) => part.trim()).filter((part) => part !== "");
122
+ return parts.map((part) => {
123
+ /* unquote a fully double-quoted item, mirroring the member
124
+ quoting of the value expression lexer */
125
+ const item = part.trim();
126
+ const m = item.match(/^"((?:\\"|[^"])*)"$/);
127
+ return m !== null ? m[1].replace(/\\"/g, "\"") : item;
128
+ }).filter((part) => part !== "");
123
129
  };
@@ -18,10 +18,14 @@ export class Parser {
18
18
  /* parse all source files into the specification AST and
19
19
  optionally validate the result against a configuration */
20
20
  parse(sources, config) {
21
+ /* reset the parsing context to allow a reuse of the parser */
22
+ this.ctx = new ParseContext();
23
+ /* parse all source files into their artifacts */
21
24
  const artifacts = new Array();
22
25
  for (const source of sources)
23
26
  artifacts.push(...parseFile(this.ctx, source));
24
27
  const specification = { artifacts };
28
+ /* validate the resulting specification AST */
25
29
  if (artifacts.length > 0) {
26
30
  this.ctx.linkIndex = buildLinkIndex(specification);
27
31
  if (config !== undefined)
@@ -9,6 +9,7 @@ export type SchemaObject = {
9
9
  desc?: string;
10
10
  optional?: boolean;
11
11
  diagram?: SchemaDiagram;
12
+ format?: SchemaFormat;
12
13
  props?: SchemaProperty[];
13
14
  childs?: SchemaObject[];
14
15
  };
@@ -24,8 +25,14 @@ export type SchemaDiagram = {
24
25
  onlyConnected?: boolean;
25
26
  collapse?: boolean;
26
27
  qualified?: boolean;
28
+ properties?: string[];
27
29
  config?: Partial<GradiaConfig>;
28
30
  };
31
+ export type SchemaFormat = {
32
+ type?: "auto" | "complex" | "concise";
33
+ maxTableColumns?: number;
34
+ withUnusedProps?: boolean;
35
+ };
29
36
  export type SchemaProperty = {
30
37
  name: string;
31
38
  desc?: string;
@@ -13,13 +13,13 @@ const GradiaConfigSchemas = Object.fromEntries(Object.entries(Gradia.config).map
13
13
  typeof value === "boolean" ? v.boolean() : typeof value === "number" ? v.number() : v.string()]));
14
14
  const SchemaDiagramConfig = v.partial(v.strictObject(GradiaConfigSchemas, (issue) => issue.expected === "never" ? "unknown Gradia rendering option" :
15
15
  "expected a map of Gradia rendering options"));
16
- const SchemaProperty = v.object({
16
+ const SchemaProperty = v.strictObject({
17
17
  name: v.string(),
18
18
  desc: v.optional(v.string()),
19
19
  value: v.optional(v.string()),
20
20
  optional: v.optional(v.boolean())
21
21
  });
22
- const SchemaDiagram = v.object({
22
+ const SchemaDiagram = v.strictObject({
23
23
  type: v.optional(v.picklist(["graph", "hub", "grid"])),
24
24
  nodes: v.optional(v.string()),
25
25
  edges: v.optional(v.string()),
@@ -31,9 +31,15 @@ const SchemaDiagram = v.object({
31
31
  onlyConnected: v.optional(v.boolean()),
32
32
  collapse: v.optional(v.boolean()),
33
33
  qualified: v.optional(v.boolean()),
34
+ properties: v.optional(v.array(v.string())),
34
35
  config: v.optional(SchemaDiagramConfig)
35
36
  });
36
- const SchemaObject = v.object({
37
+ const SchemaFormat = v.strictObject({
38
+ type: v.optional(v.picklist(["auto", "complex", "concise"])),
39
+ maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
40
+ withUnusedProps: v.optional(v.boolean())
41
+ });
42
+ const SchemaObject = v.strictObject({
37
43
  kind: v.string(),
38
44
  name: v.optional(v.string()),
39
45
  id: v.optional(v.string()),
@@ -41,6 +47,7 @@ const SchemaObject = v.object({
41
47
  desc: v.optional(v.string()),
42
48
  optional: v.optional(v.boolean()),
43
49
  diagram: v.optional(SchemaDiagram),
50
+ format: v.optional(SchemaFormat),
44
51
  props: v.optional(v.array(SchemaProperty)),
45
52
  childs: v.optional(v.array(v.lazy(() => SchemaObject)))
46
53
  });
@@ -26,7 +26,7 @@ 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
- { text: 28, muted: 22, symbol: 20, border: 16, accent: 24 } :
29
+ { text: 29, muted: 22, symbol: 20, border: 16, accent: 24 } :
30
30
  { text: 5, muted: 22, symbol: 24, border: 28, accent: 12 };
31
31
  return {
32
32
  text: colors.base[idx.text - 1],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -23,8 +23,8 @@
23
23
  "json5": "2.2.3",
24
24
  "pdf-lib": "1.17.1",
25
25
  "subset-font": "2.5.0",
26
- "@rse/mrcs": "0.9.1",
27
- "@rse/gradia": "0.9.4",
26
+ "@rse/mrcs": "0.9.2",
27
+ "@rse/gradia": "1.0.0",
28
28
  "@toon-format/toon": "4.1.1",
29
29
  "valibot": "1.4.2",
30
30
  "nunjucks": "3.2.4",
@@ -33,7 +33,7 @@
33
33
  "@swc/html": "1.16.1",
34
34
  "tokenizr": "2.2.1",
35
35
  "playwright": "1.62.1",
36
- "ai": "7.0.68",
36
+ "ai": "7.0.69",
37
37
  "@ai-sdk/anthropic": "4.0.39",
38
38
  "@ai-sdk/openai": "4.0.43",
39
39
  "@openrouter/ai-sdk-provider": "3.0.0",