@rse/specbook 1.2.2 → 1.2.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.
@@ -55,7 +55,7 @@ export const buildLinkIndex = (specification) => {
55
55
  const walk = (object, parent) => {
56
56
  const node = { object, parent, pos: index.length, name: plainText(object.name) };
57
57
  index.push(node);
58
- for (const child of object.childs)
58
+ for (const child of object.children)
59
59
  walk(child, node);
60
60
  };
61
61
  for (const artifact of specification.artifacts)
@@ -134,16 +134,16 @@ export const resolveSet = (index, reference) => {
134
134
  subtree, which beats one in another artifact) */
135
135
  const nearest = (index, matches, from) => {
136
136
  const chain = chainOf(index, from);
137
- const distance = (object) => {
137
+ const proximity = (object) => {
138
138
  const other = chainOf(index, object);
139
139
  let i = 0;
140
140
  while (i < chain.length && i < other.length && chain[i] === other[i])
141
141
  i++;
142
142
  return i;
143
143
  };
144
- const distances = matches.map((object) => distance(object));
145
- const best = Math.max(...distances);
146
- 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);
147
147
  };
148
148
  /* resolve a reference into a unique target: a single segment tries the
149
149
  ordered variants (1) id/anchor, (2) name, (3) "KIND:name-or-id", where
@@ -173,7 +173,7 @@ export const resolveUnique = (index, reference, from) => {
173
173
  };
174
174
  /* derive the fully-qualified anchor path of every object: the
175
175
  "<kind>-<id>" segments (whitespace in kinds dashed) of the ancestor
176
- chain joined with "." (the index lists parents before childs) */
176
+ chain joined with "." (the index lists parents before children) */
177
177
  export const anchorPaths = (index) => {
178
178
  const paths = new Map();
179
179
  for (const node of index) {
@@ -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+/;
@@ -129,11 +129,11 @@ const parenProp = (object, props) => {
129
129
  values matching the regexp or enum expression of the flag), where
130
130
  a kind without any sibling fails a present flag, too, but only for
131
131
  an optional kind, as the missing-kind check reports a required one */
132
- const checkSiblingFlags = (ctx, object, childs, meta) => {
132
+ const checkSiblingFlags = (ctx, object, children, meta) => {
133
133
  const marker = (flag) => flag === undefined || flag === false ? undefined :
134
134
  { filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
135
- for (const child of childs) {
136
- const siblings = object.childs.filter((c) => c.kind === child.kind);
135
+ for (const child of children) {
136
+ const siblings = object.children.filter((c) => c.kind === child.kind);
137
137
  for (const prop of child.props ?? []) {
138
138
  const unique = marker(prop.unique);
139
139
  const present = marker(prop.present);
@@ -154,7 +154,7 @@ const checkSiblingFlags = (ctx, object, childs, meta) => {
154
154
  if (first === undefined)
155
155
  seen.set(value, sibling);
156
156
  else {
157
- const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
157
+ const siblingMeta = ctx.metaOf(sibling);
158
158
  ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
159
159
  `already used by preceding ${sibling.kind} "${first.name}"`);
160
160
  }
@@ -165,9 +165,9 @@ const checkSiblingFlags = (ctx, object, childs, meta) => {
165
165
  }
166
166
  }
167
167
  };
168
- /* validate a single object (and recursively its childs) against its schema */
168
+ /* validate a single object (and recursively its children) against its schema */
169
169
  const validateObject = (ctx, object, schema, level) => {
170
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
170
+ const meta = ctx.metaOf(object);
171
171
  const props = schema.props ?? [];
172
172
  /* check the name convention (the configured name of non-artifact
173
173
  objects is a regular expression pattern) */
@@ -199,10 +199,10 @@ const validateObject = (ctx, object, schema, level) => {
199
199
  if (!props.some((p) => p.name === property.key))
200
200
  ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
201
201
  /* check the configured child objects */
202
- const childs = schema.childs ?? [];
203
- for (const child of object.childs) {
204
- const childMeta = ctx.objectMeta.get(child) ?? { file: "", line: 1 };
205
- const childSchema = childs.find((c) => c.kind === child.kind);
202
+ const children = schema.children ?? [];
203
+ for (const child of object.children) {
204
+ const childMeta = ctx.metaOf(child);
205
+ const childSchema = children.find((c) => c.kind === child.kind);
206
206
  if (childSchema === undefined) {
207
207
  ctx.diagnose(childMeta.file, childMeta.line, `unknown object kind "${child.kind}" below ${object.kind} "${object.name}"`);
208
208
  continue;
@@ -210,20 +210,20 @@ const validateObject = (ctx, object, schema, level) => {
210
210
  validateObject(ctx, child, childSchema, level + 1);
211
211
  }
212
212
  /* check the sibling-scoped property flags of the child kinds */
213
- checkSiblingFlags(ctx, object, childs, meta);
213
+ checkSiblingFlags(ctx, object, children, meta);
214
214
  /* report the configured child object kinds which are missing */
215
- for (const child of childs)
215
+ for (const child of children)
216
216
  if (child.optional !== true
217
- && !object.childs.some((c) => c.kind === child.kind))
217
+ && !object.children.some((c) => c.kind === child.kind))
218
218
  ctx.diagnose(meta.file, meta.line, `required object kind "${child.kind}" missing below ${object.kind} "${object.name}"`);
219
- /* order the childs and properties exactly along the schema
219
+ /* order the children and properties exactly along the schema
220
220
  definition (the sort is stable, so objects of the same kind
221
221
  and unknown items keep their document order) */
222
222
  const kindPos = (kind) => {
223
- const i = childs.findIndex((c) => c.kind === kind);
224
- return i >= 0 ? i : childs.length;
223
+ const i = children.findIndex((c) => c.kind === kind);
224
+ return i >= 0 ? i : children.length;
225
225
  };
226
- object.childs.sort((a, b) => kindPos(a.kind) - kindPos(b.kind));
226
+ object.children.sort((a, b) => kindPos(a.kind) - kindPos(b.kind));
227
227
  const propPos = (key) => {
228
228
  const i = props.findIndex((p) => p.name === key);
229
229
  return i >= 0 ? i : props.length;
@@ -242,8 +242,8 @@ export const collectSchemas = (specification, config) => {
242
242
  const schemas = new Map();
243
243
  const walk = (object, schema) => {
244
244
  schemas.set(object, schema);
245
- for (const child of object.childs) {
246
- const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
245
+ for (const child of object.children) {
246
+ const childSchema = (schema.children ?? []).find((c) => c.kind === child.kind);
247
247
  if (childSchema !== undefined)
248
248
  walk(child, childSchema);
249
249
  }
@@ -281,10 +281,10 @@ const checkIds = (ctx, objects) => {
281
281
  if (first === undefined)
282
282
  seen.set(key, object);
283
283
  else {
284
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
284
+ const meta = ctx.metaOf(object);
285
285
  ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
286
286
  }
287
- checkIds(ctx, object.childs);
287
+ checkIds(ctx, object.children);
288
288
  }
289
289
  };
290
290
  /* the objects a property of an object references (resolved and
@@ -317,7 +317,7 @@ const checkAutomata = (ctx, schemas) => {
317
317
  const automaton = schema.automaton;
318
318
  if (automaton === undefined)
319
319
  continue;
320
- const nodes = object.childs.filter((child) => child.kind === automaton.nodes);
320
+ const nodes = object.children.filter((child) => child.kind === automaton.nodes);
321
321
  const nodeSet = new Set(nodes);
322
322
  const flagged = (node, name) => {
323
323
  const property = findProp(ctx, node, name);
@@ -329,7 +329,7 @@ const checkAutomata = (ctx, schemas) => {
329
329
  the node set is skipped, as the property checks report it) */
330
330
  const succs = new Map(nodes.map((node) => [node, new Set()]));
331
331
  const preds = new Map(nodes.map((node) => [node, new Set()]));
332
- for (const edge of object.childs.filter((child) => child.kind === automaton.edges)) {
332
+ for (const edge of object.children.filter((child) => child.kind === automaton.edges)) {
333
333
  const source = referencedObjects(ctx, edge, automaton.source)[0];
334
334
  const target = referencedObjects(ctx, edge, automaton.target)[0];
335
335
  if (source !== undefined && target !== undefined && nodeSet.has(source) && nodeSet.has(target)) {
@@ -352,7 +352,7 @@ const checkAutomata = (ctx, schemas) => {
352
352
  const reachable = closure(initials, succs);
353
353
  const coreachable = closure(finals, preds);
354
354
  for (const node of nodes) {
355
- const meta = ctx.objectMeta.get(node) ?? { file: "", line: 1 };
355
+ const meta = ctx.metaOf(node);
356
356
  const name = `${node.kind} "${node.name}" of ${object.kind} "${object.name}"`;
357
357
  if (initials.length > 0 && !reachable.has(node))
358
358
  ctx.diagnose(meta.file, meta.line, `${name} is not reachable from the initial ${node.kind} through any ${automaton.edges}`);
@@ -383,7 +383,7 @@ const checkRelations = (ctx, schemas) => {
383
383
  for (const [prop, objects] of groups) {
384
384
  const members = new Set(objects);
385
385
  const locate = (object) => {
386
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
386
+ const meta = ctx.metaOf(object);
387
387
  const property = findProp(ctx, object, prop.name);
388
388
  const line = property !== undefined ? ctx.propMeta.get(property)?.line : undefined;
389
389
  return { file: meta.file, line: line ?? meta.line };
@@ -446,7 +446,7 @@ const checkReferenced = (ctx, specification, schemas) => {
446
446
  if (target !== undefined && !chain.includes(target))
447
447
  referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
448
448
  }
449
- object.childs.forEach(walk);
449
+ object.children.forEach(walk);
450
450
  };
451
451
  for (const artifact of specification.artifacts)
452
452
  artifact.objects.forEach(walk);
@@ -470,7 +470,7 @@ const checkReferenced = (ctx, specification, schemas) => {
470
470
  const covered = Array.from(referrers.get(object) ?? [])
471
471
  .some((referrer) => chainOf(ctx.linkIndex, referrer).some((o) => admissible.has(o)));
472
472
  if (!covered) {
473
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
473
+ const meta = ctx.metaOf(object);
474
474
  ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
475
475
  schema.referenced.map((entry) => `"${entry}"`).join(" or "), "warning");
476
476
  }
@@ -494,7 +494,7 @@ export const validate = (ctx, specification, config) => {
494
494
  references all over the corpus */
495
495
  if (object.paren !== undefined && object.anchor === undefined && !ctx.parenProps.has(object))
496
496
  assignId(ctx.linkIndex, object, object.paren);
497
- object.childs.forEach(promote);
497
+ object.children.forEach(promote);
498
498
  };
499
499
  for (const artifact of specification.artifacts)
500
500
  artifact.objects.forEach(promote);
@@ -504,7 +504,7 @@ export const validate = (ctx, specification, config) => {
504
504
  const position = new Map();
505
505
  for (const artifact of specification.artifacts) {
506
506
  for (const object of artifact.objects) {
507
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
507
+ const meta = ctx.metaOf(object);
508
508
  const schema = schemas.get(object);
509
509
  if (schema === undefined) {
510
510
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
@@ -545,7 +545,7 @@ export const validateReferences = (ctx, specification) => {
545
545
  }
546
546
  };
547
547
  const walk = (object) => {
548
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
548
+ const meta = ctx.metaOf(object);
549
549
  check(object, object.name, meta.file, meta.line);
550
550
  for (const property of object.properties)
551
551
  check(object, property.value, meta.file, ctx.propMeta.get(property)?.line ?? meta.line);
@@ -554,7 +554,7 @@ export const validateReferences = (ctx, specification) => {
554
554
  if (object.description.rationale !== undefined)
555
555
  check(object, object.description.rationale, meta.file, meta.line);
556
556
  }
557
- object.childs.forEach(walk);
557
+ object.children.forEach(walk);
558
558
  };
559
559
  for (const artifact of specification.artifacts)
560
560
  artifact.objects.forEach(walk);
@@ -150,7 +150,7 @@ const embed = (ctx, object, file) => {
150
150
  load(object.description, object.description.description, line);
151
151
  for (const property of object.properties)
152
152
  load(property, property.value, ctx.propMeta.get(property)?.line ?? line);
153
- for (const child of object.childs)
153
+ for (const child of object.children)
154
154
  embed(ctx, child, file);
155
155
  };
156
156
  /* parse an unordered list of key-values and/or concise-format objects */
@@ -229,7 +229,7 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
229
229
  id: anchor ?? slugify(name),
230
230
  name,
231
231
  properties: [],
232
- childs: []
232
+ children: []
233
233
  };
234
234
  if (anchor !== undefined)
235
235
  object.anchor = anchor;
@@ -267,7 +267,7 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
267
267
  ctx.diagnose(file, subLine, `nested ordered list below ${kind} "${name}" ignored`);
268
268
  subLine += (sub.raw.match(/\n/g) ?? []).length;
269
269
  }
270
- parent.childs.push(object);
270
+ parent.children.push(object);
271
271
  };
272
272
  /* parse a heading token into either a grouping container (e.g. "### STATE",
273
273
  collecting objects of its kind below the parent object) or a new object
@@ -280,17 +280,17 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
280
280
  state.group = null;
281
281
  if (heading.malformed !== undefined)
282
282
  ctx.diagnose(file, line, `malformed anchor "${heading.malformed}" in heading`);
283
- if (heading.name === "" && depth > 1) {
284
- const parent = state.stack[depth - 2];
285
- if (parent === undefined) {
286
- ctx.diagnose(file, line, `heading level ${depth} without parent object`);
287
- state.current = null;
288
- }
289
- else {
290
- state.group = { parent, kind: heading.kind };
291
- state.current = parent;
292
- state.stack.length = depth - 1;
293
- }
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;
294
294
  return;
295
295
  }
296
296
  const object = {
@@ -298,7 +298,7 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
298
298
  id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
299
299
  name: heading.name,
300
300
  properties: [],
301
- childs: []
301
+ children: []
302
302
  };
303
303
  if (heading.id !== undefined)
304
304
  object.anchor = heading.id;
@@ -307,17 +307,10 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
307
307
  if (heading.primary)
308
308
  object.primary = true;
309
309
  ctx.objectMeta.set(object, { file, line });
310
- if (depth === 1)
310
+ if (parent === undefined)
311
311
  state.artifacts.push({ ...stamps, objects: [object] });
312
- else {
313
- const parent = state.stack[depth - 2];
314
- if (parent === undefined) {
315
- ctx.diagnose(file, line, `heading level ${depth} without parent object`);
316
- state.current = null;
317
- return;
318
- }
319
- parent.childs.push(object);
320
- }
312
+ else
313
+ parent.children.push(object);
321
314
  state.stack.length = depth - 1;
322
315
  state.stack[depth - 1] = object;
323
316
  state.current = object;
@@ -362,7 +355,9 @@ export const parseFile = (ctx, source) => {
362
355
  const flush = () => {
363
356
  const current = state.current;
364
357
  if (current !== null && parts.length > 0) {
365
- 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)
366
361
  current.description = splitDescription(parts.join("\n\n").trim());
367
362
  else
368
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.children.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.2",
3
+ "version": "1.2.4",
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",
@@ -36,10 +36,10 @@
36
36
  "source-code-error": "1.2.0",
37
37
  "commander": "15.0.0",
38
38
  "chalk": "6.0.0",
39
- "chokidar": "5.0.0",
39
+ "chokidar5": "npm: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"