@styx-api/core 0.1.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.
Files changed (88) hide show
  1. package/dist/index.cjs +7947 -0
  2. package/dist/index.d.cts +1143 -0
  3. package/dist/index.d.cts.map +1 -0
  4. package/dist/index.d.mts +1143 -0
  5. package/dist/index.d.mts.map +1 -0
  6. package/dist/index.mjs +7877 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +55 -0
  9. package/src/backend/backend.ts +95 -0
  10. package/src/backend/boutiques/boutiques.ts +1049 -0
  11. package/src/backend/boutiques/index.ts +1 -0
  12. package/src/backend/code-builder.ts +49 -0
  13. package/src/backend/collect-field-info.ts +50 -0
  14. package/src/backend/collect-named-types.ts +103 -0
  15. package/src/backend/collect-output-fields.ts +222 -0
  16. package/src/backend/find-doc.ts +38 -0
  17. package/src/backend/find-struct-node.ts +66 -0
  18. package/src/backend/index.ts +39 -0
  19. package/src/backend/python/arg-builder.ts +454 -0
  20. package/src/backend/python/emit.ts +638 -0
  21. package/src/backend/python/index.ts +9 -0
  22. package/src/backend/python/outputs-emit.ts +430 -0
  23. package/src/backend/python/packaging.ts +173 -0
  24. package/src/backend/python/python.ts +558 -0
  25. package/src/backend/python/snippet.ts +84 -0
  26. package/src/backend/python/typemap.ts +131 -0
  27. package/src/backend/python/types.ts +8 -0
  28. package/src/backend/python/validate-emit.ts +356 -0
  29. package/src/backend/resolve-field-binding.ts +41 -0
  30. package/src/backend/resolve-output-tokens.ts +80 -0
  31. package/src/backend/schema/index.ts +2 -0
  32. package/src/backend/schema/jsonschema.ts +303 -0
  33. package/src/backend/scope.ts +50 -0
  34. package/src/backend/sig-entries.ts +97 -0
  35. package/src/backend/snippet-core.ts +185 -0
  36. package/src/backend/string-case.ts +30 -0
  37. package/src/backend/styxdefs-compat.ts +21 -0
  38. package/src/backend/type-keys.ts +52 -0
  39. package/src/backend/typescript/arg-builder.ts +420 -0
  40. package/src/backend/typescript/emit.ts +450 -0
  41. package/src/backend/typescript/index.ts +10 -0
  42. package/src/backend/typescript/outputs-emit.ts +389 -0
  43. package/src/backend/typescript/packaging.ts +130 -0
  44. package/src/backend/typescript/snippet.ts +60 -0
  45. package/src/backend/typescript/typemap.ts +47 -0
  46. package/src/backend/typescript/types.ts +8 -0
  47. package/src/backend/typescript/typescript.ts +507 -0
  48. package/src/backend/typescript/validate-emit.ts +341 -0
  49. package/src/backend/union-variants.ts +42 -0
  50. package/src/backend/validate-walk.ts +111 -0
  51. package/src/bindings/binding.ts +77 -0
  52. package/src/bindings/format.ts +176 -0
  53. package/src/bindings/index.ts +16 -0
  54. package/src/bindings/output-gate.ts +50 -0
  55. package/src/bindings/resolved-output.ts +56 -0
  56. package/src/bindings/types.ts +16 -0
  57. package/src/frontend/argdump/index.ts +1 -0
  58. package/src/frontend/argdump/parser.ts +914 -0
  59. package/src/frontend/boutiques/destruct-template.ts +50 -0
  60. package/src/frontend/boutiques/index.ts +1 -0
  61. package/src/frontend/boutiques/parser.ts +676 -0
  62. package/src/frontend/boutiques/split-command.ts +69 -0
  63. package/src/frontend/detect-format.ts +42 -0
  64. package/src/frontend/frontend.ts +31 -0
  65. package/src/frontend/index.ts +9 -0
  66. package/src/frontend/workbench/index.ts +1 -0
  67. package/src/frontend/workbench/parser.ts +351 -0
  68. package/src/index.ts +41 -0
  69. package/src/ir/builders.ts +69 -0
  70. package/src/ir/format.ts +157 -0
  71. package/src/ir/index.ts +32 -0
  72. package/src/ir/meta.ts +91 -0
  73. package/src/ir/node.ts +95 -0
  74. package/src/ir/passes/canonicalize.ts +108 -0
  75. package/src/ir/passes/flatten.ts +73 -0
  76. package/src/ir/passes/index.ts +7 -0
  77. package/src/ir/passes/pass.ts +86 -0
  78. package/src/ir/passes/pipeline.ts +21 -0
  79. package/src/ir/passes/remove-empty.ts +76 -0
  80. package/src/ir/passes/simplify.ts +179 -0
  81. package/src/ir/types.ts +15 -0
  82. package/src/manifest/context.ts +36 -0
  83. package/src/manifest/index.ts +3 -0
  84. package/src/manifest/types.ts +15 -0
  85. package/src/solver/assign-access.ts +218 -0
  86. package/src/solver/index.ts +4 -0
  87. package/src/solver/resolve-outputs.ts +233 -0
  88. package/src/solver/solver.ts +319 -0
