@rse/specbook 1.5.2 → 1.6.1

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 (63) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +288 -43
  3. package/dst/specbook-api.d.ts +23 -3
  4. package/dst/specbook-api.js +133 -59
  5. package/dst/specbook-cli.js +93 -26
  6. package/dst/specbook-cmd-describe.js +4 -4
  7. package/dst/specbook-cmd-export-schema.d.ts +8 -0
  8. package/dst/specbook-cmd-export-schema.js +441 -0
  9. package/dst/specbook-cmd-export.js +23 -6
  10. package/dst/specbook-cmd-init-sample.d.ts +4 -0
  11. package/dst/specbook-cmd-init-sample.js +322 -0
  12. package/dst/specbook-cmd-init.d.ts +1 -0
  13. package/dst/specbook-cmd-init.js +42 -10
  14. package/dst/specbook-cmd-lint.js +46 -48
  15. package/dst/specbook-cmd-query.d.ts +30 -0
  16. package/dst/specbook-cmd-query.js +165 -0
  17. package/dst/specbook-config.d.ts +9 -1
  18. package/dst/specbook-config.js +135 -10
  19. package/dst/specbook-coverage.js +2 -2
  20. package/dst/specbook-diagram.d.ts +7 -5
  21. package/dst/specbook-diagram.js +546 -143
  22. package/dst/specbook-export-ast.d.ts +1 -1
  23. package/dst/specbook-export-ast.js +11 -8
  24. package/dst/specbook-export-code.js +3 -3
  25. package/dst/specbook-export-common.d.ts +1 -1
  26. package/dst/specbook-export-common.js +3 -2
  27. package/dst/specbook-export-diagram.d.ts +1 -1
  28. package/dst/specbook-export-diagram.js +25 -7
  29. package/dst/specbook-export-html-fold.d.ts +16 -6
  30. package/dst/specbook-export-html-fold.js +65 -44
  31. package/dst/specbook-export-html-render.js +107 -26
  32. package/dst/specbook-export-html-search.js +18 -20
  33. package/dst/specbook-export-html-template.js +38 -9
  34. package/dst/specbook-export-html-widget.d.ts +2 -0
  35. package/dst/specbook-export-html-widget.js +219 -19
  36. package/dst/specbook-export-html.css +208 -38
  37. package/dst/specbook-export-html.js +96 -53
  38. package/dst/specbook-export-md.d.ts +2 -1
  39. package/dst/specbook-export-md.js +7 -8
  40. package/dst/specbook-export-pdf.js +1 -1
  41. package/dst/specbook-files.d.ts +2 -0
  42. package/dst/specbook-files.js +41 -0
  43. package/dst/specbook-format-meta.yaml +401 -0
  44. package/dst/specbook-format-pm.yaml +1240 -0
  45. package/dst/specbook-format-schema.d.ts +33 -3
  46. package/dst/specbook-format-schema.js +49 -3
  47. package/dst/specbook-format.md +427 -47
  48. package/dst/specbook-format.yaml +524 -228
  49. package/dst/specbook-ignore.js +2 -2
  50. package/dst/specbook-link.d.ts +4 -0
  51. package/dst/specbook-link.js +61 -6
  52. package/dst/specbook-mcp.js +103 -19
  53. package/dst/specbook-parse-semantic.d.ts +1 -0
  54. package/dst/specbook-parse-semantic.js +11 -4
  55. package/dst/specbook-parse-syntax.d.ts +1 -0
  56. package/dst/specbook-parse-syntax.js +1 -1
  57. package/dst/specbook-parse.d.ts +1 -1
  58. package/dst/specbook-parse.js +3 -3
  59. package/dst/specbook-project.d.ts +2 -0
  60. package/dst/specbook-project.js +15 -5
  61. package/dst/specbook-remote.d.ts +28 -0
  62. package/dst/specbook-remote.js +429 -0
  63. package/package.json +32 -20
@@ -155,8 +155,8 @@ export const excluder = (dir) => {
155
155
  return rules;
156
156
  };
