@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,914 @@
1
+ import type { AppMeta, NodeMeta } from "../../ir/meta.js";
2
+ import type {
3
+ Alternative,
4
+ Expr,
5
+ Float,
6
+ Int,
7
+ Literal,
8
+ Optional,
9
+ Path,
10
+ Repeat,
11
+ Sequence,
12
+ Str,
13
+ } from "../../ir/node.js";
14
+ import type {
15
+ Frontend,
16
+ ParseError,
17
+ ParseResult,
18
+ ParseWarning,
19
+ SourceLocation,
20
+ } from "../frontend.js";
21
+
22
+ // Find the deepest existing name in a subtree, mirroring solver semantics.
23
+ // Used by the mutex code so the synthesized inner name matches the binding
24
+ // name the solver will produce for the same subtree.
25
+ function findDeepName(node: Expr): string | undefined {
26
+ if (node.meta?.name) return node.meta.name;
27
+ if (node.kind === "optional" || node.kind === "repeat") {
28
+ return findDeepName(node.attrs.node);
29
+ }
30
+ if (node.kind === "sequence") {
31
+ for (const child of node.attrs.nodes) {
32
+ if (child.kind === "literal") continue;
33
+ const name = findDeepName(child);
34
+ if (name) return name;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ // Type guards
41
+
42
+ function isObject(x: unknown): x is Record<string, unknown> {
43
+ return typeof x === "object" && x !== null && !Array.isArray(x);
44
+ }
45
+
46
+ function isString(x: unknown): x is string {
47
+ return typeof x === "string";
48
+ }
49
+
50
+ function isNumber(x: unknown): x is number {
51
+ return typeof x === "number";
52
+ }
53
+
54
+ function isArray(x: unknown): x is unknown[] {
55
+ return Array.isArray(x);
56
+ }
57
+
58
+ // Argdump types
59
+
60
+ type AdAction = Record<string, unknown>;
61
+ type AdDescriptor = Record<string, unknown>;
62
+
63
+ interface ArgparseMarker {
64
+ __argparse__: string;
65
+ }
66
+
67
+ function isArgparseMarker(x: unknown): x is ArgparseMarker {
68
+ return isObject(x) && isString(x.__argparse__);
69
+ }
70
+
71
+ function isSuppressed(x: unknown): boolean {
72
+ return isArgparseMarker(x) && x.__argparse__ === "SUPPRESS";
73
+ }
74
+
75
+ // Parser
76
+
77
+ export class ArgdumpParser implements Frontend {
78
+ readonly name = "argdump";
79
+ readonly extensions = ["json"];
80
+
81
+ private errors: ParseError[] = [];
82
+ private warnings: ParseWarning[] = [];
83
+
84
+ private reset(): void {
85
+ this.errors = [];
86
+ this.warnings = [];
87
+ }
88
+
89
+ private error(message: string, location?: SourceLocation): void {
90
+ this.errors.push({ message, location });
91
+ }
92
+
93
+ private warn(message: string, location?: SourceLocation): void {
94
+ this.warnings.push({ message, location });
95
+ }
96
+
97
+ // JSON parsing
98
+
99
+ private parseJSON(source: string): AdDescriptor | null {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(source);
103
+ } catch (e) {
104
+ this.error(e instanceof SyntaxError ? e.message : "Invalid JSON");
105
+ return null;
106
+ }
107
+
108
+ if (!isObject(parsed)) {
109
+ this.error("JSON source is not an object");
110
+ return null;
111
+ }
112
+
113
+ return parsed;
114
+ }
115
+
116
+ // Metadata building
117
+
118
+ private buildAppMeta(descriptor: AdDescriptor): AppMeta | undefined {
119
+ const prog = descriptor.prog;
120
+ if (!isString(prog)) return undefined;
121
+
122
+ // Use prog as id, fall back to first word of description
123
+ let id = prog || undefined;
124
+ if (!id) {
125
+ const desc = descriptor.description;
126
+ if (isString(desc)) {
127
+ // Extract tool name: first word, strip trailing punctuation
128
+ const match = desc.match(/^(\S+)/);
129
+ if (match) id = match[1]!.replace(/[:;,]+$/, "") || undefined;
130
+ }
131
+ }
132
+ if (!id) return undefined;
133
+
134
+ const description = descriptor.description;
135
+ const epilog = descriptor.epilog;
136
+ // Try to extract version from a version action
137
+ let versionStr: string | undefined;
138
+ const actions = descriptor.actions;
139
+ if (isArray(actions)) {
140
+ for (const action of actions) {
141
+ if (isObject(action) && action.action_type === "version" && isString(action.version)) {
142
+ // Strip %(prog)s prefix if present
143
+ versionStr = action.version.replace(/%%?\(prog\)s\s*/g, "").trim();
144
+ if (!versionStr) versionStr = undefined;
145
+ break;
146
+ }
147
+ }
148
+ }
149
+
150
+ return {
151
+ id,
152
+ ...(versionStr && { version: versionStr }),
153
+ ...((isString(description) || isString(epilog)) && {
154
+ doc: {
155
+ ...(isString(description) && { description }),
156
+ ...(isString(epilog) && { comment: epilog }),
157
+ },
158
+ }),
159
+ };
160
+ }
161
+
162
+ // Terminal node building
163
+
164
+ private resolveTerminal(action: AdAction): Expr | null {
165
+ const typeInfo = action.type_info;
166
+ const fileTypeInfo = action.file_type_info;
167
+ const choices = action.choices;
168
+
169
+ // Choices -> enum alternative
170
+ if (isArray(choices) && choices.length > 0) {
171
+ const alts: Literal[] = [];
172
+ for (const choice of choices) {
173
+ if (isString(choice) || isNumber(choice)) {
174
+ alts.push({ kind: "literal", attrs: { str: String(choice) } });
175
+ } else {
176
+ this.warn(`Ignoring non-string/number choice: ${JSON.stringify(choice)}`);
177
+ }
178
+ }
179
+ if (alts.length === 0) return null;
180
+ return { kind: "alternative", attrs: { alts } };
181
+ }
182
+
183
+ // FileType -> path
184
+ if (isObject(fileTypeInfo)) {
185
+ return { kind: "path", attrs: {} } satisfies Path;
186
+ }
187
+
188
+ // type_info-based resolution
189
+ if (isObject(typeInfo)) {
190
+ const name = typeInfo.name;
191
+
192
+ if (!isString(name)) {
193
+ return this.inferFromSamples(action) ?? ({ kind: "str", attrs: {} } satisfies Str);
194
+ }
195
+
196
+ // Non-serializable type -> infer from samples, else str + warning
197
+ if (typeInfo.serializable === false) {
198
+ const inferred = this.inferFromSamples(action);
199
+ const fallback = inferred ? inferred.kind : "string";
200
+ this.warn(`Non-serializable type '${name}' for '${action.dest}', treating as ${fallback}`);
201
+ return inferred ?? ({ kind: "str", attrs: {} } satisfies Str);
202
+ }
203
+
204
+ switch (name) {
205
+ case "int":
206
+ return { kind: "int", attrs: {} } satisfies Int;
207
+ case "float":
208
+ return { kind: "float", attrs: {} } satisfies Float;
209
+ case "Path":
210
+ case "PosixPath":
211
+ case "WindowsPath":
212
+ return { kind: "path", attrs: {} } satisfies Path;
213
+ default: {
214
+ const moduleHit = this.resolveByModule(typeInfo.module);
215
+ if (moduleHit) return moduleHit;
216
+ // Unknown type -> infer from samples, else str
217
+ const inferred = this.inferFromSamples(action);
218
+ if (name !== "str") {
219
+ const fallback = inferred ? inferred.kind : "string";
220
+ this.warn(`Unknown type '${name}' for '${action.dest}', treating as ${fallback}`);
221
+ }
222
+ return inferred ?? ({ kind: "str", attrs: {} } satisfies Str);
223
+ }
224
+ }
225
+ }
226
+
227
+ // No type_info -> infer from samples, else str (argparse default)
228
+ return this.inferFromSamples(action) ?? ({ kind: "str", attrs: {} } satisfies Str);
229
+ }
230
+
231
+ private resolveByModule(mod: unknown): Path | Float | null {
232
+ if (!isString(mod)) return null;
233
+ if (mod === "pathlib" || mod === "os.path" || mod.includes("path")) {
234
+ return { kind: "path", attrs: {} } satisfies Path;
235
+ }
236
+ if (mod === "decimal" || mod === "fractions") {
237
+ return { kind: "float", attrs: {} } satisfies Float;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /** Infer numeric type from sample values: default (incl. list elements) or const. */
243
+ private inferFromSamples(action: AdAction): Int | Float | null {
244
+ const samples: unknown[] = [];
245
+ const def = action.default;
246
+ if (isArray(def)) samples.push(...def);
247
+ else samples.push(def);
248
+ samples.push(action.const);
249
+
250
+ let sawNumber = false;
251
+ let sawNonInteger = false;
252
+ for (const s of samples) {
253
+ if (typeof s !== "number" || !Number.isFinite(s)) continue;
254
+ sawNumber = true;
255
+ if (!Number.isInteger(s)) sawNonInteger = true;
256
+ }
257
+ if (!sawNumber) return null;
258
+ return sawNonInteger
259
+ ? ({ kind: "float", attrs: {} } satisfies Float)
260
+ : ({ kind: "int", attrs: {} } satisfies Int);
261
+ }
262
+
263
+ // Nargs wrapping
264
+
265
+ private wrapWithNargs(node: Expr, nargs: unknown): Expr {
266
+ if (nargs === null || nargs === undefined) {
267
+ // No nargs -> bare value
268
+ return node;
269
+ }
270
+
271
+ if (isArgparseMarker(nargs)) {
272
+ if (nargs.__argparse__ === "REMAINDER") {
273
+ // REMAINDER -> rep(str())
274
+ const rep: Repeat = {
275
+ kind: "repeat",
276
+ attrs: { node: { kind: "str", attrs: {} }, countMin: 0 },
277
+ };
278
+ return rep;
279
+ }
280
+ if (nargs.__argparse__ === "SUPPRESS") {
281
+ return node;
282
+ }
283
+ }
284
+
285
+ if (nargs === "?") {
286
+ const opt: Optional = { kind: "optional", attrs: { node } };
287
+ return opt;
288
+ }
289
+
290
+ if (nargs === "*") {
291
+ const rep: Repeat = { kind: "repeat", attrs: { node, countMin: 0 } };
292
+ return rep;
293
+ }
294
+
295
+ if (nargs === "+") {
296
+ const rep: Repeat = { kind: "repeat", attrs: { node, countMin: 1 } };
297
+ return rep;
298
+ }
299
+
300
+ if (isNumber(nargs) && Number.isInteger(nargs) && nargs >= 0) {
301
+ if (nargs === 1) return node;
302
+ const rep: Repeat = {
303
+ kind: "repeat",
304
+ attrs: { node, countMin: nargs, countMax: nargs },
305
+ };
306
+ return rep;
307
+ }
308
+
309
+ return node;
310
+ }
311
+
312
+ // Action building
313
+
314
+ private buildNodeMeta(action: AdAction): NodeMeta | undefined {
315
+ const dest = action.dest;
316
+ const help = action.help;
317
+ const defaultVal = action.default;
318
+ const name = this.preferredName(action) ?? (isString(dest) ? dest : undefined);
319
+
320
+ const hasName = name !== undefined;
321
+ const hasHelp = isString(help) && !isSuppressed(help);
322
+ const hasDefault =
323
+ (isString(defaultVal) || isNumber(defaultVal) || typeof defaultVal === "boolean") &&
324
+ !isSuppressed(defaultVal);
325
+
326
+ if (!hasName && !hasHelp && !hasDefault) return undefined;
327
+
328
+ return {
329
+ ...(hasName && { name }),
330
+ ...(hasHelp && { doc: { description: help } }),
331
+ ...(hasDefault && { defaultValue: defaultVal }),
332
+ };
333
+ }
334
+
335
+ private getOptionFlag(action: AdAction): string | null {
336
+ const optionStrings = action.option_strings;
337
+ if (!isArray(optionStrings) || optionStrings.length === 0) return null;
338
+
339
+ // Prefer long option, fallback to first
340
+ for (const opt of optionStrings) {
341
+ if (isString(opt) && opt.startsWith("--")) return opt;
342
+ }
343
+ const first = optionStrings[0];
344
+ return isString(first) ? first : null;
345
+ }
346
+
347
+ /** Prefer the first --long flag (without leading dashes) over dest. */
348
+ private preferredName(action: AdAction): string | undefined {
349
+ const optionStrings = action.option_strings;
350
+ if (!isArray(optionStrings)) return undefined;
351
+ for (const opt of optionStrings) {
352
+ if (isString(opt) && opt.startsWith("--") && opt.length > 2) {
353
+ return opt.slice(2);
354
+ }
355
+ }
356
+ return undefined;
357
+ }
358
+
359
+ private isPositional(action: AdAction): boolean {
360
+ const optionStrings = action.option_strings;
361
+ return !isArray(optionStrings) || optionStrings.length === 0;
362
+ }
363
+
364
+ private buildAction(action: AdAction): Expr | null {
365
+ const actionType = action.action_type ?? "store";
366
+
367
+ switch (actionType) {
368
+ case "store":
369
+ return this.buildStore(action);
370
+ case "store_true":
371
+ return this.buildStoreTrue(action);
372
+ case "store_false":
373
+ return this.buildStoreFalse(action);
374
+ case "store_const":
375
+ return this.buildStoreConst(action);
376
+ case "boolean_optional":
377
+ return this.buildBooleanOptional(action);
378
+ case "count":
379
+ return this.buildCount(action);
380
+ case "append":
381
+ case "extend":
382
+ return this.buildAppendExtend(action);
383
+ case "append_const":
384
+ return this.buildAppendConst(action);
385
+ case "parsers":
386
+ return this.buildSubparsers(action);
387
+ case "help":
388
+ case "version":
389
+ // Skip help/version actions - not part of the tool interface
390
+ return null;
391
+ case "unknown":
392
+ this.warn(
393
+ `Unknown action type for '${action.dest}'` +
394
+ (isString(action.custom_action_class)
395
+ ? ` (custom class: ${action.custom_action_class})`
396
+ : "") +
397
+ ", treating as store",
398
+ );
399
+ return this.buildStore(action);
400
+ default:
401
+ this.warn(`Unrecognized action_type '${actionType}' for '${action.dest}', skipping`);
402
+ return null;
403
+ }
404
+ }
405
+
406
+ private buildStore(action: AdAction): Expr | null {
407
+ const terminal = this.resolveTerminal(action);
408
+ if (!terminal) return null;
409
+
410
+ const meta = this.buildNodeMeta(action);
411
+ if (meta) terminal.meta = meta;
412
+
413
+ const node: Expr = this.wrapWithNargs(terminal, action.nargs);
414
+
415
+ if (this.isPositional(action)) {
416
+ // Hoist metadata from terminal to outermost wrapper
417
+ if (node !== terminal && terminal.meta) {
418
+ const { name, ...rest } = terminal.meta;
419
+ if (Object.keys(rest).length > 0) {
420
+ node.meta = { ...node.meta, ...rest };
421
+ terminal.meta = name ? { name } : undefined;
422
+ }
423
+ }
424
+ return node;
425
+ }
426
+
427
+ // Optional argument: seq(lit(flag), value)
428
+ const flag = this.getOptionFlag(action);
429
+ if (!flag) {
430
+ this.error(`Optional argument '${action.dest}' has no option strings`);
431
+ return null;
432
+ }
433
+
434
+ const flagLit: Literal = { kind: "literal", attrs: { str: flag } };
435
+ const seq: Sequence = { kind: "sequence", attrs: { nodes: [flagLit, node] } };
436
+
437
+ // Wrap in optional unless explicitly required
438
+ const isRequired = action.required === true;
439
+ let result: Expr;
440
+ if (isRequired) {
441
+ result = seq;
442
+ } else {
443
+ const opt: Optional = { kind: "optional", attrs: { node: seq } };
444
+ result = opt;
445
+ }
446
+
447
+ // Hoist metadata to outermost node
448
+ if (terminal.meta) {
449
+ const { name, ...rest } = terminal.meta;
450
+ if (Object.keys(rest).length > 0) {
451
+ result.meta = { ...result.meta, ...rest };
452
+ terminal.meta = name ? { name } : undefined;
453
+ }
454
+ }
455
+
456
+ return result;
457
+ }
458
+
459
+ private buildStoreTrue(action: AdAction): Expr | null {
460
+ const flag = this.getOptionFlag(action);
461
+ if (!flag) {
462
+ this.error(`store_true action '${action.dest}' has no option strings`);
463
+ return null;
464
+ }
465
+
466
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
467
+ const opt: Optional = { kind: "optional", attrs: { node: literal } };
468
+
469
+ const meta = this.buildNodeMeta(action);
470
+ const flagMeta = meta ?? {};
471
+ if (flagMeta.defaultValue === undefined) flagMeta.defaultValue = false;
472
+ opt.meta = flagMeta;
473
+
474
+ return opt;
475
+ }
476
+
477
+ private buildStoreFalse(action: AdAction): Expr | null {
478
+ const flag = this.getOptionFlag(action);
479
+ if (!flag) {
480
+ this.error(`store_false action '${action.dest}' has no option strings`);
481
+ return null;
482
+ }
483
+
484
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
485
+ const opt: Optional = { kind: "optional", attrs: { node: literal } };
486
+
487
+ const meta = this.buildNodeMeta(action);
488
+ const flagMeta = meta ?? {};
489
+ if (flagMeta.defaultValue === undefined) flagMeta.defaultValue = true;
490
+ opt.meta = flagMeta;
491
+
492
+ return opt;
493
+ }
494
+
495
+ private buildStoreConst(action: AdAction): Expr | null {
496
+ const flag = this.getOptionFlag(action);
497
+ if (!flag) {
498
+ this.error(`store_const action '${action.dest}' has no option strings`);
499
+ return null;
500
+ }
501
+
502
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
503
+ const opt: Optional = { kind: "optional", attrs: { node: literal } };
504
+
505
+ const meta = this.buildNodeMeta(action);
506
+ if (meta) opt.meta = meta;
507
+
508
+ return opt;
509
+ }
510
+
511
+ private buildBooleanOptional(action: AdAction): Expr | null {
512
+ const optionStrings = action.option_strings;
513
+ if (!isArray(optionStrings) || optionStrings.length === 0) {
514
+ this.error(`boolean_optional action '${action.dest}' has no option strings`);
515
+ return null;
516
+ }
517
+
518
+ // Find --flag and --no-flag forms
519
+ let posFlag: string | null = null;
520
+ let negFlag: string | null = null;
521
+
522
+ for (const opt of optionStrings) {
523
+ if (!isString(opt)) continue;
524
+ if (opt.startsWith("--no-")) {
525
+ negFlag = opt;
526
+ } else if (opt.startsWith("--")) {
527
+ posFlag = opt;
528
+ }
529
+ }
530
+
531
+ if (!posFlag) {
532
+ // Fallback: use first two option strings
533
+ posFlag = isString(optionStrings[0]) ? optionStrings[0] : null;
534
+ negFlag = isString(optionStrings[1]) ? optionStrings[1] : null;
535
+ }
536
+
537
+ if (!posFlag) {
538
+ this.error(`boolean_optional action '${action.dest}' has no valid option strings`);
539
+ return null;
540
+ }
541
+
542
+ const posLit: Literal = { kind: "literal", attrs: { str: posFlag } };
543
+
544
+ let innerNode: Expr;
545
+ if (negFlag) {
546
+ const negLit: Literal = { kind: "literal", attrs: { str: negFlag } };
547
+ const alt: Alternative = { kind: "alternative", attrs: { alts: [posLit, negLit] } };
548
+ innerNode = alt;
549
+ } else {
550
+ innerNode = posLit;
551
+ }
552
+
553
+ const opt: Optional = { kind: "optional", attrs: { node: innerNode } };
554
+
555
+ const meta = this.buildNodeMeta(action);
556
+ const flagMeta = meta ?? {};
557
+ if (flagMeta.defaultValue === undefined) flagMeta.defaultValue = false;
558
+ opt.meta = flagMeta;
559
+
560
+ return opt;
561
+ }
562
+
563
+ private buildCount(action: AdAction): Expr | null {
564
+ const flag = this.getOptionFlag(action);
565
+ if (!flag) {
566
+ this.error(`count action '${action.dest}' has no option strings`);
567
+ return null;
568
+ }
569
+
570
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
571
+ const rep: Repeat = { kind: "repeat", attrs: { node: literal, countMin: 0 } };
572
+
573
+ const meta = this.buildNodeMeta(action);
574
+ if (meta) rep.meta = meta;
575
+
576
+ return rep;
577
+ }
578
+
579
+ private buildAppendExtend(action: AdAction): Expr | null {
580
+ const terminal = this.resolveTerminal(action);
581
+ if (!terminal) return null;
582
+
583
+ const meta = this.buildNodeMeta(action);
584
+ if (meta) terminal.meta = meta;
585
+
586
+ // Inner value may have nargs
587
+ const nargsWrapped: Expr = this.wrapWithNargs(terminal, action.nargs);
588
+
589
+ // Always wrap in repeat (append/extend accumulates)
590
+ const inner: Expr =
591
+ nargsWrapped.kind === "repeat"
592
+ ? nargsWrapped
593
+ : ({ kind: "repeat", attrs: { node: nargsWrapped, countMin: 0 } } satisfies Repeat);
594
+
595
+ if (this.isPositional(action)) {
596
+ // Hoist metadata
597
+ if (inner !== terminal && terminal.meta) {
598
+ const { name, ...rest } = terminal.meta;
599
+ if (Object.keys(rest).length > 0) {
600
+ inner.meta = { ...inner.meta, ...rest };
601
+ terminal.meta = name ? { name } : undefined;
602
+ }
603
+ }
604
+ return inner;
605
+ }
606
+
607
+ // Optional argument with flag
608
+ const flag = this.getOptionFlag(action);
609
+ if (!flag) {
610
+ this.error(`append/extend action '${action.dest}' has no option strings`);
611
+ return null;
612
+ }
613
+
614
+ // For append with flag: rep(seq(lit(flag), value))
615
+ // Unwrap the repeat we just added
616
+ const valueNode = inner.kind === "repeat" ? inner.attrs.node : inner;
617
+ const flagLit: Literal = { kind: "literal", attrs: { str: flag } };
618
+ const flagSeq: Sequence = { kind: "sequence", attrs: { nodes: [flagLit, valueNode] } };
619
+ const outerRep: Repeat = { kind: "repeat", attrs: { node: flagSeq, countMin: 0 } };
620
+
621
+ // Hoist metadata
622
+ if (terminal.meta) {
623
+ const { name, ...rest } = terminal.meta;
624
+ if (Object.keys(rest).length > 0) {
625
+ outerRep.meta = { ...outerRep.meta, ...rest };
626
+ terminal.meta = name ? { name } : undefined;
627
+ }
628
+ }
629
+
630
+ return outerRep;
631
+ }
632
+
633
+ private buildAppendConst(action: AdAction): Expr | null {
634
+ const flag = this.getOptionFlag(action);
635
+ if (!flag) {
636
+ this.error(`append_const action '${action.dest}' has no option strings`);
637
+ return null;
638
+ }
639
+
640
+ // Similar to count - repeated flag
641
+ const literal: Literal = { kind: "literal", attrs: { str: flag } };
642
+ const rep: Repeat = { kind: "repeat", attrs: { node: literal, countMin: 0 } };
643
+
644
+ const meta = this.buildNodeMeta(action);
645
+ if (meta) rep.meta = meta;
646
+
647
+ return rep;
648
+ }
649
+
650
+ private buildSubparsers(action: AdAction): Expr | null {
651
+ const subparsers = action.subparsers;
652
+ if (!isObject(subparsers)) {
653
+ this.error(`parsers action '${action.dest}' has no subparsers`);
654
+ return null;
655
+ }
656
+
657
+ const aliases = isObject(action.subparsers_aliases) ? action.subparsers_aliases : {};
658
+ const alts: Expr[] = [];
659
+
660
+ for (const [name, parserInfo] of Object.entries(subparsers)) {
661
+ if (!isObject(parserInfo)) {
662
+ this.warn(`Skipping non-object subparser '${name}'`);
663
+ continue;
664
+ }
665
+
666
+ const subExpr = this.parseParserInfo(parserInfo);
667
+ if (!subExpr) continue;
668
+
669
+ // Prepend subcommand literal
670
+ const cmdLit: Literal = { kind: "literal", attrs: { str: name } };
671
+ const seq: Sequence = {
672
+ kind: "sequence",
673
+ attrs: { nodes: [cmdLit, ...subExpr.attrs.nodes] },
674
+ };
675
+
676
+ // Attach name and aliases as doc
677
+ const subAliases = aliases[name];
678
+ const aliasDoc =
679
+ isArray(subAliases) && subAliases.length > 0
680
+ ? ` (aliases: ${subAliases.filter(isString).join(", ")})`
681
+ : "";
682
+
683
+ const description = isString(parserInfo.description) ? parserInfo.description : undefined;
684
+ const docStr = description
685
+ ? description + aliasDoc
686
+ : aliasDoc
687
+ ? aliasDoc.slice(1) // Remove leading space
688
+ : undefined;
689
+
690
+ seq.meta = {
691
+ name,
692
+ ...(docStr && { doc: { description: docStr } }),
693
+ };
694
+
695
+ alts.push(seq);
696
+ }
697
+
698
+ if (alts.length === 0) {
699
+ this.error(`No valid subparsers for '${action.dest}'`);
700
+ return null;
701
+ }
702
+
703
+ if (alts.length === 1) {
704
+ return alts[0]!;
705
+ }
706
+
707
+ const alt: Alternative = { kind: "alternative", attrs: { alts } };
708
+
709
+ const isRequired = action.subparsers_required === true || action.required === true;
710
+ if (!isRequired) {
711
+ const opt: Optional = { kind: "optional", attrs: { node: alt } };
712
+
713
+ const meta = this.buildNodeMeta(action);
714
+ if (meta) opt.meta = meta;
715
+
716
+ return opt;
717
+ }
718
+
719
+ const meta = this.buildNodeMeta(action);
720
+ if (meta) alt.meta = meta;
721
+
722
+ return alt;
723
+ }
724
+
725
+ // Mutual exclusion
726
+
727
+ private applyMutualExclusion(
728
+ actionsByDest: Map<string, AdAction>,
729
+ groups: unknown[],
730
+ nodes: Expr[],
731
+ nodesByDest: Map<string, Expr>,
732
+ ): Expr[] {
733
+ const excluded = new Set<string>();
734
+
735
+ for (const group of groups) {
736
+ if (!isObject(group)) continue;
737
+ const groupActions = group.actions;
738
+ if (!isArray(groupActions)) continue;
739
+
740
+ const memberDests: string[] = [];
741
+ for (const dest of groupActions) {
742
+ if (isString(dest) && nodesByDest.has(dest)) {
743
+ memberDests.push(dest);
744
+ }
745
+ }
746
+
747
+ if (memberDests.length < 2) continue;
748
+
749
+ // Build alt from member nodes. Unwrapping the optional drops its meta
750
+ // (doc/default), so merge it onto the inner node and tag the inner
751
+ // with the dest so backends can derive a per-variant name.
752
+ const altMembers: Expr[] = [];
753
+ for (const dest of memberDests) {
754
+ const node = nodesByDest.get(dest)!;
755
+ let inner: Expr;
756
+ let outerMeta: NodeMeta | undefined;
757
+ if (node.kind === "optional") {
758
+ inner = node.attrs.node;
759
+ outerMeta = node.meta;
760
+ } else {
761
+ inner = node;
762
+ }
763
+ inner.meta = {
764
+ ...outerMeta,
765
+ ...inner.meta,
766
+ // Prefer the deepest existing name in the subtree so the synthesized
767
+ // name matches the binding the solver derives for the same node.
768
+ // Otherwise findDeepName short-circuits on the inner's new name and
769
+ // the variant struct's field key drifts from the binding name.
770
+ name: inner.meta?.name ?? findDeepName(inner) ?? dest,
771
+ };
772
+ altMembers.push(inner);
773
+ excluded.add(dest);
774
+ }
775
+
776
+ const alt: Alternative = { kind: "alternative", attrs: { alts: altMembers } };
777
+
778
+ const isRequired = group.required === true;
779
+ let groupNode: Expr;
780
+ if (isRequired) {
781
+ groupNode = alt;
782
+ } else {
783
+ groupNode = { kind: "optional", attrs: { node: alt } } satisfies Optional;
784
+ }
785
+
786
+ // Synthesize a name so backends can derive a meaningful id instead of
787
+ // a Scope-generated placeholder. Prefer an explicit title if surfaced
788
+ // by argdump, otherwise concat dests for 2-member groups, fall back
789
+ // to a "_choice" suffix for larger groups.
790
+ const title = isString(group.title) ? group.title : undefined;
791
+ const groupName =
792
+ title ??
793
+ (memberDests.length === 2
794
+ ? `${memberDests[0]}_or_${memberDests[1]}`
795
+ : `${memberDests[0]}_choice`);
796
+ groupNode.meta = { ...groupNode.meta, name: groupName };
797
+
798
+ // Insert at position of first member
799
+ const firstDest = memberDests[0]!;
800
+ const firstIdx = nodes.findIndex((n) => n === nodesByDest.get(firstDest));
801
+ if (firstIdx >= 0) {
802
+ nodes.splice(firstIdx, 0, groupNode);
803
+ } else {
804
+ nodes.push(groupNode);
805
+ }
806
+ }
807
+
808
+ // Remove excluded nodes
809
+ return nodes.filter((n) => {
810
+ // Find which dest this node corresponds to
811
+ for (const [dest, node] of nodesByDest) {
812
+ if (node === n && excluded.has(dest)) return false;
813
+ }
814
+ return true;
815
+ });
816
+ }
817
+
818
+ // Main parser
819
+
820
+ private parseParserInfo(descriptor: AdDescriptor): Sequence | null {
821
+ const actions = descriptor.actions;
822
+ if (!isArray(actions)) {
823
+ return { kind: "sequence", attrs: { nodes: [] } };
824
+ }
825
+
826
+ const positionals: Expr[] = [];
827
+ const optionals: Expr[] = [];
828
+ const actionsByDest = new Map<string, AdAction>();
829
+ const nodesByDest = new Map<string, Expr>();
830
+
831
+ for (const rawAction of actions) {
832
+ if (!isObject(rawAction)) continue;
833
+
834
+ const node = this.buildAction(rawAction);
835
+ if (!node) continue;
836
+
837
+ const dest = rawAction.dest;
838
+ if (isString(dest)) {
839
+ actionsByDest.set(dest, rawAction);
840
+ nodesByDest.set(dest, node);
841
+ }
842
+
843
+ if (this.isPositional(rawAction) && rawAction.action_type !== "parsers") {
844
+ positionals.push(node);
845
+ } else {
846
+ optionals.push(node);
847
+ }
848
+ }
849
+
850
+ // Assemble: positionals first, then optionals
851
+ let allNodes = [...positionals, ...optionals];
852
+
853
+ // Apply mutual exclusion groups
854
+ const mutexGroups = descriptor.mutually_exclusive_groups;
855
+ if (isArray(mutexGroups) && mutexGroups.length > 0) {
856
+ allNodes = this.applyMutualExclusion(actionsByDest, mutexGroups, allNodes, nodesByDest);
857
+ }
858
+
859
+ return { kind: "sequence", attrs: { nodes: allNodes } };
860
+ }
861
+
862
+ // Public API
863
+
864
+ parse(source: string, _filename?: string): ParseResult {
865
+ this.reset();
866
+
867
+ const descriptor = this.parseJSON(source);
868
+ if (descriptor === null) {
869
+ return {
870
+ expr: { kind: "sequence", attrs: { nodes: [] } },
871
+ errors: this.errors,
872
+ warnings: this.warnings,
873
+ };
874
+ }
875
+
876
+ const meta = this.buildAppMeta(descriptor);
877
+ if (!meta) {
878
+ this.error("Descriptor is missing prog");
879
+ return {
880
+ expr: { kind: "sequence", attrs: { nodes: [] } },
881
+ errors: this.errors,
882
+ warnings: this.warnings,
883
+ };
884
+ }
885
+
886
+ const expr = this.parseParserInfo(descriptor);
887
+ if (expr === null) {
888
+ this.error("Failed to parse argument structure");
889
+ return {
890
+ expr: { kind: "sequence", attrs: { nodes: [] } },
891
+ errors: this.errors,
892
+ warnings: this.warnings,
893
+ };
894
+ }
895
+
896
+ // Prepend prog as command literal (like Boutiques' command-line prefix)
897
+ const prog = descriptor.prog;
898
+ if (isString(prog) && prog) {
899
+ expr.attrs.nodes.unshift({ kind: "literal", attrs: { str: prog } });
900
+ }
901
+
902
+ // Set root struct name
903
+ if (!expr.meta?.name && meta.id) {
904
+ expr.meta = { ...expr.meta, name: meta.id };
905
+ }
906
+
907
+ return {
908
+ meta,
909
+ expr,
910
+ errors: this.errors,
911
+ warnings: this.warnings,
912
+ };
913
+ }
914
+ }