@rse/specbook 1.6.0 → 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.
package/README.md CHANGED
@@ -25,7 +25,8 @@ format* which can be configured for the specifications of particular
25
25
  contexts through a YAML-based *schema configuration*. SpecBook allows
26
26
  specifications to be *initialized*, *linted*, *exported* (JSON, JSON5,
27
27
  YAML, TOON, HTML, PDF, and normalized Markdown), *previewed* (HTML, live
28
- in the browser), and *described* to LLMs.
28
+ in the browser), *queried* (ASTq selectors on the AST), and *described*
29
+ to LLMs.
29
30
 
30
31
  **SpecBook** ships with an API class with methods `<xxx>()`, a
31
32
  CLI with commands `specbook <xxx>`, and an MCP service with tools
@@ -168,10 +169,9 @@ $ specbook export \
168
169
  [-F|--fetch <mode>] \
169
170
  [-g|--gitignore] \
170
171
  [-S|--schema] \
171
- [-o|--output [<format>:]<output-file>] \
172
+ [-o|--output [<format>:]<output-file>] [...] \
172
173
  [-w|--watch] \
173
- [-O|--omit <aspect>[,...]] \
174
- [...]
174
+ [-O|--omit <aspect>[,...]]
175
175
 
176
176
  $ specbook preview \
177
177
  [-v|--verbose [<level>]] \
@@ -184,6 +184,18 @@ $ specbook preview \
184
184
  [-a|--addr <ip-addr>] \
185
185
  [-p|--port <tcp-port>]
186
186
 
187
+ $ specbook query \
188
+ [-v|--verbose [<level>]] \
189
+ [-c|--config <schema-yaml-file>] \
190
+ [-b|--basedir <spec-md-file-basedir>] \
191
+ [-F|--fetch <mode>] \
192
+ [-g|--gitignore] \
193
+ [-S|--schema] \
194
+ [-P|--param <name>=<value>] [...] \
195
+ [-f|--format <format>] \
196
+ [-o|--output [<format>:]<output-file>] \
197
+ <selector>
198
+
187
199
  $ specbook describe \
188
200
  [-v|--verbose [<level>]] \
189
201
  [-c|--config <schema-yaml-file>] \
@@ -266,7 +278,7 @@ Options:
266
278
  whenever the download cache lacks a reference or it is no longer
267
279
  fresh), or `always` (ignoring the download cache).
268
280
 
