@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 +76 -7
- package/dst/specbook-api.d.ts +6 -0
- package/dst/specbook-api.js +24 -0
- package/dst/specbook-cli.js +29 -1
- package/dst/specbook-cmd-query.d.ts +30 -0
- package/dst/specbook-cmd-query.js +165 -0
- package/dst/specbook-mcp.js +42 -1
- package/package.json +2 -1
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*
|
|
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 `
|
|
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 `
|
|
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
|
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -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;
|
package/dst/specbook-api.js
CHANGED
|
@@ -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 */
|
package/dst/specbook-cli.js
CHANGED
|
@@ -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
|
+
}
|
package/dst/specbook-mcp.js
CHANGED
|
@@ -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.
|
|
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",
|