@rse/specbook 0.9.5 → 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 (61) hide show
  1. package/README.md +108 -328
  2. package/dst/specbook-api.d.ts +13 -20
  3. package/dst/specbook-api.js +43 -39
  4. package/dst/specbook-cli.js +42 -55
  5. package/dst/specbook-cmd-describe.d.ts +5 -2
  6. package/dst/specbook-cmd-describe.js +30 -91
  7. package/dst/specbook-cmd-export.d.ts +3 -3
  8. package/dst/specbook-cmd-export.js +23 -19
  9. package/dst/specbook-cmd-init.d.ts +3 -3
  10. package/dst/specbook-cmd-init.js +38 -15
  11. package/dst/specbook-cmd-lint.d.ts +5 -6
  12. package/dst/specbook-cmd-lint.js +61 -24
  13. package/dst/specbook-config.d.ts +2 -2
  14. package/dst/specbook-config.js +82 -73
  15. package/dst/specbook-diagnostic.js +4 -1
  16. package/dst/specbook-diagram.d.ts +6 -5
  17. package/dst/specbook-diagram.js +94 -104
  18. package/dst/specbook-export-ast.d.ts +3 -3
  19. package/dst/specbook-export-ast.js +1 -1
  20. package/dst/specbook-export-common.d.ts +11 -9
  21. package/dst/specbook-export-common.js +34 -22
  22. package/dst/specbook-export-html-search.js +10 -14
  23. package/dst/specbook-export-html.css +37 -32
  24. package/dst/specbook-export-html.d.ts +4 -4
  25. package/dst/specbook-export-html.js +153 -107
  26. package/dst/specbook-export-logo-dark.svg +35 -0
  27. package/dst/specbook-export-logo-light.svg +35 -0
  28. package/dst/specbook-export-md.d.ts +3 -3
  29. package/dst/specbook-export-md.js +50 -24
  30. package/dst/specbook-export-pdf.d.ts +4 -2
  31. package/dst/specbook-export-pdf.js +48 -38
  32. package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
  33. package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +8 -4
  34. package/dst/specbook-format-spec.d.ts +31 -0
  35. package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
  36. package/dst/specbook-format.md +750 -0
  37. package/dst/specbook-link.d.ts +3 -2
  38. package/dst/specbook-link.js +7 -4
  39. package/dst/specbook-mcp.d.ts +2 -1
  40. package/dst/specbook-mcp.js +30 -62
  41. package/dst/specbook-parse-common.d.ts +5 -3
  42. package/dst/specbook-parse-common.js +8 -0
  43. package/dst/specbook-parse-semantic.d.ts +6 -4
  44. package/dst/specbook-parse-semantic.js +98 -106
  45. package/dst/specbook-parse-syntax.d.ts +2 -2
  46. package/dst/specbook-parse-syntax.js +175 -144
  47. package/dst/specbook-parse-value.d.ts +1 -1
  48. package/dst/specbook-parse-value.js +6 -3
  49. package/dst/specbook-parse.d.ts +4 -7
  50. package/dst/specbook-parse.js +28 -35
  51. package/dst/specbook-verbose.d.ts +2 -0
  52. package/dst/specbook-verbose.js +17 -0
  53. package/package.json +3 -7
  54. package/dst/specbook-cmd-edit.d.ts +0 -9
  55. package/dst/specbook-cmd-edit.js +0 -74
  56. package/dst/specbook-cmd-import.d.ts +0 -9
  57. package/dst/specbook-cmd-import.js +0 -78
  58. package/dst/specbook-export-logo.svg +0 -35
  59. package/dst/specbook-llm.d.ts +0 -13
  60. package/dst/specbook-llm.js +0 -51
  61. package/dst/specbook-struct-spec.d.ts +0 -31
@@ -1,5 +1,6 @@
1
- import type { Specification, Object as SpecObject } from "./specbook-struct-spec.js";
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;
@@ -9,7 +10,7 @@ export interface LinkTarget {
9
10
  target?: SpecObject;
10
11
  ambiguous: boolean;
11
12
  }
12
- export declare const buildLinkIndex: (specification: Specification) => LinkIndex;
13
+ export declare const buildLinkIndex: (specification: Spec) => LinkIndex;
13
14
  export declare const resolveSet: (index: LinkIndex, reference: string) => SpecObject[];
14
15
  export declare const resolveUnique: (index: LinkIndex, reference: string) => LinkTarget;
15
16
  export declare const anchorPaths: (index: LinkIndex) => Map<SpecObject, string>;
