@rse/specbook 1.2.1 → 1.2.3

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.
@@ -8,6 +8,7 @@ interface LinkNode {
8
8
  name: string;
9
9
  }
10
10
  export type LinkIndex = LinkNode[];
11
+ export declare const assignId: (index: LinkIndex, object: SpecObject, id: string) => void;
11
12
  export declare const chainOf: (index: LinkIndex, object: SpecObject) => SpecObject[];
12
13
  export interface LinkTarget {
13
14
  target?: SpecObject;
@@ -34,6 +34,13 @@ const lookup = (index) => {
34
34
  }
35
35
  return result;
36
36
  };
37
+ /* assign the id of an indexed object, dropping the lookup structures
38
+ of its index (and with them the memoized resolutions), as they are
39
+ keyed on the ids as of their build and would freeze the old one */
40
+ export const assignId = (index, object, id) => {
41
+ object.id = id;
42
+ lookups.delete(index);
43
+ };
37
44
  /* the ancestor-or-self chain of an object, root first
38
45
  (empty for an object unknown to the index) */
39
46
  export const chainOf = (index, object) => {
@@ -127,16 +134,16 @@ export const resolveSet = (index, reference) => {
127
134
  subtree, which beats one in another artifact) */
128
135
  const nearest = (index, matches, from) => {
129
136
  const chain = chainOf(index, from);
130
- const distance = (object) => {
137
+ const proximity = (object) => {
131
138
  const other = chainOf(index, object);
132
139
  let i = 0;
133
140
  while (i < chain.length && i < other.length && chain[i] === other[i])
134
141
  i++;
135
142
  return i;
136
143
  };
137
- const distances = matches.map((object) => distance(object));
138
- const best = Math.max(...distances);
139
- return matches.filter((_, i) => distances[i] === best);
144
+ const proximities = matches.map((object) => proximity(object));
145
+ const best = Math.max(...proximities);
146
+ return matches.filter((_, i) => proximities[i] === best);
140
147
  };
141
148
  /* resolve a reference into a unique target: a single segment tries the
142
149
  ordered variants (1) id/anchor, (2) name, (3) "KIND:name-or-id", where
@@ -24,6 +24,7 @@ export declare class ParseContext {
24
24
  parenProps: WeakMap<SpecObject, SpecProperty>;
25
25
  assets: Set<string>;
26
26
  diagnose(file: string, line: number, message: string, severity?: DiagnosticSeverity): void;
27
+ metaOf(object: SpecObject): ObjectMeta;
27
28
  }
28
29
  export declare const becauseRegex: RegExp;
29
30
  export declare const embeddingRegex: RegExp;
@@ -20,6 +20,11 @@ export class ParseContext {
20
20
  diagnose(file, line, message, severity = "error") {
21
21
  this.diagnostics.push({ file, line, column: 1, severity, message });
22
22
  }
23
+ /* the source location of an object (defensively falling back onto
24
+ line 1 of no file for an object unknown to the syntactic phase) */
25
+ metaOf(object) {
26
+ return this.objectMeta.get(object) ?? { file: "", line: 1 };
27
+ }
23
28
  }
24
29
  /* the marker separating a statement from its rationale */
25
30
  export const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
@@ -1,7 +1,8 @@
1
- import { type Spec, type SpecObject } from "./specbook-format-spec.js";
1
+ import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
2
2
  import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
3
3
  import { ParseContext } from "./specbook-parse-common.js";
4
4
  export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
5
5
  export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
6
+ export declare const collectParenProps: (schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, SpecProperty>;
6
7
  export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
7
8
  export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
@@ -3,7 +3,7 @@
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, chainOf, plainText } from "./specbook-link.js";
6
+ import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText, assignId } from "./specbook-link.js";
7
7
  import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
8
8
  /* the syntax of a value consisting of exactly one Wiki-style reference */
9
9
  const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
@@ -112,21 +112,23 @@ const checkPropValue = (ctx, object, prop, expr, property, meta) => {
112
112
  `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
113
113
  `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
114
114
  };
115
- /* find the configured property whose value the trailing parenthesized
116
- name token of an object is accepted for: the first still missing
115
+ /* derive the synthetic property the trailing parenthesized name token
116
+ of an object is consumed as: the first still missing configured
117
117
  property whose pattern the token matches */
118
118
  const parenProp = (object, props) => {
119
119
  const paren = object.paren;
120
120
  if (paren === undefined)
121
121
  return undefined;
122
- return props.find((prop) => prop.value !== undefined && !object.properties.some((p) => p.key === prop.name)
123
- && directMatches(compileValueExpr(prop.value), paren));
122
+ const prop = props.find((p) => p.value !== undefined && !object.properties.some((q) => q.key === p.name)
123
+ && directMatches(compileValueExpr(p.value), paren));
124
+ return prop !== undefined ? { key: prop.name, value: paren } : undefined;
124
125
  };