157
157
  return (file) => {
158
- const rel = path.relative(root, path.resolve(file)).replace(/\\/g, "/");
159
- if (rel === "" || rel.startsWith("../"))
158
+ const rel = path.relative(root, path.resolve(file)).split(path.sep).join("/");
159
+ if (rel === "" || rel === ".." || rel.startsWith("../") || path.isAbsolute(rel))
160
160
  return false;
161
161
  const segments = rel.split("/");
162
162
  /* walk the directory chain from the working tree root down to
@@ -1,6 +1,8 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  export declare const referenceRegex: RegExp;
3
+ export declare const outsideCode: (text: string, apply: (part: string) => string) => string;
3
4
  export declare const plainText: (text: string) => string;
5
+ export declare const referenceText: (text: string) => string;
4
6
  interface LinkNode {
5
7
  object: SpecObject;
6
8
  parent?: LinkNode;
@@ -17,6 +19,8 @@ export interface LinkTarget {
17
19
  export declare const buildLinkIndex: (specification: Spec) => LinkIndex;
18
20
  export declare const resolveSet: (index: LinkIndex, reference: string) => SpecObject[];
19
21
  export declare const resolveUnique: (index: LinkIndex, reference: string, from?: SpecObject) => LinkTarget;
22
+ export declare const referenceTo: (index: LinkIndex, target: SpecObject, from: SpecObject) => string;
23
+ export declare const referenceTargets: (index: LinkIndex, object: SpecObject) => SpecObject[];
20
24
  export declare const anchorPaths: (index: LinkIndex) => Map<SpecObject, string>;
21
25
  export declare const expandReferences: (text: string, replace: (reference: string) => string) => string;
22
26
  export {};
@@ -8,21 +8,38 @@ export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
8
8
  /* the fenced code blocks of a Markdown text (from the opening fence line
9
9
  up to the closing fence line, or to the end of an unclosed one) */
10
10
  const fenceRegex = /^[ \t]*(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?(?:\n[ \t]*\1[`~]*[ \t]*(?=\n|$)|(?![\s\S]))|(?![\s\S]))/gm;
11
+ /* the inline code spans of a Markdown text (a backtick run up to the
12
+ next backtick run of the same length) */
13
+ const spanRegex = /(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
11
14
  /* apply a transformation to the parts of a text outside of its fenced
12
- code blocks, which stay verbatim, as code carries no references */
13
- const outsideFences = (text, apply) => {
15
+ code blocks and inline code spans, which stay verbatim, as code
16
+ carries no references */
17
+ export const outsideCode = (text, apply) => {
18
+ const outsideSpans = (part) => {
19
+ let result = "";
20
+ let last = 0;
21
+ for (const m of part.matchAll(spanRegex)) {
22
+ result += apply(part.slice(last, m.index)) + m[0];
23
+ last = m.index + m[0].length;
24
+ }
25
+ return result + apply(part.slice(last));
26
+ };
14
27
  let result = "";
15
28
  let last = 0;
16
29
  for (const m of text.matchAll(fenceRegex)) {
17
- result += apply(text.slice(last, m.index)) + m[0];
30
+ result += outsideSpans(text.slice(last, m.index)) + m[0];
18
31
  last = m.index + m[0].length;
19
32
  }
20
- return result + apply(text.slice(last));
33
+ return result + outsideSpans(text.slice(last));
21
34
  };
22
35
  /* strip the fenced code blocks of a description and the inline code
23
36
  markup of a name, property value, or description (preserved in the
24
37
  AST for rendering) for matching and labeling */
25
38
  export const plainText = (text) => text.replace(fenceRegex, "").replace(/`/g, "");
39
+ /* strip the fenced code blocks and the inline code spans (with their
40
+ contents) of a text for scanning its references, as code carries no
41
+ references */
42
+ export const referenceText = (text) => text.replace(fenceRegex, "").replace(spanRegex, "");
26
43
  /* provide the lookup structures of an index, built once on first use */
27
44
  const lookups = new WeakMap();
28
45
  const lookup = (index) => {
@@ -186,6 +203,44 @@ export const resolveUnique = (index, reference, from) => {
186
203
  matches = nearest(index, matches, from);
187
204
  return { target: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
188
205
  };
206
+ /* the shortest reference uniquely resolving onto a target from a
207
+ referencing object: the kind-qualified name of the target, prefixed
208
+ by as many of its ancestors (their parenthesized id, else their
209
+ kind-qualified name) as the resolution needs, and as a last resort
210
+ the kind-qualified id of the target (unique among its siblings),
211
+ should its name be shared by a sibling of its kind */
212
+ export const referenceTo = (index, target, from) => {
213
+ const chain = chainOf(index, target);
214
+ const quote = (text) => /[.:]/.test(text) ? `"${text}"` : text;
215
+ const parents = chain.slice(0, -1).map((object) => object.paren !== undefined ? object.paren : `${object.kind}:${quote(object.name)}`);
216
+ const lasts = [`${target.kind}:${quote(target.name)}`, `${target.kind}:${quote(target.id)}`];
217
+ for (const last of lasts) {
218
+ const segments = [...parents, last];
219
+ for (let i = segments.length - 1; i >= 0; i--) {
220
+ const reference = segments.slice(i).join(".");
221
+ if (resolveUnique(index, reference, from).target === target)
222
+ return reference;
223
+ }
224
+ }
225
+ return [...parents, lasts[0]].join(".");
226
+ };
227
+ /* resolve the references of the own texts of an object (its name,
228
+ property values, description, rationale, and elaboration) into
229
+ their distinct targets, in order of occurrence, leniently skipping
230
+ the unresolvable ones, as the reference pass reports those */
231
+ export const referenceTargets = (index, object) => {
232
+ const texts = [object.name, ...object.properties.map((property) => property.value)];
233
+ if (object.description !== undefined)
234
+ texts.push(object.description.description, object.description.rationale ?? "", object.description.elaboration ?? "");
235
+ const targets = new Set();
236
+ for (const text of texts)
237
+ for (const m of referenceText(text).matchAll(referenceRegex)) {
238
+ const target = resolveUnique(index, m[1].trim(), object).target;
239
+ if (target !== undefined && target !== object)
240
+ targets.add(target);
241
+ }
242
+ return Array.from(targets);
243
+ };
189
244
  /* derive the fully-qualified anchor path of every object: the
190
245
  "<kind>-<id>" segments (whitespace in kinds dashed) of the ancestor
191
246
  chain joined with "." (the index lists parents before children) */
@@ -200,5 +255,5 @@ export const anchorPaths = (index) => {
200
255
  return paths;
201
256
  };
202
257
  /* expand all references of a text via a per-reference replacer
203
- (except inside its fenced code blocks) */
204
- export const expandReferences = (text, replace) => outsideFences(text, (part) => part.replace(referenceRegex, (_, reference) => replace(reference.trim())));
258
+ (except inside its fenced code blocks and inline code spans) */
259
+ export const expandReferences = (text, replace) => outsideCode(text, (part) => part.replace(referenceRegex, (_, reference) => replace(reference.trim())));
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import { z } from "zod";
11
- import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, projectFile, omitAspects, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, projectFile, omitAspects, fetchModes, version, renderNodes, queryFormats, parseQueryOutput } from "./specbook-api.js";
12
12
  /* render an error with its cause chain into a tool error result */
13
13
  const errorResult = (err) => {
14
14
  let msg = err instanceof Error ? err.message : String(err);
@@ -24,17 +24,26 @@ export const serveMcp = async (verbose) => {
24
24
  const server = new McpServer({ name: "specbook", version });
25
25
  const specbook = new SpecBook({ verbose });
26
26
  /* the input schema fields shared by the specification processing tools */
27
- const config = z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
28
- "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
27
+ const remote = "\"npm:<pkg>[@<range>][/<path>]\" or \"github:<user>/<repo>[/<path>][#<ref>]\"";
28
+ const config = z.array(z.string()).optional().describe("YAML schema configuration files, glob " +
29
+ `patterns, or remote references (${remote}, or an "http[s]://..." URL), merged in order ` +
30
+ "(\"std\" for the bundled standard schema configuration, \"meta\" for the bundled meta one, " +
31
+ "\"pm\" for the bundled project management one; " +
29
32
  "default: the SPECBOOK_CONFIG environment variable, else the \"config\" entry " +
30
33
  "of the project configuration file, else the bundled standard " +
31
34
  "schema configuration)");
32
35
  const basedir = z.string().optional().describe("base directory of the specification Markdown files " +
33
36
  "(default: the SPECBOOK_BASEDIR environment variable, else the \"basedir\" entry " +
34
37
  "of the project configuration file, else \".\")");
38
+ const fetch = z.enum(fetchModes).optional().describe("when to fetch the remote references through " +
39
+ "the network (\"never\": the download cache only, \"auto\": whenever the download cache lacks them " +
40
+ "or is no longer fresh, \"always\": ignoring the download cache; default: \"auto\")");
35
41
  const gitignore = z.boolean().optional().describe("skip the artifact files excluded by the Git " +
36
42
  "exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
37
43
  "file), treating such a file exactly like an absent one (default: false)");
44
+ const sample = z.boolean().optional().describe("scaffold a sample object per allowed object kind " +
45
+ "into the artifact files, with placeholder values satisfying the schema configuration " +
46
+ "(default: false)");
38
47
  /* the working directory of the client, which this server does not
39
48
  necessarily share: it anchors the relative paths and starts the
40
49
  upward search for the project configuration file */
@@ -46,10 +55,11 @@ export const serveMcp = async (verbose) => {
46
55
  server.registerTool("specbook_init", {
47
56
  title: "Initialize Specification",
48
57
  description: "Initialize the configured specification artifact files below the base directory " +
49
- "with their frontmatter and artifact heading, skipping already existing files, and " +
58
+ "with their frontmatter and artifact heading (or, with \"sample\", with a sample object per " +
59
+ "allowed object kind), skipping already existing files, and " +
50
60
  `create the project configuration file "${projectFile}" in the working directory, ` +
51
61
  "persisting the effective schema configuration and base directory, unless one already applies.",
52
- inputSchema: { config, basedir, cwd }
62
+ inputSchema: { config, basedir, fetch, sample, cwd }
53
63
  }, async (args) => {
54
64
  try {
55
65
  const result = await specbook.init(args);
@@ -65,9 +75,9 @@ export const serveMcp = async (verbose) => {
65
75
  });
66
76
  server.registerTool("specbook_lint", {
67
77
  title: "Lint Specification",
68
- description: "Lint the specification Markdown files the YAML schema configuration references " +
69
- "below the base directory against this configuration and return all diagnostics.",
70
- inputSchema: { config, basedir, gitignore, cwd }
78
+ description: "Lint all specification Markdown files below the base directory against the " +
79
+ "YAML schema configuration and return all diagnostics.",
80
+ inputSchema: { config, basedir, fetch, gitignore, cwd }
71
81
  }, async (args) => {
72
82
  try {
73
83
  const result = await specbook.lint(args);
@@ -82,20 +92,25 @@ export const serveMcp = async (verbose) => {
82
92
  });
83
93
  server.registerTool("specbook_export", {
84
94
  title: "Export Specification",
85
- description: "Export the specification Markdown files the YAML schema configuration references " +
86
- "below the base directory as JSON, JSON5, " +
95
+ description: "Export all specification Markdown files below the base directory (or, with " +
96
+ "\"schema\", the effective YAML schema configuration " +
97
+ "itself, described as a SpecBook document through the bundled meta schema configuration) " +
98
+ "as JSON, JSON5, " +
87
99
  "YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
88
100
  "if an output path is given, else it is returned directly (PDF as a base64-encoded resource). " +
89
101
  "The normalized Markdown embeds its images as \"data:\" URL definitions (so it is large), and " +
90
102
  "its remaining image references are re-based onto the directory of the " +
91
103
  "output file, while a directly returned result keeps them unchanged.",
92
- inputSchema: { config, basedir, gitignore, cwd,
104
+ inputSchema: { config, basedir, fetch, gitignore, cwd,
93
105
  format: z.enum(formats).optional().describe("output format (default: inferred from the " +
94
106
  "output file extension, else json)"),
95
107
  output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)"),
96
108
  omit: z.array(z.string()).optional().describe("content aspects to omit from the HTML, " +
97
109
  "the PDF, and (the diagrams) the AST output, instead of leaving them foldable: \"diagram\" " +
98
- `(all diagrams) or "${omitAspects.join("\", \"")}" (default: none)`)
110
+ `(all diagrams) or "${omitAspects.join("\", \"")}" (default: none)`),
111
+ schema: z.boolean().optional().describe("export the effective YAML schema configuration " +
112
+ "itself as a SpecBook document instead of the specification (the base directory is not " +
113
+ "applicable then; default: false)")
99
114
  }
100
115
  }, async (args) => {
101
116
  try {
@@ -110,8 +125,8 @@ export const serveMcp = async (verbose) => {
110
125
  const rebase = spec.format === "md" && spec.output !== undefined && spec.output !== "-" ?
111
126
  path.dirname(spec.output) : undefined;
112
127
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
113
- cwd: args.cwd, formats: [spec.format], rebase: [rebase], omit: args.omit,
114
- gitignore: args.gitignore });
128
+ cwd: args.cwd, fetch: args.fetch, formats: [spec.format], rebase: [rebase],
129
+ omit: args.omit, gitignore: args.gitignore, schema: args.schema });
115
130
  if (spec.output !== undefined && spec.output !== "-") {
116
131
  await fs.promises.writeFile(outputOf(args, spec.output), data);
117
132
  return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
@@ -128,6 +143,47 @@ export const serveMcp = async (verbose) => {
128
143
  return errorResult(err);
129
144
  }
130
145
  });
146
+ server.registerTool("specbook_query", {
147
+ title: "Query Specification",
148
+ description: "Load all specification Markdown files below the base directory (or, with " +
149
+ "\"schema\", the effective YAML schema configuration itself, described as a SpecBook document " +
150
+ "through the bundled meta schema configuration), strictly validated, and query its Abstract " +
151
+ "Syntax Tree (AST) with an XPath-like ASTq selector (like \"// USE-CASE [ @STATUS == 'Done' ]\"). " +
152
+ "The root node is of type \"specification\", its children are of type \"artifact\" (attributes " +
153
+ "\"created\", \"modified\", and \"file\"), and the objects below are typed by their kind (attributes " +
154
+ "\"kind\", \"id\", \"anchor\", \"paren\", \"name\", \"primary\", \"description\", \"rationale\", " +
155
+ "\"elaboration\", and one per property key), where the query function \"refs()\" yields the objects " +
156
+ "the references of an object resolve to. The matched nodes are rendered as a JSON (or JSON5, " +
157
+ "YAML, or TOON) array (their subtrees included, the embedded file contents dropped), written to " +
158
+ "the output file if an output path is given, else returned directly.",
159
+ inputSchema: { config, basedir, fetch, gitignore, cwd,
160
+ schema: z.boolean().optional().describe("query the effective YAML schema configuration " +
161
+ "itself as a SpecBook document instead of the specification (the base directory is not " +
162
+ "applicable then; default: false)"),
163
+ selector: z.string().describe("the ASTq selector of the nodes to return"),
164
+ params: z.record(z.string(), z.string()).optional().describe("the values of the \"{name}\" " +
165
+ "parameters of the selector (default: none)"),
166
+ format: z.enum(queryFormats).optional().describe("output format (default: inferred from the " +
167
+ "output file like for the export, else json)"),
168
+ output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)")
169
+ }
170
+ }, async (args) => {
171
+ try {
172
+ const { selector, params, format, output, ...options } = args;
173
+ const spec = parseQueryOutput(output ?? "-", format);
174
+ const document = await specbook.load(options);
175
+ const text = renderNodes(document.query(selector, params), spec.format);
176
+ if (spec.output !== "-") {
177
+ await fs.promises.writeFile(outputOf(args, spec.output), text);
178
+ return { content: [{ type: "text", text: `queried specification into "${spec.output}" ` +
179
+ `(${Buffer.byteLength(text)} bytes)` }] };
180
+ }
181
+ return { content: [{ type: "text", text }] };
182
+ }
183
+ catch (err) {
184
+ return errorResult(err);
185
+ }
186
+ });
131
187
  server.registerTool("specbook_describe", {
132
188
  title: "Describe Specification Format",
133
189
  description: "Describe the SpecBook models and formats as a Markdown document, which embeds the " +
@@ -136,20 +192,24 @@ export const serveMcp = async (verbose) => {
136
192
  "points to the artifacts of that particular project. The description can be reduced to a " +
137
193
  "single part and emitted as the raw original file content instead of Markdown.",
138
194
  inputSchema: {
139
- config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
140
- "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
195
+ config: z.array(z.string()).optional().describe("YAML schema configuration files, glob " +
196
+ `patterns, or remote references (${remote}, or an "http[s]://..." URL), merged in order ` +
197
+ "(\"std\" for the bundled standard schema configuration; " +
141
198
  "default: the SPECBOOK_CONFIG environment variable, else the \"config\" entry " +
142
199
  "of the project configuration file, else the bundled standard " +
143
200
  "schema configuration, embedded)"),
144
201
  basedir: z.string().optional().describe("base directory of the specification Markdown files " +
145
202
  "(default: the SPECBOOK_BASEDIR environment variable, else the \"basedir\" entry " +
146
203
  "of the project configuration file)"),
204
+ fetch,
147
205
  embed: z.boolean().optional().describe("embed the given YAML schema configuration instead " +
148
- "of just referencing it (default: false; the bundled standard one is always embedded)"),
206
+ "of just referencing it (default: false; the bundled standard one and a remote one are " +
207
+ "always embedded)"),
149
208
  compress: z.literal(compressLevels).optional().describe("compression level of the emitted " +
150
209
  "YAML schema configuration (embedded or raw): 0 for verbatim, 1 for re-emitted with 2-space " +
151
- "indentation and without comments, 2 for additionally without its \"refs\", \"diagram\", and " +
152
- "\"format\" fields, or 3 for additionally without its \"desc\" fields (default: 2)"),
210
+ "indentation and without comments, 2 for additionally without its \"refs\", \"badge\", " +
211
+ "\"multiple\", \"diagram\", and \"format\" fields, or 3 for additionally without its " +
212
+ "\"desc\" fields (default: 2)"),
153
213
  format: z.enum(describeFormats).optional().describe("output format: \"md\" for Markdown or " +
154
214
  "\"raw\" for the raw original file content of the part (default: \"md\")"),
155
215
  part: z.enum(describeParts).optional().describe("document part: \"all\" for the entire " +
@@ -173,5 +233,29 @@ export const serveMcp = async (verbose) => {
173
233
  return errorResult(err);
174
234
  }
175
235
  });
236
+ server.registerTool("specbook_search", {
237
+ title: "Search Schema Configurations",
238
+ description: "Search the bundled standard, meta, and project management schema configurations " +
239
+ "(references \"std\", \"meta\", and \"pm\"), the npm " +
240
+ "registry (the packages carrying the keyword \"specbook-schema\"), and GitHub (the repositories " +
241
+ "carrying the same topic) for the shareable schema configurations (reusable building blocks for " +
242
+ "the \"config\" option) matching a term, and return the hits as a JSON array of objects with the " +
243
+ "fields \"ref\" (the remote reference to use), \"name\", \"version\", \"description\", and " +
244
+ "\"source\" (\"builtin\", \"npm\", or \"github\").",
245
+ inputSchema: {
246
+ term: z.string().optional().describe("the words a matching schema configuration has to carry " +
247
+ "(default: none, matching all of them)"),
248
+ fetch,
249
+ cwd
250
+ }
251
+ }, async (args) => {
252
+ try {
253
+ const hits = await specbook.search(args);
254
+ return { content: [{ type: "text", text: JSON.stringify(hits, null, 4) }] };
255
+ }
256
+ catch (err) {
257
+ return errorResult(err);
258
+ }
259
+ });
176
260
  await server.connect(new StdioServerTransport());
177
261
  };
@@ -2,6 +2,7 @@ import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format
2
2
  import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
3
3
  import { type ParseContext } from "./specbook-parse-common.js";
4
4
  export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
5
+ export declare const isRecurring: (schema: SchemaObject) => boolean;
5
6
  export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
6
7
  export declare const collectParenProps: (schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, SpecProperty>;
7
8
  export declare const validate: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -3,7 +3,7 @@
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, chainOf, plainText, assignId } from "./specbook-link.js";
6
+ import { referenceRegex, resolveUnique, resolveSet, chainOf, plainText, referenceText, assignId } from "./specbook-link.js";
7
7
  import { compileValueExpr, splitItems, anchored } from "./specbook-parse-value.js";
8
8
  import { referencedCoverage } from "./specbook-coverage.js";
9
9
  /* the syntax of a value consisting of exactly one Wiki-style reference */
@@ -233,10 +233,17 @@ const validateObject = (ctx, object, schema, level) => {
233
233
  };
234
234
  /* resolve a level 1 object onto its configured artifact, deliberately
235
235
  leniently by kind and id first and by case-insensitive name second,
236
- so deviations of the heading can still be precisely reported */
236
+ so deviations of the heading can still be precisely reported, and
237
+ by kind alone third onto a recurring artifact (one configured with
238
+ neither name nor id), which every level 1 object of its kind matches */
237
239
  export const resolveArtifact = (config, object) => config.find((s) => (s.kind === object.kind && s.id === object.id) || (s.id !== undefined && `${s.kind}-${s.id}` === object.id)) ??
238
240
  config.find((s) => s.name !== undefined
239
- && s.name.toUpperCase() === plainText(object.name).toUpperCase());
241
+ && s.name.toUpperCase() === plainText(object.name).toUpperCase()) ??
242
+ config.find((s) => isRecurring(s) && s.kind === object.kind);
243
+ /* whether a configured artifact is a recurring one: configured with
244
+ neither name nor id, so any number of level 1 objects of its kind
245
+ match it (and none at all, as it is optional by definition) */
246
+ export const isRecurring = (schema) => schema.name === undefined && schema.id === undefined;
240
247
  /* map the specification objects onto their schema configuration
241
248
  nodes (the artifact resolution is shared with the validation) */
242
249
  export const collectSchemas = (specification, config) => {
@@ -498,7 +505,7 @@ export const validate = (ctx, specification, config) => {
498
505
  its carrying object), independent of any configuration */
499
506
  export const validateReferences = (ctx, specification) => {
500
507
  const check = (object, text, file, line) => {
501
- for (const m of plainText(text).matchAll(referenceRegex)) {
508
+ for (const m of referenceText(text).matchAll(referenceRegex)) {
502
509
  const ref = m[1].trim();
503
510
  const resolution = resolveUnique(ctx.linkIndex, ref, object);
504
511
  if (resolution.ambiguous)
@@ -1,3 +1,4 @@
1
1
  import { type SpecArtifact } from "./specbook-format-spec.js";
2
2
  import { type ParseContext, type SourceFile } from "./specbook-parse-common.js";
3
+ export declare const slugify: (text: string) => string;
3
4
  export declare const parseFile: (ctx: ParseContext, source: SourceFile) => SpecArtifact[];
@@ -87,7 +87,7 @@ const parseHeadingText = (raw) => {
87
87
  };
88
88
  };
89
89
  /* derive a stable slug identifier from an object name */
90
- const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
90
+ export const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
91
91
  /* extract the plain text lines of a Markdown list item (excluding any
92
92
  nested lists), whose body is a "text" token in a tight list, but a
93
93
  "paragraph" token in a loose list (and never "item.text", as this
@@ -2,7 +2,7 @@ import { Spec } from "./specbook-format-spec.js";
2
2
  import { type Schema } from "./specbook-format-schema.js";
3
3
  import { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
4
4
  export { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
5
- export { resolveArtifact, collectSchemas } from "./specbook-parse-semantic.js";
5
+ export { resolveArtifact, isRecurring, collectSchemas } from "./specbook-parse-semantic.js";
6
6
  export declare const parseSpecification: (sources: SourceFile[], config?: Schema) => ParseResult;
7
7
  export declare const specStatistics: (specification: Spec) => {
8
8
  objects: number;
@@ -5,12 +5,12 @@
5
5
  */
6
6
  import * as v from "valibot";
7
7
  import { Spec } from "./specbook-format-spec.js";
8
- import { buildLinkIndex, referenceRegex, plainText } from "./specbook-link.js";
8
+ import { buildLinkIndex, referenceRegex, referenceText } from "./specbook-link.js";
9
9
  import { ParseContext } from "./specbook-parse-common.js";
10
10
  import { parseFile } from "./specbook-parse-syntax.js";
11
11
  import { validate, validateReferences } from "./specbook-parse-semantic.js";
12
12
  import { validateDiagrams } from "./specbook-diagram.js";
13
- export { resolveArtifact, collectSchemas } from "./specbook-parse-semantic.js";
13
+ export { resolveArtifact, isRecurring, collectSchemas } from "./specbook-parse-semantic.js";
14
14
  /* parse a set of specification Markdown files into the specification
15
15
  AST and optionally validate the result against a configuration */
16
16
  export const parseSpecification = (sources, config) => {
@@ -54,7 +54,7 @@ export const specStatistics = (specification) => {
54
54
  if (object.description !== undefined)
55
55
  texts.push(object.description.description, object.description.rationale ?? "", object.description.elaboration ?? "");
56
56
  for (const text of texts)
57
- links += (plainText(text).match(referenceRegex) ?? []).length;
57
+ links += (referenceText(text).match(referenceRegex) ?? []).length;
58
58
  object.children.forEach(walk);
59
59
  };
60
60
  for (const artifact of specification.artifacts)
@@ -1,8 +1,10 @@
1
1
  export declare const projectFile = ".specbook.yaml";
2
+ export declare const isRemote: (value: string) => boolean;
2
3
  export interface Project {
3
4
  file: string;
4
5
  config?: string[];
5
6
  basedir?: string;
6
7
  }
7
8
  export declare const loadProject: (dir: string) => Project | undefined;
9
+ export declare const loadProjectFile: (file: string) => Project;
8
10
  export declare const writeProject: (dir: string, config: string[], basedir: string) => string;
@@ -11,6 +11,12 @@ import { lineColOfPath } from "./specbook-config.js";
11
11
  import { renderDiagnostic } from "./specbook-diagnostic.js";
12
12
  /* the name of the YAML project configuration file */
13
13
  export const projectFile = ".specbook.yaml";
14
+ /* whether a schema configuration pattern is a remote reference
15
+ (resolved by the remote module) instead of a local path: the literals
16
+ "std", "meta", and "pm" (the bundled standard, meta, and project
17
+ management schema configurations), an "npm:" or "github:" reference,
18
+ or an "http:" or "https:" URL */
19
+ export const isRemote = (value) => value === "std" || value === "meta" || value === "pm" || (/^(?:npm|github):|^https?:\/\//).test(value);
14
20
  /* the schema of the YAML project configuration: the files or glob
15
21
  patterns of the YAML schema configuration and the base directory
16
22
  of the specification Markdown files */
@@ -62,12 +68,16 @@ const readProject = (file) => {
62
68
  /* find, load, and validate the project configuration, failing on any
63
69
  problem, where its relative paths resolve against the directory of
64
70
  the file itself (and not against the perhaps deeper directory the
65
- search started at) and the literal "std" still names the bundled
66
- standard schema configuration */
71
+ search started at) */
67
72
  export const loadProject = (dir) => {
68
73
  const file = findProject(dir);
69
- if (file === undefined)
70
- return undefined;
74
+ return file !== undefined ? loadProjectFile(file) : undefined;
75
+ };
76
+ /* load and validate a particular project configuration file, failing
77
+ on any problem, where its relative paths resolve against the
78
+ directory of the file and the remote references (the literal "std"
79
+ included) stay as they are */
80
+ export const loadProjectFile = (file) => {
71
81
  /* parse the YAML, reporting its syntax errors as positioned diagnostics */
72
82
  const diagnostics = new Array();
73
83
  const lines = new LineCounter();
@@ -100,7 +110,7 @@ export const loadProject = (dir) => {
100
110
  const patterns = typeof config === "string" ? [config] : config;
101
111
  return {
102
112
  file,
103
- config: patterns?.map((pattern) => pattern === "std" ? pattern : path.resolve(base, pattern)),
113
+ config: patterns?.map((pattern) => isRemote(pattern) ? pattern : path.resolve(base, pattern)),
104
114
  basedir: basedir !== undefined ? path.resolve(base, basedir) : undefined
105
115
  };
106
116
  };
@@ -0,0 +1,28 @@
1
+ import { type ConfigOverlap } from "./specbook-config.js";
2
+ import { type Verbose } from "./specbook-verbose.js";
3
+ export declare const version: string;
4
+ export declare const standardConfig: string;
5
+ export declare const metaConfig: string;
6
+ export declare const pmConfig: string;
7
+ export declare const isBundled: (file: string) => boolean;
8
+ export declare const cacheDir: string;
9
+ export declare const isCached: (file: string) => boolean;
10
+ export declare const reportOverlaps: (overlaps: ConfigOverlap[], verbose: Verbose) => void;
11
+ export declare const fetchModes: readonly ["never", "auto", "always"];
12
+ export type FetchMode = typeof fetchModes[number];
13
+ export declare const parseFetchMode: (value: string) => FetchMode;
14
+ export interface RemoteOptions {
15
+ fetch: FetchMode;
16
+ cwd: string;
17
+ verbose: Verbose;
18
+ }
19
+ export declare const expandPattern: (pattern: string) => Promise<string[]>;
20
+ export declare const resolveConfig: (ref: string, options: RemoteOptions, chain?: string[]) => Promise<string[]>;
21
+ export interface SearchHit {
22
+ ref: string;
23
+ name: string;
24
+ version?: string;
25
+ description?: string;
26
+ source: "builtin" | "npm" | "github";
27
+ }
28
+ export declare const search: (term: string, options: RemoteOptions) => Promise<SearchHit[]>;