@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
package/README.md CHANGED
@@ -22,15 +22,62 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
22
22
 
23
23
  **SpecBook** provides the following distinct features:
24
24
 
25
- - Hierarchical Object Model
26
- - Versatile Object to Markdown Mapping
27
- - Wiki-Style Object Linking
28
- - Strict Validation with Custom Schema
29
- - Object Model Diagram Visualisation
30
- - CLI, MCP, and API Interface
31
- - AST Exports for AI/LLMs
32
- - HTML Export for Developers
33
- - PDF Export for Customers
25
+ - **Hierarchical Object Model**:
26
+ A specification is a tree of typed objects, each with a kind and
27
+ name, an optional id, optional properties, optional description, and
28
+ optional child objects. The allowed object hierarchy is defined per
29
+ context by the YAML schema configuration.
30
+
31
+ - **Versatile Object to Markdown Mapping**:
32
+ Objects are authored as plain Markdown headings, property lists,
33
+ and description prose. The objects can be mapped to nested sections
34
+ ("complex" format) or a compact bullet point lists ("concise" and
35
+ "group" format).
36
+
37
+ - **Wiki-Style Object Linking**:
38
+ Objects can hierarchically reference each other through Wiki-style
39
+ `[[xxx]]` references, which are resolved against the locally-unique
40
+ ids and names of all objects. In the HTML and PDF exports they become
41
+ navigable links onto precise anchors.
42
+
43
+ - **Strict Validation with Custom Schema**:
44
+ The YAML schema configuration strictly defines the allowed object
45
+ kinds, hierarchies, and properties, whose values are constrained by an
46
+ expression language (regex, enum, tags, list, and reference). Violations
47
+ are reported as file- and line-precise diagnostics.
48
+
49
+ - **Object Model Diagram Visualisation**:
50
+ Object kinds can declare "graph", "hub", or "grid" diagrams in the
51
+ schema, whose nodes and edges are automatically derived from the
52
+ object model and its references. The diagrams are rendered with the
53
+ sibling project [Gradia](https://github.com/rse/gradia), which is
54
+ specialized on rendering object models.
55
+
56
+ - **CLI, MCP, and API Interface**:
57
+ All commands are implemented once in the API class `SpecBook`, on
58
+ which both the CLI and the MCP service are just thin wrappers.
59
+ This way, humans, scripts, and AI agents use exactly the same
60
+ functionality.
61
+
62
+ - **AST Exports for AI/LLMs**:
63
+ The parsed specification Abstract Syntax Tree (AST) can be exported in
64
+ JSON, JSON5, YAML, or TOON format for machine consumption. Together
65
+ with the `describe` command, which explains the models and formats,
66
+ this enables LLMs to both read and write specifications.
67
+
68
+ - **HTML Export for Developers**:
69
+ The HTML export is a self-contained single document with a
70
+ table-of-contents, full-text search, embedded images, and a light/dark
71
+ theme toggle. It is intended for the day-to-day online reading during
72
+ development. The HTML export can map all object kinds to nested
73
+ sections or compact tables, or an automatic mixture which collapses
74
+ only the deepest level into tables.
75
+
76
+ - **PDF Export for Customers**:
77
+ The PDF export prints the HTML rendering via Chromium and post-processes
78
+ it with page numbers, headers/footers, and a hierarchical PDF outline.
79
+ It is intended as a polished, paginated offline document for handing
80
+ over to customers.
34
81
 
35
82
  Overview Poster
36
83
  ---------------
@@ -70,7 +117,10 @@ The YAML schema configuration `-c`/`--config` is mandatory for `init`,
70
117
  artifact files its `file` fields reference are loaded and parsed, all
71
118
  other Markdown files below the base directory are ignored. A referenced
72
119
  file which is absent is reported, unless all of its artifacts are
73
- `optional`.
120
+ `optional`, and so is an artifact absent from its present file, unless
121
+ it is `optional` itself. Both `lint` and `export` report all
122
+ diagnostics and fail on any of them, so a partial or invalid
123
+ specification is never exported.
74
124
 
75
125
  The base directory `-b`/`--basedir` (default: `.`) is the directory the
76
126
  referenced artifact files are resolved against, and generated
@@ -82,6 +132,11 @@ occur multiple times. The format (`json`, `json5`, `yaml`, `toon`,
82
132
  it is explicitly given as a `<format>:` prefix, and plain `-` (stdout)
83
133
  defaults to JSON.
84
134
 
135
+ The `md` format normalizes the entire corpus into a *single* Markdown
136
+ document, whose single frontmatter block carries the earliest `Created:`
137
+ and the latest `Modified:` timestamp of all artifacts, so the
138
+ per-artifact timestamps do not survive this round-trip.
139
+
85
140
  The `describe` command outputs the description of the SpecBook models
86
141
  and formats. If the configuration file `-c`/`--config` or the base
87
142
  directory `-b`/`--basedir` is given, it additionally appends a *SpecBook
@@ -4,8 +4,6 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
7
  import { loadConfig } from "./specbook-config.js";
10
8
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
11
9
  import { initSpecification } from "./specbook-cmd-init.js";
@@ -17,12 +15,9 @@ import { literal, renderVerbose } from "./specbook-verbose.js";
17
15
  export { literal, renderVerbose };
18
16
  export { formats, parseOutputSpec };
19
17
  export { renderDiagnostic, renderDiagnosticVerbose };
20
- /* the own version, taken from the package manifest, which resides one
18
+ /* our own version, taken from the package manifest, which resides one
21
19
  level above both the source and the compiled module directory */
22
- export const version = (() => {
23
- const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
24
- return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
25
- })();
20
+ export const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
26
21
  /* the SpecBook API */
27
22
  export class SpecBook {
28
23
  verbose;
@@ -44,7 +39,7 @@ export class SpecBook {
44
39
  file = this.requireConfigFile(file);
45
40
  verbose(`loading configuration "${literal(file)}"`);
46
41
  const { config, diagnostics } = loadConfig(file);
47
- if (config === null)
42
+ if (config === undefined)
48
43
  throw new Error("invalid configuration:\n" +
49
44
  diagnostics.map(renderDiagnostic).join("\n"));
50
45
  return config;
@@ -53,8 +48,8 @@ export class SpecBook {
53
48
  below the base directory */
54
49
  async init(options) {
55
50
  const verbose = this.verboseOf("init");
56
- return initSpecification({ ...options,
57
- config: this.requireConfig(options.config, verbose), verbose });
51
+ return initSpecification({ config: this.requireConfig(options.config, verbose),
52
+ basedir: options.basedir ?? ".", verbose });
58
53
  }
59
54
  /* lint the specification Markdown files below the base directory */
60
55
  async lint(options) {
@@ -64,26 +59,32 @@ export class SpecBook {
64
59
  /* export the specification Markdown files below the base directory
65
60
  as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown,
66
61
  parsing the input just once and returning one buffer per
67
- requested format (best-effort: diagnostics do not prevent the
68
- export, as validation is the concern of lint) */
62
+ requested format (strict: any diagnostic prevents the export,
63
+ as a partial or invalid specification must never be emitted) */
69
64
  async export(options) {
70
65
  const verbose = this.verboseOf("export");
71
66
  const result = lint({ config: this.requireConfigFile(options.config),
72
67
  basedir: options.basedir ?? ".", verbose });
73
- for (const diagnostic of result.diagnostics)
74
- verbose(`diagnostic: ${renderDiagnostic(diagnostic)}`);
75
- if (result.specification.artifacts.length === 0)
76
- throw new Error("unexportable specification:\n" +
68
+ if (result.diagnostics.length > 0)
69
+ throw new Error("invalid specification:\n" +
77
70
  result.diagnostics.map(renderDiagnostic).join("\n"));
71
+ if (result.specification.artifacts.length === 0)
72
+ throw new Error("unexportable specification: no artifacts found");
78
73
  const buffers = new Array();
79
74
  for (const format of options.formats ?? ["json"])
80
75
  buffers.push(await exportSpecification(result.specification, format, verbose, result.config));
81
76
  return buffers;
82
77
  }
83
78
  /* describe the generic SpecBook models and formats as Markdown,
84
- optionally pointing to the artifacts of the particular project */
79
+ optionally pointing to the artifacts of the particular project,
80
+ whose YAML schema configuration is validated if given */
85
81
  async describe(options) {
86
- this.verboseOf("describe")("describing the SpecBook models and formats");
82
+ const verbose = this.verboseOf("describe");
83
+ if (options.config !== undefined)
84
+ this.requireConfig(options.config, verbose);
85
+ else if (options.embed === true)
86
+ throw new Error("YAML schema configuration required for embedding");
87
+ verbose("describing the SpecBook models and formats");
87
88
  return describeFormat(options);
88
89
  }
89
90
  }
@@ -56,7 +56,8 @@ const withVerboseOption = (command) => command
56
56
  /* provide the common options of the specification processing sub-commands,
57
57
  for which the YAML schema configuration is mandatory */
58
58
  const withCommonOptions = (command) => withVerboseOption(command)
59
- .requiredOption("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
59
+ .requiredOption("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"))
60
+ .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."));
60
61
  /* parse the command line */
61
62
  const program = new Command();
62
63
  program.name("specbook")
@@ -65,15 +66,16 @@ program.name("specbook")
65
66
  .action(() => {
66
67
  program.help();
67
68
  });
69
+ /* the mcp command runs all other commands as MCP tools over stdio */
68
70
  withVerboseOption(program.command("mcp"))
69
71
  .description("run as MCP stdio server")
70
72
  .action(async (opts) => {
71
73
  verboseOf(opts)("mcp", "starting MCP server on stdio");
72
74
  await serveMcp(verboseOf(opts, "mcp"));
73
75
  });
76
+ /* the init command creates the configured artifact files */
74
77
  withCommonOptions(program.command("init"))
75
78
  .description("initialize the configured specification artifact files below the base directory")
76
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
77
79
  .action(async (opts) => {
78
80
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
79
81
  const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
@@ -81,9 +83,9 @@ withCommonOptions(program.command("init"))
81
83
  `initialized artifact file(s): ${created.join(", ")}\n` :
82
84
  "no artifact files were created\n");
83
85
  });
86
+ /* the lint command reports all diagnostics and fails on any of them */
84
87
  withCommonOptions(program.command("lint"))
85
88
  .description("lint the specification Markdown files below the base directory")
86
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
87
89
  .action(async (opts) => {
88
90
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
89
91
  const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
@@ -96,16 +98,16 @@ withCommonOptions(program.command("lint"))
96
98
  else
97
99
  verboseOf(opts)("lint", "specification valid");
98
100
  });
101
+ /* the export command parses the input once and writes every output */
99
102
  withCommonOptions(program.command("export"))
100
103
  .description("export the specification Markdown files below the base directory " +
101
104
  "as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
102
- .option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
103
105
  .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
104
- "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), [])
106
+ "from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), new Array())
105
107
  .action(async (opts) => {
106
108
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
107
109
  const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
108
- .map((spec) => parseOutputSpec(spec));
110
+ .map(parseOutputSpec);
109
111
  /* parse the input once and export each distinct format once */
110
112
  const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
111
113
  const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
@@ -126,6 +128,7 @@ withVerboseOption(program.command("describe"))
126
128
  const text = await specbook.describe({ config: opts.config, basedir: opts.basedir, embed: opts.embed });
127
129
  await writeOutput(opts.output, text, "describe", verboseOf(opts));
128
130
  });
131
+ /* run the command line program */
129
132
  try {
130
133
  await program.parseAsync();
131
134
  }
@@ -4,12 +4,10 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
7
  import textframe from "textframe";
10
8
  /* provide the build-time bundled description of the generic
11
9
  SpecBook models and formats */
12
- const description = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-format.md"), "utf8");
10
+ const description = () => fs.readFileSync(new URL("specbook-format.md", import.meta.url), "utf8");
13
11
  /* describe the generic SpecBook models and formats as Markdown,
14
12
  optionally extended by the references to (or the embeddings of) the
15
13
  artifacts of the particular project instantiation */
@@ -4,7 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import { minify } from "@swc/html";
7
- import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet } from "./specbook-export-common.js";
7
+ import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints } from "./specbook-export-common.js";
8
8
  import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
9
9
  import { renderAst } from "./specbook-export-ast.js";
10
10
  import { renderMarkdown } from "./specbook-export-md.js";
@@ -13,27 +13,34 @@ import { htmlToPdf } from "./specbook-export-pdf.js";
13
13
  import { literal } from "./specbook-verbose.js";
14
14
  /* the supported export formats */
15
15
  export const formats = ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
16
- /* the filename extensions mapping onto the export formats */
17
- const extensions = {
18
- json: "json",
19
- json5: "json5",
20
- yaml: "yaml", yml: "yaml",
21
- toon: "toon",
22
- html: "html", htm: "html",
23
- pdf: "pdf",
24
- md: "md", markdown: "md"
25
- };
16
+ /* the format names and filename extensions mapping onto the export formats */
17
+ const extensions = new Map([
18
+ ["json", "json"],
19
+ ["json5", "json5"],
20
+ ["yaml", "yaml"], ["yml", "yaml"],
21
+ ["toon", "toon"],
22
+ ["html", "html"], ["htm", "html"],
23
+ ["pdf", "pdf"],
24
+ ["md", "md"], ["markdown", "md"]
25
+ ]);
26
26
  /* parse an output specification "[<format>:]<filename>", inferring the
27
27
  format from the filename extension when not explicitly given
28
28
  (plain "-" for stdout defaults to JSON) */
29
29
  export const parseOutputSpec = (spec) => {
30
- const prefixed = spec.match(/^([a-zA-Z0-9]+):(.+)$/);
31
- if (prefixed !== null && formats.includes(prefixed[1]))
32
- return { format: prefixed[1], output: prefixed[2] };
30
+ /* a single-character prefix is never a format name, so a Windows
31
+ drive letter still falls through to the extension inference */
32
+ const prefixed = spec.match(/^([a-zA-Z0-9]{2,}):(.+)$/);
33
+ if (prefixed !== null) {
34
+ const explicit = extensions.get(prefixed[1].toLowerCase());
35
+ if (explicit === undefined)
36
+ throw new Error(`unknown export format "${prefixed[1]}" in output "${spec}" ` +
37
+ `(supported: ${Array.from(extensions.keys()).join(", ")})`);
38
+ return { format: explicit, output: prefixed[2] };
39
+ }
33
40
  if (spec === "-")
34
41
  return { format: "json", output: spec };
35
42
  const ext = spec.match(/\.([a-zA-Z0-9]+)$/);
36
- const format = ext !== null ? extensions[ext[1].toLowerCase()] : undefined;
43
+ const format = ext !== null ? extensions.get(ext[1].toLowerCase()) : undefined;
37
44
  if (format !== undefined)
38
45
  return { format, output: spec };
39
46
  throw new Error(`unable to infer export format from output "${spec}" ` +
@@ -47,11 +54,11 @@ export const exportSpecification = async (specification, format, verbose, config
47
54
  if (format === "json" || format === "json5" || format === "yaml" || format === "toon")
48
55
  return renderAst(specification, format, config);
49
56
  else if (format === "md")
50
- return Buffer.from(await renderMarkdown(specification, config), "utf8");
57
+ return Buffer.from(renderMarkdown(specification, config), "utf8");
51
58
  /* the HTML-based formats share the stylesheet, with the embedded
52
59
  fonts subsetted to the CHARSET of the specification (if any) */
53
60
  const charset = documentCharset(specification);
54
- if (charset !== undefined)
61
+ if (charset !== undefined && charsetCodepoints(charset) !== undefined)
55
62
  verbose(`subsetting embedded fonts to charset "${literal(charset)}"`);
56
63
  /* the theme tone (THEME-TONE) drives the layer-1 color spread
57
64
  variables and the PDF decoration colors */
@@ -1,7 +1,7 @@
1
1
  import { type Schema } from "./specbook-format-schema.js";
2
2
  export interface InitOptions {
3
3
  config: Schema;
4
- basedir?: string;
4
+ basedir: string;
5
5
  verbose: (msg: string) => void;
6
6
  }
7
7
  export declare const initSpecification: (options: InitOptions) => string[];
@@ -18,8 +18,7 @@ const timestamp = () => {
18
18
  all artifacts configured onto the same file reside in it side by
19
19
  side, following each other on level 1 */
20
20
  export const initSpecification = (options) => {
21
- const basedir = options.basedir ?? ".";
22
- fs.mkdirSync(basedir, { recursive: true });
21
+ fs.mkdirSync(options.basedir, { recursive: true });
23
22
  const now = timestamp();
24
23
  const created = new Array();
25
24
  /* group the configured artifacts by their file, preserving the
@@ -34,8 +33,10 @@ export const initSpecification = (options) => {
34
33
  else
35
34
  group.push(artifact);
36
35
  }
36
+ /* create each absent artifact file with its frontmatter and
37
+ the level 1 headings of its artifacts */
37
38
  for (const [file, artifacts] of groups) {
38
- const target = path.join(basedir, file);
39
+ const target = path.join(options.basedir, file);
39
40
  if (fs.existsSync(target)) {
40
41
  options.verbose(`skipping existing artifact file "${literal(file)}"`);
41
42
  continue;
@@ -43,7 +44,7 @@ export const initSpecification = (options) => {
43
44
  const headings = artifacts.map((artifact) => {
44
45
  const name = artifact.name ?? "";
45
46
  const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
46
- return `# ${artifact.kind}: ${name}${paren}\n` + "\n";
47
+ return `# ${artifact.kind}: ${name}${paren}\n\n`;
47
48
  }).join("");
48
49
  const text = "---\n" +
49
50
  `Created: ${now}\n` +
@@ -11,5 +11,4 @@ export interface LintResult {
11
11
  diagnostics: Diagnostic[];
12
12
  config?: Schema;
13
13
  }
14
- export declare const schemaFiles: (config: Schema) => Map<string, boolean>;
15
14
  export declare const lint: (options: LintOptions) => LintResult;
@@ -7,11 +7,11 @@ import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { loadConfig } from "./specbook-config.js";
9
9
  import { literal } from "./specbook-verbose.js";
10
- import { parseSpecification } from "./specbook-parse.js";
10
+ import { parseSpecification, resolveArtifact } from "./specbook-parse.js";
11
11
  /* collect the distinct artifact files the schema configuration
12
12
  references, in their declaration order, each mapped onto whether
13
13
  all of its artifacts are optional and hence the file may be absent */
14
- export const schemaFiles = (config) => {
14
+ const schemaFiles = (config) => {
15
15
  const files = new Map();
16
16
  for (const artifact of config) {
17
17
  if (artifact.file === undefined)
@@ -28,17 +28,16 @@ export const lint = (options) => {
28
28
  options.verbose(`loading configuration "${literal(options.config)}"`);
29
29
  const loaded = loadConfig(options.config);
30
30
  diagnostics.push(...loaded.diagnostics);
31
- const config = loaded.config ?? undefined;
31
+ const config = loaded.config;
32
32
  /* read the artifact files the configuration references, resolved
33
33
  against the base directory and tolerating an absent file if all
34
34
  its artifacts are optional */
35
35
  const files = config !== undefined ? schemaFiles(config) : new Map();
36
- options.verbose(`parsing ${literal(files.size)} specification file(s) ` +
37
- `below "${literal(options.basedir)}"`);
38
36
  if (config !== undefined && files.size === 0)
39
37
  diagnostics.push({ file: options.config, line: 1, column: 1,
40
38
  message: "no artifact files configured" });
41
39
  const sources = new Array();
40
+ const present = new Set();
42
41
  for (const [name, optional] of files) {
43
42
  const file = path.join(options.basedir, name);
44
43
  if (!fs.existsSync(file)) {
@@ -49,13 +48,38 @@ export const lint = (options) => {
49
48
  }
50
49
  try {
51
50
  sources.push({ file, text: fs.readFileSync(file, "utf8") });
51
+ present.add(name);
52
52
  }
53
53
  catch (err) {
54
54
  diagnostics.push({ file, line: 1, column: 1,
55
55
  message: `unreadable file: ${err instanceof Error ? err.message : String(err)}` });
56
56
  }
57
57
  }
58
+ /* parse and validate the artifact files against the configuration */
59
+ options.verbose(`parsing ${literal(sources.length)} specification file(s) ` +
60
+ `below "${literal(options.basedir)}"`);
58
61
  const result = parseSpecification(sources, config);
59
62
  diagnostics.push(...result.diagnostics);
63
+ /* report the non-optional artifacts absent from the specification,
64
+ against their loaded artifact file (an absent or unreadable file
65
+ is already reported above) or else against the configuration */
66
+ if (config !== undefined) {
67
+ const found = new Set();
68
+ for (const artifact of result.specification.artifacts)
69
+ for (const object of artifact.objects) {
70
+ const schema = resolveArtifact(config, object);
71
+ if (schema !== undefined)
72
+ found.add(schema);
73
+ }
74
+ for (const schema of config) {
75
+ if (schema.optional === true || found.has(schema)
76
+ || (schema.file !== undefined && !present.has(schema.file)))
77
+ continue;
78
+ const file = schema.file !== undefined ? path.join(options.basedir, schema.file) : options.config;
79
+ const paren = schema.id !== undefined ? ` (${schema.id})` : "";
80
+ diagnostics.push({ file, line: 1, column: 1,
81
+ message: `missing artifact "${schema.kind}: ${schema.name ?? ""}${paren}"` });
82
+ }
83
+ }
60
84
  return { specification: result.specification, diagnostics, config };
61
85
  };
@@ -1,6 +1,6 @@
1
1
  import { Schema } from "./specbook-format-schema.js";
2
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
3
3
  export declare const loadConfig: (file: string) => {
4
- config: Schema | null;
4
+ config?: Schema;
5
5
  diagnostics: Diagnostic[];
6
6
  };