269
- - `-g|--gitignore` (`lint`, `export`, and `preview` only):
281
+ - `-g|--gitignore` (`lint`, `export`, `preview`, and `query` only):
270
282
  Set aside every specification file the Git exclude rules (the global
271
283
  excludes file, the repository-local `info/exclude`, and the
272
284
  `.gitignore` files, with Git's own precedence) exclude from its
@@ -274,8 +286,8 @@ Options:
274
286
  artifacts are optional, else as the error `artifact file excluded by
275
287
  Git`. A base directory outside any Git working tree excludes nothing.
276
288
 
277
- - `-S|--schema` (`export` and `preview` only):
278
- Export (or preview) the *effective schema configuration itself* --
289
+ - `-S|--schema` (`export`, `preview`, and `query` only):
290
+ Export (or preview, or query) the *effective schema configuration itself* --
279
291
  the one `-c|--config` selects, merged out of its local, bundled, or
280
292
  remote files -- as a SpecBook document, instead of the specification
281
293
  below the base directory (which is not applicable then, so an
@@ -357,6 +369,28 @@ Options:
357
369
  the connection state: its plug icon carries the search highlight
358
370
  color while disconnected and blinks for 2s after every update.
359
371
 
372
+ - `-P|--param <name>=<value>`, `<selector>` (`query` only):
373
+ Query the specification (strictly validated like `export`) with the
374
+ XPath-like selector of [ASTq](https://github.com/rse/astq) and write
375
+ the matched nodes as an array (their subtrees included, the embedded
376
+ file contents dropped), where `-P|--param` (repeatable) gives the
377
+ value of a `{name}` parameter of the selector.
378
+ The root node is of type `specification`, its children are of type
379
+ `artifact` (attributes `created`, `modified`, and `file`), and the
380
+ objects below are typed by their kind (attributes `kind`, `id`,
381
+ `anchor`, `paren`, `name`, `primary`, `description`, `rationale`,
382
+ `elaboration`, and one per property key), e.g.
383
+ `specbook query "// USE-CASE [ @ACTOR == '[[ROLE:attendee]]' ]"`. The
384
+ query function `refs()` yields the objects the references of an object
385
+ resolve to, e.g. `specbook query "// REQUIREMENT [ count(refs()) == 0 ]"`.
386
+
387
+ - `-f|--format <format>`, `-o|--output [<format>:]<output-file>` (`query` only):
388
+ The output file (default: `-` for stdout) receives the matched nodes
389
+ in the format `json`, `json5`, `yaml`, or `toon`, where an explicit
390
+ `-f|--format` wins and the format is otherwise inferred exactly like
391
+ for an `export` output (a `<format>:` prefix, else the filename
392
+ extension, while plain `-` defaults to JSON).
393
+
360
394
  - `-o|--output <output-file>` (`describe` only):
361
395
  The output file (default: `-` for stdout) receives the described
362
396
  Markdown document.
@@ -441,6 +475,41 @@ Chrome* (the equivalent of `chrome`). An explicitly configured browser
441
475
  failing to launch fails the export instead of falling back onto another
442
476
  browser.
443
477
 
478
+ ### API
479
+
480
+ Third-party scripts generating foreign documents out of a specification
481
+ load it (strictly validated like `export`) and query its AST through the
482
+ very same ASTq selectors as `specbook query`, retrieving the *original*
483
+ AST nodes:
484
+
485
+ ```ts
486
+ import { SpecBook, type SpecObject } from "@rse/specbook"
487
+
488
+ const specbook = new SpecBook()
489
+ const document = await specbook.load({ basedir: "docs/spec" })
490
+
491
+ /* register a custom query function (beside the built-in "refs()") */
492
+ document.func("contains", (_adapter, _node, list, item) =>
493
+ Array.isArray(list) && list.includes(item))
494
+
495
+ /* emit the roles with the use cases referencing them as Markdown */
496
+ for (const role of document.query("// ROLE") as SpecObject[]) {
497
+ console.log(`## ${document.plain(role.name)}\n`)
498
+ for (const useCase of document.query("// USE-CASE [ contains(refs(), {role}) ]",
499
+ { role }) as SpecObject[])
500
+ console.log(`- ${document.plain(useCase.name)}`)
501
+ }
502
+ ```
503
+
504
+ The `SpecBookDocument` returned by `load()` (options like `lint`, plus
505
+ `schema`) carries the `specification`, its `config`, and the warning
506
+ `diagnostics`, and provides `query(selector, params?)`, `parent(node)`
507
+ (the AST nodes carrying no parent references), `func(name, fn)`, and the
508
+ text helpers `plain(text, from?)` and `html(text, from?)`, which render
509
+ a Markdown text (a description part or property value) into plain text
510
+ resp. an HTML fragment, replacing every `[[...]]` reference by the name
511
+ of its target object.
512
+
444
513
  Remote References
445
514
  -----------------
446
515
 
@@ -3,6 +3,7 @@ import { version, standardConfig, metaConfig, pmConfig, cacheDir, fetchModes, pa
3
3
  import { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic, type DiagnosticSeverity } from "./specbook-diagnostic.js";
4
4
  import { type LintResult } from "./specbook-cmd-lint.js";
5
5
  import { parseOutputSpec, formats, omitAspects, type ExportFormat } from "./specbook-cmd-export.js";
6
+ import { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput, type SpecNode, type SpecQueryFunc, type SpecQueryAdapter, type QueryFormat } from "./specbook-cmd-query.js";
6
7
  import { previewAddr, previewPort } from "./specbook-cmd-preview.js";
7
8
  import { describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel, type DescribeFormat, type DescribePart, type CompressLevel } from "./specbook-cmd-describe.js";
8
9
  import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity, type Verbose, type VerboseLevel, type Verbosity } from "./specbook-verbose.js";
@@ -16,6 +17,7 @@ export { compressLevels, parseCompressLevel };
16
17
  export { type DescribeFormat, type DescribePart, type CompressLevel };
17
18
  export { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic, type DiagnosticSeverity };
18
19
  export { type LintResult };
20
+ export { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput, type SpecNode, type SpecQueryFunc, type SpecQueryAdapter, type QueryFormat };
19
21
  export { version, standardConfig, metaConfig, pmConfig, cacheDir, fetchModes, parseFetchMode, type FetchMode, type SearchHit };
20
22
  export type { Spec, SpecArtifact, SpecObject, SpecDescription, SpecProperty } from "./specbook-format-spec.js";
21
23
  export type { Schema, SchemaObject, SchemaProperty } from "./specbook-format-schema.js";
@@ -64,6 +66,10 @@ export declare class SpecBook {
64
66
  lint(options: ProjectOptions & {
65
67
  gitignore?: boolean;
66
68
  }): Promise<LintResult>;
69
+ load(options: ProjectOptions & {
70
+ gitignore?: boolean;
71
+ schema?: boolean;
72
+ }): Promise<SpecBookDocument>;
67
73
  search(options: SearchRequest): Promise<SearchHit[]>;
68
74
  private renderFormats;
69
75
  private rebaseOf;
@@ -13,6 +13,7 @@ import { lint } from "./specbook-cmd-lint.js";
13
13
  import { isBelow } from "./specbook-files.js";
14
14
  import { exportSpecification, watchSpecification, parseOutputSpec, formats, requireBrowser, parseOmit, omitAspects } from "./specbook-cmd-export.js";
15
15
  import { lintSchema } from "./specbook-cmd-export-schema.js";
16
+ import { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput } from "./specbook-cmd-query.js";
16
17
  import { servePreview, previewAddr, previewPort } from "./specbook-cmd-preview.js";
17
18
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, compressLevels, parseCompressLevel } from "./specbook-cmd-describe.js";
18
19
  import { literal, renderVerbose, verbosities, verbosityOf, parseVerbosity } from "./specbook-verbose.js";
@@ -24,6 +25,7 @@ export { projectFile };
24
25
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
25
26
  export { compressLevels, parseCompressLevel };
26
27
  export { renderDiagnostic, renderDiagnosticVerbose };
28
+ export { SpecBookDocument, renderNodes, queryFormats, parseQueryOutput };
27
29
  export { version, standardConfig, metaConfig, pmConfig, cacheDir, fetchModes, parseFetchMode };
28
30
  /* the SpecBook API */
29
31
  export class SpecBook {
@@ -138,6 +140,28 @@ export class SpecBook {
138
140
  return lint({ config: await this.configFiles(project.config, this.remoteOptions(options, verbose)),
139
141
  basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
140
142
  }
143
+ /* load the specification Markdown files below the base directory (or,
144
+ under "schema", the effective schema configuration itself, described
145
+ as a specification of the bundled meta schema configuration) as a
146
+ document queryable through ASTq selectors, strictly like "export":
147
+ any error diagnostic prevents the loading, as a partial or invalid
148
+ specification must never be consumed, while the warnings are just
149
+ surfaced as unconditional verbose messages */
150
+ async load(options) {
151
+ const verbose = this.verboseOf("load");
152
+ if (options.schema === true && options.basedir !== undefined)
153
+ throw new Error("the base directory is not applicable to the export of the schema configuration");
154
+ const project = this.project(options, verbose);
155
+ const labels = new Map();
156
+ const config = await this.configFiles(project.config, this.remoteOptions(options, verbose), labels);
157
+ const result = this.parseSource(options, config, labels, project.basedir ?? ".", verbose);
158
+ if (result.config === undefined || result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
159
+ throw new Error("invalid specification:\n" +
160
+ result.diagnostics.map(renderDiagnostic).join("\n"));
161
+ for (const diagnostic of result.diagnostics)
162
+ verbose(renderDiagnostic(diagnostic), "none");
163
+ return new SpecBookDocument(result.specification, result.config, result.diagnostics, result.origins);
164
+ }
141
165
  /* search the bundled schema configurations, the npm
142
166
  registry, and GitHub for the shareable schema configurations
143
167
  matching a term */
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { Command, CommanderError } from "commander";
10
10
  import chalk from "chalk";
11
- import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, projectFile, omitAspects, parseFetchMode, version } from "./specbook-api.js";
11
+ import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, renderVerbose, literal, parseOutputSpec, previewAddr, previewPort, describeFormats, describeParts, parseDescribeFormat, parseDescribePart, parseCompressLevel, parseVerbosity, verbosityOf, projectFile, omitAspects, parseFetchMode, version, renderNodes, queryFormats, parseQueryOutput } from "./specbook-api.js";
12
12
  import { serveMcp } from "./specbook-mcp.js";
13
13
  /* route verbose messages to stderr, keeping stdout reserved for the
14
14
  command outputs and the MCP protocol, and qualify every message with
@@ -239,6 +239,34 @@ withSchemaOption(withOmitOption(withGitignoreOption(withCommonOptions(program.co
239
239
  await specbook.preview({ config: configOf(opts), basedir: opts.basedir, fetch: fetchOf(opts),
240
240
  addr: opts.addr, port, omit: omitOf(opts), gitignore: opts.gitignore, schema: opts.schema });
241
241
  });
242
+ /* the query command loads the specification and writes the
243
+ nodes an ASTq selector matches as JSON, JSON5, YAML, or TOON */
244
+ withSchemaOption(withGitignoreOption(withCommonOptions(program.command("query"))))
245
+ .description("query the specification Markdown files below the base directory " +
246
+ "(or the schema configuration itself) with an ASTq selector, writing the matched nodes " +
247
+ "as JSON, JSON5, YAML, or TOON")
248
+ .argument("<selector>", "the ASTq selector of the nodes to write")
249
+ .option("-P, --param <name>=<value>", "value of a \"{name}\" parameter of the selector (repeatable)", (value, previous) => previous.concat(value), new Array())
250
+ .option("-f, --format <format>", `output format (${queryFormats.join(", ")}; ` +
251
+ "default: inferred from the output file like for \"export\", else json)", envDefault("format"))
252
+ .option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
253
+ .action(async (selector, opts) => {
254
+ const verbose = verboseOf(opts);
255
+ const output = parseQueryOutput(opts.output, opts.format);
256
+ const fallback = envDefault("param");
257
+ const given = opts.param.length > 0 ? opts.param : (fallback !== undefined ? [fallback] : []);
258
+ const params = {};
259
+ for (const param of given) {
260
+ const m = (/^([^=]+)=(.*)$/s).exec(param);
261
+ if (m === null)
262
+ throw new Error(`invalid query parameter "${param}" (expected "<name>=<value>")`);
263
+ params[m[1]] = m[2];
264
+ }
265
+ const specbook = new SpecBook({ verbose });
266
+ const document = await specbook.load({ config: configOf(opts), basedir: opts.basedir,
267
+ fetch: fetchOf(opts), gitignore: opts.gitignore, schema: opts.schema });
268
+ await writeOutput(output.output, renderNodes(document.query(selector, params), output.format), "query", verbose);
269
+ });
242
270
  /* the describe command also describes the generic SpecBook models and
243
271
  formats alone, so its YAML schema configuration stays optional */
244
272
  withFetchOption(withBasedirOption(withConfigOption(withVerboseOption(program.command("describe")), "the bundled standard schema configuration, embedded"), "none"))
@@ -0,0 +1,30 @@
1
+ import ASTQ from "astq";
2
+ import type { Spec, SpecArtifact, SpecObject } from "./specbook-format-spec.js";
3
+ import type { Schema } from "./specbook-format-schema.js";
4
+ import type { Diagnostic } from "./specbook-diagnostic.js";
5
+ export type SpecNode = Spec | SpecArtifact | SpecObject;
6
+ export type SpecQueryAdapter = ASTQ.Adapter<SpecNode>;
7
+ export type SpecQueryFunc = (adapter: SpecQueryAdapter, node: SpecNode, ...args: unknown[]) => unknown;
8
+ export declare const queryFormats: readonly ["json", "json5", "yaml", "toon"];
9
+ export type QueryFormat = typeof queryFormats[number];
10
+ export declare const parseQueryOutput: (output: string, format?: string) => {
11
+ format: QueryFormat;
12
+ output: string;
13
+ };
14
+ export declare const renderNodes: (nodes: SpecNode[], format?: QueryFormat) => string;
15
+ export declare class SpecBookDocument {
16
+ readonly specification: Spec;
17
+ readonly config: Schema;
18
+ readonly diagnostics: Diagnostic[];
19
+ private astq;
20
+ private parents;
21
+ private attrs;
22
+ private index;
23
+ constructor(specification: Spec, config: Schema, diagnostics: Diagnostic[], origins: Map<SpecArtifact, string>);
24
+ query(selector: string, params?: Record<string, unknown>): SpecNode[];
25
+ parent(node: SpecNode): SpecNode | undefined;
26
+ func(name: string, fn: SpecQueryFunc): this;
27
+ private expand;
28
+ plain(text: string, from?: SpecObject): string;
29
+ html(text: string, from?: SpecObject): string;
30
+ }
@@ -0,0 +1,165 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import ASTQ from "astq";
7
+ import { Marked } from "marked";
8
+ import JSON5 from "json5";
9
+ import { stringify as stringifyYaml } from "yaml";
10
+ import { encode as encodeToon } from "@toon-format/toon";
11
+ import { buildLinkIndex, referenceTargets, resolveUnique, expandReferences } from "./specbook-link.js";
12
+ import { parseOutputSpec } from "./specbook-cmd-export.js";
13
+ /* the fixed attributes of an object node, which win over
14
+ the same-named property keys */
15
+ const objectFields = ["kind", "id", "anchor", "paren", "name", "primary",
16
+ "description", "rationale", "elaboration"];
17
+ /* the private Markdown renderer of the text helpers, as the
18
+ "marked" singleton is shared with the application */
19
+ const marked = new Marked();
20
+ /* render Markdown tokens into plain text: the blocks joined by the
21
+ separator, the inline markup dropped, and the code kept verbatim */
22
+ const plainOf = (tokens, separator) => tokens.map((generic) => {
23
+ const token = generic;
24
+ if (token.type === "space" || token.type === "hr" || token.type === "html" || token.type === "def")
25
+ return "";
26
+ else if (token.type === "code" || token.type === "codespan")
27
+ return token.text;
28
+ else if (token.type === "br")
29
+ return "\n";
30
+ else if (token.type === "blockquote")
31
+ return plainOf(token.tokens, "\n\n");
32
+ else if (token.type === "list")
33
+ return token.items.map((item) => plainOf(item.tokens, "\n")).join("\n");
34
+ else if (token.type === "table")
35
+ return [token.header, ...token.rows].map((row) => row.map((cell) => plainOf(cell.tokens, "")).join(" | ")).join("\n");
36
+ else if ("tokens" in token && token.tokens !== undefined)
37
+ return plainOf(token.tokens, "");
38
+ else
39
+ return "text" in token ? token.text : "";
40
+ }).filter((part) => part !== "").join(separator);
41
+ /* the serialization formats of the query results (the AST export ones) */
42
+ export const queryFormats = ["json", "json5", "yaml", "toon"];
43
+ /* determine the format and file of a query output: an explicit format
44
+ wins, else it is inferred exactly like the one of an export output
45
+ (a "<format>:" prefix, else the filename extension, "-" being JSON),
46
+ where only the serialization formats apply */
47
+ export const parseQueryOutput = (output, format) => {
48
+ const spec = format !== undefined ? { format, output } : parseOutputSpec(output);
49
+ if (!queryFormats.includes(spec.format))
50
+ throw new Error(`unsupported query format "${spec.format}" (supported: ${queryFormats.join(", ")})`);
51
+ return { format: spec.format, output: spec.output };
52
+ };
53
+ /* render query result nodes in a serialization format: their subtrees
54
+ included, the embedded file contents dropped (the bulk no consumer
55
+ can use), and the dates as ISO strings */
56
+ export const renderNodes = (nodes, format = "json") => {
57
+ const plain = JSON.parse(JSON.stringify(nodes, (key, value) => key === "embedding" ? undefined : value));
58
+ if (format === "json")
59
+ return JSON.stringify(plain, null, 4) + "\n";
60
+ else if (format === "json5")
61
+ return JSON5.stringify(plain, null, 4) + "\n";
62
+ else if (format === "yaml")
63
+ return stringifyYaml(plain);
64
+ else
65
+ return encodeToon(plain) + "\n";
66
+ };
67
+ /* a loaded specification, queryable through the XPath-like ASTq
68
+ selectors: the root node (type "specification") holds the artifacts
69
+ (type "artifact"), which hold the object trees (typed by their kind,
70
+ whitespace dashed), and the pre-registered function "refs()" yields
71
+ the objects the references of the current object resolve to */
72
+ export class SpecBookDocument {
73
+ specification;
74
+ config;
75
+ diagnostics;
76
+ astq = new ASTQ();
77
+ parents = new WeakMap();
78
+ attrs = new WeakMap();
79
+ index;
80
+ constructor(specification, config, diagnostics, origins) {
81
+ this.specification = specification;
82
+ this.config = config;
83
+ this.diagnostics = diagnostics;
84
+ this.index = buildLinkIndex(specification);
85
+ /* index the parents once, as the objects carry no parent reference */
86
+ const walk = (object, parent) => {
87
+ this.parents.set(object, parent);
88
+ for (const child of object.children)
89
+ walk(child, object);
90
+ };
91
+ for (const artifact of specification.artifacts) {
92
+ this.parents.set(artifact, specification);
93
+ for (const object of artifact.objects)
94
+ walk(object, artifact);
95
+ }
96
+ /* determine the attributes of a node once: the timestamps and
97
+ source file of an artifact, and the fixed fields of an object
98
+ followed by its properties (the first of duplicate keys winning) */
99
+ const attributesOf = (node) => {
100
+ let attrs = this.attrs.get(node);
101
+ if (attrs === undefined) {
102
+ attrs = new Map();
103
+ if ("objects" in node) {
104
+ attrs.set("created", node.created.toISOString());
105
+ attrs.set("modified", node.modified.toISOString());
106
+ attrs.set("file", origins.get(node));
107
+ }
108
+ else if ("kind" in node) {
109
+ const fields = [node.kind, node.id, node.anchor, node.paren, node.name, node.primary,
110
+ node.description?.description, node.description?.rationale, node.description?.elaboration];
111
+ for (const [i, value] of fields.entries())
112
+ if (value !== undefined)
113
+ attrs.set(objectFields[i], value);
114
+ for (const property of node.properties)
115
+ if (!objectFields.includes(property.key) && !attrs.has(property.key))
116
+ attrs.set(property.key, property.value);
117
+ }
118
+ this.attrs.set(node, attrs);
119
+ }
120
+ return attrs;
121
+ };
122
+ /* access the specification tree exclusively, as the plain AST
123
+ objects would otherwise be tasted by the built-in JSON adapter */
124
+ const adapter = {
125
+ taste: (node) => node === specification || this.parents.has(node),
126
+ getParentNode: (node) => this.parents.get(node) ?? null,
127
+ getChildNodes: (node) => "artifacts" in node ? node.artifacts :
128
+ "objects" in node ? node.objects : node.children,
129
+ getNodeType: (node) => "artifacts" in node ? "specification" :
130
+ "objects" in node ? "artifact" : node.kind.replace(/\s+/g, "-"),
131
+ getNodeAttrNames: (node) => Array.from(attributesOf(node).keys()),
132
+ getNodeAttrValue: (node, attr) => attributesOf(node).get(attr)
133
+ };
134
+ this.astq.adapter(adapter, true);
135
+ this.astq.func("refs", (_adapter, node) => "kind" in node ? referenceTargets(this.index, node) : []);
136
+ }
137
+ /* query the nodes an ASTq selector matches, starting at the root,
138
+ with the values of the "{name}" parameters of the selector */
139
+ query(selector, params) {
140
+ return this.astq.query(this.specification, selector, params);
141
+ }
142
+ /* the parent of a node (undefined for the root) */
143
+ parent(node) {
144
+ return this.parents.get(node);
145
+ }
146
+ /* register a custom query function (or replace a registered one) */
147
+ func(name, fn) {
148
+ this.astq.func(name, fn);
149
+ return this;
150
+ }
151
+ /* replace the references of a Markdown text (outside its code) by
152
+ the names of their targets, resolved nearest to the referencing
153
+ object (if given), an unresolvable one keeping its reference text */
154
+ expand(text, from) {
155
+ return expandReferences(text, (reference) => resolveUnique(this.index, reference, from).target?.name ?? reference);
156
+ }
157
+ /* render a Markdown text (a description part or property value) into plain text */
158
+ plain(text, from) {
159
+ return plainOf(marked.lexer(this.expand(text, from)), "\n\n");
160
+ }
161
+ /* render a Markdown text (a description part or property value) into an HTML fragment */
162
+ html(text, from) {
163
+ return marked.parse(this.expand(text, from), { async: false });
164
+ }
165
+ }
@@ -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, fetchModes, 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);
@@ -143,6 +143,47 @@ export const serveMcp = async (verbose) => {
143
143
  return errorResult(err);
144
144
  }
145
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
+ });
146
187
  server.registerTool("specbook_describe", {
147
188
  title: "Describe Specification Format",
148
189
  description: "Describe the SpecBook models and formats as a Markdown document, which embeds the " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",
@@ -39,6 +39,7 @@
39
39
  "textframe": "1.2.1",
40
40
  "@swc/html": "1.16.21",
41
41
  "tokenizr": "2.2.1",
42
+ "astq": "2.8.3",
42
43
  "randexp": "0.6.1",
43
44
  "wrap-ansi": "10.0.2",
44
45
  "playwright": "1.64.1",