@rse/specbook 1.1.0 → 1.2.1

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.
@@ -4,6 +4,8 @@ export declare const plainText: (text: string) => string;
4
4
  interface LinkNode {
5
5
  object: SpecObject;
6
6
  parent?: LinkNode;
7
+ pos: number;
8
+ name: string;
7
9
  }
8
10
  export type LinkIndex = LinkNode[];
9
11
  export declare const chainOf: (index: LinkIndex, object: SpecObject) => SpecObject[];
@@ -8,22 +8,37 @@ export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
8
8
  /* strip the inline code markup of a name or property value
9
9
  (preserved in the AST for rendering) for matching and labeling */
10
10
  export const plainText = (text) => text.replace(/`/g, "");
11
- /* the per-index lookup of the nodes by their objects, built lazily on
12
- the first scoped resolution (as the index itself stays a plain list) */
11
+ /* provide the lookup structures of an index, built once on first use */
13
12
  const lookups = new WeakMap();
14
- const lookup = (index, object) => {
15
- let nodes = lookups.get(index);
16
- if (nodes === undefined) {
17
- nodes = new Map(index.map((node) => [node.object, node]));
18
- lookups.set(index, nodes);
13
+ const lookup = (index) => {
14
+ let result = lookups.get(index);
15
+ if (result === undefined) {
16
+ result = { nodes: new Map(), byKey: new Map(), byName: new Map(), byKind: new Map(),
17
+ sets: new Map(), uniques: new Map() };
18
+ const add = (map, key, node) => {
19
+ const nodes = map.get(key);
20
+ if (nodes === undefined)
21
+ map.set(key, [node]);
22
+ else
23
+ nodes.push(node);
24
+ };
25
+ for (const node of index) {
26
+ result.nodes.set(node.object, node);
27
+ add(result.byKey, node.object.id, node);
28
+ if (node.object.anchor !== undefined && node.object.anchor !== node.object.id)
29
+ add(result.byKey, node.object.anchor, node);
30
+ add(result.byName, node.name, node);
31
+ add(result.byKind, node.object.kind, node);
32
+ }
33
+ lookups.set(index, result);
19
34
  }
20
- return nodes.get(object);
35
+ return result;
21
36
  };
22
37
  /* the ancestor-or-self chain of an object, root first
23
38
  (empty for an object unknown to the index) */
24
39
  export const chainOf = (index, object) => {
25
40
  const chain = new Array();
26
- for (let node = lookup(index, object); node !== undefined; node = node.parent)
41
+ for (let node = lookup(index).nodes.get(object); node !== undefined; node = node.parent)
27
42
  chain.unshift(node.object);
28
43
  return chain;
29
44
  };
@@ -31,7 +46,7 @@ export const chainOf = (index, object) => {
31
46
  export const buildLinkIndex = (specification) => {
32
47
  const index = [];
33
48
  const walk = (object, parent) => {
34
- const node = { object, parent };
49
+ const node = { object, parent, pos: index.length, name: plainText(object.name) };
35
50
  index.push(node);
36
51
  for (const child of object.childs)
37
52
  walk(child, node);
@@ -58,40 +73,53 @@ const splitQuoted = (text, separator) => {
58
73
  };
59
74
  /* strip the optional surrounding double quotes of a part */
60
75
  const unquote = (part) => part.replace(/^"(.*)"$/s, "$1");
61
- /* match a name-or-id part against an object (with "*" wildcard) */
62
- const matchesPart = (object, part) => part === "*"
63
- || object.id === part
64
- || object.anchor === part
65
- || plainText(object.name) === part;
66
- /* match a single reference segment ("id", "name", "KIND:name-or-id",
67
- "KIND:*", or "*", each part optionally double-quoted to allow
68
- spaces, dots, and colons) against a single object */
69
- const matchesSegment = (object, segment) => {
76
+ const parseSegment = (segment) => {
70
77
  const parts = splitQuoted(segment, ":");
71
78
  if (parts.length >= 2)
72
- return object.kind === unquote(parts[0].trim())
73
- && matchesPart(object, unquote(parts.slice(1).join(":").trim()));
74
- return matchesPart(object, unquote(segment.trim()));
79
+ return { kind: unquote(parts[0].trim()), part: unquote(parts.slice(1).join(":").trim()) };
80
+ return { part: unquote(segment.trim()) };
81
+ };
82
+ /* match a parsed segment against a single node (with "*" wildcard) */
83
+ const matchesSegment = (node, segment) => (segment.kind === undefined || node.object.kind === segment.kind)
84
+ && (segment.part === "*"
85
+ || node.object.id === segment.part
86
+ || node.object.anchor === segment.part
87
+ || node.name === segment.part);
88
+ /* the candidate nodes matching a parsed segment, in index order,
89
+ found through the lookup structures instead of an index scan */
90
+ const candidates = (index, segment) => {
91
+ const { byKey, byName, byKind } = lookup(index);
92
+ if (segment.part === "*")
93
+ return segment.kind !== undefined ? (byKind.get(segment.kind) ?? []) : index;
94
+ const nodes = Array.from(new Set([...(byKey.get(segment.part) ?? []), ...(byName.get(segment.part) ?? [])]))
95
+ .sort((a, b) => a.pos - b.pos);
96
+ return segment.kind !== undefined ? nodes.filter((node) => node.object.kind === segment.kind) : nodes;
75
97
  };
76
98
  /* split a reference into its hierarchical path segments,
77
99
  honoring double-quoted parts */
78
100
  const segmentsOf = (reference) => splitQuoted(reference, ".").map((segment) => segment.trim()).filter((segment) => segment !== "");
79
101
  /* resolve a reference into the set of all matching objects: a path
80
102
  matches object chains connected by direct parent-to-child steps,
81
- with the leading segments up to the root freely omittable */
103
+ with the leading segments up to the root freely omittable (the
104
+ candidates of the last segment are walked up along the parents) */
82
105
  export const resolveSet = (index, reference) => {
83
- const segments = segmentsOf(reference);
84
- if (segments.length === 0)
85
- return [];
86
- return index.filter((node) => {
87
- let current = node;
88
- for (let i = segments.length - 1; i >= 0; i--) {
89
- if (current === undefined || !matchesSegment(current.object, segments[i]))
90
- return false;
91
- current = current.parent;
92
- }
93
- return true;
94
- }).map((node) => node.object);
106
+ const { sets } = lookup(index);
107
+ let matches = sets.get(reference);
108
+ if (matches === undefined) {
109
+ const segments = segmentsOf(reference).map(parseSegment);
110
+ matches = segments.length === 0 ? [] :
111
+ candidates(index, segments[segments.length - 1]).filter((node) => {
112
+ let current = node.parent;
113
+ for (let i = segments.length - 2; i >= 0; i--) {
114
+ if (current === undefined || !matchesSegment(current, segments[i]))
115
+ return false;
116
+ current = current.parent;
117
+ }
118
+ return true;
119
+ }).map((node) => node.object);
120
+ sets.set(reference, matches);
121
+ }
122
+ return matches;
95
123
  };
96
124
  /* narrow several matches down to the ones nearest to the referencing
97
125
  object, i.e. sharing the longest ancestor chain with it (the lexical
@@ -115,26 +143,23 @@ const nearest = (index, matches, from) => {
115
143
  the first variant yielding matches decides, while a hierarchical path
116
144
  resolves via its full match set; several matches are narrowed down to
117
145
  the ones nearest to the referencing object (if known) and remain an
118
- ambiguity only if still more than one remains */
146
+ ambiguity only if still more than one remains (the scope-independent
147
+ match set is memoized per reference) */
119
148
  export const resolveUnique = (index, reference, from) => {
120
- const segments = segmentsOf(reference);
121
- let matches = [];
122
- if (segments.length === 1) {
123
- const segment = segments[0];
124
- const unquoted = unquote(segment);
125
- const variants = [
126
- (object) => object.id === unquoted || object.anchor === unquoted,
127
- (object) => plainText(object.name) === unquoted,
128
- (object) => matchesSegment(object, segment)
129
- ];
130
- for (const variant of variants) {
131
- matches = index.filter((node) => variant(node.object)).map((node) => node.object);
132
- if (matches.length > 0)
133
- break;
149
+ const { byKey, byName, uniques } = lookup(index);
150
+ let matches = uniques.get(reference);
151
+ if (matches === undefined) {
152
+ const segments = segmentsOf(reference);
153
+ if (segments.length === 1) {
154
+ const segment = segments[0];
155
+ const unquoted = unquote(segment);
156
+ matches = (byKey.get(unquoted) ?? byName.get(unquoted) ?? candidates(index, parseSegment(segment)))
157
+ .map((node) => node.object);
134
158
  }
159
+ else
160
+ matches = resolveSet(index, reference);
161
+ uniques.set(reference, matches);
135
162
  }
136
- else
137
- matches = resolveSet(index, reference);
138
163
  if (matches.length > 1 && from !== undefined)
139
164
  matches = nearest(index, matches, from);
140
165
  return { target: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
- import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, parseCompressLevel, version } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, version } from "./specbook-api.js";
11
11
  /* render an error with its cause chain into a tool error result */
12
12
  const errorResult = (err) => {
13
13
  let msg = err instanceof Error ? err.message : String(err);
@@ -78,7 +78,7 @@ export const serveMcp = async (verbose) => {
78
78
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
79
79
  format: z.enum(formats).optional().describe("output format (default: inferred from the " +
80
80
  "output file extension, else json)"),
81
- output: z.string().optional().describe("output file path (\"-\" returns the result directly)")
81
+ output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)")
82
82
  }
83
83
  }, async (args) => {
84
84
  try {
@@ -120,7 +120,7 @@ export const serveMcp = async (verbose) => {
120
120
  basedir: z.string().optional().describe("base directory of the specification Markdown files"),
121
121
  embed: z.boolean().optional().describe("embed the given YAML schema configuration instead " +
122
122
  "of just referencing it (default: false; the bundled standard one is always embedded)"),
123
- compress: z.number().int().min(0).max(3).optional().describe("compression level of the emitted " +
123
+ compress: z.literal(compressLevels).optional().describe("compression level of the emitted " +
124
124
  "YAML schema configuration (embedded or raw): 0 for verbatim, 1 for re-emitted with 2-space " +
125
125
  "indentation and without comments, 2 for additionally without its \"refs\" fields, or 3 for " +
126
126
  "additionally without its \"desc\" fields (default: 1)"),
@@ -134,10 +134,9 @@ export const serveMcp = async (verbose) => {
134
134
  }
135
135
  }, async (args) => {
136
136
  try {
137
- const text = await specbook.describe({ ...args,
138
- compress: args.compress !== undefined ? parseCompressLevel(args.compress) : undefined });
137
+ const text = await specbook.describe(args);
139
138
  if (args.output !== undefined && args.output !== "-") {
140
- await fs.promises.writeFile(args.output, text, "utf8");
139
+ await fs.promises.writeFile(args.output, text);
141
140
  return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
142
141
  }
143
142
  return { content: [{ type: "text", text }] };
@@ -21,6 +21,7 @@ export declare class ParseContext {
21
21
  line: number;
22
22
  }>;
23
23
  linkIndex: LinkIndex;
24
+ parenProps: WeakMap<SpecObject, SpecProperty>;
24
25
  assets: Set<string>;
25
26
  diagnose(file: string, line: number, message: string, severity?: DiagnosticSeverity): void;
26
27
  }
@@ -9,6 +9,10 @@ export class ParseContext {
9
9
  objectMeta = new WeakMap();
10
10
  propMeta = new WeakMap();
11
11
  linkIndex = [];
12
+ /* the synthetic properties supplied by the parenthesized name
13
+ tokens consumed as property values (kept outside the AST, so
14
+ the tokens stay plain heading markers on export) */
15
+ parenProps = new WeakMap();
12
16
  /* the resolved paths of the embedded asset files, recorded even for
13
17
  an unreadable one, as a watching consumer has to observe it, too */
14
18
  assets = new Set();
@@ -41,8 +41,13 @@ const matchAlternatives = (ctx, object, alternatives, item) => {
41
41
  }
42
42
  return false;
43
43
  };
44
- /* find a property of an object by its key (case-sensitive) */
45
- const findProp = (object, name) => object.properties.find((p) => p.key === name);
44
+ /* find a property of an object by its key (case-sensitive), including
45
+ the synthetic one supplied by a consumed parenthesized name token */
46
+ const findProp = (ctx, object, name) => {
47
+ const synthetic = ctx.parenProps.get(object);
48
+ return object.properties.find((p) => p.key === name) ??
49
+ (synthetic?.key === name ? synthetic : undefined);
50
+ };
46
51
  /* split a multi-valued property value into its items, reporting an
47
52
  empty one -- stemming from an empty value or a stray comma -- once,
48
53
  as such an item can never satisfy any constraint */
@@ -114,9 +119,50 @@ const parenProp = (object, props) => {
114
119
  const paren = object.paren;
115
120
  if (paren === undefined)
116
121
  return undefined;
117
- return props.find((prop) => prop.value !== undefined && findProp(object, prop.name) === undefined
122
+ return props.find((prop) => prop.value !== undefined && !object.properties.some((p) => p.key === prop.name)
118
123
  && directMatches(compileValueExpr(prop.value), paren));
119
124
  };
125
+ /* check the property values flagged unique for distinctness among
126
+ 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) */
130
+ const checkSiblingFlags = (ctx, object, childs, meta) => {
131
+ const marker = (flag) => flag === undefined || flag === false ? undefined :
132
+ { filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
133
+ for (const child of childs) {
134
+ const siblings = object.childs.filter((c) => c.kind === child.kind);
135
+ for (const prop of child.props ?? []) {
136
+ const unique = marker(prop.unique);
137
+ const present = marker(prop.present);
138
+ if (unique === undefined && present === undefined)
139
+ continue;
140
+ const seen = new Map();
141
+ let found = false;
142
+ for (const sibling of siblings) {
143
+ const property = findProp(ctx, sibling, prop.name);
144
+ if (property === undefined)
145
+ continue;
146
+ const value = plainText(property.value).trim();
147
+ if (present !== undefined && (present.filter === undefined || directMatches(present.filter, value)))
148
+ found = true;
149
+ if (unique === undefined || (unique.filter !== undefined && !directMatches(unique.filter, value)))
150
+ continue;
151
+ const first = seen.get(value);
152
+ if (first === undefined)
153
+ seen.set(value, sibling);
154
+ else {
155
+ const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
156
+ ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
157
+ `already used by preceding ${sibling.kind} "${first.name}"`);
158
+ }
159
+ }
160
+ if (present !== undefined && siblings.length > 0 && !found)
161
+ ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
162
+ (typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
163
+ }
164
+ }
165
+ };
120
166
  /* validate a single object (and recursively its childs) against its schema */
121
167
  const validateObject = (ctx, object, schema, level) => {
122
168
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
@@ -132,11 +178,11 @@ const validateObject = (ctx, object, schema, level) => {
132
178
  /* check every occurrence of the configured properties (a trailing
133
179
  parenthesized name token is accepted as the value of a still
134
180
  missing property, and a repeated key is reported as duplicate) */
135
- const consumed = parenProp(object, props);
181
+ const consumed = ctx.parenProps.get(object);
136
182
  for (const prop of props) {
137
183
  const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
138
184
  const matches = object.properties.filter((p) => p.key === prop.name);
139
- if (matches.length === 0 && prop !== consumed && prop.optional !== true)
185
+ if (matches.length === 0 && prop.name !== consumed?.key && prop.optional !== true)
140
186
  ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
141
187
  for (const [i, match] of matches.entries()) {
142
188
  const line = ctx.propMeta.get(match)?.line ?? meta.line;
@@ -161,45 +207,8 @@ const validateObject = (ctx, object, schema, level) => {
161
207
  }
162
208
  validateObject(ctx, child, childSchema, level + 1);
163
209
  }
164
- /* check the property values flagged unique for distinctness among
165
- the sibling objects of the same kind and the ones flagged present
166
- for occurring on at least one of the (existing) siblings (all
167
- values, or only the values matching the regexp or enum expression
168
- of the flag) */
169
- for (const child of childs) {
170
- const siblings = object.childs.filter((c) => c.kind === child.kind);
171
- for (const prop of child.props ?? []) {
172
- const marker = (flag) => flag === undefined || flag === false ? undefined :
173
- { filter: typeof flag === "string" ? compileValueExpr(flag) : undefined };
174
- const unique = marker(prop.unique);
175
- const present = marker(prop.present);
176
- if (unique === undefined && present === undefined)
177
- continue;
178
- const seen = new Map();
179
- let found = false;
180
- for (const sibling of siblings) {
181
- const property = findProp(sibling, prop.name);
182
- if (property === undefined)
183
- continue;
184
- const value = plainText(property.value).trim();
185
- if (present !== undefined && (present.filter === undefined || directMatches(present.filter, value)))
186
- found = true;
187
- if (unique === undefined || (unique.filter !== undefined && !directMatches(unique.filter, value)))
188
- continue;
189
- const first = seen.get(value);
190
- if (first === undefined)
191
- seen.set(value, sibling);
192
- else {
193
- const siblingMeta = ctx.objectMeta.get(sibling) ?? { file: "", line: 1 };
194
- ctx.diagnose(siblingMeta.file, ctx.propMeta.get(property)?.line ?? siblingMeta.line, `value "${value}" of unique property "${prop.name}" on ${sibling.kind} "${sibling.name}" ` +
195
- `already used by preceding ${sibling.kind} "${first.name}"`);
196
- }
197
- }
198
- if (present !== undefined && siblings.length > 0 && !found)
199
- ctx.diagnose(meta.file, meta.line, `no ${child.kind} below ${object.kind} "${object.name}" carries property "${prop.name}"` +
200
- (typeof prop.present === "string" ? ` with a value matching "${prop.present}"` : ""));
201
- }
202
- }
210
+ /* check the sibling-scoped property flags of the child kinds */
211
+ checkSiblingFlags(ctx, object, childs, meta);
203
212
  /* report the configured child object kinds which are missing */
204
213
  for (const child of childs)
205
214
  if (child.optional !== true
@@ -222,7 +231,7 @@ const validateObject = (ctx, object, schema, level) => {
222
231
  /* resolve a level 1 object onto its configured artifact, deliberately
223
232
  leniently by kind and id first and by case-insensitive name second,
224
233
  so deviations of the heading can still be precisely reported */
225
- export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
234
+ export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || (s.id !== undefined && `${s.kind}-${s.id}` === object.id)) ??
226
235
  config.find((s) => s.name !== undefined
227
236
  && s.name.toUpperCase() === plainText(object.name).toUpperCase());
228
237
  /* map the specification objects onto their schema configuration
@@ -267,7 +276,7 @@ const checkIds = (ctx, objects) => {
267
276
  distinct, in document order), leniently skipping the unresolvable
268
277
  ones, as those are already reported by the reference pass */
269
278
  const referencedObjects = (ctx, object, name) => {
270
- const property = findProp(object, name);
279
+ const property = findProp(ctx, object, name);
271
280
  if (property === undefined)
272
281
  return [];
273
282
  const targets = new Array();
@@ -296,7 +305,7 @@ const checkAutomata = (ctx, schemas) => {
296
305
  const nodes = object.childs.filter((child) => child.kind === automaton.nodes);
297
306
  const nodeSet = new Set(nodes);
298
307
  const flagged = (node, name) => {
299
- const property = findProp(node, name);
308
+ const property = findProp(ctx, node, name);
300
309
  return property !== undefined && plainText(property.value).trim() === "true";
301
310
  };
302
311
  const initials = nodes.filter((node) => flagged(node, automaton.initial));
@@ -317,7 +326,7 @@ const checkAutomata = (ctx, schemas) => {
317
326
  const closure = (starts, next) => {
318
327
  const seen = new Set(starts);
319
328
  const queue = [...starts];
320
- for (let node = queue.shift(); node !== undefined; node = queue.shift())
329
+ for (const node of queue)
321
330
  for (const other of next.get(node) ?? [])
322
331
  if (!seen.has(other)) {
323
332
  seen.add(other);
@@ -349,13 +358,18 @@ const checkRelations = (ctx, schemas) => {
349
358
  const groups = new Map();
350
359
  for (const [object, schema] of schemas)
351
360
  for (const prop of schema.props ?? [])
352
- if (prop.symmetric === true || prop.acyclic === true)
353
- groups.set(prop, [...(groups.get(prop) ?? []), object]);
361
+ if (prop.symmetric === true || prop.acyclic === true) {
362
+ const group = groups.get(prop);
363
+ if (group === undefined)
364
+ groups.set(prop, [object]);
365
+ else
366
+ group.push(object);
367
+ }
354
368
  for (const [prop, objects] of groups) {
355
369
  const members = new Set(objects);
356
370
  const locate = (object) => {
357
371
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
358
- const property = findProp(object, prop.name);
372
+ const property = findProp(ctx, object, prop.name);
359
373
  const line = property !== undefined ? ctx.propMeta.get(property)?.line : undefined;
360
374
  return { file: meta.file, line: line ?? meta.line };
361
375
  };
@@ -403,11 +417,11 @@ const checkRelations = (ctx, schemas) => {
403
417
  the references from within the subtree of the object itself do not
404
418
  count (a lapse is a warning only, as the specification stays valid) */
405
419
  const checkReferenced = (ctx, specification, schemas) => {
406
- /* collect the referencing objects of every referenced object,
407
- together with the parent of every object for the ancestor walk */
420
+ /* collect the referencing objects of every referenced object
421
+ (the references from within its own subtree excluded) */
408
422
  const referrers = new Map();
409
- const parents = new Map();
410
- const walk = (object, chain) => {
423
+ const walk = (object) => {
424
+ const chain = chainOf(ctx.linkIndex, object);
411
425
  const texts = [object.name, ...object.properties.map((p) => p.value)];
412
426
  if (object.description !== undefined)
413
427
  texts.push(object.description.description, object.description.rationale ?? "");
@@ -417,14 +431,10 @@ const checkReferenced = (ctx, specification, schemas) => {
417
431
  if (target !== undefined && !chain.includes(target))
418
432
  referrers.set(target, (referrers.get(target) ?? new Set()).add(object));
419
433
  }
420
- for (const child of object.childs) {
421
- parents.set(child, object);
422
- walk(child, [...chain, child]);
423
- }
434
+ object.childs.forEach(walk);
424
435
  };
425
436
  for (const artifact of specification.artifacts)
426
- for (const object of artifact.objects)
427
- walk(object, [object]);
437
+ artifact.objects.forEach(walk);
428
438
  /* resolve the reference patterns of every flagged kind into the
429
439
  admissible referencing objects (once per shared schema node) */
430
440
  const sources = new Map();
@@ -442,12 +452,8 @@ const checkReferenced = (ctx, specification, schemas) => {
442
452
  }
443
453
  sources.set(schema, admissible);
444
454
  }
445
- const covered = Array.from(referrers.get(object) ?? []).some((referrer) => {
446
- for (let o = referrer; o !== undefined; o = parents.get(o))
447
- if (admissible.has(o))
448
- return true;
449
- return false;
450
- });
455
+ const covered = Array.from(referrers.get(object) ?? [])
456
+ .some((referrer) => chainOf(ctx.linkIndex, referrer).some((o) => admissible.has(o)));
451
457
  if (!covered) {
452
458
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
453
459
  ctx.diagnose(meta.file, meta.line, `${object.kind} "${object.name}" is not referenced from any object matching ` +
@@ -469,9 +475,13 @@ export const validate = (ctx, specification, config) => {
469
475
  token, so the token still acts as the id and a single
470
476
  unresolved heading no longer cascades into unresolvable
471
477
  references all over the corpus */
472
- if (object.paren !== undefined && object.anchor === undefined
473
- && parenProp(object, schemas.get(object)?.props ?? []) === undefined)
474
- object.id = object.paren;
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
+ }
475
485
  object.childs.forEach(promote);
476
486
  };
477
487
  for (const artifact of specification.artifacts)
@@ -513,7 +523,7 @@ export const validate = (ctx, specification, config) => {
513
523
  its carrying object), independent of any configuration */
514
524
  export const validateReferences = (ctx, specification) => {
515
525
  const check = (object, text, file, line) => {
516
- for (const m of text.matchAll(referenceRegex)) {
526
+ for (const m of plainText(text).matchAll(referenceRegex)) {
517
527
  const ref = m[1].trim();
518
528
  const resolution = resolveUnique(ctx.linkIndex, ref, object);
519
529
  if (resolution.ambiguous)
@@ -27,8 +27,8 @@ const parseMarkers = (raw) => {
27
27
  let primary = false;
28
28
  for (;;) {
29
29
  const am = text.match(/\{\{([^{}]+)\}\}\s*$/);
30
- if (am !== null && am.index !== undefined) {
31
- anchor ??= am[1].trim();
30
+ if (am !== null && am.index !== undefined && anchor === undefined) {
31
+ anchor = am[1].trim();
32
32
  text = text.slice(0, am.index).trim();
33
33
  continue;
34
34
  }
@@ -48,7 +48,8 @@ const parseMarkers = (raw) => {
48
48
  }
49
49
  return { text, anchor, paren, primary };
50
50
  };
51
- /* parse a heading text of the form "<KIND>: <name> [(<token>)] [<a id="..."></a>]" */
51
+ /* parse a heading text of the form
52
+ "<KIND>: <name> [(<token>)] [(*)] [{{<anchor>}}] [<a id="..."></a>]" */
52
53
  const parseHeadingText = (raw) => {
53
54
  let text = raw.trim();
54
55
  let id;
@@ -33,7 +33,7 @@ export const parseSpecification = (sources, config) => {
33
33
  if (!result.success)
34
34
  for (const issue of result.issues) {
35
35
  const path = v.getDotPath(issue) ?? "";
36
- ctx.diagnose(sources[0]?.file ?? "", 1, `internal AST invalid at "${path}": ${issue.message}`);
36
+ ctx.diagnose(sources[0].file, 1, `internal AST invalid at "${path}": ${issue.message}`);
37
37
  }
38
38
  }
39
39
  return { specification, diagnostics: ctx.diagnostics, assets: Array.from(ctx.assets) };
@@ -7,7 +7,6 @@ export type ThemeColors = {
7
7
  export declare const themeColors: (tone: string) => ThemeColors;
8
8
  export declare const themeStylesheet: (colors: ThemeColors) => string;
9
9
  export type ThemeMapping = {
10
- text: string;
11
10
  muted: string;
12
11
  symbol: string;
13
12
  border: string;
@@ -26,10 +26,9 @@ 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: 29, muted: 22, symbol: 20, border: 16, accent: 24 } :
30
- { text: 5, muted: 22, symbol: 24, border: 28, accent: 12 };
29
+ { muted: 22, symbol: 20, border: 16, accent: 24 } :
30
+ { muted: 22, symbol: 24, border: 28, accent: 12 };
31
31
  return {
32
- text: colors.base[idx.text - 1],
33
32
  muted: colors.base[idx.muted - 1],
34
33
  symbol: colors.base[idx.symbol - 1],
35
34
  border: colors.base[idx.border - 1],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -22,9 +22,9 @@
22
22
  "yaml": "2.9.0",
23
23
  "json5": "2.2.3",
24
24
  "pdf-lib": "1.17.1",
25
- "subset-font": "2.6.0",
25
+ "subset-font": "2.7.0",
26
26
  "@rse/mrcs": "0.9.2",
27
- "@rse/gradia": "1.1.4",
27
+ "@rse/gradia": "1.1.7",
28
28
  "@toon-format/toon": "4.1.1",
29
29
  "valibot": "1.4.2",
30
30
  "nunjucks": "3.2.4",
@@ -42,26 +42,28 @@
42
42
  "fastify": "5.12.1",
43
43
  "@fastify/websocket": "11.3.0",
44
44
  "@modelcontextprotocol/sdk": "1.30.0",
45
- "zod": "4.5.1"
45
+ "zod": "4.5.4"
46
46
  },
47
47
  "devDependencies": {
48
48
  "chokidar-cli": "3.0.0",
49
49
  "eslint": "10.9.0",
50
50
  "@eslint/js": "10.0.1",
51
- "typescript-eslint": "8.68.0",
51
+ "typescript-eslint": "8.69.0",
52
52
  "eslint-plugin-promise": "7.3.0",
53
53
  "neostandard": "0.14.0-next.1",
54
- "globals": "17.11.0",
55
- "@types/node": "26.4.0",
54
+ "globals": "17.12.0",
55
+ "markdownlint": "0.41.1",
56
+ "markdownlint-cli2": "0.23.2",
57
+ "@types/node": "26.4.1",
56
58
  "@types/subset-font": "2.3.0",
57
59
  "@types/nunjucks": "3.2.6",
58
60
  "@types/ws": "8.18.1",
59
61
  "typescript": "6.0.3",
60
- "tsx": "4.23.12",
62
+ "tsx": "4.23.13",
61
63
  "source-sans": "3.52.0",
62
64
  "source-code-pro": "2.42.0",
63
65
  "@fontsource/noto-sans-symbols": "5.3.0",
64
- "postcss": "8.5.26",
66
+ "postcss": "8.5.28",
65
67
  "postcss-cli": "11.0.1",
66
68
  "postcss-url": "10.1.4",
67
69
  "stylus": "0.64.0",