@mrfentmen/openedge-abl-mcp 1.0.0 → 1.0.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 +18 -23
- package/dist/core.d.ts +2 -2
- package/dist/core.js +214 -573
- package/dist/format.d.ts +2 -0
- package/dist/format.js +105 -0
- package/dist/index.js +2 -2
- package/dist/run.d.ts +4 -15
- package/dist/run.js +8 -49
- package/dist/server.d.ts +1 -0
- package/dist/server.js +5 -4
- package/dist/types.d.ts +22 -38
- package/package.json +7 -5
- package/server.json +4 -4
- package/dist/config.d.ts +0 -2
- package/dist/config.js +0 -47
package/dist/core.js
CHANGED
|
@@ -1,301 +1,146 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { errorMessage, fail,
|
|
5
|
-
const MAX_INPUT =
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
15
|
-
}
|
|
16
|
-
function escapeRegExp(value) {
|
|
17
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18
|
-
}
|
|
19
|
-
async function fileExists(file) {
|
|
20
|
-
try {
|
|
21
|
-
await fs.stat(file);
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return false;
|
|
4
|
+
import { errorMessage, fail, text, ToolError } from "./run.js";
|
|
5
|
+
const MAX_INPUT = 4_000_000;
|
|
6
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".mcp-build"]);
|
|
7
|
+
async function readInput(input) {
|
|
8
|
+
if (input.path && input.content !== undefined)
|
|
9
|
+
throw new ToolError("Use path or content, not both");
|
|
10
|
+
if (input.content !== undefined) {
|
|
11
|
+
if (input.content.length > MAX_INPUT)
|
|
12
|
+
throw new ToolError(`Input is larger than ${MAX_INPUT} characters`);
|
|
13
|
+
return { body: input.content };
|
|
26
14
|
}
|
|
27
|
-
|
|
28
|
-
|
|
15
|
+
if (!input.path)
|
|
16
|
+
throw new ToolError("Provide a path or content");
|
|
17
|
+
if (input.path.includes("\0"))
|
|
18
|
+
throw new ToolError("Path contains a null byte");
|
|
19
|
+
const file = path.isAbsolute(input.path) ? input.path : path.resolve(input.cwd ? path.resolve(input.cwd) : process.cwd(), input.path);
|
|
29
20
|
const stat = await fs.stat(file);
|
|
30
21
|
if (!stat.isFile())
|
|
31
22
|
throw new ToolError(`Not a file: ${file}`);
|
|
32
23
|
if (stat.size > MAX_INPUT)
|
|
33
24
|
throw new ToolError(`File is larger than ${MAX_INPUT} bytes`);
|
|
34
|
-
return fs.readFile(file, "utf8");
|
|
35
|
-
}
|
|
36
|
-
async function loadSource(input) {
|
|
37
|
-
const cwd = resolveDir(input.cwd);
|
|
38
|
-
if (input.path && input.content !== undefined) {
|
|
39
|
-
throw new ToolError("Use path or content, not both");
|
|
40
|
-
}
|
|
41
|
-
if (input.content !== undefined) {
|
|
42
|
-
return { content: input.content, cwd };
|
|
43
|
-
}
|
|
44
|
-
if (!input.path)
|
|
45
|
-
throw new ToolError("Provide a source path or content");
|
|
46
|
-
const file = resolveFile(input.path, cwd);
|
|
47
|
-
return { content: await readFileSafe(file), file, cwd: path.dirname(file) };
|
|
48
|
-
}
|
|
49
|
-
function commandContext(file, cwd, config) {
|
|
50
|
-
const ext = path.extname(file);
|
|
51
|
-
return {
|
|
52
|
-
file,
|
|
53
|
-
dir: cwd,
|
|
54
|
-
build: path.join(cwd, config.buildDir ?? ".mcp-build"),
|
|
55
|
-
stem: path.basename(file, ext),
|
|
56
|
-
ext,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
function expandCommand(spec, ctx) {
|
|
60
|
-
const values = {
|
|
61
|
-
file: ctx.file,
|
|
62
|
-
dir: ctx.dir,
|
|
63
|
-
build: ctx.build,
|
|
64
|
-
stem: ctx.stem,
|
|
65
|
-
ext: ctx.ext,
|
|
66
|
-
};
|
|
67
|
-
const replace = (value) => value.replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
|
|
68
|
-
return {
|
|
69
|
-
command: replace(spec.command),
|
|
70
|
-
args: spec.args.map(replace),
|
|
71
|
-
timeoutMs: spec.timeoutMs,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
async function commandAvailable(command) {
|
|
75
|
-
const result = await run("which", [command], { timeoutMs: 5_000 });
|
|
76
|
-
return result.code === 0;
|
|
25
|
+
return { body: await fs.readFile(file, "utf8"), file };
|
|
77
26
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
timeoutMs: step.timeoutMs ?? timeoutMs,
|
|
85
|
-
});
|
|
86
|
-
output += result.stdout;
|
|
87
|
-
if (result.stderr.trim().length > 0) {
|
|
88
|
-
if (output.length > 0 && !output.endsWith("\n"))
|
|
89
|
-
output += "\n";
|
|
90
|
-
output += result.stderr;
|
|
91
|
-
}
|
|
92
|
-
if (result.code !== 0) {
|
|
93
|
-
throw new ToolError((result.stderr || result.stdout || `${expanded.command} exited with code ${result.code}`).trim());
|
|
94
|
-
}
|
|
27
|
+
function parseOrThrow(format, body) {
|
|
28
|
+
const parsed = format.parse(body);
|
|
29
|
+
const errors = parsed.issues.filter((issue) => issue.severity === "error");
|
|
30
|
+
if (errors.length > 0) {
|
|
31
|
+
const first = errors.slice(0, 5).map((issue) => (issue.line ? `line ${issue.line}: ${issue.message}` : issue.message));
|
|
32
|
+
throw new ToolError(`${format.displayName} could not be parsed:\n${first.join("\n")}`);
|
|
95
33
|
}
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
async function pickWorkflow(steps) {
|
|
99
|
-
if (steps.length === 0)
|
|
100
|
-
throw new ToolError("No command is configured");
|
|
101
|
-
if (await commandAvailable(steps[0].command))
|
|
102
|
-
return steps;
|
|
103
|
-
throw new ToolError(`${steps[0].command} is not installed or not on PATH. Install one of: ${[
|
|
104
|
-
...new Set(steps.map((step) => step.command)),
|
|
105
|
-
].join(", ")}`);
|
|
34
|
+
return parsed;
|
|
106
35
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
let inString = null;
|
|
117
|
-
let stringLine = 0;
|
|
118
|
-
let escaped = false;
|
|
119
|
-
let line = 1;
|
|
120
|
-
for (let index = 0; index < content.length; index += 1) {
|
|
121
|
-
const char = content[index];
|
|
122
|
-
const rest = content.slice(index);
|
|
123
|
-
if (inString) {
|
|
124
|
-
if (escaped) {
|
|
125
|
-
escaped = false;
|
|
126
|
-
}
|
|
127
|
-
else if (char === "\\") {
|
|
128
|
-
escaped = true;
|
|
129
|
-
}
|
|
130
|
-
else if (char === inString) {
|
|
131
|
-
inString = null;
|
|
132
|
-
}
|
|
133
|
-
if (char === "\n")
|
|
134
|
-
line += 1;
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
if (config.blockComment && rest.startsWith(config.blockComment.start)) {
|
|
138
|
-
const end = content.indexOf(config.blockComment.end, index + config.blockComment.start.length);
|
|
139
|
-
if (end === -1) {
|
|
140
|
-
issues.push(`Line ${line}: unclosed block comment`);
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
const skipped = content.slice(index, end + config.blockComment.end.length);
|
|
144
|
-
line += (skipped.match(/\n/g) ?? []).length;
|
|
145
|
-
index = end + config.blockComment.end.length - 1;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (config.comment && rest.startsWith(config.comment)) {
|
|
149
|
-
const newline = content.indexOf("\n", index);
|
|
150
|
-
if (newline === -1)
|
|
151
|
-
break;
|
|
152
|
-
index = newline;
|
|
153
|
-
line += 1;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (quotes.includes(char)) {
|
|
157
|
-
inString = char;
|
|
158
|
-
stringLine = line;
|
|
159
|
-
escaped = false;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (char === "\n")
|
|
163
|
-
line += 1;
|
|
164
|
-
if (opens.has(char)) {
|
|
165
|
-
stack.push({ char, line });
|
|
166
|
-
}
|
|
167
|
-
else if (char in pairs) {
|
|
168
|
-
const open = stack.pop();
|
|
169
|
-
if (!open) {
|
|
170
|
-
issues.push(`Line ${line}: unexpected ${char}`);
|
|
36
|
+
function numericStats(records) {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const record of records) {
|
|
39
|
+
for (const [key, value] of Object.entries(record.fields)) {
|
|
40
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
41
|
+
continue;
|
|
42
|
+
const current = out[key];
|
|
43
|
+
if (!current) {
|
|
44
|
+
out[key] = { count: 1, min: value, max: value, mean: value, sum: value };
|
|
171
45
|
}
|
|
172
|
-
else
|
|
173
|
-
|
|
46
|
+
else {
|
|
47
|
+
current.count += 1;
|
|
48
|
+
current.sum += value;
|
|
49
|
+
current.min = Math.min(current.min, value);
|
|
50
|
+
current.max = Math.max(current.max, value);
|
|
51
|
+
current.mean = current.sum / current.count;
|
|
174
52
|
}
|
|
175
53
|
}
|
|
176
54
|
}
|
|
177
|
-
|
|
178
|
-
issues.push(`Line ${stringLine}: unclosed string`);
|
|
179
|
-
for (const item of stack.slice(-5))
|
|
180
|
-
issues.push(`Line ${item.line}: unclosed ${item.char}`);
|
|
181
|
-
return issues;
|
|
182
|
-
}
|
|
183
|
-
function cleanFormat(content, expandTabs, indentWidth) {
|
|
184
|
-
const normalized = content.replace(/\r\n?/g, "\n");
|
|
185
|
-
const lines = normalized.split("\n").map((line) => {
|
|
186
|
-
const noTrailing = line.replace(/[ \t]+$/, "");
|
|
187
|
-
return expandTabs ? noTrailing.replace(/\t/g, " ".repeat(indentWidth)) : noTrailing;
|
|
188
|
-
});
|
|
189
|
-
const body = lines.join("\n").replace(/\n+$/, "");
|
|
190
|
-
return body.length > 0 ? `${body}\n` : "";
|
|
55
|
+
return out;
|
|
191
56
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const root = path.resolve(dir);
|
|
195
|
-
async function walk(current, depth) {
|
|
196
|
-
if (depth > maxDepth || scan.files >= 2_000)
|
|
197
|
-
return;
|
|
198
|
-
let entries;
|
|
199
|
-
try {
|
|
200
|
-
entries = await fs.readdir(current, { withFileTypes: true });
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
for (const entry of entries) {
|
|
206
|
-
if (entry.name.startsWith(".") && entry.name !== ".env")
|
|
207
|
-
continue;
|
|
208
|
-
const full = path.join(current, entry.name);
|
|
209
|
-
if (entry.isDirectory()) {
|
|
210
|
-
if (SKIP_DIRS.has(entry.name))
|
|
211
|
-
continue;
|
|
212
|
-
await walk(full, depth + 1);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (!entry.isFile())
|
|
216
|
-
continue;
|
|
217
|
-
const ext = path.extname(entry.name).toLowerCase();
|
|
218
|
-
const isMarker = config.markers.includes(entry.name);
|
|
219
|
-
if (!config.extensions.includes(ext) && !isMarker)
|
|
220
|
-
continue;
|
|
221
|
-
scan.files += 1;
|
|
222
|
-
if (ext)
|
|
223
|
-
scan.byExtension[ext] = (scan.byExtension[ext] ?? 0) + 1;
|
|
224
|
-
if (isMarker)
|
|
225
|
-
scan.markers[entry.name] = (scan.markers[entry.name] ?? 0) + 1;
|
|
226
|
-
if (scan.samples.length < 20)
|
|
227
|
-
scan.samples.push(path.relative(root, full));
|
|
228
|
-
if (scan.files >= 2_000)
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
await walk(root, 0);
|
|
233
|
-
return scan;
|
|
57
|
+
function round(value) {
|
|
58
|
+
return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : value;
|
|
234
59
|
}
|
|
235
|
-
export function
|
|
60
|
+
export function registerFormatTools(server, format) {
|
|
236
61
|
const sourceArgs = {
|
|
237
62
|
path: z.string().min(1).max(2_000).optional(),
|
|
238
63
|
content: z.string().max(MAX_INPUT).optional(),
|
|
239
64
|
cwd: z.string().min(1).max(2_000).optional(),
|
|
240
65
|
};
|
|
241
|
-
|
|
242
|
-
server.tool("detect_project", `Find ${config.displayName} files, project markers and the toolchain layout in a directory.`, {
|
|
66
|
+
server.tool("detect_files", `Find ${format.displayName} files (${format.extensions.join(", ")}) in a directory tree.`, {
|
|
243
67
|
path: z.string().min(1).max(2_000).default("."),
|
|
244
|
-
maxDepth: z.number().int().min(0).max(8).default(
|
|
68
|
+
maxDepth: z.number().int().min(0).max(8).default(4),
|
|
245
69
|
}, async (a) => {
|
|
246
70
|
try {
|
|
247
|
-
const root =
|
|
71
|
+
const root = path.resolve(a.path);
|
|
248
72
|
const stat = await fs.stat(root);
|
|
249
73
|
if (!stat.isDirectory())
|
|
250
74
|
throw new ToolError(`Not a directory: ${root}`);
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
75
|
+
const found = [];
|
|
76
|
+
let scanned = 0;
|
|
77
|
+
async function walk(dir, depth) {
|
|
78
|
+
if (depth > a.maxDepth || found.length >= 500)
|
|
79
|
+
return;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.name.startsWith("."))
|
|
89
|
+
continue;
|
|
90
|
+
const full = path.join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
if (SKIP_DIRS.has(entry.name))
|
|
93
|
+
continue;
|
|
94
|
+
await walk(full, depth + 1);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.isFile())
|
|
98
|
+
continue;
|
|
99
|
+
scanned += 1;
|
|
100
|
+
if (!format.extensions.includes(path.extname(entry.name).toLowerCase()))
|
|
101
|
+
continue;
|
|
102
|
+
found.push(path.relative(root, full));
|
|
103
|
+
if (found.length >= 500)
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await walk(root, 0);
|
|
108
|
+
return text(JSON.stringify({ format: format.displayName, root, filesScanned: scanned, matches: found.length, files: found }, null, 2));
|
|
262
109
|
}
|
|
263
110
|
catch (error) {
|
|
264
111
|
return fail(errorMessage(error));
|
|
265
112
|
}
|
|
266
113
|
});
|
|
267
|
-
server.tool("
|
|
114
|
+
server.tool("read_file", `Read a ${format.displayName} file, optionally a line range.`, {
|
|
268
115
|
path: z.string().min(1).max(2_000),
|
|
269
116
|
startLine: z.number().int().min(1).default(1),
|
|
270
117
|
endLine: z.number().int().min(1).optional(),
|
|
271
118
|
cwd: z.string().min(1).max(2_000).optional(),
|
|
272
119
|
}, async (a) => {
|
|
273
120
|
try {
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
const lines = content.split("\n");
|
|
121
|
+
const { body } = await readInput({ path: a.path, cwd: a.cwd });
|
|
122
|
+
const lines = body.split(/\r?\n/);
|
|
277
123
|
const end = Math.min(a.endLine ?? lines.length, lines.length);
|
|
278
|
-
|
|
279
|
-
return text(body);
|
|
124
|
+
return text(lines.slice(a.startLine - 1, end).join("\n"));
|
|
280
125
|
}
|
|
281
126
|
catch (error) {
|
|
282
127
|
return fail(errorMessage(error));
|
|
283
128
|
}
|
|
284
129
|
});
|
|
285
|
-
server.tool("
|
|
130
|
+
server.tool("write_file", `Write a ${format.displayName} file. Refuses to overwrite unless overwrite is true.`, {
|
|
286
131
|
path: z.string().min(1).max(2_000),
|
|
287
132
|
content: z.string().max(MAX_INPUT),
|
|
288
133
|
overwrite: z.boolean().default(false),
|
|
289
|
-
createDirs: z.boolean().default(true),
|
|
290
134
|
cwd: z.string().min(1).max(2_000).optional(),
|
|
291
135
|
}, async (a) => {
|
|
292
136
|
try {
|
|
293
|
-
const file =
|
|
294
|
-
if (!a.overwrite
|
|
295
|
-
|
|
137
|
+
const file = path.isAbsolute(a.path) ? a.path : path.resolve(a.cwd ? path.resolve(a.cwd) : process.cwd(), a.path);
|
|
138
|
+
if (!a.overwrite) {
|
|
139
|
+
const exists = await fs.stat(file).then(() => true).catch(() => false);
|
|
140
|
+
if (exists)
|
|
141
|
+
throw new ToolError(`File already exists: ${file}. Set overwrite=true to replace it.`);
|
|
296
142
|
}
|
|
297
|
-
|
|
298
|
-
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
143
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
299
144
|
await fs.writeFile(file, a.content, "utf8");
|
|
300
145
|
return text(`Wrote ${Buffer.byteLength(a.content)} bytes to ${file}`);
|
|
301
146
|
}
|
|
@@ -303,400 +148,196 @@ export function registerLanguageTools(server, config) {
|
|
|
303
148
|
return fail(errorMessage(error));
|
|
304
149
|
}
|
|
305
150
|
});
|
|
306
|
-
server.tool("
|
|
307
|
-
...sourceArgs,
|
|
308
|
-
}, async (a) => {
|
|
309
|
-
try {
|
|
310
|
-
const source = await loadSource(a);
|
|
311
|
-
const issues = scanStructure(source.content, config);
|
|
312
|
-
let parserChecked = false;
|
|
313
|
-
if (config.syntax && source.file) {
|
|
314
|
-
const ctx = commandContext(source.file, source.cwd, config);
|
|
315
|
-
const command = expandCommand(config.syntax, ctx);
|
|
316
|
-
const result = await run(command.command, command.args, {
|
|
317
|
-
cwd: source.cwd,
|
|
318
|
-
timeoutMs: DEFAULT_TIMEOUT,
|
|
319
|
-
});
|
|
320
|
-
parserChecked = result.code === 0;
|
|
321
|
-
if (result.code !== 0) {
|
|
322
|
-
issues.push((result.stderr || result.stdout || "parser rejected the file").trim());
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
if (issues.length > 0)
|
|
326
|
-
throw new ToolError(issues.join("\n"));
|
|
327
|
-
return text(parserChecked ? "Syntax OK (parser accepted the file)" : "Syntax OK (structure check)");
|
|
328
|
-
}
|
|
329
|
-
catch (error) {
|
|
330
|
-
return fail(errorMessage(error));
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
server.tool("format_source", `Trim trailing whitespace, normalize line endings and ensure a final newline in ${config.displayName} code.`, {
|
|
334
|
-
...sourceArgs,
|
|
335
|
-
expandTabs: z.boolean().default(false),
|
|
336
|
-
indentWidth: z.number().int().min(2).max(8).default(2),
|
|
337
|
-
write: z.boolean().default(false),
|
|
338
|
-
}, async (a) => {
|
|
151
|
+
server.tool("parse", `Parse ${format.displayName} into records, fields and document metadata.`, { ...sourceArgs }, async (a) => {
|
|
339
152
|
try {
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
153
|
+
const { body, file } = await readInput(a);
|
|
154
|
+
const parsed = parseOrThrow(format, body);
|
|
155
|
+
return text(JSON.stringify({
|
|
156
|
+
file: file ?? null,
|
|
157
|
+
format: parsed.format,
|
|
158
|
+
meta: parsed.meta,
|
|
159
|
+
records: parsed.records.length,
|
|
160
|
+
kinds: parsed.records.reduce((acc, record) => {
|
|
161
|
+
acc[record.kind] = (acc[record.kind] ?? 0) + 1;
|
|
162
|
+
return acc;
|
|
163
|
+
}, {}),
|
|
164
|
+
issues: parsed.issues,
|
|
165
|
+
}, null, 2));
|
|
347
166
|
}
|
|
348
167
|
catch (error) {
|
|
349
168
|
return fail(errorMessage(error));
|
|
350
169
|
}
|
|
351
170
|
});
|
|
352
|
-
server.tool("
|
|
353
|
-
...sourceArgs,
|
|
354
|
-
maxLineLength: z.number().int().min(40).max(400).default(120),
|
|
355
|
-
}, async (a) => {
|
|
171
|
+
server.tool("validate", `Validate ${format.displayName} against its structural rules and report every issue with a line number.`, { ...sourceArgs }, async (a) => {
|
|
356
172
|
try {
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
});
|
|
369
|
-
if (source.content.length > 0 && !source.content.endsWith("\n")) {
|
|
370
|
-
issues.push("File does not end with a newline");
|
|
371
|
-
}
|
|
372
|
-
issues.push(...scanStructure(source.content, config));
|
|
373
|
-
if (issues.length === 0)
|
|
374
|
-
return text("No lint issues");
|
|
375
|
-
return text(issues.join("\n"));
|
|
173
|
+
const { body } = await readInput(a);
|
|
174
|
+
const parsed = format.parse(body);
|
|
175
|
+
const errors = parsed.issues.filter((issue) => issue.severity === "error").length;
|
|
176
|
+
const warnings = parsed.issues.length - errors;
|
|
177
|
+
return text(JSON.stringify({
|
|
178
|
+
format: parsed.format,
|
|
179
|
+
valid: errors === 0,
|
|
180
|
+
errors,
|
|
181
|
+
warnings,
|
|
182
|
+
issues: parsed.issues,
|
|
183
|
+
}, null, 2));
|
|
376
184
|
}
|
|
377
185
|
catch (error) {
|
|
378
186
|
return fail(errorMessage(error));
|
|
379
187
|
}
|
|
380
188
|
});
|
|
381
|
-
server.tool("
|
|
382
|
-
...sourceArgs,
|
|
383
|
-
}, async (a) => {
|
|
189
|
+
server.tool("summarize", `Summarize a ${format.displayName} document: metadata, record counts and per-kind totals.`, { ...sourceArgs }, async (a) => {
|
|
384
190
|
try {
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
|
|
191
|
+
const { body } = await readInput(a);
|
|
192
|
+
const parsed = parseOrThrow(format, body);
|
|
193
|
+
const kinds = parsed.records.reduce((acc, record) => {
|
|
194
|
+
acc[record.kind] = (acc[record.kind] ?? 0) + 1;
|
|
195
|
+
return acc;
|
|
196
|
+
}, {});
|
|
197
|
+
const lines = [
|
|
198
|
+
`${format.displayName} document`,
|
|
199
|
+
`records: ${parsed.records.length}`,
|
|
200
|
+
...Object.entries(kinds).map(([kind, count]) => ` ${kind}: ${count}`),
|
|
201
|
+
...Object.entries(parsed.meta).map(([key, value]) => `${key}: ${String(value)}`),
|
|
202
|
+
`issues: ${parsed.issues.length}`,
|
|
203
|
+
];
|
|
204
|
+
return text(lines.join("\n"));
|
|
397
205
|
}
|
|
398
206
|
catch (error) {
|
|
399
207
|
return fail(errorMessage(error));
|
|
400
208
|
}
|
|
401
209
|
});
|
|
402
|
-
server.tool("
|
|
210
|
+
server.tool("list_records", `List ${format.displayName} record ids and kinds, with a limit.`, {
|
|
403
211
|
...sourceArgs,
|
|
212
|
+
limit: z.number().int().min(1).max(1_000).default(50),
|
|
404
213
|
}, async (a) => {
|
|
405
214
|
try {
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
lines.forEach((line, index) => {
|
|
412
|
-
const match = regex.exec(line);
|
|
413
|
-
if (match)
|
|
414
|
-
found.push(`Line ${index + 1}: ${rule.kind} ${match[1] ?? match[0]}`);
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
return text(found.length > 0 ? found.join("\n") : "No imports found");
|
|
215
|
+
const { body } = await readInput(a);
|
|
216
|
+
const parsed = parseOrThrow(format, body);
|
|
217
|
+
const rows = parsed.records.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
|
|
218
|
+
const rest = parsed.records.length - rows.length;
|
|
219
|
+
return text(rows.join("\n") + (rest > 0 ? `\n... ${rest} more` : ""));
|
|
418
220
|
}
|
|
419
221
|
catch (error) {
|
|
420
222
|
return fail(errorMessage(error));
|
|
421
223
|
}
|
|
422
224
|
});
|
|
423
|
-
server.tool("
|
|
225
|
+
server.tool("get_record", `Return one ${format.displayName} record by id, with all parsed fields.`, {
|
|
424
226
|
...sourceArgs,
|
|
227
|
+
id: z.string().min(1).max(300),
|
|
425
228
|
}, async (a) => {
|
|
426
229
|
try {
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
return text(found.length > 0 ? found.join("\n") : "No TODO comments found");
|
|
434
|
-
}
|
|
435
|
-
catch (error) {
|
|
436
|
-
return fail(errorMessage(error));
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
server.tool("find_references", `Find every mention of a symbol in one ${config.displayName} source file.`, {
|
|
440
|
-
path: z.string().min(1).max(2_000),
|
|
441
|
-
symbol: z.string().min(1).max(200),
|
|
442
|
-
cwd: z.string().min(1).max(2_000).optional(),
|
|
443
|
-
}, async (a) => {
|
|
444
|
-
try {
|
|
445
|
-
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
446
|
-
const content = await readFileSafe(file);
|
|
447
|
-
const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.symbol)}(?![A-Za-z0-9_])`, "g");
|
|
448
|
-
const found = [];
|
|
449
|
-
content.split("\n").forEach((line, index) => {
|
|
450
|
-
const count = [...line.matchAll(regex)].length;
|
|
451
|
-
if (count > 0)
|
|
452
|
-
found.push(`Line ${index + 1}: ${count} reference(s)`);
|
|
453
|
-
});
|
|
454
|
-
return text(found.length > 0 ? found.join("\n") : `No references to ${a.symbol}`);
|
|
455
|
-
}
|
|
456
|
-
catch (error) {
|
|
457
|
-
return fail(errorMessage(error));
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
server.tool("rename_symbol", `Rename one symbol in a ${config.displayName} file. Defaults to a dry run.`, {
|
|
461
|
-
path: z.string().min(1).max(2_000),
|
|
462
|
-
oldName: z.string().min(1).max(200),
|
|
463
|
-
newName: z.string().min(1).max(200),
|
|
464
|
-
dryRun: z.boolean().default(true),
|
|
465
|
-
cwd: z.string().min(1).max(2_000).optional(),
|
|
466
|
-
}, async (a) => {
|
|
467
|
-
try {
|
|
468
|
-
if (/[\r\n]/.test(a.newName))
|
|
469
|
-
throw new ToolError("New name cannot contain a newline");
|
|
470
|
-
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
471
|
-
const content = await readFileSafe(file);
|
|
472
|
-
const regex = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(a.oldName)}(?![A-Za-z0-9_])`, "g");
|
|
473
|
-
const count = [...content.matchAll(regex)].length;
|
|
474
|
-
if (count === 0)
|
|
475
|
-
return text(`No references to ${a.oldName}`);
|
|
476
|
-
if (a.dryRun)
|
|
477
|
-
return text(`Dry run: would replace ${count} occurrence(s) in ${file}`);
|
|
478
|
-
await fs.writeFile(file, content.replace(regex, () => a.newName), "utf8");
|
|
479
|
-
return text(`Renamed ${count} occurrence(s) in ${file}`);
|
|
480
|
-
}
|
|
481
|
-
catch (error) {
|
|
482
|
-
return fail(errorMessage(error));
|
|
483
|
-
}
|
|
484
|
-
});
|
|
485
|
-
server.tool("insert_snippet", `Insert a real ${config.displayName} starter snippet into a file. Defaults to a dry run.`, {
|
|
486
|
-
path: z.string().min(1).max(2_000),
|
|
487
|
-
snippet: z.string().min(1).max(80),
|
|
488
|
-
line: z.number().int().min(1).optional(),
|
|
489
|
-
dryRun: z.boolean().default(true),
|
|
490
|
-
cwd: z.string().min(1).max(2_000).optional(),
|
|
491
|
-
}, async (a) => {
|
|
492
|
-
try {
|
|
493
|
-
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
494
|
-
const snippet = config.snippets[a.snippet];
|
|
495
|
-
if (!snippet) {
|
|
496
|
-
throw new ToolError(`Unknown snippet: ${a.snippet}. Available: ${Object.entries(config.snippets)
|
|
497
|
-
.map(([key, value]) => `${key} (${value.description})`)
|
|
498
|
-
.join(", ")}`);
|
|
499
|
-
}
|
|
500
|
-
const content = await readFileSafe(file);
|
|
501
|
-
const lines = content.split("\n");
|
|
502
|
-
const index = a.line ? Math.min(a.line - 1, lines.length) : lines.length;
|
|
503
|
-
lines.splice(index, 0, ...snippet.body.split("\n"));
|
|
504
|
-
const next = lines.join("\n");
|
|
505
|
-
if (a.dryRun) {
|
|
506
|
-
return text(`Dry run: would insert ${a.snippet} at line ${index + 1} in ${file}`);
|
|
507
|
-
}
|
|
508
|
-
await fs.writeFile(file, next, "utf8");
|
|
509
|
-
return text(`Inserted ${a.snippet} at line ${index + 1} in ${file}`);
|
|
230
|
+
const { body } = await readInput(a);
|
|
231
|
+
const parsed = parseOrThrow(format, body);
|
|
232
|
+
const record = parsed.records.find((item) => item.id === a.id);
|
|
233
|
+
if (!record)
|
|
234
|
+
throw new ToolError(`No record with id ${a.id}. Use list_records to see available ids.`);
|
|
235
|
+
return text(JSON.stringify(record, null, 2));
|
|
510
236
|
}
|
|
511
237
|
catch (error) {
|
|
512
238
|
return fail(errorMessage(error));
|
|
513
239
|
}
|
|
514
240
|
});
|
|
515
|
-
server.tool("
|
|
241
|
+
server.tool("search", `Search ${format.displayName} records by plain text or regular expression.`, {
|
|
516
242
|
...sourceArgs,
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
243
|
+
query: z.string().min(1).max(500),
|
|
244
|
+
regex: z.boolean().default(false),
|
|
245
|
+
limit: z.number().int().min(1).max(500).default(50),
|
|
520
246
|
}, async (a) => {
|
|
521
247
|
try {
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
248
|
+
const { body } = await readInput(a);
|
|
249
|
+
const parsed = parseOrThrow(format, body);
|
|
250
|
+
let test;
|
|
251
|
+
if (a.regex) {
|
|
252
|
+
let pattern;
|
|
253
|
+
try {
|
|
254
|
+
pattern = new RegExp(a.query, "i");
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
throw new ToolError(`Invalid regular expression: ${a.query}`);
|
|
531
258
|
}
|
|
259
|
+
test = (value) => pattern.test(value);
|
|
532
260
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return text(`Commented ${changed} line(s) in ${source.file}`);
|
|
261
|
+
else {
|
|
262
|
+
const needle = a.query.toLowerCase();
|
|
263
|
+
test = (value) => value.toLowerCase().includes(needle);
|
|
537
264
|
}
|
|
538
|
-
|
|
265
|
+
const matches = parsed.records.filter((record) => test(`${record.id} ${record.kind} ${JSON.stringify(record.fields)}`));
|
|
266
|
+
const rows = matches.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
|
|
267
|
+
return text(`${matches.length} match(es)\n${rows.join("\n")}`.trim());
|
|
539
268
|
}
|
|
540
269
|
catch (error) {
|
|
541
270
|
return fail(errorMessage(error));
|
|
542
271
|
}
|
|
543
272
|
});
|
|
544
|
-
server.tool("
|
|
273
|
+
server.tool("stats", `Numeric statistics for ${format.displayName} fields across all records.`, {
|
|
545
274
|
...sourceArgs,
|
|
546
|
-
|
|
547
|
-
endLine: z.number().int().min(1).optional(),
|
|
548
|
-
write: z.boolean().default(false),
|
|
275
|
+
field: z.string().min(1).max(120).optional(),
|
|
549
276
|
}, async (a) => {
|
|
550
277
|
try {
|
|
551
|
-
const
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const indent = match[1];
|
|
560
|
-
const rest = match[2];
|
|
561
|
-
if (!rest.startsWith(config.comment))
|
|
562
|
-
continue;
|
|
563
|
-
const after = rest.slice(config.comment.length);
|
|
564
|
-
lines[index] = `${indent}${after.startsWith(" ") ? after.slice(1) : after}`;
|
|
565
|
-
changed += 1;
|
|
278
|
+
const { body } = await readInput(a);
|
|
279
|
+
const parsed = parseOrThrow(format, body);
|
|
280
|
+
const stats = numericStats(parsed.records);
|
|
281
|
+
if (a.field) {
|
|
282
|
+
const one = stats[a.field];
|
|
283
|
+
if (!one)
|
|
284
|
+
throw new ToolError(`Field ${a.field} has no numeric values. Numeric fields: ${Object.keys(stats).join(", ") || "none"}`);
|
|
285
|
+
return text(JSON.stringify({ [a.field]: { ...one, mean: round(one.mean) } }, null, 2));
|
|
566
286
|
}
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
await fs.writeFile(source.file, next, "utf8");
|
|
570
|
-
return text(`Uncommented ${changed} line(s) in ${source.file}`);
|
|
571
|
-
}
|
|
572
|
-
return text(next);
|
|
287
|
+
const rounded = Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, { ...value, mean: round(value.mean) }]));
|
|
288
|
+
return text(JSON.stringify(rounded, null, 2));
|
|
573
289
|
}
|
|
574
290
|
catch (error) {
|
|
575
291
|
return fail(errorMessage(error));
|
|
576
292
|
}
|
|
577
293
|
});
|
|
578
|
-
server.tool("
|
|
579
|
-
path: z.string().min(1).max(2_000).default("."),
|
|
580
|
-
projectName: z.string().min(1).max(120).optional(),
|
|
581
|
-
overwrite: z.boolean().default(false),
|
|
582
|
-
}, async (a) => {
|
|
294
|
+
server.tool("to_json", `Export every parsed ${format.displayName} record as JSON.`, { ...sourceArgs }, async (a) => {
|
|
583
295
|
try {
|
|
584
|
-
const
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
throw new ToolError(`Unsafe scaffold path: ${relative}`);
|
|
594
|
-
}
|
|
595
|
-
const target = path.join(root, normalized);
|
|
596
|
-
if (!a.overwrite && (await fileExists(target))) {
|
|
597
|
-
throw new ToolError(`File already exists: ${target}`);
|
|
598
|
-
}
|
|
599
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
600
|
-
await fs.writeFile(target, file.content.replace(/\{\{name\}\}/g, name), "utf8");
|
|
601
|
-
created.push(normalized);
|
|
602
|
-
}
|
|
603
|
-
return text(`Created ${created.length} file(s):\n${created.join("\n")}`);
|
|
296
|
+
const { body, file } = await readInput(a);
|
|
297
|
+
const parsed = parseOrThrow(format, body);
|
|
298
|
+
return text(JSON.stringify({
|
|
299
|
+
file: file ?? null,
|
|
300
|
+
format: parsed.format,
|
|
301
|
+
meta: parsed.meta,
|
|
302
|
+
issues: parsed.issues,
|
|
303
|
+
records: parsed.records,
|
|
304
|
+
}, null, 2));
|
|
604
305
|
}
|
|
605
306
|
catch (error) {
|
|
606
307
|
return fail(errorMessage(error));
|
|
607
308
|
}
|
|
608
309
|
});
|
|
609
|
-
server.tool("
|
|
610
|
-
|
|
310
|
+
server.tool("diff", `Compare two ${format.displayName} documents: added, removed and changed records.`, {
|
|
311
|
+
leftPath: z.string().min(1).max(2_000).optional(),
|
|
312
|
+
leftContent: z.string().max(MAX_INPUT).optional(),
|
|
313
|
+
rightPath: z.string().min(1).max(2_000).optional(),
|
|
314
|
+
rightContent: z.string().max(MAX_INPUT).optional(),
|
|
611
315
|
cwd: z.string().min(1).max(2_000).optional(),
|
|
612
|
-
timeoutMs: timeout,
|
|
613
|
-
}, async (a) => {
|
|
614
|
-
try {
|
|
615
|
-
if (!config.runner || config.runner.length === 0) {
|
|
616
|
-
throw new ToolError(`No runner is configured for ${config.displayName}`);
|
|
617
|
-
}
|
|
618
|
-
const file = resolveFile(a.path, resolveDir(a.cwd));
|
|
619
|
-
const cwd = path.dirname(file);
|
|
620
|
-
const ctx = commandContext(file, cwd, config);
|
|
621
|
-
await ensureBuildDir(ctx);
|
|
622
|
-
const workflow = await pickWorkflow(config.runner);
|
|
623
|
-
const output = await runSteps(workflow, ctx, cwd, a.timeoutMs);
|
|
624
|
-
return text(output.trim().length > 0 ? output : "(no output)");
|
|
625
|
-
}
|
|
626
|
-
catch (error) {
|
|
627
|
-
return fail(errorMessage(error));
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
server.tool("run_tests", `Run the configured ${config.displayName} test command in a project directory.`, {
|
|
631
|
-
cwd: z.string().min(1).max(2_000).default("."),
|
|
632
|
-
timeoutMs: timeout,
|
|
633
|
-
}, async (a) => {
|
|
634
|
-
try {
|
|
635
|
-
if (!config.test || config.test.length === 0) {
|
|
636
|
-
throw new ToolError(`No test command is configured for ${config.displayName}`);
|
|
637
|
-
}
|
|
638
|
-
const cwd = resolveDir(a.cwd);
|
|
639
|
-
const ctx = commandContext(path.join(cwd, config.slug), cwd, config);
|
|
640
|
-
await ensureBuildDir(ctx);
|
|
641
|
-
const output = await runSteps(config.test, ctx, cwd, a.timeoutMs);
|
|
642
|
-
return text(output.trim().length > 0 ? output : "Tests passed");
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
return fail(errorMessage(error));
|
|
646
|
-
}
|
|
647
|
-
});
|
|
648
|
-
server.tool("build_project", `Build a ${config.displayName} project with the configured compiler command.`, {
|
|
649
|
-
cwd: z.string().min(1).max(2_000).default("."),
|
|
650
|
-
path: z.string().min(1).max(2_000).optional(),
|
|
651
|
-
timeoutMs: timeout,
|
|
652
316
|
}, async (a) => {
|
|
653
317
|
try {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
server.tool("toolchain_status", `Show which ${config.displayName} compilers, interpreters, build tools and test commands are installed.`, {}, async () => {
|
|
675
|
-
try {
|
|
676
|
-
const commands = new Set();
|
|
677
|
-
for (const step of [
|
|
678
|
-
...(config.runner ?? []),
|
|
679
|
-
...(config.syntax ? [config.syntax] : []),
|
|
680
|
-
...(config.build ?? []),
|
|
681
|
-
...(config.test ?? []),
|
|
682
|
-
]) {
|
|
683
|
-
commands.add(step.command);
|
|
684
|
-
}
|
|
685
|
-
const lines = [];
|
|
686
|
-
for (const command of [...commands].sort()) {
|
|
687
|
-
if (!(await commandAvailable(command))) {
|
|
688
|
-
lines.push(`${command}: not found`);
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
const version = await run(command, ["--version"], { timeoutMs: 5_000 });
|
|
692
|
-
const first = (version.stdout || version.stderr).split("\n").find((line) => line.trim().length > 0);
|
|
693
|
-
lines.push(`${command}: ${first?.trim() ?? "installed"}`);
|
|
694
|
-
}
|
|
695
|
-
return text(lines.join("\n") || "No toolchain commands configured");
|
|
318
|
+
const left = await readInput({ path: a.leftPath, content: a.leftContent, cwd: a.cwd });
|
|
319
|
+
const right = await readInput({ path: a.rightPath, content: a.rightContent, cwd: a.cwd });
|
|
320
|
+
const before = parseOrThrow(format, left.body);
|
|
321
|
+
const after = parseOrThrow(format, right.body);
|
|
322
|
+
const beforeFields = new Map(before.records.map((record) => [record.id, JSON.stringify(record.fields)]));
|
|
323
|
+
const afterFields = new Map(after.records.map((record) => [record.id, JSON.stringify(record.fields)]));
|
|
324
|
+
const added = [...afterFields.keys()].filter((id) => !beforeFields.has(id));
|
|
325
|
+
const removed = [...beforeFields.keys()].filter((id) => !afterFields.has(id));
|
|
326
|
+
const changed = [...afterFields.keys()].filter((id) => beforeFields.has(id) && beforeFields.get(id) !== afterFields.get(id));
|
|
327
|
+
return text(JSON.stringify({
|
|
328
|
+
format: before.format,
|
|
329
|
+
added: added.length,
|
|
330
|
+
removed: removed.length,
|
|
331
|
+
changed: changed.length,
|
|
332
|
+
unchanged: afterFields.size - added.length - changed.length,
|
|
333
|
+
records: { added, removed, changed },
|
|
334
|
+
issues: { left: before.issues.length, right: after.issues.length },
|
|
335
|
+
}, null, 2));
|
|
696
336
|
}
|
|
697
337
|
catch (error) {
|
|
698
338
|
return fail(errorMessage(error));
|
|
699
339
|
}
|
|
700
340
|
});
|
|
701
|
-
server.tool("
|
|
341
|
+
server.tool("format_reference", `Return a compact ${format.displayName} reference: structure, required fields and common pitfalls.`, {}, async () => text(format.reference));
|
|
342
|
+
server.tool("sample", `Return a small but valid ${format.displayName} example document.`, {}, async () => text(format.sample()));
|
|
702
343
|
}
|