@rse/specbook 0.9.7 → 0.9.8

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.
package/README.md CHANGED
@@ -132,6 +132,11 @@ occur multiple times. The format (`json`, `json5`, `yaml`, `toon`,
132
132
  it is explicitly given as a `<format>:` prefix, and plain `-` (stdout)
133
133
  defaults to JSON.
134
134
 
135
+ The `md` format normalizes the entire corpus into a *single* Markdown
136
+ document, whose single frontmatter block carries the earliest `Created:`
137
+ and the latest `Modified:` timestamp of all artifacts, so the
138
+ per-artifact timestamps do not survive this round-trip.
139
+
135
140
  The `describe` command outputs the description of the SpecBook models
136
141
  and formats. If the configuration file `-c`/`--config` or the base
137
142
  directory `-b`/`--basedir` is given, it additionally appends a *SpecBook
@@ -27,11 +27,15 @@ const extensions = new Map([
27
27
  format from the filename extension when not explicitly given
28
28
  (plain "-" for stdout defaults to JSON) */
29
29
  export const parseOutputSpec = (spec) => {
30
- const prefixed = spec.match(/^([a-zA-Z0-9]+):(.+)$/);
30
+ /* a single-character prefix is never a format name, so a Windows
31
+ drive letter still falls through to the extension inference */
32
+ const prefixed = spec.match(/^([a-zA-Z0-9]{2,}):(.+)$/);
31
33
  if (prefixed !== null) {
32
34
  const explicit = extensions.get(prefixed[1].toLowerCase());
33
- if (explicit !== undefined)
34
- return { format: explicit, output: prefixed[2] };
35
+ if (explicit === undefined)
36
+ throw new Error(`unknown export format "${prefixed[1]}" in output "${spec}" ` +
37
+ `(supported: ${Array.from(extensions.keys()).join(", ")})`);
38
+ return { format: explicit, output: prefixed[2] };
35
39
  }
36
40
  if (spec === "-")
37
41
  return { format: "json", output: spec };
@@ -22,10 +22,11 @@ const lineColOfPath = (doc, lines, path) => {
22
22
  return { line: 1, column: 1 };
23
23
  };
24
24
  /* check the constraints of a structurally valid configuration which
25
- are beyond its schema: "file" fields are allowed on the first
26
- (artifact) level only, property value expressions have to be
27
- syntactically valid, and the names of non-artifact objects have to
28
- be valid patterns (as they are compiled into regular expressions) */
25
+ are beyond its schema: sibling objects have to stay distinctly
26
+ resolvable, "file" fields are allowed on the first (artifact) level
27
+ only, property value expressions have to be syntactically valid, and
28
+ the names of non-artifact objects have to be valid patterns (as they
29
+ are compiled into regular expressions) */
29
30
  const checkConstraints = (config, file, posOfPath) => {
30
31
  const diagnostics = new Array();
31
32
  const diagnose = (path, message) => {
@@ -33,8 +34,17 @@ const checkConstraints = (config, file, posOfPath) => {
33
34
  diagnostics.push({ file, line: pos.line, column: pos.column, message });
34
35
  };
35
36
  const check = (objects, path, depth) => {
37
+ /* the sibling objects are resolved by kind and id on the first
38
+ (artifact) level and by kind alone below it, so a sibling
39
+ colliding on this key stays unreachable dead configuration */
40
+ const seen = new Set();
36
41
  for (const [i, object] of objects.entries()) {
37
42
  const at = [...path, i];
43
+ const ident = depth > 1 ? object.kind : `${object.kind}:${object.id ?? object.name ?? ""}`;
44
+ if (seen.has(ident))
45
+ diagnose([...at, "kind"], `object "${ident}" collides with a preceding sibling object (the later one is unreachable)`);
46
+ else
47
+ seen.add(ident);
38
48
  if (depth > 1 && object.file !== undefined)
39
49
  diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
40
50
  for (const [j, prop] of (object.props ?? []).entries()) {
@@ -1,10 +1,14 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
3
  import type { ParseContext } from "./specbook-parse-common.js";
4
+ export interface DiagramError {
5
+ reason: string;
6
+ object?: SpecObject;
7
+ }
4
8
  export interface DiagramResult {
5
9
  spec?: string;
6
10
  config?: SchemaDiagram["config"];
7
- errors: string[];
11
+ errors: DiagramError[];
8
12
  }
9
13
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
10
14
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -110,13 +110,13 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
110
110
  edgeNode(edgeObject, diagram.edgeSource) : parents.get(edgeObject);
111
111
  const target = edgeNode(edgeObject, diagram.edgeTarget);
112
112
  if (source === undefined) {
113
- errors.push(diagram.edgeSource !== undefined ?
114
- `diagram edge object "${edgeObject.name}" carries no resolvable source reference` :
115
- `diagram edge object "${edgeObject.name}" has no parent object as source`);
113
+ errors.push({ object: edgeObject, reason: diagram.edgeSource !== undefined ?
114
+ "carries no resolvable source reference" :
115
+ "has no parent object as source" });
116
116
  continue;
117
117
  }
118
118
  if (target === undefined) {
119
- errors.push(`diagram edge object "${edgeObject.name}" carries no resolvable target reference`);
119
+ errors.push({ object: edgeObject, reason: "carries no resolvable target reference" });
120
120
  continue;
121
121
  }
122
122
  if (!nodeSet.has(source) || !nodeSet.has(target))
@@ -136,11 +136,11 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
136
136
  }
137
137
  else {
138
138
  if (diagram.edges !== undefined)
139
- errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
139
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"edges\" configuration" });
140
140
  if (diagram.hierarchy === true)
141
- errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
141
+ errors.push({ reason: "\"grid\" diagram cannot carry a \"hierarchy\" configuration" });
142
142
  if (diagram.onlyConnected === true)
143
- errors.push("\"grid\" diagram cannot carry an \"onlyConnected\" configuration");
143
+ errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
144
144
  }
145
145
  /* deduplicate the edges (the same reference can occur in
146
146
  multiple texts of the same node object) */
@@ -167,7 +167,7 @@ const resolvePatterns = (index, errors, value, field) => {
167
167
  const pattern = m[1].trim();
168
168
  const set = resolveSet(index, pattern);
169
169
  if (set.length === 0)
170
- errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
170
+ errors.push({ reason: `unresolvable diagram "${field}" pattern "[[${pattern}]]"` });
171
171
  for (const match of set) {
172
172
  if (!seen.has(match)) {
173
173
  seen.add(match);
@@ -176,7 +176,7 @@ const resolvePatterns = (index, errors, value, field) => {
176
176
  }
177
177
  }
178
178
  if (!found)
179
- errors.push(`diagram "${field}" carries no "[[...]]" reference pattern`);
179
+ errors.push({ reason: `diagram "${field}" carries no "[[...]]" reference pattern` });
180
180
  return matches;
181
181
  };
182
182
  /* derive the Gradia spec of a single object from its "diagram:"
@@ -210,13 +210,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
210
210
  resolved object of an explicit "[[...]]" reference otherwise */
211
211
  let center = object;
212
212
  if (type !== "hub" && diagram.center !== undefined)
213
- errors.push(`"${type}" diagram cannot carry a "center" configuration`);
213
+ errors.push({ reason: `"${type}" diagram cannot carry a "center" configuration` });
214
214
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
215
215
  const reference = diagram.center.match(referenceOnce)?.[1].trim();
216
216
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
217
217
  if (resolved?.target === undefined) {
218
- errors.push((resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
219
- ` diagram "center" reference "${diagram.center}"`);
218
+ errors.push({ reason: (resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
219
+ ` diagram "center" reference "${diagram.center}"` });
220
220
  return { errors };
221
221
  }
222
222
  center = resolved.target;
@@ -227,7 +227,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
227
227
  Gradia requires exactly this constrained topology */
228
228
  if (type === "hub") {
229
229
  if (!nodeSet.has(center))
230
- errors.push(`"hub" diagram center "${center.name}" is not part of the node set`);
230
+ errors.push({ reason: `"hub" diagram center "${center.name}" is not part of the node set` });
231
231
  else {
232
232
  edges = edges.filter((edge) => (edge.source === center || edge.target === center)
233
233
  && edge.source !== edge.target);
@@ -250,7 +250,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
250
250
  nodes = nodes.filter((node) => connected.has(node));
251
251
  }
252
252
  if (nodes.length === 0)
253
- errors.push("diagram yields no nodes");
253
+ errors.push({ reason: "diagram yields no nodes" });
254
254
  if (errors.length > 0)
255
255
  return { errors };
256
256
  /* the "collapse" handling (enabled by default) silently omits a
@@ -277,9 +277,21 @@ export const specDiagrams = (specification, config) => {
277
277
  /* validate all configured diagrams of a specification, reporting
278
278
  every invalid diagram situation as a file/line-precise diagnostic */
279
279
  export const validateDiagrams = (ctx, specification, config) => {
280
- for (const [object, result] of specDiagrams(specification, config)) {
281
- const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
282
- for (const error of result.errors)
283
- ctx.diagnose(meta.file, meta.line, `invalid diagram on ${object.kind} "${object.name}": ${error}`);
284
- }
280
+ const seen = new Set();
281
+ for (const [object, result] of specDiagrams(specification, config))
282
+ for (const error of result.errors) {
283
+ /* attribute an edge object situation to the offending edge
284
+ object itself and report it just once, as a wildcard
285
+ "edges" pattern lets every diagram walk the very same
286
+ edge objects again */
287
+ const meta = ctx.objectMeta.get(error.object ?? object) ?? { file: "", line: 1 };
288
+ const message = error.object !== undefined ?
289
+ `invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
290
+ `invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
291
+ const key = `${meta.file}${meta.line}${message}`;
292
+ if (seen.has(key))
293
+ continue;
294
+ seen.add(key);
295
+ ctx.diagnose(meta.file, meta.line, message);
296
+ }
285
297
  };
@@ -342,7 +342,8 @@ const inlineValue = (kind, { key, value, embedding }) => {
342
342
  .map((content) => `<div class="embedding">${content}</div>`).join("");
343
343
  if (member === undefined || text === "")
344
344
  return safe(`${inline(text)}${embeddings}`);
345
- const items = member === "tags" ? splitItems(text) : [text];
345
+ const items = member === "tags" ?
346
+ splitItems(text).filter((item) => item !== "") : [text];
346
347
  return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" ") + embeddings);
347
348
  };
348
349
  /* expand the inline Markdown of the property values */
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import { specDiagrams } from "./specbook-diagram.js";
7
+ import { becauseRegex } from "./specbook-parse-common.js";
7
8
  /* format a timestamp in the frontmatter format */
8
9
  const formatTimestamp = (date) => {
9
10
  const pad = (n) => String(n).padStart(2, "0");
@@ -19,6 +20,10 @@ const renderKeyValuesMd = (properties, indent = "") => {
19
20
  `${indent}- ${property.key}:\n${indent} ${property.value}` :
20
21
  `${indent}- ${`${property.key}:`.padEnd(width)} ${property.value}`.trimEnd()).join("\n");
21
22
  };
23
+ /* whether a property can be a Concise Format segment: a value carrying
24
+ a ";" would split the segment, and a value carrying the rationale
25
+ marker would not be taken for a property at all on re-parsing */
26
+ const segmentableMd = (property) => !property.value.includes(";") && !becauseRegex.test(property.value);
22
27
  /* render a description statement with its optional rationale */
23
28
  const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
24
29
  `, BECAUSE ${description.rationale}` : "");
@@ -27,20 +32,22 @@ const renderDescriptionMd = (description) => description.description + (descript
27
32
  const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
28
33
  (object.paren !== undefined ? ` (${object.paren})` : "") +
29
34
  (object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
30
- /* whether any descendant of an object carries a multi-line description
31
- (a fenced code block, an ordered list, a blockquote, or multiple
32
- paragraphs), which no Concise Format item can carry */
33
- const multiLineBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
34
- && renderDescriptionMd(child.description).includes("\n")) || multiLineBelowMd(child));
35
+ /* whether any descendant of an object carries a description which no
36
+ Concise Format item can carry -- a multi-line one (a fenced code
37
+ block, an ordered list, a blockquote, or multiple paragraphs) or one
38
+ carrying a ";", as only a ";"-free description stays a single segment
39
+ on re-parsing instead of splitting off a spurious property */
40
+ const complexBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
41
+ && /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
35
42
  /* render an object in the Concise Format as a list item of the
36
43
  given nesting depth, with its childs as nested list items */
37
44
  const renderConciseMd = (object, depth = 0) => {
38
45
  const indent = " ".repeat(depth * 4);
39
46
  const segments = [`${object.kind}: ${object.name}${nameSuffixMd(object)}`];
40
- /* a value carrying ";" cannot be a segment, so its property has to
41
- become a nested key/value item below the item instead */
42
- const wrapped = object.properties.filter((property) => property.value.includes(";"));
43
- const inline = object.properties.filter((property) => !property.value.includes(";"));
47
+ /* a property which cannot be a segment has to become a nested
48
+ key/value item below the item instead */
49
+ const wrapped = object.properties.filter((property) => !segmentableMd(property));
50
+ const inline = object.properties.filter((property) => segmentableMd(property));
44
51
  segments.push(...inline.map((property) => `${property.key}: ${property.value}`));
45
52
  if (object.description !== undefined)
46
53
  segments.push(renderDescriptionMd(object.description));
@@ -57,10 +64,10 @@ const renderConciseMd = (object, depth = 0) => {
57
64
  };
58
65
  /* render an object in the Complex Format (levels 1-3), with childs from
59
66
  level 4 upwards in the Concise Format -- unless a descendant carries
60
- a multi-line description, so all childs (as siblings have to share
61
- the format) stay in the Complex Format down to heading level 6 --,
62
- and the optionally derived Gradia diagram spec embedded as a
63
- "gradia" fenced code block below the heading */
67
+ a description no concise item can carry, so all childs (as siblings
68
+ have to share the format) stay in the Complex Format down to heading
69
+ level 6 --, and the optionally derived Gradia diagram spec embedded
70
+ as a "gradia" fenced code block below the heading */
64
71
  const renderObjectMd = (object, level, diagrams) => {
65
72
  const heading = `${"#".repeat(level)}${" ".repeat(Math.max(1, 4 - level))}` +
66
73
  `${object.kind}: ${object.name}${nameSuffixMd(object)}`;
@@ -74,7 +81,7 @@ const renderObjectMd = (object, level, diagrams) => {
74
81
  parts.push(renderDescriptionMd(object.description));
75
82
  /* the childs share one format, as a concise item following
76
83
  a heading would attach to the heading's object instead */
77
- if (level >= 3 && !multiLineBelowMd(object))
84
+ if (level >= 3 && !complexBelowMd(object))
78
85
  parts.push(object.childs.map((child) => renderConciseMd(child)).join("\n"));
79
86
  else
80
87
  parts.push(...object.childs.map((child) => renderObjectMd(child, level + 1, diagrams)));
@@ -160,7 +160,8 @@ type SchemaGradiaConfig = Partial<{
160
160
  BECAUSE artifacts and their content follow one recursive structure
161
161
 
162
162
  - `SchemaObject.kind: string`:
163
- kind name of the object (e.g. `SPEC`, `REQUIREMENT`),
163
+ kind name of the object (e.g. `SPEC`, `REQUIREMENT`), unique among
164
+ its siblings, on level 1 together with its `id`,
164
165
  BECAUSE it discriminates objects and anchors `[[xxx]]` references
165
166
 
166
167
  - `SchemaObject.name?: string`:
@@ -557,6 +558,12 @@ follow each other on level 1, below the single frontmatter block of that
557
558
  file. Both ATX headings (`# <text/>`, `## <text/>`, ...) and Setext
558
559
  headings (`<text/>` underlined with `===` or `---`) are supported.
559
560
 
561
+ As the timestamps are a property of the *file* and not of the individual
562
+ artifact, the normalized Markdown export -- which merges the entire
563
+ corpus into a single document -- carries the earliest `Created:` and the
564
+ latest `Modified:` timestamp of all artifacts in its single frontmatter
565
+ block, so the per-artifact timestamps do not survive this round-trip.
566
+
560
567
  ### Object Model
561
568
 
562
569
  An artifact carries a tree of *objects*. Every object has a mandatory
@@ -644,12 +651,13 @@ the item would look like a key/value pair) -- backquote it in this case.
644
651
 
645
652
  When **SpecBook** itself generates specification Markdown, it emits
646
653
  the Complex Format on levels 1-3 and the Concise Format from level 4
647
- upwards -- unless a descendant of an object carries a multi-line
648
- description (a fenced code block, an ordered list, a blockquote, or
649
- multiple paragraphs), which no single list item can carry: all child
650
- objects of such an object then stay in the Complex Format (as sibling
651
- objects have to share the format), with headings reaching down to level
652
- 6. When **SpecBook** edits existing files, it mirrors the format each
654
+ upwards -- unless a descendant of an object carries a description which
655
+ no single list item can carry: a multi-line one (a fenced code block, an
656
+ ordered list, a blockquote, or multiple paragraphs) or one carrying a
657
+ `;` (which a re-parse would split into a spurious property plus a
658
+ truncated description). All child objects of such an object then stay in
659
+ the Complex Format (as sibling objects have to share the format), with
660
+ headings reaching down to level 6. When **SpecBook** edits existing files, it mirrors the format each
653
661
  existing object already uses.
654
662
 
655
663
  ### Names, Anchors, and Ids
@@ -679,9 +687,10 @@ In the Complex Format, each property is an unordered list item of the
679
687
  form `<key/>: <value/>`, whose value may continue on the following
680
688
  indented lines of the item (joined with spaces). In the Concise Format,
681
689
  each property is a `<key/>: <value/>` segment. A value carrying a `;`
682
- cannot be a segment and has to start on a continuation line of a
683
- key/value item, as a `;` on the first line of a list item marks a
684
- Concise Format object. The key is matched
690
+ or a ` BECAUSE ` cannot be a segment and has to become a key/value item
691
+ nested below the item instead -- a `;` value additionally starting on a
692
+ continuation line of that item, as a `;` on the first line of a list
693
+ item marks a Concise Format object. The key is matched
685
694
  against the schema exactly as written and may occur only once per
686
695
  object, and the value has to satisfy the configured constraint as a
687
696
  whole.
@@ -695,7 +704,9 @@ fenced code blocks. A fenced code block
695
704
  of language `gradia` is skipped, as it is the derived diagram which
696
705
  **SpecBook** itself emits into exported Markdown and which must not
697
706
  become authored content on a re-parse. In the Concise Format, the
698
- description is formed by the non-property segments of the item.
707
+ description is formed by the non-property segments of the item, so a
708
+ description carrying a `;` cannot be a segment either and forces its
709
+ object into the Complex Format.
699
710
 
700
711
  A description is split into its *statement* and *rationale* at the first
701
712
  `, BECAUSE ` (or `, **BECAUSE** `) occurrence:
@@ -22,6 +22,7 @@ export declare class ParseContext {
22
22
  linkIndex: LinkIndex;
23
23
  diagnose(file: string, line: number, message: string): void;
24
24
  }
25
+ export declare const becauseRegex: RegExp;
25
26
  export declare const embeddingRegex: RegExp;
26
27
  export declare const embeddingThemes: readonly ["light", "dark"];
27
28
  export declare const embeddingVariants: (reference: string) => string[];
@@ -14,6 +14,8 @@ export class ParseContext {
14
14
  this.diagnostics.push({ file, line, column: 1, message });
15
15
  }
16
16
  }
17
+ /* the marker separating a statement from its rationale */
18
+ export const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
17
19
  /* the Markdown image embedding syntax ("![alt](file)") */
18
20
  export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
19
21
  /* the theme variants of a theme-aware image embedding */
@@ -36,6 +36,15 @@ const matchAlternatives = (ctx, alternatives, item) => {
36
36
  };
37
37
  /* find a property of an object by its key (case-sensitive) */
38
38
  const findProp = (object, name) => object.properties.find((p) => p.key === name);
39
+ /* split a multi-valued property value into its items, reporting an
40
+ empty one -- stemming from an empty value or a stray comma -- once,
41
+ as such an item can never satisfy any constraint */
42
+ const splitPropItems = (ctx, prop, property, meta) => {
43
+ const items = splitItems(plainText(property.value));
44
+ if (items.some((item) => item === ""))
45
+ ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" contains an empty item`);
46
+ return items.filter((item) => item !== "");
47
+ };
39
48
  /* check a property value against the compiled value expression of its
40
49
  configured schema property */
41
50
  const checkPropValue = (ctx, prop, expr, property, meta) => {
@@ -56,7 +65,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
56
65
  /* a tags constraint: the value is a comma-separated set of
57
66
  configured tags, each occurring at most once */
58
67
  const seen = new Set();
59
- for (const item of splitItems(plainText(property.value))) {
68
+ for (const item of splitPropItems(ctx, prop, property, meta)) {
60
69
  if (!expr.members.includes(item))
61
70
  ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
62
71
  else if (seen.has(item))
@@ -67,7 +76,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
67
76
  else if (expr.kind === "list") {
68
77
  /* a list constraint: the value is a comma-separated list
69
78
  of items, each matching at least one alternative */
70
- for (const item of splitItems(plainText(property.value)))
79
+ for (const item of splitPropItems(ctx, prop, property, meta))
71
80
  if (!matchAlternatives(ctx, expr.alternatives, item))
72
81
  ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
73
82
  }
@@ -201,10 +210,19 @@ export const validate = (ctx, specification, config) => {
201
210
  precedence), which has to happen before any property check, as
202
211
  those resolve references by id across all artifacts */
203
212
  const schemas = collectSchemas(specification, config);
204
- for (const [object, schema] of schemas)
213
+ const promote = (object) => {
214
+ /* an object below an unresolved artifact or an unknown kind
215
+ carries no schema and hence no property able to consume the
216
+ token, so the token still acts as the id and a single
217
+ unresolved heading no longer cascades into unresolvable
218
+ references all over the corpus */
205
219
  if (object.paren !== undefined && object.anchor === undefined
206
- && parenProp(object, schema.props ?? []) === undefined)
220
+ && parenProp(object, schemas.get(object)?.props ?? []) === undefined)
207
221
  object.id = object.paren;
222
+ object.childs.forEach(promote);
223
+ };
224
+ for (const artifact of specification.artifacts)
225
+ artifact.objects.forEach(promote);
208
226
  /* check the ids for local uniqueness, now that all of them are final */
209
227
  checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
210
228
  /* validate every level 1 object against its artifact schema */
@@ -6,9 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { marked } from "marked";
9
- import { embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
10
- /* the marker separating a statement from its rationale */
11
- const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
9
+ import { becauseRegex, embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
12
10
  /* split a description text into statement and optional rationale */
13
11
  const splitDescription = (text) => {
14
12
  const m = text.match(becauseRegex);
@@ -268,7 +266,9 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
268
266
  };
269
267
  /* parse a heading token into either a grouping container (e.g. "### STATE",
270
268
  collecting objects of its kind below the parent object) or a new object
271
- (an artifact on level 1, a child of the enclosing object below) */
269
+ (an artifact on level 1, a child of the enclosing object below); a heading
270
+ without a parent object detaches the current object, so the content below
271
+ it is reported, too, instead of being absorbed by the previous object */
272
272
  const parseHeading = (ctx, token, state, stamps, file, line) => {
273
273
  const { depth, text } = token;
274
274
  const heading = parseHeadingText(text);
@@ -277,8 +277,10 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
277
277
  ctx.diagnose(file, line, `malformed anchor "${heading.malformed}" in heading`);
278
278
  if (heading.name === "" && depth > 1) {
279
279
  const parent = state.stack[depth - 2];
280
- if (parent === undefined)
280
+ if (parent === undefined) {
281
281
  ctx.diagnose(file, line, `heading level ${depth} without parent object`);
282
+ state.current = null;
283
+ }
282
284
  else {
283
285
  state.group = { parent, kind: heading.kind };
284
286
  state.current = parent;
@@ -306,6 +308,7 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
306
308
  const parent = state.stack[depth - 2];
307
309
  if (parent === undefined) {
308
310
  ctx.diagnose(file, line, `heading level ${depth} without parent object`);
311
+ state.current = null;
309
312
  return;
310
313
  }
311
314
  parent.childs.push(object);
@@ -314,6 +317,15 @@ const parseHeading = (ctx, token, state, stamps, file, line) => {
314
317
  state.stack[depth - 1] = object;
315
318
  state.current = object;
316
319
  };
320
+ /* the block-level token types which carry content no description can
321
+ hold, mapped onto their diagnostic name (the only other unhandled
322
+ type is "space", which carries no content at all) */
323
+ const unsupportedTokens = {
324
+ table: "table",
325
+ html: "raw HTML",
326
+ hr: "horizontal rule",
327
+ def: "link definition"
328
+ };
317
329
  /* parse a single source file into its artifacts */
318
330
  export const parseFile = (ctx, source) => {
319
331
  const { present, created, modified, body, offset } = parseFrontmatter(source.text);
@@ -380,8 +392,11 @@ export const parseFile = (ctx, source) => {
380
392
  else
381
393
  collect(token.raw, line);
382
394
  }
383
- else if (token.type === "table")
384
- ctx.diagnose(source.file, line, "unsupported table content ignored");
395
+ else {
396
+ const unsupported = unsupportedTokens[token.type];
397
+ if (unsupported !== undefined)
398
+ ctx.diagnose(source.file, line, `unsupported ${unsupported} content ignored`);
399
+ }
385
400
  line += (token.raw.match(/\n/g) ?? []).length;
386
401
  }
387
402
  flush();
@@ -98,7 +98,8 @@ export const compileValueExpr = (source) => {
98
98
  return expr;
99
99
  };
100
100
  /* split a property value at top-level commas, honoring double-quoted
101
- sections and "[[...]]" reference bracketing */
101
+ sections and "[[...]]" reference bracketing (empty items are kept,
102
+ as only the caller can decide whether to report or skip them) */
102
103
  export const splitItems = (text) => {
103
104
  const parts = [""];
104
105
  let quoted = false;
@@ -128,5 +129,5 @@ export const splitItems = (text) => {
128
129
  const item = part.trim();
129
130
  const m = item.match(/^"((?:\\"|[^"])*)"$/);
130
131
  return m !== null ? m[1].replace(/\\"/g, "\"") : item;
131
- }).filter((part) => part !== "");
132
+ });
132
133
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",