@rse/specbook 0.9.6 → 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.
Files changed (41) hide show
  1. package/README.md +65 -10
  2. package/dst/specbook-api.js +19 -18
  3. package/dst/specbook-cli.js +9 -6
  4. package/dst/specbook-cmd-describe.js +1 -3
  5. package/dst/specbook-cmd-export.js +24 -17
  6. package/dst/specbook-cmd-init.d.ts +1 -1
  7. package/dst/specbook-cmd-init.js +5 -4
  8. package/dst/specbook-cmd-lint.d.ts +0 -1
  9. package/dst/specbook-cmd-lint.js +29 -5
  10. package/dst/specbook-config.d.ts +1 -1
  11. package/dst/specbook-config.js +90 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +8 -3
  14. package/dst/specbook-diagram.js +101 -105
  15. package/dst/specbook-export-ast.d.ts +1 -1
  16. package/dst/specbook-export-ast.js +1 -1
  17. package/dst/specbook-export-common.d.ts +2 -1
  18. package/dst/specbook-export-common.js +12 -14
  19. package/dst/specbook-export-html-search.js +10 -14
  20. package/dst/specbook-export-html.css +24 -29
  21. package/dst/specbook-export-html.js +101 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +56 -23
  24. package/dst/specbook-export-pdf.d.ts +4 -2
  25. package/dst/specbook-export-pdf.js +48 -38
  26. package/dst/specbook-format-schema.js +6 -3
  27. package/dst/specbook-format.md +47 -20
  28. package/dst/specbook-link.d.ts +1 -0
  29. package/dst/specbook-link.js +7 -4
  30. package/dst/specbook-mcp.js +17 -10
  31. package/dst/specbook-parse-common.d.ts +2 -1
  32. package/dst/specbook-parse-common.js +2 -0
  33. package/dst/specbook-parse-semantic.d.ts +4 -2
  34. package/dst/specbook-parse-semantic.js +114 -43
  35. package/dst/specbook-parse-syntax.js +179 -137
  36. package/dst/specbook-parse-value.d.ts +1 -1
  37. package/dst/specbook-parse-value.js +9 -5
  38. package/dst/specbook-parse.d.ts +2 -5
  39. package/dst/specbook-parse.js +27 -34
  40. package/dst/specbook-verbose.js +2 -1
  41. package/package.json +1 -1
@@ -8,9 +8,12 @@ import { Gradia } from "@rse/gradia";
8
8
  /* ==== Schema ==== */
9
9
  /* the Gradia rendering options, derived from the configuration
10
10
  defaults of Gradia: the option names constrain the allowed keys and
11
- the types of the default values constrain the allowed value types */
11
+ the types of the default values constrain the allowed value types
12
+ (with the numbers restricted to the finite, non-negative values
13
+ Gradia honors, as it silently drops all others) */
12
14
  const GradiaConfigSchemas = Object.fromEntries(Object.entries(Gradia.config).map(([key, value]) => [key,
13
- typeof value === "boolean" ? v.boolean() : typeof value === "number" ? v.number() : v.string()]));
15
+ typeof value === "boolean" ? v.boolean() :
16
+ typeof value === "number" ? v.pipe(v.number(), v.finite(), v.minValue(0)) : v.string()]));
14
17
  const SchemaDiagramConfig = v.partial(v.strictObject(GradiaConfigSchemas, (issue) => issue.expected === "never" ? "unknown Gradia rendering option" :
15
18
  "expected a map of Gradia rendering options"));
