@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,676 @@
1
+ import { nodeRef } from "../../ir/meta.js";
2
+ import type { AppMeta, NodeMeta, NodeRef, Output, OutputToken } from "../../ir/meta.js";
3
+ import type { Documentation } from "../../ir/types.js";
4
+ import type {
5
+ Alternative,
6
+ Expr,
7
+ Float,
8
+ Int,
9
+ Literal,
10
+ Optional,
11
+ Path,
12
+ Repeat,
13
+ Sequence,
14
+ Str,
15
+ } from "../../ir/node.js";
16
+ import type {
17
+ Frontend,
18
+ ParseError,
19
+ ParseResult,
20
+ ParseWarning,
21
+ SourceLocation,
22
+ } from "../frontend.js";
23
+ import { destructTemplate } from "./destruct-template.js";
24
+ import { boutiquesSplitCommand } from "./split-command.js";
25
+
26
+ // Type guards
27
+
28
+ function isObject(x: unknown): x is Record<string, unknown> {
29
+ return typeof x === "object" && x !== null && !Array.isArray(x);
30
+ }
31
+
32
+ function isString(x: unknown): x is string {
33
+ return typeof x === "string";
34
+ }
35
+
36
+ function isNumber(x: unknown): x is number {
37
+ return typeof x === "number";
38
+ }
39
+
40
+ function isArray(x: unknown): x is unknown[] {
41
+ return Array.isArray(x);
42
+ }
43
+
44
+ // Outputs attach to the rootSeq of the descriptor they were declared in
45
+ // (root or a subcommand's sequence). Per-ref gating is computed downstream
46
+ // from each referenced binding's `gate`.
47
+
48
+ // Boutiques types
49
+
50
+ type BtInput = Record<string, unknown>;
51
+ type BtDescriptor = Record<string, unknown>;
52
+
53
+ enum InputTypePrimitive {
54
+ String = "String",
55
+ Float = "Float",
56
+ Integer = "Integer",
57
+ File = "File",
58
+ Flag = "Flag",
59
+ SubCommand = "SubCommand",
60
+ SubCommandUnion = "SubCommandUnion",
61
+ }
62
+
63
+ interface InputType {
64
+ primitive: InputTypePrimitive;
65
+ isList: boolean;
66
+ isOptional: boolean;
67
+ isEnum: boolean;
68
+ }
69
+
70
+ // Parser
71
+
72
+ export class BoutiquesParser implements Frontend {
73
+ readonly name = "boutiques";
74
+ readonly extensions = ["json"];
75
+
76
+ private errors: ParseError[] = [];
77
+ private warnings: ParseWarning[] = [];
78
+
79
+ private reset(): void {
80
+ this.errors = [];
81
+ this.warnings = [];
82
+ }
83
+
84
+ private error(message: string, location?: SourceLocation): void {
85
+ this.errors.push({ message, location });
86
+ }
87
+
88
+ private warn(message: string, location?: SourceLocation): void {
89
+ this.warnings.push({ message, location });
90
+ }
91
+
92
+ // JSON parsing
93
+
94
+ private parseJSON(source: string): BtDescriptor | null {
95
+ let parsed: unknown;
96
+ try {
97
+ parsed = JSON.parse(source);
98
+ } catch (e) {
99
+ this.error(e instanceof SyntaxError ? e.message : "Invalid JSON");
100
+ return null;
101
+ }
102
+
103
+ if (!isObject(parsed)) {
104
+ this.error("JSON source is not an object");
105
+ return null;
106
+ }
107
+
108
+ return parsed;
109
+ }
110
+
111
+ // Input type detection
112
+
113
+ private getInputTypePrimitive(btInput: BtInput): InputTypePrimitive | null {
114
+ const btType = btInput.type;
115
+
116
+ if (btType === undefined) {
117
+ this.error(`type is missing for input: '${btInput.id}'`);
118
+ return null;
119
+ }
120
+
121
+ if (isObject(btType)) return InputTypePrimitive.SubCommand;
122
+ if (isArray(btType)) return InputTypePrimitive.SubCommandUnion;
123
+
124
+ const typeName = isString(btType) ? btType : String(btType);
125
+
126
+ switch (typeName) {
127
+ case "String":
128
+ return InputTypePrimitive.String;
129
+ case "File":
130
+ return InputTypePrimitive.File;
131
+ case "Flag":
132
+ return InputTypePrimitive.Flag;
133
+ case "Number":
134
+ return btInput.integer ? InputTypePrimitive.Integer : InputTypePrimitive.Float;
135
+ default:
136
+ this.error(`Unknown input type: '${typeName}'`);
137
+ return null;
138
+ }
139
+ }
140
+
141
+ private getInputType(btInput: BtInput): InputType | null {
142
+ const primitive = this.getInputTypePrimitive(btInput);
143
+ if (primitive === null) return null;
144
+
145
+ if (primitive === InputTypePrimitive.Flag) {
146
+ return { primitive, isList: false, isOptional: true, isEnum: false };
147
+ }
148
+
149
+ const isList = btInput.list === true;
150
+ const isOptional = btInput.optional === true;
151
+ const isEnum = btInput["value-choices"] !== undefined;
152
+
153
+ if (primitive === InputTypePrimitive.File && isEnum) {
154
+ this.error(`File input '${btInput.id}' cannot have value-choices`);
155
+ return null;
156
+ }
157
+
158
+ return { primitive, isList, isOptional, isEnum };
159
+ }
160
+
161
+ // Metadata building
162
+
163
+ private buildNodeMeta(btInput: BtInput): NodeMeta | undefined {
164
+ const name = btInput.id;
165
+ const title = btInput.name;
166
+ const description = btInput.description;
167
+ const defaultValue = btInput["default-value"];
168
+
169
+ const hasDefault =
170
+ isString(defaultValue) || isNumber(defaultValue) || typeof defaultValue === "boolean";
171
+
172
+ if (!isString(name) && !isString(title) && !isString(description) && !hasDefault) {
173
+ return undefined;
174
+ }
175
+
176
+ return {
177
+ ...(isString(name) && { name }),
178
+ ...((isString(title) || isString(description)) && {
179
+ doc: {
180
+ ...(isString(title) && { title }),
181
+ ...(isString(description) && { description }),
182
+ },
183
+ }),
184
+ ...(hasDefault && { defaultValue }),
185
+ };
186
+ }
187
+
188
+ private buildStreamMeta(
189
+ bt: Record<string, unknown>,
190
+ ): { name: string; doc?: { title?: string; description?: string } } | undefined {
191
+ const id = bt.id;
192
+ if (!isString(id)) return undefined;
193
+
194
+ const name = bt.name;
195
+ const description = bt.description;
196
+
197
+ return {
198
+ name: id,
199
+ ...((isString(name) || isString(description)) && {
200
+ doc: {
201
+ ...(isString(name) && { title: name }),
202
+ ...(isString(description) && { description }),
203
+ },
204
+ }),
205
+ };
206
+ }
207
+
208
+ private buildOutput(
209
+ out: BtInput,
210
+ lookup: Record<string, NodeRef>,
211
+ idOptional: Set<string>,
212
+ ): { output: Output } | null {
213
+ const id = out.id;
214
+ if (!isString(id)) {
215
+ this.error("output-files entry missing id");
216
+ return null;
217
+ }
218
+
219
+ const template = out["path-template"];
220
+ if (!isString(template)) {
221
+ this.error(`output-files entry '${id}' missing path-template`);
222
+ return null;
223
+ }
224
+
225
+ const stripRaw = out["path-template-stripped-extensions"];
226
+ const stripExtensions =
227
+ isArray(stripRaw) && stripRaw.every(isString) && stripRaw.length > 0
228
+ ? (stripRaw as string[])
229
+ : undefined;
230
+
231
+ const parts = destructTemplate<NodeRef>(template, lookup);
232
+
233
+ const tokens: OutputToken[] = parts.map((part) => {
234
+ if (typeof part === "string") return { kind: "literal" as const, value: part };
235
+ return {
236
+ kind: "ref" as const,
237
+ target: part,
238
+ ...(stripExtensions && { stripExtensions }),
239
+ // Boutiques substitutes an unset optional input with the empty string.
240
+ ...(idOptional.has(part.name) && { fallback: "" }),
241
+ };
242
+ });
243
+
244
+ const title = out.name;
245
+ const description = out.description;
246
+ const output: Output = { name: id, tokens };
247
+ if (isString(title) || isString(description)) {
248
+ output.doc = {
249
+ ...(isString(title) && { title }),
250
+ ...(isString(description) && { description }),
251
+ };
252
+ }
253
+ // Boutiques' `optional: bool` on output-files is a tool-author hint and is
254
+ // re-derived at emit time from the refs' bindings - we don't store it.
255
+
256
+ return { output };
257
+ }
258
+
259
+ /**
260
+ * Attach `output-files` entries to the descriptor's rootSeq. Per-output
261
+ * gating (which refs are optional, which arm we're inside) is recovered
262
+ * downstream from each ref binding's `gate`.
263
+ */
264
+ private attachOutputs(rootSeq: Sequence, bt: BtDescriptor): void {
265
+ const outputFiles = bt["output-files"];
266
+ if (!isArray(outputFiles)) return;
267
+
268
+ const lookup: Record<string, NodeRef> = {};
269
+ const idOptional = new Set<string>();
270
+ const inputs = bt["inputs"];
271
+ if (isArray(inputs)) {
272
+ for (const input of inputs) {
273
+ if (isObject(input) && isString(input["value-key"]) && isString(input.id)) {
274
+ lookup[input["value-key"]] = nodeRef(input.id);
275
+ if (input.optional === true) idOptional.add(input.id);
276
+ }
277
+ }
278
+ }
279
+
280
+ for (const out of outputFiles) {
281
+ if (!isObject(out)) {
282
+ this.warn("Skipping non-object output-files entry");
283
+ continue;
284
+ }
285
+ const built = this.buildOutput(out, lookup, idOptional);
286
+ if (!built) continue;
287
+
288
+ if (!rootSeq.meta) rootSeq.meta = {};
289
+ rootSeq.meta.outputs = [...(rootSeq.meta.outputs ?? []), built.output];
290
+ }
291
+ }
292
+
293
+ private buildAppMeta(bt: BtDescriptor): AppMeta | undefined {
294
+ const id = bt.id ?? bt.name;
295
+ if (!isString(id)) return undefined;
296
+
297
+ const name = bt.name;
298
+ const description = bt.description;
299
+ const version = bt["tool-version"];
300
+ const author = bt.author;
301
+ const url = bt.url;
302
+ const container = bt["container-image"];
303
+ const stdout = bt["stdout-output"];
304
+ const stderr = bt["stderr-output"];
305
+
306
+ const doc: Documentation = {
307
+ ...(isString(name) && { title: name }),
308
+ ...(isString(description) && { description }),
309
+ ...(isString(author) && { authors: [author] }),
310
+ ...(isString(url) && { urls: [url] }),
311
+ };
312
+
313
+ return {
314
+ id,
315
+ ...(isString(version) && { version }),
316
+ ...(Object.keys(doc).length > 0 && { doc }),
317
+ ...(isObject(container) &&
318
+ isString(container.image) && {
319
+ container: {
320
+ image: container.image,
321
+ ...(isString(container.type) && {
322
+ type: container.type as "docker" | "singularity",
323
+ }),
324
+ },
325
+ }),
326
+ ...(isObject(stdout) && { stdout: this.buildStreamMeta(stdout) }),
327
+ ...(isObject(stderr) && { stderr: this.buildStreamMeta(stderr) }),
328
+ };
329
+ }
330
+
331
+ // Terminal node building
332
+
333
+ private buildEnumAlternative(choices: unknown[], meta?: NodeMeta): Alternative | null {
334
+ const alts: Literal[] = [];
335
+
336
+ for (const choice of choices) {
337
+ if (isString(choice)) {
338
+ alts.push({ kind: "literal", attrs: { str: choice } });
339
+ } else if (isNumber(choice)) {
340
+ alts.push({ kind: "literal", attrs: { str: String(choice) } });
341
+ } else {
342
+ this.warn(`Ignoring non-string/number enum choice: ${JSON.stringify(choice)}`);
343
+ }
344
+ }
345
+
346
+ if (alts.length === 0) return null;
347
+
348
+ const node: Alternative = { kind: "alternative", attrs: { alts } };
349
+ if (meta) node.meta = meta;
350
+ return node;
351
+ }
352
+
353
+ private buildTerminal(btInput: BtInput, inputType: InputType): Expr | null {
354
+ const meta = this.buildNodeMeta(btInput);
355
+
356
+ if (inputType.isEnum) {
357
+ const choices = btInput["value-choices"];
358
+ if (!isArray(choices)) {
359
+ this.error(`Invalid value-choices for '${btInput.id}'`);
360
+ return null;
361
+ }
362
+ return this.buildEnumAlternative(choices, meta);
363
+ }
364
+
365
+ switch (inputType.primitive) {
366
+ case InputTypePrimitive.String: {
367
+ const node: Str = { kind: "str", attrs: {} };
368
+ if (meta) node.meta = meta;
369
+ return node;
370
+ }
371
+
372
+ case InputTypePrimitive.Integer: {
373
+ const node: Int = { kind: "int", attrs: {} };
374
+ if (isNumber(btInput.minimum)) {
375
+ node.attrs.minValue = Math.floor(btInput.minimum);
376
+ if (btInput["exclusive-minimum"] === true) node.attrs.minValue += 1;
377
+ }
378
+ if (isNumber(btInput.maximum)) {
379
+ node.attrs.maxValue = Math.floor(btInput.maximum);
380
+ if (btInput["exclusive-maximum"] === true) node.attrs.maxValue -= 1;
381
+ }
382
+ if (meta) node.meta = meta;
383
+ return node;
384
+ }
385
+
386
+ case InputTypePrimitive.Float: {
387
+ const node: Float = { kind: "float", attrs: {} };
388
+ if (isNumber(btInput.minimum)) node.attrs.minValue = btInput.minimum;
389
+ if (isNumber(btInput.maximum)) node.attrs.maxValue = btInput.maximum;
390
+ if (meta) node.meta = meta;
391
+ return node;
392
+ }
393
+
394
+ case InputTypePrimitive.File: {
395
+ const node: Path = {
396
+ kind: "path",
397
+ attrs: {
398
+ ...(btInput["resolve-parent"] === true && { resolveParent: true }),
399
+ ...(btInput.mutable === true && { mutable: true }),
400
+ },
401
+ };
402
+ if (meta) node.meta = meta;
403
+ return node;
404
+ }
405
+
406
+ case InputTypePrimitive.Flag: {
407
+ const flag = btInput["command-line-flag"];
408
+ if (!isString(flag)) {
409
+ this.error(`Flag input '${btInput.id}' missing command-line-flag`);
410
+ return null;
411
+ }
412
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
413
+ const node: Optional = { kind: "optional", attrs: { node: literal } };
414
+ const flagMeta = meta ?? {};
415
+ if (flagMeta.defaultValue === undefined) flagMeta.defaultValue = false;
416
+ node.meta = flagMeta;
417
+ return node;
418
+ }
419
+
420
+ case InputTypePrimitive.SubCommand: {
421
+ const nested = btInput.type;
422
+ if (!isObject(nested)) {
423
+ this.error(`Invalid subcommand type for '${btInput.id}'`);
424
+ return null;
425
+ }
426
+ const node = this.parseDescriptor(nested);
427
+ if (node && meta) node.meta = meta;
428
+ return node;
429
+ }
430
+
431
+ case InputTypePrimitive.SubCommandUnion: {
432
+ const alts = btInput.type;
433
+ if (!isArray(alts)) {
434
+ this.error(`Invalid subcommand union type for '${btInput.id}'`);
435
+ return null;
436
+ }
437
+ const parsedAlts: Expr[] = [];
438
+ // A discriminated union dispatches on a unique `@type` (the subcommand
439
+ // id), recorded as `variantTag` so it survives a single-field
440
+ // sub-command collapsing onto its inner field. Two genuinely-duplicate
441
+ // ids (c3d c2d/c3d/c4d declare two byte-identical sub-commands) are
442
+ // dodged to a unique tag so both arms stay addressable rather than the
443
+ // second being an unreachable, codegen-breaking duplicate (the backend
444
+ // rejects duplicate `@type`s outright).
445
+ const usedTags = new Set<string>();
446
+ for (const alt of alts) {
447
+ if (!isObject(alt)) {
448
+ this.warn("Skipping non-object subcommand alternative");
449
+ continue;
450
+ }
451
+ const parsed = this.parseDescriptor(alt);
452
+ if (parsed) {
453
+ // Tag the arm from the subcommand descriptor's id.
454
+ const altMeta = this.buildAppMeta(alt);
455
+ if (altMeta?.id) {
456
+ let tag = altMeta.id;
457
+ if (usedTags.has(tag)) {
458
+ let n = 2;
459
+ while (usedTags.has(`${altMeta.id}_${n}`)) n++;
460
+ tag = `${altMeta.id}_${n}`;
461
+ this.warn(
462
+ `Duplicate subcommand id '${altMeta.id}' in union '${btInput.id}'; ` +
463
+ `renamed variant to '${tag}' to keep the @type discriminator unique.`,
464
+ );
465
+ }
466
+ usedTags.add(tag);
467
+ // `variantTag` is the discriminator (survives collapse); `name`
468
+ // gives a unique binding/type name for non-collapsing (multi-field)
469
+ // arms - it is clobbered by the inner field's name when the arm
470
+ // collapses, which is why the tag needs its own channel.
471
+ parsed.meta = { ...parsed.meta, name: tag, variantTag: tag };
472
+ }
473
+ parsedAlts.push(parsed);
474
+ }
475
+ }
476
+ if (parsedAlts.length === 0) {
477
+ this.error(`No valid alternatives for subcommand union '${btInput.id}'`);
478
+ return null;
479
+ }
480
+ if (parsedAlts.length === 1) {
481
+ const node = parsedAlts[0]!;
482
+ if (meta) node.meta = { ...node.meta, ...meta };
483
+ return node;
484
+ }
485
+ const node: Alternative = { kind: "alternative", attrs: { alts: parsedAlts } };
486
+ if (meta) node.meta = meta;
487
+ return node;
488
+ }
489
+
490
+ default:
491
+ return null;
492
+ }
493
+ }
494
+
495
+ // Node wrapping (repeat, flag, optional)
496
+
497
+ private wrapWithRepeat(node: Expr, btInput: BtInput): Repeat {
498
+ return {
499
+ kind: "repeat",
500
+ attrs: {
501
+ node,
502
+ ...(isString(btInput["list-separator"]) && { join: btInput["list-separator"] }),
503
+ ...(isNumber(btInput["min-list-entries"]) && { countMin: btInput["min-list-entries"] }),
504
+ ...(isNumber(btInput["max-list-entries"]) && { countMax: btInput["max-list-entries"] }),
505
+ },
506
+ };
507
+ }
508
+
509
+ private wrapWithFlag(node: Expr, btInput: BtInput): Expr {
510
+ const flag = btInput["command-line-flag"];
511
+ if (!isString(flag)) return node;
512
+
513
+ const flagSep = btInput["command-line-flag-separator"];
514
+ const prefix: Literal = {
515
+ kind: "literal",
516
+ attrs: { str: flag + (flagSep ?? "") },
517
+ };
518
+
519
+ return { kind: "sequence", attrs: { nodes: [prefix, node] } };
520
+ }
521
+
522
+ private wrapWithOptional(node: Expr): Optional {
523
+ return { kind: "optional", attrs: { node } };
524
+ }
525
+
526
+ private wrapNode(node: Expr, btInput: BtInput, inputType: InputType): Expr {
527
+ // Flags handle their own optional wrapping
528
+ if (inputType.primitive === InputTypePrimitive.Flag) {
529
+ return node;
530
+ }
531
+
532
+ const inner = node;
533
+
534
+ // Order: repeat -> flag -> optional
535
+ // This produces: optional(sequence(flag, repeat(value)))
536
+
537
+ if (inputType.isList) {
538
+ node = this.wrapWithRepeat(node, btInput);
539
+ }
540
+
541
+ node = this.wrapWithFlag(node, btInput);
542
+
543
+ if (inputType.isOptional) {
544
+ node = this.wrapWithOptional(node);
545
+ }
546
+
547
+ // Hoist metadata (doc, default) to outermost wrapper so backends find it
548
+ // on the binding node. Keep name on inner for solver's findDeepName.
549
+ if (node !== inner && inner.meta) {
550
+ const { name, ...rest } = inner.meta;
551
+ if (Object.keys(rest).length > 0) {
552
+ node.meta = { ...node.meta, ...rest };
553
+ inner.meta = name ? { name } : undefined;
554
+ }
555
+ }
556
+
557
+ return node;
558
+ }
559
+
560
+ // Command line parsing
561
+
562
+ private parseCommandLineTemplate(
563
+ template: string,
564
+ inputsLookup: Map<string, BtInput>,
565
+ ): Array<Array<string | BtInput>> {
566
+ let args: string[];
567
+ try {
568
+ args = boutiquesSplitCommand(template);
569
+ } catch (e) {
570
+ this.error(`Failed to parse command-line: ${e instanceof Error ? e.message : String(e)}`);
571
+ return [];
572
+ }
573
+
574
+ const lookupObj = Object.fromEntries(inputsLookup);
575
+ return args.map((arg) => destructTemplate(arg, lookupObj));
576
+ }
577
+
578
+ private parseDescriptor(bt: BtDescriptor): Sequence | null {
579
+ // Build inputs lookup
580
+ const inputs = bt["inputs"];
581
+ const inputsLookup = new Map<string, BtInput>();
582
+
583
+ if (isArray(inputs)) {
584
+ for (const input of inputs) {
585
+ if (isObject(input) && isString(input["value-key"])) {
586
+ inputsLookup.set(input["value-key"], input);
587
+ }
588
+ }
589
+ }
590
+
591
+ // Parse command line template
592
+ const commandLine = bt["command-line"];
593
+ const segments = isString(commandLine)
594
+ ? this.parseCommandLineTemplate(commandLine, inputsLookup)
595
+ : [];
596
+
597
+ // Build IR
598
+ const rootSeq: Sequence = { kind: "sequence", attrs: { nodes: [] } };
599
+
600
+ for (const segment of segments) {
601
+ const seq: Sequence = { kind: "sequence", attrs: { nodes: [], join: "" } };
602
+
603
+ for (const elem of segment) {
604
+ if (isObject(elem)) {
605
+ const inputType = this.getInputType(elem);
606
+ if (inputType === null) continue;
607
+
608
+ let node = this.buildTerminal(elem, inputType);
609
+ if (node === null) continue;
610
+
611
+ node = this.wrapNode(node, elem, inputType);
612
+ seq.attrs.nodes.push(node);
613
+ } else {
614
+ seq.attrs.nodes.push({ kind: "literal", attrs: { str: elem } });
615
+ }
616
+ }
617
+
618
+ // Flatten single-node sequences
619
+ if (seq.attrs.nodes.length === 1) {
620
+ rootSeq.attrs.nodes.push(seq.attrs.nodes[0]!);
621
+ } else if (seq.attrs.nodes.length > 1) {
622
+ rootSeq.attrs.nodes.push(seq);
623
+ }
624
+ }
625
+
626
+ this.attachOutputs(rootSeq, bt);
627
+ return rootSeq;
628
+ }
629
+
630
+ // Public API
631
+
632
+ parse(source: string, _filename?: string): ParseResult {
633
+ this.reset();
634
+
635
+ const bt = this.parseJSON(source);
636
+ if (bt === null) {
637
+ return {
638
+ expr: { kind: "sequence", attrs: { nodes: [] } },
639
+ errors: this.errors,
640
+ warnings: this.warnings,
641
+ };
642
+ }
643
+
644
+ const baseMeta = this.buildAppMeta(bt);
645
+ if (!baseMeta) {
646
+ this.error("Descriptor is missing id/name");
647
+ return {
648
+ expr: { kind: "sequence", attrs: { nodes: [] } },
649
+ errors: this.errors,
650
+ warnings: this.warnings,
651
+ };
652
+ }
653
+
654
+ const expr = this.parseDescriptor(bt);
655
+ if (expr === null) {
656
+ this.error("Failed to parse command structure");
657
+ return {
658
+ expr: { kind: "sequence", attrs: { nodes: [] } },
659
+ errors: this.errors,
660
+ warnings: this.warnings,
661
+ };
662
+ }
663
+
664
+ // Set root struct name from descriptor id if not already set
665
+ if (!expr.meta?.name && baseMeta?.id) {
666
+ expr.meta = { ...expr.meta, name: baseMeta.id };
667
+ }
668
+
669
+ return {
670
+ meta: baseMeta,
671
+ expr,
672
+ errors: this.errors,
673
+ warnings: this.warnings,
674
+ };
675
+ }
676
+ }