@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 +5 -0
- package/dst/specbook-cmd-export.js +7 -3
- package/dst/specbook-config.js +14 -4
- package/dst/specbook-diagram.d.ts +5 -1
- package/dst/specbook-diagram.js +31 -19
- package/dst/specbook-export-html.js +2 -1
- package/dst/specbook-export-md.js +21 -14
- package/dst/specbook-format.md +22 -11
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.js +22 -4
- package/dst/specbook-parse-syntax.js +22 -7
- package/dst/specbook-parse-value.js +3 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
34
|
-
|
|
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 };
|
package/dst/specbook-config.js
CHANGED
|
@@ -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:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
be valid patterns (as they
|
|
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:
|
|
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;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
for (const error of result.errors)
|
|
283
|
-
|
|
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" ?
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
const wrapped = object.properties.filter((property) => property
|
|
43
|
-
const inline = object.properties.filter((property) =>
|
|
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
|
|
61
|
-
the format) stay in the Complex Format down to heading
|
|
62
|
-
and the optionally derived Gradia diagram spec embedded
|
|
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 && !
|
|
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)));
|
package/dst/specbook-format.md
CHANGED
|
@@ -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
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
683
|
-
|
|
684
|
-
|
|
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 ("") */
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
384
|
-
|
|
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
|
-
})
|
|
132
|
+
});
|
|
132
133
|
};
|