16
19
  const SchemaProperty = v.strictObject({
@@ -37,7 +40,7 @@ const SchemaDiagram = v.strictObject({
37
40
  });
38
41
  const SchemaFormat = v.strictObject({
39
42
  type: v.optional(v.picklist(["auto", "complex", "concise"])),
40
- maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
43
+ maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
41
44
  withUnusedProps: v.optional(v.boolean())
42
45
  });
43
46
  const SchemaObject = v.strictObject({
@@ -56,7 +56,8 @@ SpecBook SCHEMA Meta Model
56
56
  This meta model defines which domain-specific objects are allowed in
57
57
  a *Schema*: the object kinds with their `file` mapping on the first
58
58
  level, the object kinds of the deeper levels, and their properties
59
- with optional value constraints: regular expressions `/xxx/`,
59
+ with optional value constraints: regular expressions `/xxx/` (implicitly
60
+ anchored, so they have to match the value as a whole),
60
61
  object references `[[xxx]]`, enumerations `enum(xxx,yyy)`, tag sets
61
62
  `tags(xxx,yyy)`, and lists `list(xxx[, ...])` of such alternatives.
62
63
 
@@ -159,7 +160,8 @@ type SchemaGradiaConfig = Partial<{
159
160
  BECAUSE artifacts and their content follow one recursive structure
160
161
 
161
162
  - `SchemaObject.kind: string`:
162
- 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`,
163
165
  BECAUSE it discriminates objects and anchors `[[xxx]]` references
164
166
 
165
167
  - `SchemaObject.name?: string`:
@@ -180,7 +182,8 @@ type SchemaGradiaConfig = Partial<{
180
182
  BECAUSE it guides the authors and instructs the AI
181
183
 
182
184
  - `SchemaObject.optional?: boolean`:
183
- whether the kind may be absent below its parent (default: `false`),
185
+ whether the kind may be absent below its parent or, for a level 1
186
+ artifact, from its file (default: `false`),
184
187
  BECAUSE not every kind is mandatory below every parent object
185
188
 
186
189
  - `SchemaObject.diagram?: SchemaDiagram`:
@@ -502,6 +505,7 @@ type SpecProperty = {
502
505
 
503
506
  - `SpecDescription.embedding?: string[]`:
504
507
  image files embedded via `![xxx](yyy)`, inlined at parse time,
508
+ one entry per file in markup order (empty for an unreadable file),
505
509
  BECAUSE the exports have to stand alone, without the image files
506
510
 
507
511
  - `SpecProperty`:
@@ -518,6 +522,7 @@ type SpecProperty = {
518
522
 
519
523
  - `SpecProperty.embedding?: string[]`:
520
524
  image files embedded in the value, inlined at parse time,
525
+ one entry per file in markup order (empty for an unreadable file),
521
526
  BECAUSE the exports have to stand alone, without the image files
522
527
 
523
528
  SpecBook SCHEMA Model
@@ -553,15 +558,21 @@ follow each other on level 1, below the single frontmatter block of that
553
558
  file. Both ATX headings (`# <text/>`, `## <text/>`, ...) and Setext
554
559
  headings (`<text/>` underlined with `===` or `---`) are supported.
555
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
+
556
567
  ### Object Model
557
568
 
558
569
  An artifact carries a tree of *objects*. Every object has a mandatory
559
- *kind* (e.g. `ENTITY`), a mandatory *name*, a unique anchor *id*
560
- (explicitly given or derived from the name), an optional *primary*
561
- marker, optional *properties* (key/value pairs), an optional
562
- *description* statement with an optional rationale, and optional *child*
563
- objects. Which kinds, properties, and nestings are allowed is defined by
564
- the schema.
570
+ *kind* (e.g. `ENTITY`), a mandatory *name*, an anchor *id* (explicitly
571
+ given or derived from the name, and unique among the sibling objects of
572
+ its kind), an optional *primary* marker, optional *properties* (key/value
573
+ pairs), an optional *description* statement with an optional rationale,
574
+ and optional *child* objects. Which kinds, properties, and nestings are
575
+ allowed is defined by the schema.
565
576
 
566
577
  ### Format Variant: Complex Format
567
578
 
@@ -599,11 +610,14 @@ multiple (indented) lines:
599
610
 
600
611
  A list item is recognized as a Concise Format object (instead of a
601
612
  property of the enclosing object) by carrying at least one `;` on its
602
- first line. Every segment of the form `<key/>: <value/>` becomes a
603
- property (unless it contains a ` BECAUSE `); all remaining segments are
604
- joined into the description. Child objects nest as indented list items
605
- below their parent item, where key/value items become properties and
606
- concise items become child objects of that parent:
613
+ first line. Every segment of the form `<key/>: <value/>` (with a
614
+ possibly empty value) becomes a property, unless it contains a
615
+ ` BECAUSE ` or is the last segment of a `.`-terminated item (as a
616
+ description like `Note: xxx` has to survive the round-trip through
617
+ exported Markdown); all remaining segments are joined into the
618
+ description. Child objects nest as indented list items below their
619
+ parent item, where key/value items become properties and concise items
620
+ become child objects of that parent:
607
621
 
608
622
  ```
609
623
  - <kind/>: <name/>; <key/>: <value/>;
@@ -637,8 +651,14 @@ the item would look like a key/value pair) -- backquote it in this case.
637
651
 
638
652
  When **SpecBook** itself generates specification Markdown, it emits
639
653
  the Complex Format on levels 1-3 and the Concise Format from level 4
640
- upwards. When **SpecBook** edits existing files, it mirrors the format
641
- each existing object already uses.
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
661
+ existing object already uses.
642
662
 
643
663
  ### Names, Anchors, and Ids
644
664
 
@@ -666,9 +686,14 @@ explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
666
686
  In the Complex Format, each property is an unordered list item of the
667
687
  form `<key/>: <value/>`, whose value may continue on the following
668
688
  indented lines of the item (joined with spaces). In the Concise Format,
669
- each property is a `<key/>: <value/>` segment. The key is matched
670
- against the schema exactly as written, and the value has to satisfy the
671
- configured constraint as a whole.
689
+ each property is a `<key/>: <value/>` segment. A value carrying a `;`
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
694
+ against the schema exactly as written and may occur only once per
695
+ object, and the value has to satisfy the configured constraint as a
696
+ whole.
672
697
 
673
698
  ### Descriptions and Rationales
674
699
 
@@ -679,7 +704,9 @@ fenced code blocks. A fenced code block
679
704
  of language `gradia` is skipped, as it is the derived diagram which
680
705
  **SpecBook** itself emits into exported Markdown and which must not
681
706
  become authored content on a re-parse. In the Concise Format, the
682
- 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.
683
710
 
684
711
  A description is split into its *statement* and *rationale* at the first
685
712
  `, BECAUSE ` (or `, **BECAUSE** `) occurrence:
@@ -1,5 +1,6 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  export declare const referenceRegex: RegExp;
3
+ export declare const plainText: (text: string) => string;
3
4
  interface LinkNode {
4
5
  object: SpecObject;
5
6
  parent?: LinkNode;
@@ -5,6 +5,9 @@
5
5
  */
6
6
  /* the Wiki-style reference syntax ("[[xxx]]") */
7
7
  export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
8
+ /* strip the inline code markup of a name or property value
9
+ (preserved in the AST for rendering) for matching and labeling */
10
+ export const plainText = (text) => text.replace(/`/g, "");
8
11
  /* build the resolution index over all objects of a specification */
9
12
  export const buildLinkIndex = (specification) => {
10
13
  const index = [];
@@ -40,7 +43,7 @@ const unquote = (part) => part.replace(/^"(.*)"$/s, "$1");
40
43
  const matchesPart = (object, part) => part === "*"
41
44
  || object.id === part
42
45
  || object.anchor === part
43
- || object.name.replace(/`/g, "") === part;
46
+ || plainText(object.name) === part;
44
47
  /* match a single reference segment ("id", "name", "KIND:name-or-id",
45
48
  "KIND:*", or "*", each part optionally double-quoted to allow
46
49
  spaces, dots, and colons) against a single object */
@@ -80,10 +83,10 @@ export const resolveUnique = (index, reference) => {
80
83
  let matches = [];
81
84
  if (segments.length === 1) {
82
85
  const segment = segments[0];
83
- const plain = unquote(segment);
86
+ const unquoted = unquote(segment);
84
87
  const variants = [
85
- (object) => object.id === plain || object.anchor === plain,
86
- (object) => object.name.replace(/`/g, "") === plain,
88
+ (object) => object.id === unquoted || object.anchor === unquoted,
89
+ (object) => plainText(object.name) === unquoted,
87
90
  (object) => matchesSegment(object, segment)
88
91
  ];
89
92
  for (const variant of variants) {
@@ -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, formats, version } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, formats, parseOutputSpec, 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);
@@ -70,18 +70,25 @@ export const serveMcp = async (verbose) => {
70
70
  inputSchema: {
71
71
  config: z.string().describe("YAML schema configuration file"),
72
72
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
73
- format: z.enum(formats).optional().describe("output format (default: json)"),
74
- output: z.string().optional().describe("output file path")
73
+ format: z.enum(formats).optional().describe("output format (default: inferred from the " +
74
+ "output file extension, else json)"),
75
+ output: z.string().optional().describe("output file path (\"-\" returns the result directly)")
75
76
  }
76
77
  }, async (args) => {
77
78
  try {
79
+ /* an explicit format takes the output as a plain file path, while
80
+ otherwise the output is an "[<format>:]<file>" specification
81
+ (the "-" stdout sentinel returns the result directly) */
82
+ const spec = args.format !== undefined || args.output === undefined ?
83
+ { format: args.format ?? "json", output: args.output } :
84
+ parseOutputSpec(args.output);
78
85
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
79
- formats: [args.format ?? "json"] });
80
- if (args.output !== undefined) {
81
- await fs.promises.writeFile(args.output, data);
82
- return { content: [{ type: "text", text: `exported specification into "${args.output}" (${data.length} bytes)` }] };
86
+ formats: [spec.format] });
87
+ if (spec.output !== undefined && spec.output !== "-") {
88
+ await fs.promises.writeFile(spec.output, data);
89
+ return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
83
90
  }
84
- else if ((args.format ?? "json") === "pdf")
91
+ else if (spec.format === "pdf")
85
92
  return { content: [{
86
93
  type: "resource",
87
94
  resource: { uri: "specbook:export.pdf", mimeType: "application/pdf", blob: data.toString("base64") }
@@ -103,12 +110,12 @@ export const serveMcp = async (verbose) => {
103
110
  basedir: z.string().optional().describe("base directory of the specification Markdown files"),
104
111
  embed: z.boolean().optional().describe("embed the YAML schema configuration instead of " +
105
112
  "just referencing it (default: false)"),
106
- output: z.string().optional().describe("output file path (default: return description)")
113
+ output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)")
107
114
  }
108
115
  }, async (args) => {
109
116
  try {
110
117
  const text = await specbook.describe(args);
111
- if (args.output !== undefined) {
118
+ if (args.output !== undefined && args.output !== "-") {
112
119
  await fs.promises.writeFile(args.output, text, "utf8");
113
120
  return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
114
121
  }
@@ -22,7 +22,8 @@ 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
- export declare const embeddingThemes: string[];
27
+ export declare const embeddingThemes: readonly ["light", "dark"];
27
28
  export declare const embeddingVariants: (reference: string) => string[];
28
29
  export declare const embeddingMimeType: (reference: string) => string | undefined;
@@ -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 */
@@ -1,5 +1,7 @@
1
- import { type Spec } from "./specbook-format-spec.js";
2
- import { type Schema } from "./specbook-format-schema.js";
1
+ import { type Spec, type SpecObject } from "./specbook-format-spec.js";
2
+ import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
3
3
  import { ParseContext } from "./specbook-parse-common.js";
4
+ export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
5
+ export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
4
6
  export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
5
7
  export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
@@ -3,25 +3,19 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { referenceRegex, resolveUnique, resolveSet } from "./specbook-link.js";
7
- import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
8
- /* strip the inline code markup of a name or property value
9
- (preserved in the AST for rendering) for matching purposes */
10
- const plainText = (text) => text.replace(/`/g, "");
11
- /* compile a configured pattern into an anchored regular expression */
12
- const anchored = (pattern, flags = "") => new RegExp(`^(?:${pattern})$`, flags);
6
+ import { referenceRegex, resolveUnique, resolveSet, plainText } from "./specbook-link.js";
7
+ import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
8
+ /* the syntax of a value consisting of exactly one Wiki-style reference */
9
+ const singleReferenceRegex = /^\[\[([^[\]]+)\]\]$/;
13
10
  /* match a full property value directly against a value expression
14
11
  (defined for the regex and enum kinds only) */
15
- const directMatches = (expr, text) => expr.kind === "regex" ?
16
- expr.regex.test(text) :
17
- expr.kind === "enum" ?
18
- expr.members.includes(text) :
19
- false;
12
+ const directMatches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
13
+ || (expr.kind === "enum" && expr.members.includes(text));
20
14
  /* match a single list item against the alternatives of a list
21
15
  constraint (a reference item resolves leniently, as unresolvable
22
16
  and ambiguous ones are already reported by the reference pass) */
23
17
  const matchAlternatives = (ctx, alternatives, item) => {
24
- const rm = item.match(/^\[\[([^[\]]+)\]\]$/);
18
+ const rm = item.match(singleReferenceRegex);
25
19
  for (const alternative of alternatives) {
26
20
  if (alternative.kind === "reference") {
27
21
  if (rm === null)
@@ -32,26 +26,36 @@ const matchAlternatives = (ctx, alternatives, item) => {
32
26
  return true;
33
27
  }
34
28
  else if (alternative.kind === "tags") {
35
- if (alternative.members.includes(plainText(item)))
29
+ if (alternative.members.includes(item))
36
30
  return true;
37
31
  }
38
- else if (directMatches(alternative, plainText(item)))
32
+ else if (directMatches(alternative, item))
39
33
  return true;
40
34
  }
41
35
  return false;
42
36
  };
43
37
  /* find a property of an object by its key (case-sensitive) */
44
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
+ };
45
48
  /* check a property value against the compiled value expression of its
46
49
  configured schema property */
47
50
  const checkPropValue = (ctx, prop, expr, property, meta) => {
48
51
  if (expr.kind === "reference") {
49
52
  /* a reference constraint: the value has to be exactly one
50
53
  reference resolving into the constraint's wildcard match set */
51
- if (!/^\[\[[^[\]]+\]\]$/.test(property.value.trim()))
54
+ const rm = plainText(property.value).trim().match(singleReferenceRegex);
55
+ if (rm === null)
52
56
  ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
53
57
  else {
54
- const ref = property.value.trim().slice(2, -2).trim();
58
+ const ref = rm[1].trim();
55
59
  const target = resolveUnique(ctx.linkIndex, ref).target;
56
60
  if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
57
61
  ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
@@ -61,7 +65,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
61
65
  /* a tags constraint: the value is a comma-separated set of
62
66
  configured tags, each occurring at most once */
63
67
  const seen = new Set();
64
- for (const item of splitItems(plainText(property.value))) {
68
+ for (const item of splitPropItems(ctx, prop, property, meta)) {
65
69
  if (!expr.members.includes(item))
66
70
  ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
67
71
  else if (seen.has(item))
@@ -72,7 +76,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
72
76
  else if (expr.kind === "list") {
73
77
  /* a list constraint: the value is a comma-separated list
74
78
  of items, each matching at least one alternative */
75
- for (const item of splitItems(property.value))
79
+ for (const item of splitPropItems(ctx, prop, property, meta))
76
80
  if (!matchAlternatives(ctx, expr.alternatives, item))
77
81
  ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
78
82
  }
@@ -81,6 +85,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
81
85
  `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
82
86
  `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
83
87
  };
88
+ /* find the configured property whose value the trailing parenthesized
89
+ name token of an object is accepted for: the first still missing
90
+ property whose pattern the token matches */
91
+ const parenProp = (object, props) => {
92
+ const paren = object.paren;
93
+ if (paren === undefined)
94
+ return undefined;
95
+ return props.find((prop) => prop.value !== undefined && findProp(object, prop.name) === undefined
96
+ && directMatches(compileValueExpr(prop.value), paren));
97
+ };
84
98
  /* validate a single object (and recursively its childs) against its schema */
85
99
  const validateObject = (ctx, object, schema, level) => {
86
100
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
@@ -93,33 +107,27 @@ const validateObject = (ctx, object, schema, level) => {
93
107
  input, via either "{{<id>}}" or "(<id>)" */
94
108
  if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
95
109
  ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
96
- /* check the configured properties */
97
- let parenConsumed = false;
110
+ /* check every occurrence of the configured properties (a trailing
111
+ parenthesized name token is accepted as the value of a still
112
+ missing property, and a repeated key is reported as duplicate) */
113
+ const consumed = parenProp(object, props);
98
114
  for (const prop of props) {
99
115
  const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
100
- const match = findProp(object, prop.name);
101
- if (match === undefined) {
102
- /* accept a trailing parenthesized name token as the value of a
103
- still missing property when it matches the property pattern */
104
- if (object.paren !== undefined && expr !== undefined
105
- && directMatches(expr, object.paren)) {
106
- parenConsumed = true;
107
- continue;
108
- }
109
- if (prop.optional !== true)
110
- ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
116
+ const matches = object.properties.filter((p) => p.key === prop.name);
117
+ if (matches.length === 0 && prop !== consumed && prop.optional !== true)
118
+ ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
119
+ for (const [i, match] of matches.entries()) {
120
+ const line = ctx.propMeta.get(match)?.line ?? meta.line;
121
+ if (i > 0)
122
+ ctx.diagnose(meta.file, line, `duplicate property "${prop.name}" on ${object.kind} "${object.name}"`);
123
+ if (expr !== undefined)
124
+ checkPropValue(ctx, prop, expr, match, { file: meta.file, line });
111
125
  }
112
- else if (expr !== undefined)
113
- checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
114
126
  }
115
127
  /* report the properties not configured by the schema */
116
128
  for (const property of object.properties)
117
129
  if (!props.some((p) => p.name === property.key))
118
130
  ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
119
- /* a parenthesized token not consumed as a property value acts as
120
- the implicit anchor (an explicit "{{...}}" anchor takes precedence) */
121
- if (object.paren !== undefined && !parenConsumed && object.anchor === undefined)
122
- object.id = object.paren;
123
131
  /* check the configured child objects */
124
132
  const childs = schema.childs ?? [];
125
133
  for (const child of object.childs) {
@@ -150,22 +158,85 @@ const validateObject = (ctx, object, schema, level) => {
150
158
  };
151
159
  object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
152
160
  };
161
+ /* resolve a level 1 object onto its configured artifact, deliberately
162
+ leniently by kind and id first and by case-insensitive name second,
163
+ so deviations of the heading can still be precisely reported */
164
+ export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
165
+ config.find((s) => s.name !== undefined
166
+ && s.name.toUpperCase() === plainText(object.name).toUpperCase());
167
+ /* map the specification objects onto their schema configuration
168
+ nodes (the artifact resolution is shared with the validation) */
169
+ export const collectSchemas = (specification, config) => {
170
+ const schemas = new Map();
171
+ const walk = (object, schema) => {
172
+ schemas.set(object, schema);
173
+ for (const child of object.childs) {
174
+ const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
175
+ if (childSchema !== undefined)
176
+ walk(child, childSchema);
177
+ }
178
+ };
179
+ for (const artifact of specification.artifacts) {
180
+ for (const object of artifact.objects) {
181
+ const schema = resolveArtifact(config, object);
182
+ if (schema !== undefined)
183
+ walk(object, schema);
184
+ }
185
+ }
186
+ return schemas;
187
+ };
188
+ /* report the objects colliding on kind and id among their siblings
189
+ (recursively from the artifacts down), as such objects derive
190
+ identical anchor paths and hence cannot be addressed distinctly */
191
+ const checkIds = (ctx, objects) => {
192
+ const seen = new Map();
193
+ for (const object of objects) {
194
+ const key = `${object.kind}:${object.id}`;
195
+ const first = seen.get(key);
196
+ if (first === undefined)
197
+ seen.set(key, object);
198
+ else {
199
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
200
+ ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
201
+ }
202
+ checkIds(ctx, object.childs);
203
+ }
204
+ };
153
205
  /* validate the parsed specification against the configuration */
154
206
  export const validate = (ctx, specification, config) => {
207
+ /* map all objects onto their schema nodes and assign the implicit
208
+ ids up-front: a parenthesized token not consumed as a property
209
+ value acts as the id (an explicit "{{...}}" anchor takes
210
+ precedence), which has to happen before any property check, as
211
+ those resolve references by id across all artifacts */
212
+ const schemas = collectSchemas(specification, config);
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 */
219
+ if (object.paren !== undefined && object.anchor === undefined
220
+ && parenProp(object, schemas.get(object)?.props ?? []) === undefined)
221
+ object.id = object.paren;
222
+ object.childs.forEach(promote);
223
+ };
224
+ for (const artifact of specification.artifacts)
225
+ artifact.objects.forEach(promote);
226
+ /* check the ids for local uniqueness, now that all of them are final */
227
+ checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
228
+ /* validate every level 1 object against its artifact schema */
155
229
  const position = new Map();
156
230
  for (const artifact of specification.artifacts) {
157
231
  for (const object of artifact.objects) {
158
232
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
159
- const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
160
- config.find((s) => s.name !== undefined
161
- && s.name.toUpperCase() === plainText(object.name).toUpperCase());
233
+ const schema = schemas.get(object);
162
234
  if (schema === undefined) {
163
235
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
164
236
  continue;
165
237
  }
166
238
  /* the artifact heading must carry the configured kind and
167
- name verbatim (the resolution above is deliberately
168
- lenient, so deviations are still precisely reported) */
239
+ name verbatim (the resolution is lenient on purpose) */
169
240
  if (object.kind !== schema.kind)
170
241
  ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
171
242
  if (schema.name !== undefined && plainText(object.name) !== schema.name)