@@ -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) {
@@ -1 +1,2 @@
1
- export declare const serveMcp: (verbose: (msg: string) => void) => Promise<void>;
1
+ import { type VerboseSink } from "./specbook-api.js";
2
+ export declare const serveMcp: (verbose: VerboseSink) => Promise<void>;
@@ -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);
@@ -43,10 +43,10 @@ export const serveMcp = async (verbose) => {
43
43
  });
44
44
  server.registerTool("specbook_lint", {
45
45
  title: "Lint Specification",
46
- description: "Lint the specification Markdown files below the base directory against the " +
47
- "optional YAML schema configuration and return all diagnostics.",
46
+ description: "Lint the specification Markdown files the YAML schema configuration references " +
47
+ "below the base directory against this configuration and return all diagnostics.",
48
48
  inputSchema: {
49
- config: z.string().optional().describe("YAML schema configuration file"),
49
+ config: z.string().describe("YAML schema configuration file"),
50
50
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
51
51
  }
52
52
  }, async (args) => {
@@ -63,24 +63,32 @@ export const serveMcp = async (verbose) => {
63
63
  });
64
64
  server.registerTool("specbook_export", {
65
65
  title: "Export Specification",
66
- description: "Export the specification Markdown files below the base directory as JSON, JSON5, " +
66
+ description: "Export the specification Markdown files the YAML schema configuration references " +
67
+ "below the base directory as JSON, JSON5, " +
67
68
  "YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
68
69
  "if an output path is given, else it is returned directly (PDF as a base64-encoded resource).",
69
70
  inputSchema: {
70
- config: z.string().optional().describe("YAML schema configuration file"),
71
+ config: z.string().describe("YAML schema configuration file"),
71
72
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
72
- format: z.enum(formats).optional().describe("output format (default: json)"),
73
- 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)")
74
76
  }
75
77
  }, async (args) => {
76
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);
77
85
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
78
- formats: [args.format ?? "json"] });
79
- if (args.output !== undefined) {
80
- await fs.promises.writeFile(args.output, data);
81
- 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)` }] };
82
90
  }
83
- else if ((args.format ?? "json") === "pdf")
91
+ else if (spec.format === "pdf")
84
92
  return { content: [{
85
93
  type: "resource",
86
94
  resource: { uri: "specbook:export.pdf", mimeType: "application/pdf", blob: data.toString("base64") }
@@ -94,16 +102,20 @@ export const serveMcp = async (verbose) => {
94
102
  });
95
103
  server.registerTool("specbook_describe", {
96
104
  title: "Describe Specification Format",
97
- description: "Describe the specification format configured by the YAML schema configuration " +
98
- "as a Markdown document.",
105
+ description: "Describe the SpecBook models and formats as a Markdown document. If the YAML " +
106
+ "schema configuration or the base directory is given, the description additionally points " +
107
+ "to the artifacts of that particular project.",
99
108
  inputSchema: {
100
- config: z.string().describe("YAML schema configuration file"),
101
- output: z.string().optional().describe("output file path (default: return description)")
109
+ config: z.string().optional().describe("YAML schema configuration file"),
110
+ basedir: z.string().optional().describe("base directory of the specification Markdown files"),
111
+ embed: z.boolean().optional().describe("embed the YAML schema configuration instead of " +
112
+ "just referencing it (default: false)"),
113
+ output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)")
102
114
  }
103
115
  }, async (args) => {
104
116
  try {
105
117
  const text = await specbook.describe(args);
106
- if (args.output !== undefined) {
118
+ if (args.output !== undefined && args.output !== "-") {
107
119
  await fs.promises.writeFile(args.output, text, "utf8");
108
120
  return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
109
121
  }
@@ -113,49 +125,5 @@ export const serveMcp = async (verbose) => {
113
125
  return errorResult(err);
114
126
  }
115
127
  });
116
- server.registerTool("specbook_import", {
117
- title: "Import Foreign Sources",
118
- description: "Import the information of foreign source files into the configured specification " +
119
- "artifact files below the base directory, using an LLM.",
120
- inputSchema: {
121
- config: z.string().describe("YAML schema configuration file"),
122
- basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
123
- inputs: z.array(z.string()).describe("foreign source files to import"),
124
- provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
125
- model: z.string().optional().describe("AI model")
126
- }
127
- }, async (args) => {
128
- try {
129
- const written = await specbook.import(args);
130
- return { content: [{ type: "text", text: written.length > 0 ?
131
- `imported into artifact file(s): ${written.join(", ")}` :
132
- "no artifact files were changed" }] };
133
- }
134
- catch (err) {
135
- return errorResult(err);
136
- }
137
- });
138
- server.registerTool("specbook_edit", {
139
- title: "Edit Specification",
140
- description: "Apply a free-text edit request to the configured specification artifact files " +
141
- "below the base directory, using an LLM.",
142
- inputSchema: {
143
- config: z.string().describe("YAML schema configuration file"),
144
- basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
145
- query: z.string().describe("free-text edit request"),
146
- provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
147
- model: z.string().optional().describe("AI model")
148
- }
149
- }, async (args) => {
150
- try {
151
- const written = await specbook.edit(args);
152
- return { content: [{ type: "text", text: written.length > 0 ?
153
- `edited artifact file(s): ${written.join(", ")}` :
154
- "no artifact files were changed" }] };
155
- }
156
- catch (err) {
157
- return errorResult(err);
158
- }
159
- });
160
128
  await server.connect(new StdioServerTransport());
161
129
  };
@@ -1,4 +1,4 @@
1
- import { type Specification, type Object as SpecObject, type Property } from "./specbook-struct-spec.js";
1
+ import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
2
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
3
3
  import { type LinkIndex } from "./specbook-link.js";
4
4
  export interface SourceFile {
@@ -6,7 +6,7 @@ export interface SourceFile {
6
6
  text: string;
7
7
  }
8
8
  export interface ParseResult {
9
- specification: Specification;
9
+ specification: Spec;
10
10
  diagnostics: Diagnostic[];
11
11
  }
12
12
  export interface ObjectMeta {
@@ -16,11 +16,13 @@ export interface ObjectMeta {
16
16
  export declare class ParseContext {
17
17
  diagnostics: Diagnostic[];
18
18
  objectMeta: WeakMap<SpecObject, ObjectMeta>;
19
- propMeta: WeakMap<Property, {
19
+ propMeta: WeakMap<SpecProperty, {
20
20
  line: number;
21
21
  }>;
22
22
  linkIndex: LinkIndex;
23
23
  diagnose(file: string, line: number, message: string): void;
24
24
  }
25
25
  export declare const embeddingRegex: RegExp;
26
+ export declare const embeddingThemes: readonly ["light", "dark"];
27
+ export declare const embeddingVariants: (reference: string) => string[];
26
28
  export declare const embeddingMimeType: (reference: string) => string | undefined;
@@ -16,6 +16,14 @@ export class ParseContext {
16
16
  }
17
17
  /* the Markdown image embedding syntax ("![alt](file)") */
18
18
  export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
19
+ /* the theme variants of a theme-aware image embedding */
20
+ export const embeddingThemes = ["light", "dark"];
21
+ /* expand an image embedding reference into its variants: a reference
22
+ carrying the "{theme}" placeholder yields one variant per theme,
23
+ while every other reference stays its own single variant */
24
+ export const embeddingVariants = (reference) => reference.includes("{theme}") ?
25
+ embeddingThemes.map((theme) => reference.replace(/\{theme\}/g, theme)) :
26
+ [reference];
19
27
  /* the embeddable file types and their MIME types */
20
28
  const embeddingTypes = {
21
29
  svg: "image/svg+xml",
@@ -1,5 +1,7 @@
1
- import { type Specification } from "./specbook-struct-spec.js";
2
- import { type SchemaSpecification } from "./specbook-struct-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 validate: (ctx: ParseContext, specification: Specification, config: SchemaSpecification) => void;
5
- export declare const validateReferences: (ctx: ParseContext, specification: Specification) => void;
4
+ export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
5
+ export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
6
+ export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
7
+ export declare const validateReferences: (ctx: ParseContext, specification: Spec) => void;
@@ -3,35 +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 a trailing parenthesized annotation from a
9
- property key (e.g. "WHEN (Context)") */
10
- const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
11
- /* strip the inline code markup of a name or property value
12
- (preserved in the AST for rendering) for matching purposes */
13
- const plainText = (text) => text.replace(/`/g, "");
14
- /* compile a configured pattern into an anchored regular expression */
15
- const anchored = (pattern, flags = "") => new RegExp(`^(?:${pattern})$`, flags);
16
- /* match a single inline token against a value expression
17
- (case-insensitively, as inline tokens are decorations) */
18
- const tokenMatches = (expr, token) => expr.kind === "regex" ?
19
- anchored(expr.source, "i").test(token) :
20
- expr.kind === "enum" ?
21
- expr.members.some((member) => member.toLowerCase() === token.toLowerCase()) :
22
- false;
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 = /^\[\[([^[\]]+)\]\]$/;
23
10
  /* match a full property value directly against a value expression
24
11
  (defined for the regex and enum kinds only) */
