@rse/specbook 0.9.6 → 0.9.7

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 (40) hide show
  1. package/README.md +60 -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 +19 -16
  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 +80 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +3 -2
  14. package/dst/specbook-diagram.js +75 -91
  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 +100 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +50 -24
  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 +34 -18
  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 +1 -1
  32. package/dst/specbook-parse-semantic.d.ts +4 -2
  33. package/dst/specbook-parse-semantic.js +95 -42
  34. package/dst/specbook-parse-syntax.js +163 -136
  35. package/dst/specbook-parse-value.d.ts +1 -1
  36. package/dst/specbook-parse-value.js +6 -3
  37. package/dst/specbook-parse.d.ts +2 -5
  38. package/dst/specbook-parse.js +27 -34
  39. package/dst/specbook-verbose.js +2 -1
  40. package/package.json +1 -1
@@ -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
 
@@ -180,7 +181,8 @@ type SchemaGradiaConfig = Partial<{
180
181
  BECAUSE it guides the authors and instructs the AI
181
182
 
182
183
  - `SchemaObject.optional?: boolean`:
183
- whether the kind may be absent below its parent (default: `false`),
184
+ whether the kind may be absent below its parent or, for a level 1
185
+ artifact, from its file (default: `false`),
184
186
  BECAUSE not every kind is mandatory below every parent object
185
187
 
186
188
  - `SchemaObject.diagram?: SchemaDiagram`:
@@ -502,6 +504,7 @@ type SpecProperty = {
502
504
 
503
505
  - `SpecDescription.embedding?: string[]`:
504
506
  image files embedded via `![xxx](yyy)`, inlined at parse time,
507
+ one entry per file in markup order (empty for an unreadable file),
505
508
  BECAUSE the exports have to stand alone, without the image files
506
509
 
507
510
  - `SpecProperty`:
@@ -518,6 +521,7 @@ type SpecProperty = {
518
521
 
519
522
  - `SpecProperty.embedding?: string[]`:
520
523
  image files embedded in the value, inlined at parse time,
524
+ one entry per file in markup order (empty for an unreadable file),
521
525
  BECAUSE the exports have to stand alone, without the image files
522
526
 
523
527
  SpecBook SCHEMA Model
@@ -556,12 +560,12 @@ headings (`<text/>` underlined with `===` or `---`) are supported.
556
560
  ### Object Model
557
561
 
558
562
  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.
563
+ *kind* (e.g. `ENTITY`), a mandatory *name*, an anchor *id* (explicitly
564
+ given or derived from the name, and unique among the sibling objects of
565
+ its kind), an optional *primary* marker, optional *properties* (key/value
566
+ pairs), an optional *description* statement with an optional rationale,
567
+ and optional *child* objects. Which kinds, properties, and nestings are
568
+ allowed is defined by the schema.
565
569
 
566
570
  ### Format Variant: Complex Format
567
571
 
@@ -599,11 +603,14 @@ multiple (indented) lines:
599
603
 
600
604
  A list item is recognized as a Concise Format object (instead of a
601
605
  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:
606
+ first line. Every segment of the form `<key/>: <value/>` (with a
607
+ possibly empty value) becomes a property, unless it contains a
608
+ ` BECAUSE ` or is the last segment of a `.`-terminated item (as a
609
+ description like `Note: xxx` has to survive the round-trip through
610
+ exported Markdown); all remaining segments are joined into the
611
+ description. Child objects nest as indented list items below their
612
+ parent item, where key/value items become properties and concise items
613
+ become child objects of that parent:
607
614
 
608
615
  ```
609
616
  - <kind/>: <name/>; <key/>: <value/>;
@@ -637,8 +644,13 @@ the item would look like a key/value pair) -- backquote it in this case.
637
644
 
638
645
  When **SpecBook** itself generates specification Markdown, it emits
639
646
  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.
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
653
+ existing object already uses.
642
654
 
643
655
  ### Names, Anchors, and Ids
644
656
 
@@ -666,9 +678,13 @@ explicitly written in the input, via either `{{<id/>}}` or `(<id/>)`.
666
678
  In the Complex Format, each property is an unordered list item of the
667
679
  form `<key/>: <value/>`, whose value may continue on the following
668
680
  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.
681
+ 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
685
+ against the schema exactly as written and may occur only once per
686
+ object, and the value has to satisfy the configured constraint as a
687
+ whole.
672
688
 
673
689
  ### Descriptions and Rationales
674
690
 
@@ -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
  }
@@ -23,6 +23,6 @@ export declare class ParseContext {
23
23
  diagnose(file: string, line: number, message: string): void;
24
24
  }
25
25
  export declare const embeddingRegex: RegExp;
26
- export declare const embeddingThemes: string[];
26
+ export declare const embeddingThemes: readonly ["light", "dark"];
27
27
  export declare const embeddingVariants: (reference: string) => string[];
28
28
  export declare const embeddingMimeType: (reference: string) => string | undefined;
@@ -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,10 +26,10 @@ 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;
@@ -48,10 +42,11 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
48
42
  if (expr.kind === "reference") {
49
43
  /* a reference constraint: the value has to be exactly one
50
44
  reference resolving into the constraint's wildcard match set */
51
- if (!/^\[\[[^[\]]+\]\]$/.test(property.value.trim()))
45
+ const rm = plainText(property.value).trim().match(singleReferenceRegex);
46
+ if (rm === null)
52
47
  ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
53
48
  else {
54
- const ref = property.value.trim().slice(2, -2).trim();
49
+ const ref = rm[1].trim();
55
50
  const target = resolveUnique(ctx.linkIndex, ref).target;
56
51
  if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
57
52
  ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
@@ -72,7 +67,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
72
67
  else if (expr.kind === "list") {
73
68
  /* a list constraint: the value is a comma-separated list
74
69
  of items, each matching at least one alternative */
75
- for (const item of splitItems(property.value))
70
+ for (const item of splitItems(plainText(property.value)))
76
71
  if (!matchAlternatives(ctx, expr.alternatives, item))
77
72
  ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
78
73
  }
@@ -81,6 +76,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
81
76
  `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
82
77
  `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
83
78
  };
79
+ /* find the configured property whose value the trailing parenthesized
80
+ name token of an object is accepted for: the first still missing
81
+ property whose pattern the token matches */
82
+ const parenProp = (object, props) => {
83
+ const paren = object.paren;
84
+ if (paren === undefined)
85
+ return undefined;
86
+ return props.find((prop) => prop.value !== undefined && findProp(object, prop.name) === undefined
87
+ && directMatches(compileValueExpr(prop.value), paren));
88
+ };
84
89
  /* validate a single object (and recursively its childs) against its schema */
85
90
  const validateObject = (ctx, object, schema, level) => {
86
91
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
@@ -93,33 +98,27 @@ const validateObject = (ctx, object, schema, level) => {
93
98
  input, via either "{{<id>}}" or "(<id>)" */
94
99
  if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
95
100
  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;
101
+ /* check every occurrence of the configured properties (a trailing
102
+ parenthesized name token is accepted as the value of a still
103
+ missing property, and a repeated key is reported as duplicate) */
104
+ const consumed = parenProp(object, props);
98
105
  for (const prop of props) {
99
106
  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}"`);
107
+ const matches = object.properties.filter((p) => p.key === prop.name);
108
+ if (matches.length === 0 && prop !== consumed && prop.optional !== true)
109
+ ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
110
+ for (const [i, match] of matches.entries()) {
111
+ const line = ctx.propMeta.get(match)?.line ?? meta.line;
112
+ if (i > 0)
113
+ ctx.diagnose(meta.file, line, `duplicate property "${prop.name}" on ${object.kind} "${object.name}"`);
114
+ if (expr !== undefined)
115
+ checkPropValue(ctx, prop, expr, match, { file: meta.file, line });
111
116
  }
112
- else if (expr !== undefined)
113
- checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
114
117
  }
115
118
  /* report the properties not configured by the schema */
116
119
  for (const property of object.properties)
117
120
  if (!props.some((p) => p.name === property.key))
118
121
  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
122
  /* check the configured child objects */
124
123
  const childs = schema.childs ?? [];
125
124
  for (const child of object.childs) {
@@ -150,22 +149,76 @@ const validateObject = (ctx, object, schema, level) => {
150
149
  };
151
150
  object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
152
151
  };
152
+ /* resolve a level 1 object onto its configured artifact, deliberately
153
+ leniently by kind and id first and by case-insensitive name second,
154
+ so deviations of the heading can still be precisely reported */
155
+ export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
156
+ config.find((s) => s.name !== undefined
157
+ && s.name.toUpperCase() === plainText(object.name).toUpperCase());
158
+ /* map the specification objects onto their schema configuration
159
+ nodes (the artifact resolution is shared with the validation) */
160
+ export const collectSchemas = (specification, config) => {
161
+ const schemas = new Map();
162
+ const walk = (object, schema) => {
163
+ schemas.set(object, schema);
164
+ for (const child of object.childs) {
165
+ const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
166
+ if (childSchema !== undefined)
167
+ walk(child, childSchema);
168
+ }
169
+ };
170
+ for (const artifact of specification.artifacts) {
171
+ for (const object of artifact.objects) {
172
+ const schema = resolveArtifact(config, object);
173
+ if (schema !== undefined)
174
+ walk(object, schema);
175
+ }
176
+ }
177
+ return schemas;
178
+ };
179
+ /* report the objects colliding on kind and id among their siblings
180
+ (recursively from the artifacts down), as such objects derive
181
+ identical anchor paths and hence cannot be addressed distinctly */
182
+ const checkIds = (ctx, objects) => {
183
+ const seen = new Map();
184
+ for (const object of objects) {
185
+ const key = `${object.kind}:${object.id}`;
186
+ const first = seen.get(key);
187
+ if (first === undefined)
188
+ seen.set(key, object);
189
+ else {
190
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
191
+ ctx.diagnose(meta.file, meta.line, `id "${object.id}" of ${object.kind} "${object.name}" collides with preceding ${object.kind} "${first.name}"`);
192
+ }
193
+ checkIds(ctx, object.childs);
194
+ }
195
+ };
153
196
  /* validate the parsed specification against the configuration */
154
197
  export const validate = (ctx, specification, config) => {
198
+ /* map all objects onto their schema nodes and assign the implicit
199
+ ids up-front: a parenthesized token not consumed as a property
200
+ value acts as the id (an explicit "{{...}}" anchor takes
201
+ precedence), which has to happen before any property check, as
202
+ those resolve references by id across all artifacts */
203
+ const schemas = collectSchemas(specification, config);
204
+ for (const [object, schema] of schemas)
205
+ if (object.paren !== undefined && object.anchor === undefined
206
+ && parenProp(object, schema.props ?? []) === undefined)
207
+ object.id = object.paren;
208
+ /* check the ids for local uniqueness, now that all of them are final */
209
+ checkIds(ctx, specification.artifacts.flatMap((artifact) => artifact.objects));
210
+ /* validate every level 1 object against its artifact schema */
155
211
  const position = new Map();
156
212
  for (const artifact of specification.artifacts) {
157
213
  for (const object of artifact.objects) {
158
214
  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());
215
+ const schema = schemas.get(object);
162
216
  if (schema === undefined) {
163
217
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
164
218
  continue;
165
219
  }
166
220
  /* 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) */
221
+ name verbatim (the resolution is lenient on purpose) */
169
222
  if (object.kind !== schema.kind)
170
223
  ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
171
224
  if (schema.name !== undefined && plainText(object.name) !== schema.name)