@@ -0,0 +1,1049 @@
1
+ import type {
2
+ Binding,
3
+ BindingId,
4
+ BoundType,
5
+ BoundVariant,
6
+ GateAtom,
7
+ OutputScope,
8
+ ResolvedOutput,
9
+ } from "../../bindings/index.js";
10
+ import type { Expr, ScalarKind } from "../../ir/index.js";
11
+ import type { AppMeta } from "../../ir/meta.js";
12
+ import type { CodegenContext } from "../../manifest/index.js";
13
+ import type { Backend, EmittedApp, EmitWarning } from "../backend.js";
14
+ import { collectFieldInfo } from "../collect-field-info.js";
15
+ import { findDoc } from "../find-doc.js";
16
+ import { findStructNode } from "../find-struct-node.js";
17
+ import { outputGate } from "../resolve-output-tokens.js";
18
+ import { resolveFieldBinding } from "../resolve-field-binding.js";
19
+ import { Scope } from "../scope.js";
20
+ import { screamingSnakeCase } from "../string-case.js";
21
+
22
+ // Boutiques descriptor types (output format)
23
+
24
+ interface BtDescriptor {
25
+ name?: string;
26
+ id?: string;
27
+ description?: string;
28
+ "schema-version"?: string;
29
+ "tool-version"?: string;
30
+ author?: string;
31
+ url?: string;
32
+ "container-image"?: { image: string; type?: string };
33
+ "command-line"?: string;
34
+ inputs?: BtInput[];
35
+ "stdout-output"?: { id: string; name?: string; description?: string };
36
+ "stderr-output"?: { id: string; name?: string; description?: string };
37
+ "output-files"?: BtOutputFile[];
38
+ }
39
+
40
+ interface BtOutputFile {
41
+ id: string;
42
+ name?: string;
43
+ description?: string;
44
+ "path-template": string;
45
+ optional?: boolean;
46
+ list?: boolean;
47
+ "path-template-stripped-extensions"?: string[];
48
+ }
49
+
50
+ interface BtInput {
51
+ id: string;
52
+ name?: string;
53
+ description?: string;
54
+ type: string | BtDescriptor | BtDescriptor[];
55
+ "value-key": string;
56
+ optional?: boolean;
57
+ list?: boolean;
58
+ "list-separator"?: string;
59
+ "min-list-entries"?: number;
60
+ "max-list-entries"?: number;
61
+ "command-line-flag"?: string;
62
+ "command-line-flag-separator"?: string;
63
+ "value-choices"?: (string | number)[];
64
+ "default-value"?: string | number | boolean;
65
+ minimum?: number;
66
+ maximum?: number;
67
+ integer?: boolean;
68
+ "resolve-parent"?: boolean;
69
+ mutable?: boolean;
70
+ }
71
+
72
+ // Wrapper peeling result
73
+
74
+ interface PeeledInput {
75
+ isOptional: boolean;
76
+ isList: boolean;
77
+ listSeparator?: string;
78
+ minListEntries?: number;
79
+ maxListEntries?: number;
80
+ flag?: string;
81
+ flagSeparator?: string;
82
+ }
83
+
84
+ class BoutiquesEmitter {
85
+ private warnings: EmitWarning[] = [];
86
+ // Scope binding id -> outputs declared on that struct. The solver forces a
87
+ // binding on every output-carrying sequence, so this is a one-liner.
88
+ private outputsByScope = new Map<BindingId, OutputScope>();
89
+
90
+ constructor(private ctx: CodegenContext) {
91
+ for (const scope of ctx.outputScopes) this.outputsByScope.set(scope.scope, scope);
92
+ }
93
+
94
+ private warn(message: string): void {
95
+ this.warnings.push({ message });
96
+ }
97
+
98
+ emit(): { descriptor: BtDescriptor; warnings: EmitWarning[] } {
99
+ const descriptor = this.buildRootDescriptor();
100
+ return { descriptor, warnings: this.warnings };
101
+ }
102
+
103
+ private buildRootDescriptor(): BtDescriptor {
104
+ const bt: BtDescriptor = { "schema-version": "0.5+styx" };
105
+
106
+ // Map AppMeta to root descriptor fields
107
+ const app = this.ctx.app;
108
+ if (app) {
109
+ this.applyAppMeta(bt, app);
110
+ }
111
+
112
+ // Resolve root binding
113
+ const rootBinding = this.ctx.resolve(this.ctx.expr);
114
+ if (!rootBinding) {
115
+ // No root binding - the solver collapsed single-field sequences.
116
+ // Walk the expression tree directly to synthesize the descriptor.
117
+ this.buildFromUnboundSequence(bt, this.ctx.expr);
118
+ return bt;
119
+ }
120
+
121
+ this.buildDescriptorBody(bt, rootBinding, this.ctx.expr);
122
+ return bt;
123
+ }
124
+
125
+ private applyAppMeta(bt: BtDescriptor, app: AppMeta): void {
126
+ // Boutiques requires `name` at root and disallows `id` there.
127
+ const rootName = app.doc?.title ?? app.id;
128
+ if (rootName) bt.name = rootName;
129
+ if (app.doc?.description) bt.description = app.doc.description;
130
+ if (app.version) bt["tool-version"] = app.version;
131
+ if (app.doc?.authors?.[0]) bt.author = app.doc.authors[0];
132
+ if (app.doc?.urls?.[0]) bt.url = app.doc.urls[0];
133
+ if (app.container) {
134
+ bt["container-image"] = {
135
+ image: app.container.image,
136
+ ...(app.container.type && { type: app.container.type }),
137
+ };
138
+ }
139
+ if (app.stdout) {
140
+ bt["stdout-output"] = {
141
+ id: app.stdout.name,
142
+ ...(app.stdout.doc?.title && { name: app.stdout.doc.title }),
143
+ ...(app.stdout.doc?.description && { description: app.stdout.doc.description }),
144
+ };
145
+ }
146
+ if (app.stderr) {
147
+ bt["stderr-output"] = {
148
+ id: app.stderr.name,
149
+ ...(app.stderr.doc?.title && { name: app.stderr.doc.title }),
150
+ ...(app.stderr.doc?.description && { description: app.stderr.doc.description }),
151
+ };
152
+ }
153
+ }
154
+
155
+ private buildDescriptorBody(bt: BtDescriptor, binding: Binding, expr: Expr): void {
156
+ const type = binding.type;
157
+
158
+ if (type.kind === "struct") {
159
+ this.buildFromStruct(bt, type, expr);
160
+ } else {
161
+ // Root is not a struct (e.g. simplified to a literal) - just build command line
162
+ bt["command-line"] = this.buildCommandLineFromExpr(expr);
163
+ }
164
+ }
165
+
166
+ // Handle sequences where the solver collapsed single-field structs.
167
+ // Walk children, emitting literals as command-line text and bound nodes as inputs.
168
+ private buildFromUnboundSequence(bt: BtDescriptor, expr: Expr): void {
169
+ if (expr.kind !== "sequence") {
170
+ bt["command-line"] = this.buildCommandLineFromExpr(expr);
171
+ return;
172
+ }
173
+
174
+ const scope = new Scope();
175
+ const idScope = new Scope();
176
+ const commandParts: string[] = [];
177
+ const inputs: BtInput[] = [];
178
+ const valueKeyByBinding = new Map<BindingId, string>();
179
+
180
+ for (const child of expr.attrs.nodes) {
181
+ if (child.kind === "literal") {
182
+ commandParts.push(child.attrs.str);
183
+ continue;
184
+ }
185
+
186
+ const binding = this.ctx.resolve(child);
187
+ if (binding) {
188
+ // Direct binding on this child
189
+ const id = idScope.add(this.sanitizeId(binding.name));
190
+ const valueKey = scope.add(screamingSnakeCase(id));
191
+ const valueKeyStr = `[${valueKey}]`;
192
+ valueKeyByBinding.set(binding.id, valueKeyStr);
193
+ const peeled = this.peelNode(child, binding.type);
194
+ if (this.isBool(binding.type) && !peeled.flag) {
195
+ const flagStr = this.extractBoolFlag(child);
196
+ if (flagStr) peeled.flag = flagStr;
197
+ }
198
+ const input = this.buildInputFromBinding(binding, id, valueKeyStr, peeled, child);
199
+ commandParts.push(valueKeyStr);
200
+ inputs.push(input);
201
+ } else {
202
+ // No direct binding - might be a nested sequence (subcommand).
203
+ // Try to find bindings deeper inside.
204
+ const deepBinding = this.findDeepBinding(child);
205
+ if (deepBinding) {
206
+ const rawName = child.meta?.name ?? deepBinding.name;
207
+ const id = idScope.add(this.sanitizeId(rawName));
208
+ const valueKey = scope.add(screamingSnakeCase(id));
209
+ const valueKeyStr = `[${valueKey}]`;
210
+ const subBt = this.buildSubCommandFromUnbound(child);
211
+ const input: BtInput = {
212
+ id,
213
+ type: subBt,
214
+ "value-key": valueKeyStr,
215
+ };
216
+ this.finalizeInput(input);
217
+ commandParts.push(valueKeyStr);
218
+ inputs.push(input);
219
+ } else {
220
+ commandParts.push(this.buildCommandLineFromExpr(child));
221
+ }
222
+ }
223
+ }
224
+
225
+ bt["command-line"] = commandParts.join(" ");
226
+ bt.inputs = inputs;
227
+ this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
228
+ }
229
+
230
+ // Build a subcommand descriptor from an unbound sequence node
231
+ private buildSubCommandFromUnbound(node: Expr): BtDescriptor {
232
+ const bt: BtDescriptor = {};
233
+ if (node.meta?.name) {
234
+ bt.name = node.meta.name;
235
+ bt.id = this.sanitizeId(node.meta.name);
236
+ }
237
+ this.buildFromUnboundSequence(bt, node);
238
+ return bt;
239
+ }
240
+
241
+ // Find any binding in a subtree
242
+ private findDeepBinding(node: Expr): Binding | undefined {
243
+ const binding = this.ctx.resolve(node);
244
+ if (binding) return binding;
245
+
246
+ switch (node.kind) {
247
+ case "sequence":
248
+ for (const child of node.attrs.nodes) {
249
+ const found = this.findDeepBinding(child);
250
+ if (found) return found;
251
+ }
252
+ return undefined;
253
+ case "optional":
254
+ return this.findDeepBinding(node.attrs.node);
255
+ case "repeat":
256
+ return this.findDeepBinding(node.attrs.node);
257
+ case "alternative":
258
+ for (const alt of node.attrs.alts) {
259
+ const found = this.findDeepBinding(alt);
260
+ if (found) return found;
261
+ }
262
+ return undefined;
263
+ default:
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ // Build an input from a binding directly (without struct context)
269
+ private buildInputFromBinding(
270
+ binding: Binding,
271
+ id: string,
272
+ valueKey: string,
273
+ peeled: PeeledInput,
274
+ wrapperNode: Expr,
275
+ ): BtInput {
276
+ const innerType = this.unwrapType(binding.type);
277
+ const mapped = this.mapType(innerType, wrapperNode);
278
+
279
+ const input: BtInput = {
280
+ id,
281
+ type: mapped.type,
282
+ "value-key": valueKey,
283
+ };
284
+
285
+ if (binding.node.meta?.doc?.title) input.name = binding.node.meta.doc.title;
286
+ if (binding.node.meta?.doc?.description) input.description = binding.node.meta.doc.description;
287
+
288
+ if (peeled.isOptional || mapped.optional) input.optional = true;
289
+ const isList = peeled.isList || mapped.list === true;
290
+ if (isList) {
291
+ input.list = true;
292
+ const listSep = peeled.listSeparator ?? mapped.listSeparator;
293
+ const minEntries = peeled.minListEntries ?? mapped.minListEntries;
294
+ if (listSep !== undefined) input["list-separator"] = listSep;
295
+ if (minEntries !== undefined) input["min-list-entries"] = minEntries;
296
+ if (peeled.maxListEntries !== undefined) input["max-list-entries"] = peeled.maxListEntries;
297
+ }
298
+ if (peeled.flag) {
299
+ input["command-line-flag"] = peeled.flag;
300
+ if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
301
+ }
302
+ if (mapped.integer) input.integer = true;
303
+ if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
304
+ if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
305
+ if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
306
+ if (mapped.resolveParent) input["resolve-parent"] = true;
307
+ if (mapped.mutable) input["mutable"] = true;
308
+
309
+ if (innerType.kind !== "count") {
310
+ const defaultValue = binding.node.meta?.defaultValue;
311
+ if (defaultValue !== undefined) input["default-value"] = defaultValue;
312
+ }
313
+
314
+ this.finalizeInput(input);
315
+ return input;
316
+ }
317
+
318
+ // Boutiques' default-value substitutes when the user omits the input;
319
+ // argparse's default is the parser's internal fallback, not a CLI value
320
+ // to materialize. Surface it in the description instead.
321
+ private mergeDefaultIntoDescription(input: BtInput): void {
322
+ const dv = input["default-value"];
323
+ if (dv === undefined) return;
324
+ delete input["default-value"];
325
+ if (input.type === "Flag") return;
326
+ const formatted = typeof dv === "string" ? JSON.stringify(dv) : String(dv);
327
+ const suffix = `Default: ${formatted}`;
328
+ const desc = input.description;
329
+ input.description = desc ? `${desc.replace(/\s+$/, "")} (${suffix})` : suffix;
330
+ }
331
+
332
+ private buildFromStruct(
333
+ bt: BtDescriptor,
334
+ structType: Extract<BoundType, { kind: "struct" }>,
335
+ expr: Expr,
336
+ ): void {
337
+ const structNode = findStructNode(expr, this.ctx, structType);
338
+ if (!structNode) {
339
+ bt["command-line"] = "";
340
+ bt.inputs = [];
341
+ return;
342
+ }
343
+
344
+ // `findStructNode` may descend below `expr` to the sequence whose direct
345
+ // children are the struct's fields (e.g. when a single-field inner sequence
346
+ // was preserved by flatten to keep its doc, then collapsed). Any command
347
+ // literals on the path from `expr` down to that node would otherwise be
348
+ // dropped - including the tool's own command name. Recover them as a prefix.
349
+ const prefixLiterals =
350
+ structNode === expr ? [] : (commandPrefixLiterals(expr, structNode) ?? []);
351
+
352
+ const scope = new Scope();
353
+ const idScope = new Scope();
354
+ const commandParts: string[] = [...prefixLiterals];
355
+ const inputs: BtInput[] = [];
356
+ const valueKeyByBinding = new Map<BindingId, string>();
357
+ const fieldInfo = collectFieldInfo(this.ctx, structType);
358
+
359
+ for (const child of structNode.attrs.nodes) {
360
+ // Literal nodes -> command-line text
361
+ if (child.kind === "literal") {
362
+ commandParts.push(child.attrs.str);
363
+ continue;
364
+ }
365
+
366
+ // Try to resolve to a field binding
367
+ const match = resolveFieldBinding(child, this.ctx, structType);
368
+ if (!match) {
369
+ // Unbound non-literal node - no command-line text we can emit.
370
+ continue;
371
+ }
372
+
373
+ const { binding, wrapperNode } = match;
374
+ const fieldType = structType.fields[binding.name];
375
+ if (!fieldType) continue;
376
+
377
+ // Skip literal fields (union discriminators, not user-facing)
378
+ if (fieldType.kind === "literal") continue;
379
+
380
+ const id = idScope.add(this.sanitizeId(binding.name));
381
+ const valueKey = scope.add(screamingSnakeCase(id));
382
+ const valueKeyStr = `[${valueKey}]`;
383
+ valueKeyByBinding.set(binding.id, valueKeyStr);
384
+
385
+ // Peel wrapper layers from the IR node
386
+ const peeled = this.peelNode(wrapperNode, fieldType);
387
+
388
+ // Bool IR pattern: optional(literal("-v")) - the literal IS the flag.
389
+ if (this.isBool(fieldType) && !peeled.flag) {
390
+ const flagStr = this.extractBoolFlag(wrapperNode);
391
+ if (flagStr) peeled.flag = flagStr;
392
+ }
393
+
394
+ const input = this.buildInput(
395
+ binding,
396
+ id,
397
+ fieldType,
398
+ valueKeyStr,
399
+ peeled,
400
+ fieldInfo,
401
+ wrapperNode,
402
+ );
403
+
404
+ // Add flag to command line if present, then value-key
405
+ if (peeled.flag) {
406
+ if (
407
+ fieldType.kind === "bool" ||
408
+ (fieldType.kind === "optional" && this.isBool(fieldType))
409
+ ) {
410
+ // Bool flags: the value-key IS the flag
411
+ commandParts.push(valueKeyStr);
412
+ } else {
413
+ // Value flags: flag then value-key as separate args
414
+ commandParts.push(valueKeyStr);
415
+ }
416
+ } else {
417
+ commandParts.push(valueKeyStr);
418
+ }
419
+
420
+ inputs.push(input);
421
+ }
422
+
423
+ bt["command-line"] = commandParts.join(" ");
424
+ bt.inputs = inputs;
425
+ this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
426
+ }
427
+
428
+ // Emit `output-files` entries declared on this struct binding. `optional`
429
+ // and `list` are derived from the unified gate (scope binding's gate plus
430
+ // each ref's binding gate plus type-derived atoms). Refs that point to
431
+ // bindings outside the scope are dropped with a warning; output ids share
432
+ // the input id scope so they cannot collide.
433
+ private emitOutputFiles(
434
+ bt: BtDescriptor,
435
+ scopeNode: Expr,
436
+ valueKeys: Map<BindingId, string>,
437
+ idScope: Scope,
438
+ ): void {
439
+ const scopeBinding = this.ctx.resolve(scopeNode);
440
+ if (!scopeBinding) return;
441
+ const scope = this.outputsByScope.get(scopeBinding.id);
442
+ if (!scope || scope.outputs.length === 0) return;
443
+
444
+ const files: BtOutputFile[] = [];
445
+ for (const output of scope.outputs) {
446
+ const file = this.buildOutputFile(scopeBinding.gate, output, valueKeys, idScope);
447
+ if (file) files.push(file);
448
+ }
449
+ if (files.length > 0) bt["output-files"] = files;
450
+ }
451
+
452
+ private buildOutputFile(
453
+ scopeGate: GateAtom[],
454
+ output: ResolvedOutput,
455
+ valueKeys: Map<BindingId, string>,
456
+ idScope: Scope,
457
+ ): BtOutputFile | null {
458
+ let template = "";
459
+ let stripExtensions: string[] | undefined;
460
+ let droppedRef = false;
461
+
462
+ for (const token of output.tokens) {
463
+ if (token.kind === "literal") {
464
+ template += token.value;
465
+ continue;
466
+ }
467
+ const key = valueKeys.get(token.binding);
468
+ if (!key) {
469
+ const bindingName = this.ctx.bindings.get(token.binding)?.name ?? "<unknown>";
470
+ this.warn(
471
+ `Output '${output.name}' references binding '${bindingName}' that is not in the same descriptor scope`,
472
+ );
473
+ droppedRef = true;
474
+ continue;
475
+ }
476
+ template += key;
477
+ // The parser applies the same stripped-extensions list to every ref in
478
+ // an output, so taking the first non-empty list round-trips cleanly.
479
+ if (token.stripExtensions && !stripExtensions) stripExtensions = token.stripExtensions;
480
+ }
481
+
482
+ // If we couldn't resolve any refs and the template is empty, drop the
483
+ // output entirely. (A literal-only template stays.)
484
+ if (droppedRef && template === "") return null;
485
+
486
+ const gate = outputGate(scopeGate, output, this.ctx.bindings);
487
+ const isOptional = gate.some((a) => a.kind === "present" || a.kind === "variant");
488
+ const isList = gate.some((a) => a.kind === "iter");
489
+
490
+ const id = idScope.add(this.sanitizeId(output.name));
491
+ const file: BtOutputFile = { id, "path-template": template };
492
+ if (output.doc?.title) file.name = output.doc.title;
493
+ if (output.doc?.description) file.description = output.doc.description;
494
+ if (isOptional) file.optional = true;
495
+ if (isList) file.list = true;
496
+ if (stripExtensions) file["path-template-stripped-extensions"] = stripExtensions;
497
+ return file;
498
+ }
499
+
500
+ // Extract flag literal from a bool IR pattern: optional(literal("-v"))
501
+ private extractBoolFlag(node: Expr): string | undefined {
502
+ if (node.kind === "optional") return this.extractBoolFlag(node.attrs.node);
503
+ if (node.kind === "literal") return node.attrs.str;
504
+ return undefined;
505
+ }
506
+
507
+ private buildInput(
508
+ binding: Binding,
509
+ id: string,
510
+ fieldType: BoundType,
511
+ valueKey: string,
512
+ peeled: PeeledInput,
513
+ fieldInfo: Map<string, { doc?: string; defaultValue?: string | number | boolean }>,
514
+ wrapperNode: Expr,
515
+ ): BtInput {
516
+ const info = fieldInfo.get(binding.name);
517
+ const innerType = this.unwrapType(fieldType);
518
+ const mapped = this.mapType(innerType, wrapperNode);
519
+
520
+ const input: BtInput = {
521
+ id,
522
+ type: mapped.type,
523
+ "value-key": valueKey,
524
+ };
525
+
526
+ // Name (short label) - only from explicit title, not description
527
+ const title = binding.node.meta?.doc?.title;
528
+ if (title) input.name = title;
529
+
530
+ // Description (longer help text)
531
+ const description =
532
+ info?.doc ?? binding.node.meta?.doc?.description ?? findDoc(binding.node, fieldType);
533
+ if (description) input.description = description;
534
+
535
+ if (peeled.isOptional || mapped.optional) input.optional = true;
536
+
537
+ const isList = peeled.isList || mapped.list === true;
538
+ if (isList) {
539
+ input.list = true;
540
+ const listSep = peeled.listSeparator ?? mapped.listSeparator;
541
+ const minEntries = peeled.minListEntries ?? mapped.minListEntries;
542
+ if (listSep !== undefined) input["list-separator"] = listSep;
543
+ if (minEntries !== undefined) input["min-list-entries"] = minEntries;
544
+ if (peeled.maxListEntries !== undefined) input["max-list-entries"] = peeled.maxListEntries;
545
+ }
546
+
547
+ // Flag
548
+ if (peeled.flag) {
549
+ input["command-line-flag"] = peeled.flag;
550
+ if (peeled.flagSeparator) input["command-line-flag-separator"] = peeled.flagSeparator;
551
+ }
552
+
553
+ // Constraints from mapped type
554
+ if (mapped.integer) input.integer = true;
555
+ if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
556
+ if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
557
+ if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
558
+ if (mapped.resolveParent) input["resolve-parent"] = true;
559
+ if (mapped.mutable) input["mutable"] = true;
560
+
561
+ // count's "default" is implicit via min-list-entries:0; skip emitting it.
562
+ if (innerType.kind !== "count") {
563
+ const defaultValue = info?.defaultValue ?? binding.node.meta?.defaultValue;
564
+ if (defaultValue !== undefined) input["default-value"] = defaultValue;
565
+ }
566
+
567
+ this.finalizeInput(input);
568
+ return input;
569
+ }
570
+
571
+ // Normalize an input so the descriptor is valid even when upstream types
572
+ // are dynamic (functools.partial, custom action classes) and the parser
573
+ // had to fall back to String.
574
+ private finalizeInput(input: BtInput): void {
575
+ if (input.name === undefined) input.name = input.id;
576
+
577
+ // A String with a bool default and no choices is really a Flag.
578
+ if (
579
+ input.type === "String" &&
580
+ typeof input["default-value"] === "boolean" &&
581
+ input["value-choices"] === undefined
582
+ ) {
583
+ input.type = "Flag";
584
+ }
585
+
586
+ if (input.type === "Flag") {
587
+ delete input["default-value"];
588
+ delete input["value-choices"];
589
+ delete input.list;
590
+ delete input["list-separator"];
591
+ delete input["min-list-entries"];
592
+ delete input["max-list-entries"];
593
+ } else if (input.type === "String") {
594
+ const dv = input["default-value"];
595
+ if (dv !== undefined && typeof dv !== "string") {
596
+ input["default-value"] = String(dv);
597
+ }
598
+ const choices = input["value-choices"];
599
+ if (Array.isArray(choices)) {
600
+ input["value-choices"] = choices.map((c) => (typeof c === "string" ? c : String(c)));
601
+ }
602
+ } else if (input.type === "Number") {
603
+ const dv = input["default-value"];
604
+ if (dv !== undefined && typeof dv !== "number") {
605
+ const num = Number(dv);
606
+ if (Number.isFinite(num)) input["default-value"] = num;
607
+ else delete input["default-value"];
608
+ }
609
+ }
610
+
611
+ // Default must be one of the choices, or dropped.
612
+ const choices = input["value-choices"];
613
+ const dv = input["default-value"];
614
+ if (Array.isArray(choices) && dv !== undefined && !choices.some((c) => c === dv)) {
615
+ delete input["default-value"];
616
+ }
617
+
618
+ this.mergeDefaultIntoDescription(input);
619
+ }
620
+
621
+ // Peel wrapper layers from an IR node to extract Boutiques input properties.
622
+ // Walks from outermost to innermost, detecting optional/repeat/flag patterns.
623
+ private peelNode(node: Expr, type: BoundType): PeeledInput {
624
+ const result: PeeledInput = { isOptional: false, isList: false };
625
+ this.peelNodeInner(node, type, result);
626
+ return result;
627
+ }
628
+
629
+ private peelNodeInner(node: Expr, type: BoundType, result: PeeledInput): void {
630
+ switch (node.kind) {
631
+ case "optional":
632
+ result.isOptional = true;
633
+ this.peelNodeInner(node.attrs.node, type.kind === "optional" ? type.inner : type, result);
634
+ break;
635
+
636
+ case "repeat":
637
+ // count's Repeat is the count, not a list - mapType handles it.
638
+ if (this.isCount(type)) {
639
+ break;
640
+ }
641
+ result.isList = true;
642
+ if (node.attrs.join !== undefined) result.listSeparator = node.attrs.join;
643
+ if (node.attrs.countMin !== undefined) result.minListEntries = node.attrs.countMin;
644
+ if (node.attrs.countMax !== undefined) result.maxListEntries = node.attrs.countMax;
645
+ this.peelNodeInner(node.attrs.node, type.kind === "list" ? type.item : type, result);
646
+ break;
647
+
648
+ case "sequence": {
649
+ // Detect flag pattern: seq(lit(flag), inner)
650
+ const nodes = node.attrs.nodes;
651
+ if (nodes.length === 2 && nodes[0]!.kind === "literal") {
652
+ const flagLit = nodes[0]!.attrs.str;
653
+ const { flag, separator } = this.splitFlagLiteral(flagLit);
654
+ result.flag = flag;
655
+ if (separator) result.flagSeparator = separator;
656
+ this.peelNodeInner(nodes[1]!, type, result);
657
+ }
658
+ // Otherwise don't peel further (it's a struct sequence or similar)
659
+ break;
660
+ }
661
+
662
+ default:
663
+ // Terminal or other node - nothing more to peel
664
+ break;
665
+ }
666
+ }
667
+
668
+ // Split a flag literal like "-f " or "--flag=" into flag + separator.
669
+ // The parser merges flag + separator into one literal: `flag + (flagSep ?? "")`
670
+ private splitFlagLiteral(str: string): { flag: string; separator: string } {
671
+ // If it ends with "=", split there
672
+ if (str.endsWith("=")) {
673
+ return { flag: str.slice(0, -1), separator: "=" };
674
+ }
675
+ // If it ends with whitespace, strip it
676
+ const trimmed = str.trimEnd();
677
+ if (trimmed.length < str.length) {
678
+ return { flag: trimmed, separator: str.slice(trimmed.length) };
679
+ }
680
+ // No separator
681
+ return { flag: str, separator: "" };
682
+ }
683
+
684
+ // Unwrap optional/list layers to get the "core" type for mapping
685
+ private unwrapType(type: BoundType): BoundType {
686
+ if (type.kind === "optional") return this.unwrapType(type.inner);
687
+ if (type.kind === "list") return this.unwrapType(type.item);
688
+ return type;
689
+ }
690
+
691
+ // Returned list/optional fields override peeled values (for `count`, whose
692
+ // IR Repeat does not correspond to a Boutiques list).
693
+ private mapType(
694
+ type: BoundType,
695
+ node: Expr,
696
+ ): {
697
+ type: string | BtDescriptor | BtDescriptor[];
698
+ integer?: boolean;
699
+ minimum?: number;
700
+ maximum?: number;
701
+ valueChoices?: (string | number)[];
702
+ resolveParent?: boolean;
703
+ mutable?: boolean;
704
+ list?: boolean;
705
+ listSeparator?: string;
706
+ minListEntries?: number;
707
+ optional?: boolean;
708
+ } {
709
+ switch (type.kind) {
710
+ case "scalar":
711
+ return this.mapScalar(type.scalar, node);
712
+
713
+ case "bool":
714
+ return { type: "Flag" };
715
+
716
+ case "count":
717
+ return this.mapCount(node);
718
+
719
+ case "literal":
720
+ // Single literal - emit as String with value-choices
721
+ return {
722
+ type: "String",
723
+ valueChoices: [type.value],
724
+ };
725
+
726
+ case "struct":
727
+ return { type: this.buildSubCommand(type, node) };
728
+
729
+ case "union":
730
+ return this.mapUnion(type, node);
731
+
732
+ // optional/list should have been unwrapped already
733
+ case "optional":
734
+ return this.mapType(type.inner, node);
735
+ case "list":
736
+ return this.mapType(type.item, node);
737
+ }
738
+ }
739
+
740
+ private mapScalar(
741
+ scalar: ScalarKind,
742
+ node: Expr,
743
+ ): {
744
+ type: string;
745
+ integer?: boolean;
746
+ minimum?: number;
747
+ maximum?: number;
748
+ resolveParent?: boolean;
749
+ mutable?: boolean;
750
+ } {
751
+ const terminal = this.findTerminal(node);
752
+
753
+ switch (scalar) {
754
+ case "int": {
755
+ const result: { type: string; integer: true; minimum?: number; maximum?: number } = {
756
+ type: "Number",
757
+ integer: true,
758
+ };
759
+ if (terminal?.kind === "int") {
760
+ if (terminal.attrs.minValue !== undefined) result.minimum = terminal.attrs.minValue;
761
+ if (terminal.attrs.maxValue !== undefined) result.maximum = terminal.attrs.maxValue;
762
+ }
763
+ return result;
764
+ }
765
+
766
+ case "float": {
767
+ const result: { type: string; minimum?: number; maximum?: number } = { type: "Number" };
768
+ if (terminal?.kind === "float") {
769
+ if (terminal.attrs.minValue !== undefined) result.minimum = terminal.attrs.minValue;
770
+ if (terminal.attrs.maxValue !== undefined) result.maximum = terminal.attrs.maxValue;
771
+ }
772
+ return result;
773
+ }
774
+
775
+ case "str":
776
+ return { type: "String" };
777
+
778
+ case "path": {
779
+ const result: { type: string; resolveParent?: boolean; mutable?: boolean } = {
780
+ type: "File",
781
+ };
782
+ if (terminal?.kind === "path") {
783
+ if (terminal.attrs.resolveParent) result.resolveParent = true;
784
+ if (terminal.attrs.mutable) result.mutable = true;
785
+ }
786
+ return result;
787
+ }
788
+ }
789
+ }
790
+
791
+ private mapUnion(
792
+ type: Extract<BoundType, { kind: "union" }>,
793
+ node: Expr,
794
+ ): {
795
+ type: string | BtDescriptor | BtDescriptor[];
796
+ valueChoices?: (string | number)[];
797
+ } {
798
+ // All-literal union -> value-choices
799
+ const allLiteral = type.variants.every((v: BoundVariant) => v.type.kind === "literal");
800
+ if (allLiteral) {
801
+ return {
802
+ type: "String",
803
+ valueChoices: type.variants.map((v: BoundVariant) =>
804
+ v.type.kind === "literal" ? v.type.value : "",
805
+ ),
806
+ };
807
+ }
808
+
809
+ // All-struct union -> SubCommandUnion
810
+ const allStruct = type.variants.every((v: BoundVariant) => v.type.kind === "struct");
811
+ if (allStruct) {
812
+ return { type: this.buildSubCommandUnion(type, node) };
813
+ }
814
+
815
+ // Mixed union -> wrap each variant as a SubCommand descriptor
816
+ return { type: this.buildMixedUnionAsSubCommands(type, node) };
817
+ }
818
+
819
+ private buildSubCommand(type: Extract<BoundType, { kind: "struct" }>, node: Expr): BtDescriptor {
820
+ const bt: BtDescriptor = {};
821
+ const structNode = findStructNode(node, this.ctx, type);
822
+ if (structNode) {
823
+ // Recursively serialize as nested descriptor
824
+ this.buildFromStruct(bt, type, node);
825
+ }
826
+ if (node.meta?.name) {
827
+ bt.name = node.meta.name;
828
+ bt.id = this.sanitizeId(node.meta.name);
829
+ }
830
+ return bt;
831
+ }
832
+
833
+ private buildSubCommandUnion(
834
+ type: Extract<BoundType, { kind: "union" }>,
835
+ node: Expr,
836
+ ): BtDescriptor[] {
837
+ const alts = node.kind === "alternative" ? node.attrs.alts : [node];
838
+
839
+ return type.variants.map((variant: BoundVariant, i: number) => {
840
+ const altNode = alts[i] ?? node;
841
+ if (variant.type.kind === "struct") {
842
+ const bt = this.buildSubCommand(variant.type, altNode);
843
+ // The variant has its own name; the wrapperNode's name is the
844
+ // parent (mutex group) name and would otherwise leak down.
845
+ if (variant.name) {
846
+ bt.name = variant.name;
847
+ bt.id = this.sanitizeId(variant.name);
848
+ }
849
+ return bt;
850
+ }
851
+ return this.wrapAsDescriptor(variant, altNode);
852
+ });
853
+ }
854
+
855
+ private buildMixedUnionAsSubCommands(
856
+ type: Extract<BoundType, { kind: "union" }>,
857
+ node: Expr,
858
+ ): BtDescriptor[] {
859
+ const alts = node.kind === "alternative" ? node.attrs.alts : [node];
860
+
861
+ return type.variants.map((variant: BoundVariant, i: number) => {
862
+ const altNode = alts[i] ?? node;
863
+ if (variant.type.kind === "struct") {
864
+ const bt = this.buildSubCommand(variant.type, altNode);
865
+ // The variant has its own name; the wrapperNode's name is the
866
+ // parent (mutex group) name and would otherwise leak down.
867
+ if (variant.name) {
868
+ bt.name = variant.name;
869
+ bt.id = this.sanitizeId(variant.name);
870
+ }
871
+ return bt;
872
+ }
873
+ return this.wrapAsDescriptor(variant, altNode);
874
+ });
875
+ }
876
+
877
+ // Wrap a non-struct variant as a trivial single-input descriptor
878
+ private wrapAsDescriptor(variant: BoundVariant, node: Expr): BtDescriptor {
879
+ const name = variant.name ?? "value";
880
+ const id = this.sanitizeId(name);
881
+ const mapped = this.mapType(variant.type, node);
882
+ const input: BtInput = {
883
+ id,
884
+ type: mapped.type,
885
+ "value-key": `[${screamingSnakeCase(id)}]`,
886
+ };
887
+ if (mapped.valueChoices) input["value-choices"] = mapped.valueChoices;
888
+ if (mapped.integer) input.integer = true;
889
+ if (mapped.minimum !== undefined) input.minimum = mapped.minimum;
890
+ if (mapped.maximum !== undefined) input.maximum = mapped.maximum;
891
+
892
+ this.finalizeInput(input);
893
+
894
+ return {
895
+ name,
896
+ id,
897
+ "command-line": `[${screamingSnakeCase(id)}]`,
898
+ inputs: [input],
899
+ };
900
+ }
901
+
902
+ // Find terminal node through wrappers
903
+ private findTerminal(node: Expr): Expr | undefined {
904
+ switch (node.kind) {
905
+ case "optional":
906
+ return this.findTerminal(node.attrs.node);
907
+ case "repeat":
908
+ return this.findTerminal(node.attrs.node);
909
+ case "sequence": {
910
+ const nonLiteral = node.attrs.nodes.find((n) => n.kind !== "literal");
911
+ return nonLiteral ? this.findTerminal(nonLiteral) : undefined;
912
+ }
913
+ default:
914
+ return node;
915
+ }
916
+ }
917
+
918
+ // Build a simple command-line string from literal nodes in an expression
919
+ private buildCommandLineFromExpr(expr: Expr): string {
920
+ if (expr.kind === "literal") return expr.attrs.str;
921
+ if (expr.kind === "sequence") {
922
+ return expr.attrs.nodes.map((n) => this.buildCommandLineFromExpr(n)).join(" ");
923
+ }
924
+ return "";
925
+ }
926
+
927
+ private isBool(type: BoundType): boolean {
928
+ if (type.kind === "bool") return true;
929
+ if (type.kind === "optional") return this.isBool(type.inner);
930
+ return false;
931
+ }
932
+
933
+ private isCount(type: BoundType): boolean {
934
+ if (type.kind === "count") return true;
935
+ if (type.kind === "optional") return this.isCount(type.inner);
936
+ return false;
937
+ }
938
+
939
+ // Boutiques `id` must match /^[0-9A-Za-z_]+$/ (input ids, sub-descriptor
940
+ // ids, value-keys after [ ] removal). Non-matching chars (e.g. argparse
941
+ // subparser names with hyphens) become underscores.
942
+ private sanitizeId(raw: string): string {
943
+ const cleaned = raw.replace(/[^0-9A-Za-z_]/g, "_");
944
+ return cleaned.length > 0 ? cleaned : "id";
945
+ }
946
+
947
+ private findCountRepeat(node: Expr): Extract<Expr, { kind: "repeat" }> | undefined {
948
+ switch (node.kind) {
949
+ case "repeat":
950
+ return node;
951
+ case "optional":
952
+ return this.findCountRepeat(node.attrs.node);
953
+ default:
954
+ return undefined;
955
+ }
956
+ }
957
+
958
+ // Bounded count -> String + enumerated value-choices.
959
+ // Unbounded count -> SubCommand + list:true (no list-separator: each
960
+ // occurrence must be a separate argv element for argparse to count it).
961
+ private mapCount(node: Expr): {
962
+ type: string | BtDescriptor;
963
+ valueChoices?: string[];
964
+ list?: boolean;
965
+ listSeparator?: string;
966
+ minListEntries?: number;
967
+ optional?: boolean;
968
+ } {
969
+ const repeat = this.findCountRepeat(node);
970
+ if (!repeat || repeat.attrs.node.kind !== "literal") {
971
+ return { type: "Flag" };
972
+ }
973
+ const flag = repeat.attrs.node.attrs.str;
974
+ const countMin = repeat.attrs.countMin ?? 0;
975
+ const countMax = repeat.attrs.countMax;
976
+
977
+ if (countMax !== undefined && countMax >= Math.max(countMin, 1)) {
978
+ const choices: string[] = [];
979
+ const start = Math.max(countMin, 1);
980
+ for (let i = start; i <= countMax; i++) choices.push(flag.repeat(i));
981
+ return {
982
+ type: "String",
983
+ valueChoices: choices,
984
+ ...(countMin === 0 && { optional: true }),
985
+ };
986
+ }
987
+
988
+ const dest = repeat.meta?.name;
989
+ const subId = this.sanitizeId(dest ? `${dest}_token` : "count_token");
990
+ const subBt: BtDescriptor = {
991
+ name: subId,
992
+ id: subId,
993
+ "command-line": flag,
994
+ inputs: [],
995
+ };
996
+
997
+ return {
998
+ type: subBt,
999
+ list: true,
1000
+ minListEntries: countMin,
1001
+ };
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * The ordered command literals on the path from `node` down to `target`,
1007
+ * descending only through sequences (the structure `findStructNode` follows to
1008
+ * reach a struct whose fields are direct children). Returns `null` when
1009
+ * `target` is not reachable that way - callers then emit no prefix. Literals
1010
+ * inside `target` itself are excluded; the caller walks those separately.
1011
+ */
1012
+ function commandPrefixLiterals(node: Expr, target: Expr): string[] | null {
1013
+ if (node === target) return [];
1014
+ if (node.kind !== "sequence") return null;
1015
+ const lits: string[] = [];
1016
+ for (const child of node.attrs.nodes) {
1017
+ if (child === target) return lits;
1018
+ if (child.kind === "literal") {
1019
+ lits.push(child.attrs.str);
1020
+ continue;
1021
+ }
1022
+ const deeper = commandPrefixLiterals(child, target);
1023
+ if (deeper !== null) return [...lits, ...deeper];
1024
+ }
1025
+ return null;
1026
+ }
1027
+
1028
+ export function generateBoutiques(ctx: CodegenContext): {
1029
+ descriptor: BtDescriptor;
1030
+ warnings: EmitWarning[];
1031
+ } {
1032
+ return new BoutiquesEmitter(ctx).emit();
1033
+ }
1034
+
1035
+ export class BoutiquesBackend implements Backend {
1036
+ readonly name = "boutiques";
1037
+ readonly target = "boutiques";
1038
+
1039
+ emitApp(ctx: CodegenContext): EmittedApp {
1040
+ const { descriptor, warnings } = generateBoutiques(ctx);
1041
+ const json = JSON.stringify(descriptor, null, 2);
1042
+ return {
1043
+ meta: ctx.app,
1044
+ files: new Map([["descriptor.json", json]]),
1045
+ errors: [],
1046
+ warnings,
1047
+ };
1048
+ }
1049
+ }