25
- const directMatches = (expr, text) => expr.kind === "regex" ?
26
- expr.regex.test(text) :
27
- expr.kind === "enum" ?
28
- expr.members.includes(text) :
29
- false;
12
+ const directMatches = (expr, text) => (expr.kind === "regex" && expr.regex.test(text))
13
+ || (expr.kind === "enum" && expr.members.includes(text));
30
14
  /* match a single list item against the alternatives of a list
31
15
  constraint (a reference item resolves leniently, as unresolvable
32
16
  and ambiguous ones are already reported by the reference pass) */
33
17
  const matchAlternatives = (ctx, alternatives, item) => {
34
- const rm = item.match(/^\[\[([^[\]]+)\]\]$/);
18
+ const rm = item.match(singleReferenceRegex);
35
19
  for (const alternative of alternatives) {
36
20
  if (alternative.kind === "reference") {
37
21
  if (rm === null)
@@ -42,75 +26,27 @@ const matchAlternatives = (ctx, alternatives, item) => {
42
26
  return true;
43
27
  }
44
28
  else if (alternative.kind === "tags") {
45
- if (alternative.members.includes(plainText(item)))
29
+ if (alternative.members.includes(item))
46
30
  return true;
47
31
  }
48
- else if (directMatches(alternative, plainText(item)))
32
+ else if (directMatches(alternative, item))
49
33
  return true;
50
34
  }
51
35
  return false;
52
36
  };
53
37
  /* find a property of an object by its key (case-sensitive) */
54
- const findProp = (object, name) => object.properties.find((p) => plainKey(p.key) === name);
55
- /* distribute the inline tokens split off the multi-token property values
56
- of an object across its configured properties and report the
57
- unassignable ones (the properties assigned a token are returned) */
58
- const assignInlineTokens = (ctx, object, props, meta) => {
59
- const tokens = new Array();
60
- const tokenAssigned = new Set();
61
- /* explode a multi-token property value whose direct pattern check
62
- fails: keep the token matching the own pattern and distribute
63
- the remaining tokens across the sibling properties */
64
- for (const prop of props) {
65
- if (prop.value === undefined)
66
- continue;
67
- const expr = compileValueExpr(prop.value);
68
- if (expr.kind !== "regex" && expr.kind !== "enum")
69
- continue;
70
- const match = findProp(object, prop.name);
71
- if (match === undefined || directMatches(expr, plainText(match.value)))
72
- continue;
73
- const parts = plainText(match.value).split(/\s+/);
74
- if (parts.length < 2)
75
- continue;
76
- const idx = parts.findIndex((part) => tokenMatches(expr, part));
77
- if (idx < 0)
78
- continue;
79
- match.value = parts[idx];
80
- tokenAssigned.add(prop.name);
81
- parts.splice(idx, 1);
82
- tokens.push(...parts);
83
- }
84
- /* assign the collected tokens to the still unset configured
85
- properties whose pattern they match */
86
- for (const prop of props) {
87
- if (prop.value === undefined || findProp(object, prop.name) !== undefined)
88
- continue;
89
- const expr = compileValueExpr(prop.value);
90
- if (expr.kind !== "regex" && expr.kind !== "enum")
91
- continue;
92
- const idx = tokens.findIndex((token) => tokenMatches(expr, token));
93
- if (idx >= 0) {
94
- object.properties.push({ key: prop.name, value: tokens[idx] });
95
- tokenAssigned.add(prop.name);
96
- tokens.splice(idx, 1);
97
- }
98
- }
99
- /* report the tokens which no configured property accepts */
100
- for (const token of tokens)
101
- ctx.diagnose(meta.file, meta.line, `unassignable inline token "${token}" on ${object.kind} "${object.name}"`);
102
- return tokenAssigned;
103
- };
38
+ const findProp = (object, name) => object.properties.find((p) => p.key === name);
104
39
  /* check a property value against the compiled value expression of its
105
40
  configured schema property */
106
41
  const checkPropValue = (ctx, prop, expr, property, meta) => {
107
42
  if (expr.kind === "reference") {
108
43
  /* a reference constraint: the value has to be exactly one
109
44
  reference resolving into the constraint's wildcard match set */
110
- if (!/^\[\[[^[\]]+\]\]$/.test(property.value.trim()))
45
+ const rm = plainText(property.value).trim().match(singleReferenceRegex);
46
+ if (rm === null)
111
47
  ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
112
48
  else {
113
- const ref = property.value.trim().slice(2, -2).trim();
49
+ const ref = rm[1].trim();
114
50
  const target = resolveUnique(ctx.linkIndex, ref).target;
115
51
  if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
116
52
  ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
@@ -131,7 +67,7 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
131
67
  else if (expr.kind === "list") {
132
68
  /* a list constraint: the value is a comma-separated list
133
69
  of items, each matching at least one alternative */
134
- for (const item of splitItems(property.value))
70
+ for (const item of splitItems(plainText(property.value)))
135
71
  if (!matchAlternatives(ctx, expr.alternatives, item))
136
72
  ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
137
73
  }
@@ -140,6 +76,16 @@ const checkPropValue = (ctx, prop, expr, property, meta) => {
140
76
  `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
141
77
  `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
142
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
+ };
143
89
  /* validate a single object (and recursively its childs) against its schema */
144
90
  const validateObject = (ctx, object, schema, level) => {
145
91
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
@@ -152,35 +98,27 @@ const validateObject = (ctx, object, schema, level) => {
152
98
  input, via either "{{<id>}}" or "(<id>)" */
153
99
  if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
154
100
  ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
155
- /* distribute the inline tokens split off the multi-token property values */
156
- const tokenAssigned = assignInlineTokens(ctx, object, props, meta);
157
- /* check the configured properties */
158
- 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);
159
105
  for (const prop of props) {
160
106
  const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
161
- const match = findProp(object, prop.name);
162
- if (match === undefined) {
163
- /* accept a trailing parenthesized name token as the value of a
164
- still missing property when it matches the property pattern */
165
- if (object.paren !== undefined && expr !== undefined
166
- && directMatches(expr, object.paren)) {
167
- parenConsumed = true;
168
- continue;
169
- }
170
- if (prop.optional !== true)
171
- 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 });
172
116
  }
173
- else if (expr !== undefined && !tokenAssigned.has(prop.name))
174
- checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
175
117
  }
176
118
  /* report the properties not configured by the schema */
177
119
  for (const property of object.properties)
178
- if (!props.some((p) => p.name === plainKey(property.key)))
120
+ if (!props.some((p) => p.name === property.key))
179
121
  ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
180
- /* a parenthesized token not consumed as a property value acts as
181
- the implicit anchor (an explicit "{{...}}" anchor takes precedence) */
182
- if (object.paren !== undefined && !parenConsumed && object.anchor === undefined)
183
- object.id = object.paren;
184
122
  /* check the configured child objects */
185
123
  const childs = schema.childs ?? [];
186
124
  for (const child of object.childs) {
@@ -206,27 +144,81 @@ const validateObject = (ctx, object, schema, level) => {
206
144
  };
207
145
  object.childs.sort((a, b) => kindPos(a.kind) - kindPos(b.kind));
208
146
  const propPos = (key) => {
209
- const i = props.findIndex((p) => p.name === plainKey(key));
147
+ const i = props.findIndex((p) => p.name === key);
210
148
  return i >= 0 ? i : props.length;
211
149
  };
212
150
  object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
213
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
+ };
214
196
  /* validate the parsed specification against the configuration */
215
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 */
216
211
  const position = new Map();
217
212
  for (const artifact of specification.artifacts) {
218
213
  for (const object of artifact.objects) {
219
214
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
220
- const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
221
- config.find((s) => s.name !== undefined
222
- && s.name.toUpperCase() === plainText(object.name).toUpperCase());
215
+ const schema = schemas.get(object);
223
216
  if (schema === undefined) {
224
217
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
225
218
  continue;
226
219
  }
227
220
  /* the artifact heading must carry the configured kind and
228
- name verbatim (the resolution above is deliberately
229
- lenient, so deviations are still precisely reported) */
221
+ name verbatim (the resolution is lenient on purpose) */
230
222
  if (object.kind !== schema.kind)
231
223
  ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
232
224
  if (schema.name !== undefined && plainText(object.name) !== schema.name)
@@ -1,3 +1,3 @@
1
- import { type Artifact } from "./specbook-struct-spec.js";
1
+ import { type SpecArtifact } from "./specbook-format-spec.js";
2
2
  import { ParseContext, type SourceFile } from "./specbook-parse-common.js";
3
- export declare const parseFile: (ctx: ParseContext, source: SourceFile) => Artifact[];
3
+ export declare const parseFile: (ctx: ParseContext, source: SourceFile) => SpecArtifact[];