@vertesia/build-tools 1.5.0-dev.20260725.083715Z → 1.5.0-dev.20260804.124748Z
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 +297 -266
- package/lib/bin/build.js +26 -1
- package/lib/bin/build.js.map +1 -1
- package/lib/bin/config.d.ts +12 -0
- package/lib/bin/config.d.ts.map +1 -1
- package/lib/bin/config.js +25 -0
- package/lib/bin/config.js.map +1 -1
- package/lib/core/skill-markdown/load-catalog.d.ts +30 -0
- package/lib/core/skill-markdown/load-catalog.d.ts.map +1 -0
- package/lib/core/skill-markdown/load-catalog.js +69 -0
- package/lib/core/skill-markdown/load-catalog.js.map +1 -0
- package/lib/core/skill-markdown/preprocess.d.ts +127 -0
- package/lib/core/skill-markdown/preprocess.d.ts.map +1 -0
- package/lib/core/skill-markdown/preprocess.js +455 -0
- package/lib/core/skill-markdown/preprocess.js.map +1 -0
- package/lib/core/skill-markdown/schema-validator.d.ts +109 -0
- package/lib/core/skill-markdown/schema-validator.d.ts.map +1 -0
- package/lib/core/skill-markdown/schema-validator.js +420 -0
- package/lib/core/skill-markdown/schema-validator.js.map +1 -0
- package/lib/core/transformers/index.d.ts +1 -1
- package/lib/core/transformers/index.d.ts.map +1 -1
- package/lib/core/transformers/index.js +1 -1
- package/lib/core/transformers/index.js.map +1 -1
- package/lib/core/transformers/skill.d.ts +15 -1
- package/lib/core/transformers/skill.d.ts.map +1 -1
- package/lib/core/transformers/skill.js +82 -60
- package/lib/core/transformers/skill.js.map +1 -1
- package/lib/import-transform/detector.d.ts +19 -6
- package/lib/import-transform/detector.d.ts.map +1 -1
- package/lib/import-transform/detector.js +73 -24
- package/lib/import-transform/detector.js.map +1 -1
- package/lib/import-transform/index.d.ts.map +1 -1
- package/lib/import-transform/index.js +10 -1
- package/lib/import-transform/index.js.map +1 -1
- package/lib/import-transform/patterns.d.ts +7 -8
- package/lib/import-transform/patterns.d.ts.map +1 -1
- package/lib/import-transform/patterns.js +7 -8
- package/lib/import-transform/patterns.js.map +1 -1
- package/lib/index.d.ts +7 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -3
- package/lib/index.js.map +1 -1
- package/lib/vite/dev-server.d.ts +10 -0
- package/lib/vite/dev-server.d.ts.map +1 -1
- package/lib/vite/dev-server.js +36 -4
- package/lib/vite/dev-server.js.map +1 -1
- package/package.json +9 -12
- package/src/bin/build.ts +31 -2
- package/src/bin/config.ts +27 -0
- package/src/core/skill-markdown/load-catalog.ts +100 -0
- package/src/core/skill-markdown/preprocess.ts +632 -0
- package/src/core/skill-markdown/schema-validator.ts +517 -0
- package/src/core/transformers/index.ts +3 -0
- package/src/core/transformers/skill.ts +116 -53
- package/src/import-transform/detector.ts +78 -26
- package/src/import-transform/index.ts +12 -2
- package/src/import-transform/patterns.ts +7 -12
- package/src/index.ts +35 -2
- package/src/vite/dev-server.ts +52 -4
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic preprocessor for skill Markdown.
|
|
3
|
+
*
|
|
4
|
+
* Markdown gives syntax but no semantics: in "Use `execute_parallel_work_streams` with
|
|
5
|
+
* `customer_orders`", nothing distinguishes the tool from the sample data. Rather than guess,
|
|
6
|
+
* this preprocessor recognises exactly four explicit constructs and leaves every other byte of
|
|
7
|
+
* prose untouched:
|
|
8
|
+
*
|
|
9
|
+
* - `{@tool search_documents}` → renders as `` `search_documents` ``
|
|
10
|
+
* - `{@skill workstreams}` → renders as `` `learn_workstreams` ``
|
|
11
|
+
* - `{@param search_documents.query}` → renders as `` `query` ``, checked against the schema
|
|
12
|
+
* - ```` ```json tool=batch_execute ```` → validated against that tool, tag stripped on render
|
|
13
|
+
*
|
|
14
|
+
* What that buys is *fail-closed validation and consistent rendering*, not centralised naming —
|
|
15
|
+
* the name is still spelled out at each site, so a rename still touches every reference. The
|
|
16
|
+
* `{@skill}` form earns the most: it takes the `learn_` prefix convention out of the author's
|
|
17
|
+
* hands, and that convention is exactly what drifts.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately pure. It reads no files, imports no registry, and never inspects Git; it is a
|
|
20
|
+
* function of (markdown, catalog) alone, so a build, a test, and CI cannot disagree — and a
|
|
21
|
+
* tarball or shallow checkout builds identically. Schema knowledge stays with the caller via
|
|
22
|
+
* `validateExample`, which keeps AJV and the dispatch model out of build-tools entirely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The runtime convention: a skill named `x` is offered to the model as the tool `learn_x`. */
|
|
26
|
+
export const DEFAULT_SKILL_TOOL_PREFIX = 'learn_';
|
|
27
|
+
|
|
28
|
+
export type SkillReferenceKind = 'tool' | 'skill' | 'param' | 'subagent_tool';
|
|
29
|
+
|
|
30
|
+
export interface SkillReference {
|
|
31
|
+
kind: SkillReferenceKind;
|
|
32
|
+
/** Name exactly as written in the source. For a `param`, the tool the field belongs to. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** Dotted/`[]` path into the tool's params schema. Only on a `param` reference. */
|
|
35
|
+
path?: string;
|
|
36
|
+
/** Text substituted into the rendered Markdown. */
|
|
37
|
+
rendered: string;
|
|
38
|
+
/** 1-based line of the construct, for error messages. */
|
|
39
|
+
line: number;
|
|
40
|
+
resolved: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SkillExample {
|
|
44
|
+
/** Tool named by the fence's `tool=` tag. */
|
|
45
|
+
tool: string;
|
|
46
|
+
/** Fence language, e.g. `json`. */
|
|
47
|
+
lang: string;
|
|
48
|
+
/** Raw fence body. */
|
|
49
|
+
source: string;
|
|
50
|
+
/** Parsed body. Absent when the body is not strict JSON. */
|
|
51
|
+
value?: unknown;
|
|
52
|
+
/** 1-based line of the opening fence. */
|
|
53
|
+
line: number;
|
|
54
|
+
/** Problems from JSON parsing and from `validateExample`. */
|
|
55
|
+
errors: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PreprocessSkillMarkdownOptions {
|
|
59
|
+
/** Tool names the consuming build can resolve. */
|
|
60
|
+
tools: ReadonlySet<string>;
|
|
61
|
+
/** Skill names the consuming build can resolve. */
|
|
62
|
+
skills: ReadonlySet<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Names owned by more than one provider. An unqualified reference to one of these is an
|
|
65
|
+
* error: a flat name set would silently collapse the two definitions and resolve the
|
|
66
|
+
* reference to whichever the consumer happened to keep. Collisions must be resolved at the
|
|
67
|
+
* source, or the syntax grows provider qualification — not be papered over here.
|
|
68
|
+
*/
|
|
69
|
+
ambiguousTools?: ReadonlySet<string>;
|
|
70
|
+
ambiguousSkills?: ReadonlySet<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Tools that exist but whose schema this build cannot see, so a tagged example for them
|
|
73
|
+
* would get an existence check and nothing more.
|
|
74
|
+
*
|
|
75
|
+
* Tagging one is rejected rather than silently half-checked: a `tool=` fence is a claim that
|
|
76
|
+
* the payload is verified, and a fence that only *looks* gated is worse than an untagged one.
|
|
77
|
+
*/
|
|
78
|
+
unvalidatableTools?: ReadonlySet<string>;
|
|
79
|
+
/**
|
|
80
|
+
* Consumer-supplied validation for a tagged example: schema checks, dispatcher resolution,
|
|
81
|
+
* anything else that needs the registry. Returns human-readable problems; empty means valid.
|
|
82
|
+
* Exceptions it throws are captured as errors, never propagated.
|
|
83
|
+
*
|
|
84
|
+
* `createSchemaExampleValidator` in this package is the usual implementation.
|
|
85
|
+
*/
|
|
86
|
+
validateExample?: (example: { tool: string; value: unknown }) => string[];
|
|
87
|
+
/**
|
|
88
|
+
* Consumer-supplied validation for a `{@param tool.field}` reference: does that tool's schema
|
|
89
|
+
* declare that field? Returns human-readable problems; empty means the field exists.
|
|
90
|
+
* Exceptions it throws are captured as errors, never propagated.
|
|
91
|
+
*
|
|
92
|
+
* `createSchemaFieldValidator` in this package is the usual implementation. A catalog that
|
|
93
|
+
* omits it makes every `{@param …}` an error rather than taking the field on trust: the
|
|
94
|
+
* construct's only purpose is to be checked, so a misconfigured consumer must not quietly
|
|
95
|
+
* turn the whole tree into decoration.
|
|
96
|
+
*/
|
|
97
|
+
validateField?: (ref: { tool: string; path: string }) => string[];
|
|
98
|
+
/** Override the `learn_` prefix if the runtime convention ever changes. */
|
|
99
|
+
skillToolPrefix?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Fence languages a `tool=` tag may appear on. A tag on a `bash` or `python` fence is a
|
|
102
|
+
* mistake: the body is not a tool payload, so nothing would ever validate it.
|
|
103
|
+
*/
|
|
104
|
+
exampleLanguages?: ReadonlySet<string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PreprocessSkillMarkdownResult {
|
|
108
|
+
/** Markdown with constructs rendered and validation tags stripped. */
|
|
109
|
+
markdown: string;
|
|
110
|
+
references: SkillReference[];
|
|
111
|
+
examples: SkillExample[];
|
|
112
|
+
/**
|
|
113
|
+
* Every problem found, already prefixed with a line number. The caller decides whether to
|
|
114
|
+
* throw — this function never does, so it stays usable from a linter as well as from a build.
|
|
115
|
+
*/
|
|
116
|
+
errors: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Snake_case, with hyphens tolerated because some remote skill names use them. */
|
|
120
|
+
const NAME = /^[a-z][a-z0-9_-]*$/;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `{@word …}`. The keyword is matched loosely so `{@tolo x}` is a reported typo, not prose.
|
|
124
|
+
*
|
|
125
|
+
* Two things keep the cost linear, neither of which costs any expressiveness — a construct's
|
|
126
|
+
* arguments are names and separators:
|
|
127
|
+
*
|
|
128
|
+
* - The argument group is anchored on a character the keyword cannot take, so the two cannot
|
|
129
|
+
* divide the same text. With both able to match `x`, `{@Axxxxxx…` with no closing brace gave
|
|
130
|
+
* the engine quadratically many ways to fail at a single position.
|
|
131
|
+
* - Neither argument class can cross a `{`. Otherwise the *scan* is quadratic rather than any
|
|
132
|
+
* one match: on `{@A/{@A/{@A/…` every opener sweeps to the end of the line before failing, so
|
|
133
|
+
* 240 KB took 5.2s. Stopping at the next `{` bounds each attempt by the distance to the next
|
|
134
|
+
* candidate start.
|
|
135
|
+
*
|
|
136
|
+
* The empty alternative keeps the group a string, and keeps `{@tool}` matching — and so being
|
|
137
|
+
* reported as naming nothing — rather than falling through as prose.
|
|
138
|
+
*/
|
|
139
|
+
const CONSTRUCT = /\{@([A-Za-z][A-Za-z0-9_]*)([^A-Za-z0-9_{}][^{}]*|)\}/g;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The same construct, restricted to the real keywords, for use inside fenced blocks. The loose
|
|
143
|
+
* form would flag template syntax that fenced examples legitimately contain — Handlebars
|
|
144
|
+
* `{{@index}}`, for one — which the renderer only ever sees in prose.
|
|
145
|
+
*
|
|
146
|
+
* `[^{}]` for the same reason as above, and it keeps this guard reporting exactly what the
|
|
147
|
+
* renderer would have rendered: neither form matches a construct with a `{` in its arguments.
|
|
148
|
+
*/
|
|
149
|
+
const FENCED_CONSTRUCT = /\{@(?:subagent_tool|tool|skill|param)\b[^{}]*\}/g;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A code fence line: up to 3 spaces of indent, a run of markers, then the info string.
|
|
153
|
+
*
|
|
154
|
+
* The info string is spelled `[^\r\n]*` rather than `.*` because lines are split on `\n` alone, so
|
|
155
|
+
* a CRLF file leaves a trailing `\r` that `.` — which excludes line terminators — cannot cross. The
|
|
156
|
+
* earlier spelling matched no fence at all in such a file: tags went unstripped and unvalidated,
|
|
157
|
+
* and the in-fence construct guard never ran, all without a single error.
|
|
158
|
+
*/
|
|
159
|
+
// The info string is anchored on a non-blank first character so it cannot overlap the run of
|
|
160
|
+
// spaces before it. With both able to match a tab, a line of tabs gave the engine quadratically
|
|
161
|
+
// many ways to split the same text — harmless on skill sources, but this is a published library
|
|
162
|
+
// and its input is whatever a consumer passes. The empty alternative keeps group 3 a string.
|
|
163
|
+
const FENCE = /^([ \t]{0,3})(`{3,}|~{3,})[ \t]*([^ \t\r\n][^\r\n]*|)\r?$/;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Inline code spans, masked so `` `{@tool x}` `` can document the syntax without invoking it.
|
|
167
|
+
*
|
|
168
|
+
* Scanned rather than matched: the regex form needs a backreference and a body that can match a
|
|
169
|
+
* backtick two ways, which costs quadratic time on a run of them.
|
|
170
|
+
*
|
|
171
|
+
* The scan tokenises the backtick runs once and indexes them by suffix maximum, because the
|
|
172
|
+
* obvious scan is quadratic too: an opener with no closer would rescan the whole remainder, so
|
|
173
|
+
* descending run lengths (```` ```…``` ```` then ```` ``…`` ```` then …) make every opener fail
|
|
174
|
+
* after a full sweep. `suffixMax` answers "is any later run long enough?" in constant time, which
|
|
175
|
+
* leaves only openers that do close — and those consume everything they scanned past.
|
|
176
|
+
*/
|
|
177
|
+
function maskInlineCode(source: string, masked: string[]): string {
|
|
178
|
+
// Maximal runs of backticks: `start[i]` is where run i begins, `len[i]` how long it is.
|
|
179
|
+
const start: number[] = [];
|
|
180
|
+
const len: number[] = [];
|
|
181
|
+
for (let at = 0; at < source.length; ) {
|
|
182
|
+
const open = source.indexOf('`', at);
|
|
183
|
+
if (open < 0) {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
let end = open + 1;
|
|
187
|
+
while (end < source.length && source[end] === '`') {
|
|
188
|
+
end++;
|
|
189
|
+
}
|
|
190
|
+
start.push(open);
|
|
191
|
+
len.push(end - open);
|
|
192
|
+
at = end;
|
|
193
|
+
}
|
|
194
|
+
if (start.length === 0) {
|
|
195
|
+
return source;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// suffixMax[i] is the longest run at or after i; suffixMax[start.length] is 0.
|
|
199
|
+
const suffixMax = new Array<number>(start.length + 1).fill(0);
|
|
200
|
+
for (let i = start.length - 1; i >= 0; i--) {
|
|
201
|
+
suffixMax[i] = Math.max(len[i], suffixMax[i + 1]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let out = '';
|
|
205
|
+
let at = 0;
|
|
206
|
+
let i = 0;
|
|
207
|
+
while (i < start.length) {
|
|
208
|
+
// Skip runs already consumed by an earlier span; `at` can also sit inside run i when a
|
|
209
|
+
// longer run closed a shorter opener and left backticks over.
|
|
210
|
+
if (start[i] + len[i] <= at) {
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const open = Math.max(start[i], at);
|
|
215
|
+
const width = start[i] + len[i] - open;
|
|
216
|
+
const after = start[i] + len[i];
|
|
217
|
+
if (suffixMax[i + 1] < width) {
|
|
218
|
+
// No closing run: the backticks are literal text, and the search resumes after them so
|
|
219
|
+
// a later span in the same line is still masked.
|
|
220
|
+
out += source.slice(at, after);
|
|
221
|
+
at = after;
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
let close = i + 1;
|
|
226
|
+
while (len[close] < width) {
|
|
227
|
+
close++;
|
|
228
|
+
}
|
|
229
|
+
const end = start[close] + width;
|
|
230
|
+
masked.push(source.slice(open, end));
|
|
231
|
+
out += `${source.slice(at, open)}${MASK_OPEN}${masked.length - 1}${MASK_CLOSE}`;
|
|
232
|
+
at = end;
|
|
233
|
+
i = close;
|
|
234
|
+
}
|
|
235
|
+
return out + source.slice(at);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** NUL cannot occur in these sources, so masking round-trips even through numeric prose. */
|
|
239
|
+
const MASK_OPEN = '\u0000';
|
|
240
|
+
const MASK_CLOSE = '\u0001';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* An ATX heading: up to 3 spaces of indent, 1-6 `#`, then its text.
|
|
244
|
+
*
|
|
245
|
+
* The optional closing `#` run is trimmed in code rather than in the pattern. Expressed as
|
|
246
|
+
* `(.*?)[ \t]*#*[ \t]*$` it gave the engine several ways to divide the same trailing whitespace,
|
|
247
|
+
* which is quadratic on a line of tabs.
|
|
248
|
+
*/
|
|
249
|
+
// The text group is anchored on a non-blank first character, for the same reason as `FENCE`: with
|
|
250
|
+
// the separator and the text both able to match a tab, a heading followed by a run of them gave
|
|
251
|
+
// the engine quadratically many ways to divide it. The empty alternative keeps group 1 a string.
|
|
252
|
+
const HEADING = /^ {0,3}#{1,6}(?:[ \t]+([^ \t\r\n][^\r\n]*|))?\r?$/;
|
|
253
|
+
|
|
254
|
+
/** The heading's text without its optional closing `#` run, trimmed as the pattern used to. */
|
|
255
|
+
function headingText(raw: string): string {
|
|
256
|
+
let end = raw.length;
|
|
257
|
+
while (end > 0 && (raw[end - 1] === ' ' || raw[end - 1] === '\t')) end--;
|
|
258
|
+
while (end > 0 && raw[end - 1] === '#') end--;
|
|
259
|
+
while (end > 0 && (raw[end - 1] === ' ' || raw[end - 1] === '\t')) end--;
|
|
260
|
+
return raw.slice(0, end);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const TAG_PREFIX = 'tool=';
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The tool a heading names, when it names exactly one — `## batch_execute`, `` ## `batch_execute` ``
|
|
267
|
+
* or `## {@tool batch_execute}`.
|
|
268
|
+
*
|
|
269
|
+
* A section titled after a tool is the author stating, in prose, which tool the examples below
|
|
270
|
+
* belong to. That is independent evidence about the tag, and the only kind available: field overlap
|
|
271
|
+
* cannot tell two tools apart when both declare `id`.
|
|
272
|
+
*/
|
|
273
|
+
function toolNamedByHeading(line: string, tools: ReadonlySet<string>): string | undefined {
|
|
274
|
+
const heading = HEADING.exec(line);
|
|
275
|
+
if (!heading) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
// The unwrapped name allows `-` because `NAME` does: some remote skill and tool names use it,
|
|
279
|
+
// and a narrower pattern here would silently skip the heading agreement check for them.
|
|
280
|
+
const named = headingText(heading[1] ?? '')
|
|
281
|
+
.replace(/^\{@tool[ \t]+([A-Za-z][A-Za-z0-9_-]*)\}$/, '$1')
|
|
282
|
+
.replace(/^`([A-Za-z][A-Za-z0-9_-]*)`$/, '$1');
|
|
283
|
+
return tools.has(named) ? named : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const DEFAULT_EXAMPLE_LANGUAGES: ReadonlySet<string> = new Set(['json']);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `{@param tool.field}`: a tool name, then a dotted/`[]` path into its params schema.
|
|
290
|
+
*
|
|
291
|
+
* The path uses the same spelling as a `dispatch` descriptor — `inputs[].input` — so one path
|
|
292
|
+
* syntax covers both, and renders as the path alone: the tool is usually named in the same
|
|
293
|
+
* sentence, and repeating it reads worse than the prose the construct replaces.
|
|
294
|
+
*/
|
|
295
|
+
const PARAM = /^([a-z][a-z0-9_-]*)\.([A-Za-z_][A-Za-z0-9_]*(?:\[\])*(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\[\])*)*)$/;
|
|
296
|
+
|
|
297
|
+
function renderParam(
|
|
298
|
+
whole: string,
|
|
299
|
+
args: string[],
|
|
300
|
+
line: number,
|
|
301
|
+
options: PreprocessSkillMarkdownOptions,
|
|
302
|
+
references: SkillReference[],
|
|
303
|
+
errors: string[],
|
|
304
|
+
): string {
|
|
305
|
+
const match = args.length === 1 ? PARAM.exec(args[0]) : null;
|
|
306
|
+
if (!match) {
|
|
307
|
+
errors.push(`line ${line}: '${whole}' must be written {@param tool.field}, with one dotted path`);
|
|
308
|
+
return whole;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const [, name, path] = match;
|
|
312
|
+
const rendered = `\`${path}\``;
|
|
313
|
+
const ambiguous = options.ambiguousTools?.has(name) ?? false;
|
|
314
|
+
let resolved = false;
|
|
315
|
+
|
|
316
|
+
if (ambiguous) {
|
|
317
|
+
errors.push(
|
|
318
|
+
`line ${line}: '${whole}' is ambiguous — more than one provider defines the tool ` +
|
|
319
|
+
`'${name}'. Resolve the collision at the source before referencing it.`,
|
|
320
|
+
);
|
|
321
|
+
} else if (!options.tools.has(name)) {
|
|
322
|
+
errors.push(`line ${line}: '${whole}' refers to a tool no provider registers`);
|
|
323
|
+
} else if (!options.validateField) {
|
|
324
|
+
// The construct exists only to be checked. A catalog that supplies no field validator
|
|
325
|
+
// would turn every `{@param …}` in the tree into decoration, and nothing would say so.
|
|
326
|
+
errors.push(
|
|
327
|
+
`line ${line}: '${whole}' cannot be checked: this build's catalog supplies no ` +
|
|
328
|
+
'validateField. Add one (createSchemaFieldValidator), or write the field as prose.',
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
let problems: string[];
|
|
332
|
+
try {
|
|
333
|
+
problems = options.validateField({ tool: name, path });
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// Same contract as validateExample: a broken validator is a finding, not an exception
|
|
336
|
+
// thrown from an unrelated frame.
|
|
337
|
+
problems = [`could not be checked: ${(err as Error).message}`];
|
|
338
|
+
}
|
|
339
|
+
errors.push(...problems.map((problem) => `line ${line}: '${whole}' ${problem}`));
|
|
340
|
+
resolved = problems.length === 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
references.push({ kind: 'param', name, path, rendered, line, resolved });
|
|
344
|
+
return rendered;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `{@subagent_tool launcher tool}` — a tool the sub-agent launched by `launcher` will call.
|
|
349
|
+
*
|
|
350
|
+
* Renders as the tool name alone, since that is what the reader needs: the sentence around it
|
|
351
|
+
* already says whose action it is. Both names are resolved here; whether `launcher` really
|
|
352
|
+
* launches an interaction configured with `tool` is checked by `check:skills`, which is the only
|
|
353
|
+
* place that can see interaction definitions.
|
|
354
|
+
*/
|
|
355
|
+
function renderSubagentTool(
|
|
356
|
+
whole: string,
|
|
357
|
+
args: string[],
|
|
358
|
+
line: number,
|
|
359
|
+
options: PreprocessSkillMarkdownOptions,
|
|
360
|
+
references: SkillReference[],
|
|
361
|
+
errors: string[],
|
|
362
|
+
): string {
|
|
363
|
+
if (args.length !== 2 || !NAME.test(args[0]) || !NAME.test(args[1])) {
|
|
364
|
+
errors.push(`line ${line}: '${whole}' must be written {@subagent_tool launcher tool}, naming two tools`);
|
|
365
|
+
return whole;
|
|
366
|
+
}
|
|
367
|
+
const [launcher, name] = args;
|
|
368
|
+
const rendered = `\`${name}\``;
|
|
369
|
+
let resolved = true;
|
|
370
|
+
for (const candidate of [launcher, name]) {
|
|
371
|
+
if (options.ambiguousTools?.has(candidate)) {
|
|
372
|
+
errors.push(
|
|
373
|
+
`line ${line}: '${whole}' is ambiguous — more than one provider defines the tool ` +
|
|
374
|
+
`'${candidate}'. Resolve the collision at the source before referencing it.`,
|
|
375
|
+
);
|
|
376
|
+
resolved = false;
|
|
377
|
+
} else if (!options.tools.has(candidate)) {
|
|
378
|
+
errors.push(`line ${line}: '${whole}' refers to a tool no provider registers: '${candidate}'`);
|
|
379
|
+
resolved = false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
references.push({ kind: 'subagent_tool', name, rendered, line, resolved });
|
|
383
|
+
return rendered;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Substitute constructs on a single line, leaving inline code spans verbatim. */
|
|
387
|
+
function renderLine(
|
|
388
|
+
source: string,
|
|
389
|
+
line: number,
|
|
390
|
+
options: PreprocessSkillMarkdownOptions,
|
|
391
|
+
references: SkillReference[],
|
|
392
|
+
errors: string[],
|
|
393
|
+
): string {
|
|
394
|
+
if (!source.includes('{@')) {
|
|
395
|
+
return source;
|
|
396
|
+
}
|
|
397
|
+
const prefix = options.skillToolPrefix ?? DEFAULT_SKILL_TOOL_PREFIX;
|
|
398
|
+
|
|
399
|
+
const masked: string[] = [];
|
|
400
|
+
const text = maskInlineCode(source, masked);
|
|
401
|
+
|
|
402
|
+
// Every `{@` must be consumed by a well-formed construct. An unterminated one would
|
|
403
|
+
// otherwise sail through as prose and reach the model as raw template syntax.
|
|
404
|
+
const consumed = new Set<number>();
|
|
405
|
+
|
|
406
|
+
const rendered = text.replace(CONSTRUCT, (whole, keyword: string, rest: string, offset: number) => {
|
|
407
|
+
consumed.add(offset);
|
|
408
|
+
const args = rest.trim().split(/\s+/).filter(Boolean);
|
|
409
|
+
|
|
410
|
+
if (keyword !== 'tool' && keyword !== 'skill' && keyword !== 'param' && keyword !== 'subagent_tool') {
|
|
411
|
+
errors.push(
|
|
412
|
+
`line ${line}: unknown construct '{@${keyword} …}' (expected {@tool …}, {@skill …}, ` +
|
|
413
|
+
'{@param …} or {@subagent_tool …})',
|
|
414
|
+
);
|
|
415
|
+
return whole;
|
|
416
|
+
}
|
|
417
|
+
if (keyword === 'param') {
|
|
418
|
+
return renderParam(whole, args, line, options, references, errors);
|
|
419
|
+
}
|
|
420
|
+
if (keyword === 'subagent_tool') {
|
|
421
|
+
return renderSubagentTool(whole, args, line, options, references, errors);
|
|
422
|
+
}
|
|
423
|
+
if (args.length !== 1 || !NAME.test(args[0])) {
|
|
424
|
+
errors.push(`line ${line}: '${whole}' must name exactly one ${keyword}`);
|
|
425
|
+
return whole;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const name = args[0];
|
|
429
|
+
const kind: SkillReferenceKind = keyword;
|
|
430
|
+
const known = kind === 'tool' ? options.tools : options.skills;
|
|
431
|
+
const ambiguous = (kind === 'tool' ? options.ambiguousTools : options.ambiguousSkills)?.has(name) ?? false;
|
|
432
|
+
const resolved = known.has(name) && !ambiguous;
|
|
433
|
+
const target = kind === 'skill' ? `${prefix}${name}` : name;
|
|
434
|
+
|
|
435
|
+
if (ambiguous) {
|
|
436
|
+
errors.push(
|
|
437
|
+
`line ${line}: '${whole}' is ambiguous — more than one provider defines the ${kind} ` +
|
|
438
|
+
`'${name}'. Resolve the collision at the source before referencing it.`,
|
|
439
|
+
);
|
|
440
|
+
} else if (!known.has(name)) {
|
|
441
|
+
errors.push(`line ${line}: '${whole}' refers to a ${kind} no provider registers`);
|
|
442
|
+
}
|
|
443
|
+
references.push({ kind, name, rendered: `\`${target}\``, line, resolved });
|
|
444
|
+
return `\`${target}\``;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
for (const opener of text.matchAll(/\{@/g)) {
|
|
448
|
+
if (!consumed.has(opener.index)) {
|
|
449
|
+
errors.push(`line ${line}: malformed construct at '${text.slice(opener.index, opener.index + 24)}…'`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return rendered.replace(
|
|
454
|
+
new RegExp(`${MASK_OPEN}(\\d+)${MASK_CLOSE}`, 'g'),
|
|
455
|
+
(_, index: string) => masked[Number(index)],
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function isClosingFence(line: string, marker: string): boolean {
|
|
460
|
+
const match = FENCE.exec(line);
|
|
461
|
+
return !!match && match[2][0] === marker[0] && match[2].length >= marker.length && match[3].trim() === '';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Render explicit constructs and validate tagged examples.
|
|
466
|
+
*
|
|
467
|
+
* Never throws and never reads the environment: a caller wanting a hard failure inspects
|
|
468
|
+
* `errors`, or uses `assertSkillMarkdown`.
|
|
469
|
+
*/
|
|
470
|
+
export function preprocessSkillMarkdown(
|
|
471
|
+
markdown: string,
|
|
472
|
+
options: PreprocessSkillMarkdownOptions,
|
|
473
|
+
): PreprocessSkillMarkdownResult {
|
|
474
|
+
const lines = markdown.split('\n');
|
|
475
|
+
const out: string[] = [];
|
|
476
|
+
const references: SkillReference[] = [];
|
|
477
|
+
const examples: SkillExample[] = [];
|
|
478
|
+
const errors: string[] = [];
|
|
479
|
+
|
|
480
|
+
// The tool named by the most recent heading, if it named one. Any other heading clears it, so a
|
|
481
|
+
// later unrelated section never inherits the claim.
|
|
482
|
+
let headingTool: string | undefined;
|
|
483
|
+
let headingLine = 0;
|
|
484
|
+
|
|
485
|
+
for (let i = 0; i < lines.length; ) {
|
|
486
|
+
const fence = FENCE.exec(lines[i]);
|
|
487
|
+
if (!fence) {
|
|
488
|
+
if (HEADING.test(lines[i])) {
|
|
489
|
+
headingTool = toolNamedByHeading(lines[i], options.tools);
|
|
490
|
+
headingLine = i + 1;
|
|
491
|
+
}
|
|
492
|
+
out.push(renderLine(lines[i], i + 1, options, references, errors));
|
|
493
|
+
i++;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Fenced block: emitted verbatim apart from the `tool=` tag, which is build metadata.
|
|
498
|
+
const [, indent, marker, info] = fence;
|
|
499
|
+
const openLine = i + 1;
|
|
500
|
+
const parts = info.trim().split(/\s+/).filter(Boolean);
|
|
501
|
+
const lang = parts[0] ?? '';
|
|
502
|
+
const tagIndexes = parts.flatMap((p, index) => (index > 0 && p.startsWith(TAG_PREFIX) ? [index] : []));
|
|
503
|
+
const tagIndex = tagIndexes[0] ?? -1;
|
|
504
|
+
const tag = tagIndex === -1 ? undefined : parts[tagIndex].slice(TAG_PREFIX.length);
|
|
505
|
+
|
|
506
|
+
if (tagIndexes.length > 1) {
|
|
507
|
+
// Silently honouring the first would validate against a tool the author did not mean.
|
|
508
|
+
errors.push(
|
|
509
|
+
`line ${openLine}: fence carries ${tagIndexes.length} ${TAG_PREFIX} tags ` +
|
|
510
|
+
`(${tagIndexes.map((index) => parts[index]).join(', ')}); exactly one is allowed`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const body: string[] = [];
|
|
515
|
+
let j = i + 1;
|
|
516
|
+
for (; j < lines.length && !isClosingFence(lines[j], marker); j++) {
|
|
517
|
+
body.push(lines[j]);
|
|
518
|
+
}
|
|
519
|
+
const closed = j < lines.length;
|
|
520
|
+
|
|
521
|
+
if (!closed && tag !== undefined) {
|
|
522
|
+
// An unterminated tagged fence swallows the rest of the document into its "payload",
|
|
523
|
+
// so the tag validates nonsense and the prose below it is never rendered.
|
|
524
|
+
errors.push(`line ${openLine}: fence tagged '${TAG_PREFIX}${tag}' is never closed`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Fence bodies are passed through untouched, so a construct written inside one is never
|
|
528
|
+
// rendered and reaches the model verbatim — and, because it is not a reference either, no
|
|
529
|
+
// other check sees it. Silence is the whole danger, so report it here.
|
|
530
|
+
for (const [offset, line] of body.entries()) {
|
|
531
|
+
for (const found of line.matchAll(FENCED_CONSTRUCT)) {
|
|
532
|
+
errors.push(
|
|
533
|
+
`line ${openLine + 1 + offset}: '${found[0]}' is inside a code fence, where it ` +
|
|
534
|
+
'is passed through unrendered; move it into prose or write the rendered form',
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Rewrite the opening line only when there is a tag to remove, so untagged fences keep
|
|
540
|
+
// their exact spelling — a preprocessor that reformats untouched prose is a liability.
|
|
541
|
+
// The line's own terminator is carried over for the same reason: rewriting one CRLF line
|
|
542
|
+
// as LF would leave the file mixed.
|
|
543
|
+
const cr = lines[i].endsWith('\r') ? '\r' : '';
|
|
544
|
+
const open =
|
|
545
|
+
tagIndex === -1
|
|
546
|
+
? lines[i]
|
|
547
|
+
: `${indent}${marker}${parts.filter((_, index) => index !== tagIndex).join(' ')}${cr}`;
|
|
548
|
+
out.push(open, ...body);
|
|
549
|
+
if (closed) {
|
|
550
|
+
out.push(lines[j]);
|
|
551
|
+
}
|
|
552
|
+
i = closed ? j + 1 : j;
|
|
553
|
+
|
|
554
|
+
if (tag !== undefined) {
|
|
555
|
+
if (headingTool !== undefined && tag !== headingTool) {
|
|
556
|
+
// Both were written deliberately, so one of them is wrong and only the author knows
|
|
557
|
+
// which. Schema validation cannot arbitrate: it would have accepted either.
|
|
558
|
+
errors.push(
|
|
559
|
+
`line ${openLine}: fence is tagged '${TAG_PREFIX}${tag}' under the heading for ` +
|
|
560
|
+
`'${headingTool}' (line ${headingLine}); the tag and the heading name ` +
|
|
561
|
+
'different tools',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
examples.push(validateFenceExample(tag, lang, body.join('\n'), openLine, options, errors));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return { markdown: out.join('\n'), references, examples, errors };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function validateFenceExample(
|
|
572
|
+
tag: string,
|
|
573
|
+
lang: string,
|
|
574
|
+
body: string,
|
|
575
|
+
line: number,
|
|
576
|
+
options: PreprocessSkillMarkdownOptions,
|
|
577
|
+
errors: string[],
|
|
578
|
+
): SkillExample {
|
|
579
|
+
const example: SkillExample = { tool: tag, lang, source: body, line, errors: [] };
|
|
580
|
+
const languages = options.exampleLanguages ?? DEFAULT_EXAMPLE_LANGUAGES;
|
|
581
|
+
|
|
582
|
+
if (!NAME.test(tag)) {
|
|
583
|
+
example.errors.push(`'${TAG_PREFIX}${tag}' is not a valid tool name`);
|
|
584
|
+
} else if (!languages.has(lang)) {
|
|
585
|
+
// A tag on a shell or Python fence can never be validated, so it is a false assurance:
|
|
586
|
+
// the fence looks gated in review while nothing checks it.
|
|
587
|
+
example.errors.push(
|
|
588
|
+
`is tagged '${TAG_PREFIX}${tag}' on a '${lang || '(none)'}' fence; only ` +
|
|
589
|
+
`${[...languages].join(', ')} fences can be validated`,
|
|
590
|
+
);
|
|
591
|
+
} else if (!options.tools.has(tag)) {
|
|
592
|
+
example.errors.push(`is tagged for '${tag}', which no provider registers`);
|
|
593
|
+
} else if (options.ambiguousTools?.has(tag)) {
|
|
594
|
+
example.errors.push(`is tagged for '${tag}', which more than one provider defines`);
|
|
595
|
+
} else if (options.unvalidatableTools?.has(tag)) {
|
|
596
|
+
example.errors.push(
|
|
597
|
+
`is tagged for '${tag}', whose schema this build cannot see, so the payload cannot be ` +
|
|
598
|
+
'verified. Remove the tag, or make the schema available to the catalog.',
|
|
599
|
+
);
|
|
600
|
+
} else {
|
|
601
|
+
try {
|
|
602
|
+
example.value = JSON.parse(body);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
example.errors.push(`is tagged for validation but is not strict JSON: ${(err as Error).message}`);
|
|
605
|
+
}
|
|
606
|
+
if (example.value !== undefined && options.validateExample) {
|
|
607
|
+
try {
|
|
608
|
+
example.errors.push(...options.validateExample({ tool: tag, value: example.value }));
|
|
609
|
+
} catch (err) {
|
|
610
|
+
// The no-throw contract is the caller's guarantee; a broken validator must be
|
|
611
|
+
// reported as a finding, not turned into an exception from an unrelated frame.
|
|
612
|
+
example.errors.push(`could not be validated: ${(err as Error).message}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
errors.push(...example.errors.map((message) => `line ${line}: example ${message}`));
|
|
618
|
+
return example;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Convenience for build callers: preprocess, or throw once with every problem listed. */
|
|
622
|
+
export function assertSkillMarkdown(
|
|
623
|
+
markdown: string,
|
|
624
|
+
options: PreprocessSkillMarkdownOptions,
|
|
625
|
+
source: string,
|
|
626
|
+
): PreprocessSkillMarkdownResult {
|
|
627
|
+
const result = preprocessSkillMarkdown(markdown, options);
|
|
628
|
+
if (result.errors.length > 0) {
|
|
629
|
+
throw new Error(`Skill markdown errors in ${source}:\n${result.errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|