@styx-api/core 0.6.0 → 0.7.0

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.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Lower an `AstDocument` to Styx IR (`Expr` + `AppMeta`).
3
+ *
4
+ * Mapping highlights:
5
+ * - Combinators map 1:1 except `set` -> `sequence` (order-not-meaningful is not
6
+ * modeled in the IR) and `any` -> its first branch (the spec's "emit branch 0"
7
+ * rule; the interchangeable alternatives are dropped with a warning).
8
+ * - Aliases are resolved by substitution with cycle detection.
9
+ * - `.output(...)` declarations attach to the nearest enclosing sequence scope,
10
+ * so an output nested in a repeat / subcommand keeps its list / struct shape
11
+ * (per-output gating is recovered downstream from each ref binding's gate).
12
+ */
13
+
14
+ import { alt, float, int, lit, opt, path as pathTerm, rep, seq, str } from "../../ir/builders.js";
15
+ import { nodeRef } from "../../ir/meta.js";
16
+ import type { AppMeta, NodeMeta, Output, OutputToken, StreamOutput } from "../../ir/meta.js";
17
+ import type { Documentation } from "../../ir/types.js";
18
+ import type { Expr, Sequence } from "../../ir/node.js";
19
+ import type { AstAlias, AstDocument, AstNode, AstOutput } from "./ast.js";
20
+
21
+ export interface LowerResult {
22
+ meta?: AppMeta;
23
+ expr: Sequence;
24
+ errors: string[];
25
+ warnings: string[];
26
+ }
27
+
28
+ /** Build IR `Documentation` from an AST node's already-split title/description. */
29
+ function docFrom(node: { title?: string; description?: string }): Documentation | undefined {
30
+ const doc: Documentation = {
31
+ ...(node.title && { title: node.title }),
32
+ ...(node.description && { description: node.description }),
33
+ };
34
+ return Object.keys(doc).length > 0 ? doc : undefined;
35
+ }
36
+
37
+ class Lowerer {
38
+ readonly errors: string[] = [];
39
+ readonly warnings: string[] = [];
40
+ /** Extensions whose annotations the document actually uses, collected during
41
+ * lowering so `lowerDocument` can flag any used-but-undeclared in frontmatter. */
42
+ readonly usedExtensions = new Set<string>();
43
+ private aliases = new Map<string, AstNode>();
44
+
45
+ constructor(aliases: AstAlias[]) {
46
+ for (const a of aliases) {
47
+ if (this.aliases.has(a.name))
48
+ this.warnings.push(`Duplicate alias '${a.name}'; last definition wins`);
49
+ this.aliases.set(a.name, a.expr);
50
+ }
51
+ }
52
+
53
+ lower(doc: AstDocument): LowerResult {
54
+ // Each output attaches to its nearest enclosing sequence scope (via the
55
+ // `sink`), preserving nesting so a per-repeat / per-subcommand output keeps
56
+ // its list/struct shape. `rootSink` catches outputs declared directly on a
57
+ // non-sequence root (a sequence root manages its own outputs).
58
+ const rootSink: Output[] = [];
59
+ const expr = this.lowerNode(doc.root, new Set(), undefined, rootSink);
60
+
61
+ const root: Sequence = expr.kind === "sequence" ? expr : seq(expr);
62
+ if (root !== expr) {
63
+ root.meta = { ...(doc.root.name && { name: doc.root.name }) };
64
+ }
65
+ if (rootSink.length > 0) {
66
+ root.meta = { ...root.meta, outputs: [...(root.meta?.outputs ?? []), ...rootSink] };
67
+ }
68
+
69
+ return { expr: root, errors: this.errors, warnings: this.warnings };
70
+ }
71
+
72
+ /**
73
+ * @param expanding - alias names currently being expanded (cycle guard).
74
+ * @param selfName - nearest enclosing named node, for `{}` self-references.
75
+ * @param sink - outputs array of the nearest enclosing sequence; a `.output()`
76
+ * on this node attaches here (seq/set nodes instead own their outputs).
77
+ */
78
+ private lowerNode(
79
+ node: AstNode,
80
+ expanding: Set<string>,
81
+ selfName: string | undefined,
82
+ sink: Output[],
83
+ ): Expr {
84
+ // Alias reference: substitute then lower.
85
+ if (node.kind === "ref") {
86
+ return this.lowerRef(node, expanding, selfName, sink);
87
+ }
88
+
89
+ const name = node.name ?? selfName;
90
+
91
+ // `.join` collapses a node's subtree into one argv element. It is carried on
92
+ // sequence/repeat in the IR directly, and on `opt` by pushing it onto the
93
+ // wrapped content (`lowerComb`). On other nodes it has no meaning, so warn.
94
+ const joinable =
95
+ node.kind === "comb" &&
96
+ (node.op === "seq" || node.op === "set" || node.op === "rep" || node.op === "opt");
97
+ if (node.join !== undefined && !joinable) {
98
+ this.warnings.push("`.join()` is only supported on seq/set/rep/opt; ignored here");
99
+ }
100
+
101
+ // A type-specific modifier that lands on an incompatible node is silently
102
+ // dropped downstream (only the matching lower* case reads it), so warn -
103
+ // same contract as the `.join()` check. Refs are already resolved above, so
104
+ // by here the node's concrete kind is known and these checks are accurate.
105
+ const isNumericTerminal =
106
+ node.kind === "terminal" && (node.terminal === "int" || node.terminal === "float");
107
+ const isRep = node.kind === "comb" && node.op === "rep";
108
+ const isPath = node.kind === "terminal" && node.terminal === "path";
109
+ if ((node.min !== undefined || node.max !== undefined) && !isNumericTerminal) {
110
+ this.warnings.push(
111
+ "`.min()`/`.max()` is only supported on int/float terminals; ignored here",
112
+ );
113
+ }
114
+ if ((node.countMin !== undefined || node.countMax !== undefined) && !isRep) {
115
+ this.warnings.push(
116
+ "`.count()`/`.countMin()`/`.countMax()` is only supported on rep; ignored here",
117
+ );
118
+ }
119
+ if ((node.mutable || node.resolveParent) && !isPath) {
120
+ this.warnings.push("`.mutable()`/`.resolveParent()` is only supported on path; ignored here");
121
+ }
122
+ if (node.mediaTypes?.length && !isPath) {
123
+ this.warnings.push("`.mediaType()` is only supported on path; ignored here");
124
+ }
125
+
126
+ // Record which extensions' annotations the author reached for, so a document
127
+ // that uses one without declaring it in frontmatter can be flagged. Only count
128
+ // an annotation that actually applies here: a misapplied `path`-only modifier
129
+ // is already warned about and dropped just above, so recording it would make
130
+ // declare-before-use contradict that ("ignored" yet "declare it").
131
+ if (node.outputs?.length) this.usedExtensions.add("outputs");
132
+ if (node.mediaTypes?.length && isPath) this.usedExtensions.add("mediatypes");
133
+ if ((node.mutable || node.resolveParent) && isPath) this.usedExtensions.add("paths");
134
+
135
+ // A `.output()` on a non-sequence node attaches to the enclosing sequence
136
+ // scope (`sink`); seq/set own their outputs (handled in `lowerComb`).
137
+ const isSeqSet = node.kind === "comb" && (node.op === "seq" || node.op === "set");
138
+ if (node.outputs?.length && !isSeqSet) {
139
+ for (const o of node.outputs) sink.push(this.toOutput(o, name));
140
+ }
141
+
142
+ switch (node.kind) {
143
+ case "literal": {
144
+ const e = lit(node.value ?? "");
145
+ this.applyMeta(e, node);
146
+ return e;
147
+ }
148
+ case "terminal":
149
+ return this.lowerTerminal(node);
150
+ case "comb":
151
+ return this.lowerComb(node, expanding, name, sink);
152
+ default: {
153
+ this.errors.push(`Unknown AST node kind '${(node as AstNode).kind}'`);
154
+ return seq();
155
+ }
156
+ }
157
+ }
158
+
159
+ private lowerRef(
160
+ node: AstNode,
161
+ expanding: Set<string>,
162
+ selfName: string | undefined,
163
+ sink: Output[],
164
+ ): Expr {
165
+ const target = node.refName!;
166
+ const aliasExpr = this.aliases.get(target);
167
+ if (!aliasExpr) {
168
+ this.errors.push(`Unknown alias '${target}'`);
169
+ return seq();
170
+ }
171
+ if (expanding.has(target)) {
172
+ this.errors.push(`Recursive alias '${target}' is not allowed`);
173
+ return seq();
174
+ }
175
+ const clone = structuredClone(aliasExpr);
176
+ // Overlay use-site decorations onto the inlined alias. The use site wins for
177
+ // each scalar decoration it specifies; outputs and media types accumulate.
178
+ // Without this, `size: Dimension.join(",")` would silently drop the join.
179
+ clone.name = node.name ?? clone.name;
180
+ clone.title = node.title ?? clone.title;
181
+ clone.description = node.description ?? clone.description;
182
+ if (node.default !== undefined) clone.default = node.default;
183
+ if (node.min !== undefined) clone.min = node.min;
184
+ if (node.max !== undefined) clone.max = node.max;
185
+ if (node.join !== undefined) clone.join = node.join;
186
+ if (node.countMin !== undefined) clone.countMin = node.countMin;
187
+ if (node.countMax !== undefined) clone.countMax = node.countMax;
188
+ if (node.mediaTypes?.length)
189
+ clone.mediaTypes = [...(clone.mediaTypes ?? []), ...node.mediaTypes];
190
+ if (node.mutable) clone.mutable = true;
191
+ if (node.resolveParent) clone.resolveParent = true;
192
+ if (node.outputs?.length) clone.outputs = [...(clone.outputs ?? []), ...node.outputs];
193
+
194
+ const next = new Set(expanding);
195
+ next.add(target);
196
+ return this.lowerNode(clone, next, selfName, sink);
197
+ }
198
+
199
+ private lowerTerminal(node: AstNode): Expr {
200
+ switch (node.terminal) {
201
+ case "int": {
202
+ const e = int();
203
+ this.checkBounds(node.min, node.max, "value", "min", "max");
204
+ if (node.min !== undefined) e.attrs.minValue = node.min;
205
+ if (node.max !== undefined) e.attrs.maxValue = node.max;
206
+ this.applyMeta(e, node);
207
+ return e;
208
+ }
209
+ case "float": {
210
+ const e = float();
211
+ this.checkBounds(node.min, node.max, "value", "min", "max");
212
+ if (node.min !== undefined) e.attrs.minValue = node.min;
213
+ if (node.max !== undefined) e.attrs.maxValue = node.max;
214
+ this.applyMeta(e, node);
215
+ return e;
216
+ }
217
+ case "str": {
218
+ // A `str.mediaType(...)` is warned about generically in `lowerNode`
219
+ // (media types are a `path`-only annotation), so nothing to do here.
220
+ const e = str();
221
+ this.applyMeta(e, node);
222
+ return e;
223
+ }
224
+ case "path": {
225
+ const e = pathTerm();
226
+ if (node.mediaTypes?.length) e.attrs.mediaTypes = node.mediaTypes;
227
+ if (node.mutable) e.attrs.mutable = true;
228
+ if (node.resolveParent) e.attrs.resolveParent = true;
229
+ this.applyMeta(e, node);
230
+ return e;
231
+ }
232
+ default: {
233
+ this.errors.push(`Unknown terminal '${String(node.terminal)}'`);
234
+ return str();
235
+ }
236
+ }
237
+ }
238
+
239
+ private lowerComb(
240
+ node: AstNode,
241
+ expanding: Set<string>,
242
+ name: string | undefined,
243
+ sink: Output[],
244
+ ): Expr {
245
+ const children = node.children ?? [];
246
+ const lowerChildren = (s: Output[]): Expr[] =>
247
+ children.map((c) => this.lowerNode(c, expanding, name, s));
248
+
249
+ switch (node.op) {
250
+ case "seq":
251
+ case "set": {
252
+ // `set` is lowered to a sequence: the IR does not model "order not
253
+ // meaningful". A sequence is an output scope, so its own `.output()`s
254
+ // and any outputs its children declare attach here (not the parent).
255
+ const selfOutputs: Output[] = [];
256
+ for (const o of node.outputs ?? []) selfOutputs.push(this.toOutput(o, name));
257
+ const lowered = lowerChildren(selfOutputs);
258
+ this.dedupeSiblingNames(lowered);
259
+ const e = seq(...lowered);
260
+ if (node.join !== undefined) e.attrs.join = node.join;
261
+ this.applyMeta(e, node);
262
+ if (selfOutputs.length > 0) {
263
+ e.meta = { ...e.meta, outputs: [...(e.meta?.outputs ?? []), ...selfOutputs] };
264
+ }
265
+ return e;
266
+ }
267
+ case "alt": {
268
+ // An alt arm's name is its discriminant, scoped to the union. Two arms
269
+ // with the same label would collide the same way two same-named seq/set
270
+ // fields do (the solver keys variants by name), so disambiguate them the
271
+ // same way. `any` is exempt: it emits branch 0 and its branches are
272
+ // deliberately binding-compatible (same names by design).
273
+ const arms = lowerChildren(sink);
274
+ this.dedupeSiblingNames(arms);
275
+ const e = alt(...arms);
276
+ this.applyMeta(e, node);
277
+ return e;
278
+ }
279
+ case "any": {
280
+ // Emit the first branch only. The `any` branches are interchangeable
281
+ // spellings of one token (`--output` / `-output` / `-o`), and the spec
282
+ // makes branch 0 authoritative for builders - so for a generator this
283
+ // is lossless and expected, not a degradation worth warning about.
284
+ // (Only a parser/validator would need to accept the other spellings.)
285
+ if (children.length === 0) {
286
+ this.errors.push("`any(...)` requires at least one branch");
287
+ return seq();
288
+ }
289
+ const e = this.lowerNode(children[0]!, expanding, name, sink);
290
+ // Overlay the any's own name/doc onto the emitted branch.
291
+ if (node.name) e.meta = { ...e.meta, name: node.name };
292
+ {
293
+ const d = docFrom(node);
294
+ if (d) e.meta = { ...e.meta, doc: d };
295
+ }
296
+ return e;
297
+ }
298
+ case "opt": {
299
+ const inner = this.wrapChildren(children, expanding, name, sink);
300
+ // `.join()` on the opt collapses its content into one argv element; push
301
+ // it onto the inner sequence/repeat (a single terminal is already one
302
+ // element, so a join there is a harmless no-op).
303
+ if (node.join !== undefined && (inner.kind === "sequence" || inner.kind === "repeat")) {
304
+ inner.attrs.join = node.join;
305
+ }
306
+ const e = opt(inner);
307
+ this.applyMeta(e, node);
308
+ // A bare-literal flag resolves to a bool; give it a false default to
309
+ // match the other frontends' flag convention.
310
+ if (inner.kind === "literal") e.meta = { ...e.meta, defaultValue: false };
311
+ return e;
312
+ }
313
+ case "rep": {
314
+ const inner = this.wrapChildren(children, expanding, name, sink);
315
+ const e = rep(inner);
316
+ if (node.join !== undefined) e.attrs.join = node.join;
317
+ this.checkBounds(node.countMin, node.countMax, "repetition count", "countMin", "countMax");
318
+ if (node.countMin !== undefined) e.attrs.countMin = node.countMin;
319
+ if (node.countMax !== undefined) e.attrs.countMax = node.countMax;
320
+ this.applyMeta(e, node);
321
+ return e;
322
+ }
323
+ default: {
324
+ this.errors.push(`Unknown combinator '${String(node.op)}'`);
325
+ return seq();
326
+ }
327
+ }
328
+ }
329
+
330
+ /** Warn on an inverted `[min, max]` pair (a lower bound above its upper
331
+ * bound), which yields an unsatisfiable constraint downstream. */
332
+ private checkBounds(
333
+ min: number | undefined,
334
+ max: number | undefined,
335
+ what: string,
336
+ minLabel: string,
337
+ maxLabel: string,
338
+ ): void {
339
+ if (min !== undefined && max !== undefined && min > max) {
340
+ this.warnings.push(`Inverted ${what} bounds: ${minLabel} (${min}) > ${maxLabel} (${max})`);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Disambiguate duplicate explicit names among the direct children of a
346
+ * sequence/set (struct fields) or the arms of an alternative (union
347
+ * discriminants). The solver turns each named child into a struct field / union
348
+ * variant keyed by its name; two siblings with the same name would silently
349
+ * collapse (the second overwrites the first, dropping a parameter). argtype is
350
+ * hand-authored
351
+ * and gives no uniqueness guarantee, so - like the mrtrix frontend - we rename
352
+ * collisions (`name_2`, `name_3`, ...) and warn. (This catches duplicate
353
+ * explicit labels; it does not detect collisions between solver-derived names
354
+ * of otherwise-unnamed children, which the solver also does not guard.)
355
+ */
356
+ private dedupeSiblingNames(children: Expr[]): void {
357
+ const used = new Set<string>();
358
+ for (const child of children) {
359
+ const nm = child.meta?.name;
360
+ if (nm === undefined) continue;
361
+ if (!used.has(nm)) {
362
+ used.add(nm);
363
+ continue;
364
+ }
365
+ let n = 2;
366
+ while (used.has(`${nm}_${n}`)) n++;
367
+ const renamed = `${nm}_${n}`;
368
+ used.add(renamed);
369
+ child.meta = { ...child.meta, name: renamed };
370
+ this.warnings.push(`Duplicate sibling name '${nm}'; renamed one occurrence to '${renamed}'`);
371
+ }
372
+ }
373
+
374
+ /** `opt`/`rep` with multiple children implicitly wrap them in a sequence,
375
+ * which - like any sequence - is the output scope for those children. */
376
+ private wrapChildren(
377
+ children: AstNode[],
378
+ expanding: Set<string>,
379
+ name: string | undefined,
380
+ sink: Output[],
381
+ ): Expr {
382
+ if (children.length === 1) return this.lowerNode(children[0]!, expanding, name, sink);
383
+ const selfOutputs: Output[] = [];
384
+ const e = seq(...children.map((c) => this.lowerNode(c, expanding, name, selfOutputs)));
385
+ if (selfOutputs.length > 0) e.meta = { ...e.meta, outputs: selfOutputs };
386
+ return e;
387
+ }
388
+
389
+ /** Fold an AST node's name/doc/default decorations onto an IR node's meta. */
390
+ private applyMeta(e: Expr, node: AstNode): void {
391
+ const meta: NodeMeta = {};
392
+ if (node.name) meta.name = node.name;
393
+ const doc = docFrom(node);
394
+ if (doc) meta.doc = doc;
395
+ if (node.default !== undefined) meta.defaultValue = node.default;
396
+ if (Object.keys(meta).length > 0) e.meta = { ...e.meta, ...meta };
397
+ }
398
+
399
+ private toOutput(o: AstOutput, selfName: string | undefined): Output {
400
+ const tokens: OutputToken[] = [];
401
+ for (const t of o.tokens) {
402
+ if (t.kind === "literal") {
403
+ tokens.push({ kind: "literal", value: t.value });
404
+ continue;
405
+ }
406
+ const targetName = t.name ?? selfName;
407
+ if (!targetName) {
408
+ this.warnings.push(
409
+ "Output self-reference '{}' has no named node to resolve to; emitting empty",
410
+ );
411
+ tokens.push({ kind: "literal", value: "" });
412
+ continue;
413
+ }
414
+ if (t.stripPrefix?.length)
415
+ this.warnings.push("Output ref op 'strip_prefix' is not supported by the IR; ignored");
416
+ if (t.basename)
417
+ this.warnings.push("Output ref op 'basename' is not supported by the IR; ignored");
418
+ tokens.push({
419
+ kind: "ref",
420
+ target: nodeRef(targetName),
421
+ ...(t.stripSuffix?.length && { stripExtensions: t.stripSuffix }),
422
+ ...(t.or !== undefined && { fallback: t.or }),
423
+ });
424
+ }
425
+ if (o.fallback !== undefined) {
426
+ this.warnings.push("Output-level '.or(...)' fallback is not supported by the IR; ignored");
427
+ }
428
+ const doc = docFrom(o);
429
+ return { ...(o.name && { name: o.name }), ...(doc && { doc }), tokens };
430
+ }
431
+ }
432
+
433
+ export function buildAppMeta(
434
+ fm: Record<string, unknown> | undefined,
435
+ rootDoc: { title?: string; description?: string },
436
+ rootName: string | undefined,
437
+ ): { meta?: AppMeta; warnings: string[] } {
438
+ const warnings: string[] = [];
439
+ const rootTitleDesc = docFrom(rootDoc);
440
+ if (!fm) {
441
+ // No frontmatter: id is the root definition name; still surface the root doc.
442
+ const meta: AppMeta = {
443
+ ...(rootName && { id: rootName }),
444
+ ...(rootTitleDesc && { doc: rootTitleDesc }),
445
+ };
446
+ return Object.keys(meta).length > 0 ? { meta, warnings } : { warnings };
447
+ }
448
+
449
+ const asStr = (x: unknown): string | undefined =>
450
+ typeof x === "string" && x.length > 0 ? x : undefined;
451
+ // The tool id is the root definition name; `exe` is the executable (argv[0]),
452
+ // which can differ (e.g. a `wb_command <sub>` tool has id `<sub>`, exe
453
+ // `wb_command`). Fall back to `exe`/`id` frontmatter only if the root is unnamed.
454
+ const id = asStr(rootName) ?? asStr(fm.exe) ?? asStr(fm.id);
455
+ // An unquoted numeric version (`version: 6.0`) is parsed as a number by the
456
+ // frontmatter reader; accept it by stringifying rather than dropping it.
457
+ const version =
458
+ asStr(fm.version) ?? (typeof fm.version === "number" ? String(fm.version) : undefined);
459
+ const strList = (x: unknown): string[] =>
460
+ Array.isArray(x) ? x.filter((a): a is string => typeof a === "string") : [];
461
+ const authors = strList(fm.authors);
462
+ const urls = strList(fm.urls);
463
+ const references = strList(fm.references);
464
+
465
+ const doc: Documentation = {
466
+ ...rootTitleDesc,
467
+ ...(authors.length > 0 && { authors }),
468
+ ...(urls.length > 0 && { urls }),
469
+ ...(references.length > 0 && { literature: references }),
470
+ };
471
+
472
+ const meta: AppMeta = {
473
+ ...(id && { id }),
474
+ ...(version && { version }),
475
+ ...(Object.keys(doc).length > 0 && { doc }),
476
+ };
477
+
478
+ // container: { image, type }
479
+ if (isRecord(fm.container)) {
480
+ const image = asStr(fm.container.image);
481
+ const type = asStr(fm.container.type);
482
+ if (image) {
483
+ meta.container = {
484
+ image,
485
+ ...(type === "docker" || type === "singularity" ? { type } : {}),
486
+ };
487
+ }
488
+ }
489
+
490
+ meta.stdout = streamFrom(fm.stdout, warnings, "stdout");
491
+ meta.stderr = streamFrom(fm.stderr, warnings, "stderr");
492
+ if (!meta.stdout) delete meta.stdout;
493
+ if (!meta.stderr) delete meta.stderr;
494
+
495
+ return { meta, warnings };
496
+ }
497
+
498
+ function streamFrom(x: unknown, warnings: string[], key: string): StreamOutput | undefined {
499
+ if (x === undefined || x === null) return undefined;
500
+ if (isRecord(x)) {
501
+ const name = typeof x.name === "string" ? x.name : undefined;
502
+ if (!name) {
503
+ warnings.push(`Frontmatter '${key}' is missing a 'name'; ignored`);
504
+ return undefined;
505
+ }
506
+ const description = typeof x.description === "string" ? x.description : undefined;
507
+ return { name, ...(description && { doc: { description } }) };
508
+ }
509
+ if (typeof x === "string") return { name: x };
510
+ warnings.push(`Frontmatter '${key}' has an unexpected shape; ignored`);
511
+ return undefined;
512
+ }
513
+
514
+ function isRecord(x: unknown): x is Record<string, unknown> {
515
+ return typeof x === "object" && x !== null && !Array.isArray(x);
516
+ }
517
+
518
+ export function lowerDocument(doc: AstDocument): LowerResult {
519
+ const lowerer = new Lowerer(doc.aliases);
520
+ const result = lowerer.lower(doc);
521
+
522
+ // argtype describes the arguments only (everything after argv[0]); the
523
+ // executable lives in frontmatter. Prepend it as the command's first token so
524
+ // the IR is a complete command line (the backend strips it back out).
525
+ const exe = typeof doc.frontmatter?.exe === "string" ? doc.frontmatter.exe : undefined;
526
+ if (exe) result.expr.attrs.nodes.unshift(lit(exe));
527
+
528
+ const { meta, warnings } = buildAppMeta(
529
+ doc.frontmatter,
530
+ { title: doc.root.title, description: doc.root.description },
531
+ doc.rootName,
532
+ );
533
+
534
+ // Declare-before-use: when a document has frontmatter, an extension whose
535
+ // annotations it uses should be listed in `extensions:`. We only check this
536
+ // when frontmatter is present - a frontmatter-less snippet hasn't opted into
537
+ // the declaration discipline, so flagging every quick `.output()` would be
538
+ // noise. The warning is advisory (extensions are ignorable by contract).
539
+ const extWarnings: string[] = [];
540
+ if (doc.frontmatter) {
541
+ const declared = new Set(
542
+ Array.isArray(doc.frontmatter.extensions)
543
+ ? doc.frontmatter.extensions.filter((e): e is string => typeof e === "string")
544
+ : [],
545
+ );
546
+ for (const used of lowerer.usedExtensions) {
547
+ if (!declared.has(used)) {
548
+ extWarnings.push(
549
+ `Uses the '${used}' extension but does not declare it in frontmatter (add it to 'extensions:')`,
550
+ );
551
+ }
552
+ }
553
+ }
554
+
555
+ return {
556
+ ...(meta && { meta }),
557
+ expr: result.expr,
558
+ errors: result.errors,
559
+ warnings: [...result.warnings, ...warnings, ...extWarnings],
560
+ };
561
+ }
@@ -0,0 +1,51 @@
1
+ import type { Sequence } from "../../ir/node.js";
2
+ import type { Frontend, ParseError, ParseResult, ParseWarning } from "../frontend.js";
3
+ import { parseArgtype } from "./parser.js";
4
+ import { lowerDocument } from "./lower.js";
5
+
6
+ /** A fresh empty root sequence for error returns (IR passes mutate in place). */
7
+ function emptyExpr(): Sequence {
8
+ return { kind: "sequence", attrs: { nodes: [] } };
9
+ }
10
+
11
+ /**
12
+ * Frontend for the argtype sugar DSL - the hand-authored, TypeScript-types-like
13
+ * language for describing CLI argument grammars (see the argtype spec). Parses
14
+ * the text into an AST, then lowers it to Styx IR + AppMeta.
15
+ *
16
+ * Supported today: the argtype core (combinators, terminals, literals, naming,
17
+ * aliases, value constraints, `.join`/`.count`/`.default`, doc comments,
18
+ * frontmatter) plus the `outputs`, `mediatypes`, and `paths` extensions. `set`
19
+ * lowers to a sequence and `any` to its first branch; the `constraints`
20
+ * extension is parsed-and-ignored.
21
+ */
22
+ export class ArgtypeParser implements Frontend {
23
+ readonly name = "argtype";
24
+ readonly extensions = ["argtype"];
25
+
26
+ parse(source: string, _filename?: string): ParseResult {
27
+ const { doc, errors: parseErrors, warnings: parseWarnings } = parseArgtype(source);
28
+ const toLocation = (e: { line?: number; column?: number }) =>
29
+ e.line !== undefined ? { location: { line: e.line, column: e.column } } : {};
30
+ const errors: ParseError[] = parseErrors.map((e) => ({ message: e.message, ...toLocation(e) }));
31
+ const warnings: ParseWarning[] = parseWarnings.map((e) => ({
32
+ message: e.message,
33
+ ...toLocation(e),
34
+ }));
35
+
36
+ if (!doc) {
37
+ return { expr: emptyExpr(), errors, warnings };
38
+ }
39
+
40
+ const lowered = lowerDocument(doc);
41
+ warnings.push(...lowered.warnings.map((message) => ({ message })));
42
+ errors.push(...lowered.errors.map((message) => ({ message })));
43
+
44
+ return {
45
+ ...(lowered.meta && { meta: lowered.meta }),
46
+ expr: lowered.expr,
47
+ errors,
48
+ warnings,
49
+ };
50
+ }
51
+ }