125
126
  /* check the property values flagged unique for distinctness among
126
127
  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) */
128
+ for occurring on at least one sibling (all values, or only the
129
+ values matching the regexp or enum expression of the flag), where
130
+ a kind without any sibling fails a present flag, too, but only for
131
+ an optional kind, as the missing-kind check reports a required one */
130
132
  const checkSiblingFlags = (ctx, object, childs, meta) => {
131
133
  const marker = (flag) => flag === undefined || flag === false ? undefined :
132
134
  { filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
@@ -152,12 +154,12 @@ const checkSiblingFlags = (ctx, object, childs, meta) => {
152
154
  if (first === undefined)
153
155
  seen.set(value, sibling);
154
156
  else {
155
- const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
157
+ const siblingMeta = ctx.metaOf(sibling);
156
158
  ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
157
159
  `already used by preceding ${sibling.kind} "${first.name}"`);
158
160
  }
159
161
  }
160
- if (present !== undefined && siblings.length > 0 && !found)
162
+ if (present !== undefined && (siblings.length > 0 || child.optional === true) && !found)
161
163
  ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
162
164
  (typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
163
165
  }
@@ -165,7 +167,7 @@ const checkSiblingFlags = (ctx, object, childs, meta) => {
165
167
  };
166
168
  /* validate a single object (and recursively its childs) against its schema */
167
169
  const validateObject = (ctx, object, schema, level) => {
168
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
170
+ const meta = ctx.metaOf(object);
169
171
  const props = schema.props ?? [];
170
172
  /* check the name convention (the configured name of non-artifact
171
173
  objects is a regular expression pattern) */
@@ -199,7 +201,7 @@ const validateObject = (ctx, object, schema, level) => {
199
201
  /* check the configured child objects */
200
202
  const childs = schema.childs ?? [];
201
203
  for (const child of object.childs) {
202
- const childMeta = ctx.objectMeta.get(child) ?? { file: "", line: 1 };
204
+ const childMeta = ctx.metaOf(child);
203
205
  const childSchema = childs.find((c) => c.kind === child.kind);
204
206
  if (childSchema === undefined) {
205
207
  ctx.diagnose(childMeta.file, childMeta.line, `unknown object kind "${child.kind}" below ${object.kind} "${object.name}"`);
@@ -255,6 +257,19 @@ export const collectSchemas = (specification, config) => {
255
257
  }
256
258
  return schemas;
257
259
  };
260
+ /* derive the synthetic properties supplied by the consumed parenthesized
261
+ name tokens of a schema mapping, so a consumer without a parsing
262
+ context -- the diagram derivation of an export -- sees exactly the
263
+ properties the validation sees */
264
+ export const collectParenProps = (schemas) => {
265
+ const parenProps = new Map();
266
+ for (const [object, schema] of schemas) {
267
+ const property = parenProp(object, schema.props ?? []);
268
+ if (property !== undefined)
269
+ parenProps.set(object, property);
270
+ }
271
+ return parenProps;
272
+ };
258
273
  /* report the objects colliding on kind and id among their siblings
259
274
  (recursively from the artifacts down), as such objects derive
260
275
  identical anchor paths and hence cannot be addressed distinctly */
@@ -266,7 +281,7 @@ const checkIds = (ctx, objects) => {
266
281
  if (first === undefined)
267
282
  seen.set(key, object);
268
283
  else {
269
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
284
+ const meta = ctx.metaOf(object);
270
285
  ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
271
286
  }
272
287
  checkIds(ctx, object.childs);
@@ -337,7 +352,7 @@ const checkAutomata = (ctx, schemas) => {
337
352
  const reachable = closure(initials, succs);
338
353
  const coreachable = closure(finals, preds);
339
354
  for (const node of nodes) {
340
- const meta = ctx.objectMeta.get(node) ?? { file: "", line: 1 };
355
+ const meta = ctx.metaOf(node);
341
356
  const name = `${node.kind} "${node.name}" of ${object.kind} "${object.name}"`;
342
357
  if (initials.length > 0 && !reachable.has(node))
343
358
  ctx.diagnose(meta.file, meta.line, `${name} is not reachable from the initial ${node.kind} through any ${automaton.edges}`);
@@ -368,7 +383,7 @@ const checkRelations = (ctx, schemas) => {
368
383
  for (const [prop, objects] of groups) {
369
384
  const members = new Set(objects);
370
385
  const locate = (object) => {
371
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
386
+ const meta = ctx.metaOf(object);
372
387
  const property = findProp(ctx, object, prop.name);
373
388
  const line = property !== undefined ? ctx.propMeta.get(property)?.line : undefined;
374
389
  return { file: meta.file, line: line ?? meta.line };
@@ -455,7 +470,7 @@ const checkReferenced = (ctx, specification, schemas) => {
455
470
  const covered = Array.from(referrers.get(object) ?? [])
456
471
  .some((referrer) => chainOf(ctx.linkIndex, referrer).some((o) => admissible.has(o)));
457
472
  if (!covered) {
458
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
473
+ const meta = ctx.metaOf(object);
459
474
  ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
460
475
  schema.referenced.map((entry) => `"${entry}"`).join(" or "), "warning");
461
476
  }
@@ -469,19 +484,16 @@ export const validate = (ctx, specification, config) => {
469
484
  precedence), which has to happen before any property check, as
470
485
  those resolve references by id across all artifacts */
471
486
  const schemas = collectSchemas(specification, config);
487
+ for (const [object, property] of collectParenProps(schemas))
488
+ ctx.parenProps.set(object, property);
472
489
  const promote = (object) => {
473
490
  /* an object below an unresolved artifact or an unknown kind
474
491
  carries no schema and hence no property able to consume the
475
492
  token, so the token still acts as the id and a single
476
493
  unresolved heading no longer cascades into unresolvable
477
494
  references all over the corpus */
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
- }
495
+ if (object.paren !== undefined && object.anchor === undefined && !ctx.parenProps.has(object))
496
+ assignId(ctx.linkIndex, object, object.paren);
485
497
  object.childs.forEach(promote);
486
498
  };
487
499
  for (const artifact of specification.artifacts)
@@ -492,7 +504,7 @@ export const validate = (ctx, specification, config) => {
492
504
  const position = new Map();
493
505
  for (const artifact of specification.artifacts) {
494
506
  for (const object of artifact.objects) {
495
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
507
+ const meta = ctx.metaOf(object);
496
508
  const schema = schemas.get(object);
497
509
  if (schema === undefined) {
498
510
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
@@ -533,7 +545,7 @@ export const validateReferences = (ctx, specification) => {
533
545
  }
534
546
  };
535
547
  const walk = (object) => {
536
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
548
+ const meta = ctx.metaOf(object);
537
549
  check(object, object.name, meta.file, meta.line);
538
550
  for (const property of object.properties)
539
551
  check(object, property.value, meta.file, ctx.propMeta.get(property)?.line ?? meta.line);
@@ -201,13 +201,15 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
201
201
  let kind;
202
202
  let name;
203
203
  if (group !== undefined) {
204
- const nm = head.match(/^`([^`]+)`$|^([^`:]+)$/);
204
+ const nm = head.match(/^`([^`]+)`\s*([^`]*)$|^([^`:]+)$/);
205
205
  if (nm === null) {
206
206
  ctx.diagnose(file, line, `unrecognized ${group} item "${text}"`);
207
207
  return;
208
208
  }
209
209
  kind = group;
210
- name = (nm[1] ?? nm[2]).trim();
210
+ /* a backquoted name keeps its trailing decorations outside the
211
+ backquotes, so they are re-joined for the marker detection below */
212
+ name = (nm[1] !== undefined ? `${nm[1]} ${nm[2]}` : nm[3]).trim();
211
213
  }
212
214
  else {
213
215
  const km = head.match(/^([^:]+):\s*(.*)$/);
@@ -278,17 +280,17 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
278
280
  state.group = null;
279
281
  if (heading.malformed !== undefined)
280
282
  ctx.diagnose(file, line, `malformed anchor "${heading.malformed}" in heading`);
281
- if (heading.name === "" && depth > 1) {
282
- const parent = state.stack[depth - 2];
283
- if (parent === undefined) {
284
- ctx.diagnose(file, line, `heading level ${depth} without parent object`);
285
- state.current = null;
286
- }
287
- else {
288
- state.group = { parent, kind: heading.kind };
289
- state.current = parent;
290
- state.stack.length = depth - 1;
291
- }
283
+ /* a nested heading requires the enclosing object one level above */
284
+ const parent = depth > 1 ? state.stack[depth - 2] : undefined;
285
+ if (depth > 1 && parent === undefined) {
286
+ ctx.diagnose(file, line, `heading level ${depth} without parent object`);
287
+ state.current = null;
288
+ return;
289
+ }
290
+ if (parent !== undefined && heading.name === "") {
291
+ state.group = { parent, kind: heading.kind };
292
+ state.current = parent;
293
+ state.stack.length = depth - 1;
292
294
  return;
293
295
  }
294
296
  const object = {
@@ -305,17 +307,10 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
305
307
  if (heading.primary)
306
308
  object.primary = true;
307
309
  ctx.objectMeta.set(object, { file, line });
308
- if (depth === 1)
310
+ if (parent === undefined)
309
311
  state.artifacts.push({ ...stamps, objects: [object] });
310
- else {
311
- const parent = state.stack[depth - 2];
312
- if (parent === undefined) {
313
- ctx.diagnose(file, line, `heading level ${depth} without parent object`);
314
- state.current = null;
315
- return;
316
- }
312
+ else
317
313
  parent.childs.push(object);
318
- }
319
314
  state.stack.length = depth - 1;
320
315
  state.stack[depth - 1] = object;
321
316
  state.current = object;
@@ -360,7 +355,9 @@ export const parseFile = (ctx, source) => {
360
355
  const flush = () => {
361
356
  const current = state.current;
362
357
  if (current !== null && parts.length > 0) {
363
- if (current.description === undefined)
358
+ if (state.group !== null)
359
+ ctx.diagnose(source.file, partsLine, `content ignored below ${state.group.kind} container of ${current.kind} "${current.name}"`);
360
+ else if (current.description === undefined)
364
361
  current.description = splitDescription(parts.join("\n\n").trim());
365
362
  else
366
363
  ctx.diagnose(source.file, partsLine, `content ignored, as ${current.kind} "${current.name}" already carries a description`);
@@ -1,5 +1,10 @@
1
+ import { Spec } from "./specbook-format-spec.js";
1
2
  import { type Schema } from "./specbook-format-schema.js";
2
3
  import { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
3
4
  export { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
4
5
  export { resolveArtifact } from "./specbook-parse-semantic.js";
5
6
  export declare const parseSpecification: (sources: SourceFile[], config?: Schema) => ParseResult;
7
+ export declare const specStatistics: (specification: Spec) => {
8
+ objects: number;
9
+ links: number;
10
+ };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as v from "valibot";
7
7
  import { Spec } from "./specbook-format-spec.js";
8
- import { buildLinkIndex } from "./specbook-link.js";
8
+ import { buildLinkIndex, referenceRegex, plainText } from "./specbook-link.js";
9
9
  import { ParseContext } from "./specbook-parse-common.js";
10
10
  import { parseFile } from "./specbook-parse-syntax.js";
11
11
  import { validate, validateReferences } from "./specbook-parse-semantic.js";
@@ -38,3 +38,22 @@ export const parseSpecification = (sources, config) => {
38
38
  }
39
39
  return { specification, diagnostics: ctx.diagnostics, assets: Array.from(ctx.assets) };
40
40
  };
41
+ /* the statistics of a specification: the number of defined objects
42
+ (recursively) and of relationships, i.e. the Wiki-style references
43
+ in the names, property values, descriptions, and rationales */
44
+ export const specStatistics = (specification) => {
45
+ let objects = 0;
46
+ let links = 0;
47
+ const walk = (object) => {
48
+ objects++;
49
+ const texts = [object.name, ...object.properties.map((p) => p.value)];
50
+ if (object.description !== undefined)
51
+ texts.push(object.description.description, object.description.rationale ?? "");
52
+ for (const text of texts)
53
+ links += (plainText(text).match(referenceRegex) ?? []).length;
54
+ object.childs.forEach(walk);
55
+ };
56
+ for (const artifact of specification.artifacts)
57
+ artifact.objects.forEach(walk);
58
+ return { objects, links };
59
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -24,7 +24,7 @@
24
24
  "pdf-lib": "1.17.1",
25
25
  "subset-font": "2.7.0",
26
26
  "@rse/mrcs": "0.9.2",
27
- "@rse/gradia": "1.1.7",
27
+ "@rse/gradia": "1.1.9",
28
28
  "@toon-format/toon": "4.1.1",
29
29
  "valibot": "1.4.2",
30
30
  "nunjucks": "3.2.4",
@@ -39,7 +39,7 @@
39
39
  "chokidar": "5.0.0",
40
40
  "glob": "13.0.6",
41
41
  "es-toolkit": "1.52.0",
42
- "fastify": "5.12.1",
42
+ "fastify": "5.12.3",
43
43
  "@fastify/websocket": "11.3.0",
44
44
  "@modelcontextprotocol/sdk": "1.30.0",
45
45
  "zod": "4.5.4"