@styx-api/core 0.6.1 → 0.8.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 (52) hide show
  1. package/dist/index.cjs +2559 -553
  2. package/dist/index.d.cts +41 -37
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +41 -37
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2558 -549
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
  10. package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
  11. package/src/backend/argtype/argtype.ts +45 -0
  12. package/src/backend/argtype/emit.ts +676 -0
  13. package/src/backend/argtype/index.ts +2 -0
  14. package/src/backend/boutiques/boutiques.ts +55 -47
  15. package/src/backend/field-defaults.ts +40 -0
  16. package/src/backend/find-struct-node.ts +7 -0
  17. package/src/backend/index.ts +2 -9
  18. package/src/backend/nipype/emit.ts +11 -3
  19. package/src/backend/python/arg-builder.ts +2 -27
  20. package/src/backend/python/emit.ts +18 -6
  21. package/src/backend/python/outputs-emit.ts +27 -42
  22. package/src/backend/python/python.ts +46 -22
  23. package/src/backend/python/validate-emit.ts +4 -1
  24. package/src/backend/resolve-output-tokens.ts +0 -76
  25. package/src/backend/typescript/arg-builder.ts +2 -24
  26. package/src/backend/typescript/emit.ts +7 -4
  27. package/src/backend/typescript/outputs-emit.ts +15 -39
  28. package/src/backend/typescript/typemap.ts +9 -1
  29. package/src/backend/typescript/typescript.ts +41 -18
  30. package/src/backend/typescript/validate-emit.ts +4 -1
  31. package/src/backend/union-variants.ts +41 -1
  32. package/src/frontend/argdump/parser.ts +20 -19
  33. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  34. package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
  35. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  36. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  37. package/src/frontend/argtype/ast.ts +114 -0
  38. package/src/frontend/argtype/doc.ts +56 -0
  39. package/src/frontend/argtype/frontmatter.ts +237 -0
  40. package/src/frontend/argtype/index.ts +1 -0
  41. package/src/frontend/argtype/lexer.ts +264 -0
  42. package/src/frontend/argtype/lower.ts +601 -0
  43. package/src/frontend/argtype/parser-frontend.ts +51 -0
  44. package/src/frontend/argtype/parser.ts +533 -0
  45. package/src/frontend/argtype/template.ts +147 -0
  46. package/src/frontend/boutiques/destruct-template.ts +24 -17
  47. package/src/frontend/boutiques/parser.ts +27 -8
  48. package/src/frontend/detect-format.ts +28 -3
  49. package/src/index.ts +23 -9
  50. package/src/ir/passes/canonicalize.ts +18 -5
  51. package/src/ir/passes/simplify.ts +17 -2
  52. package/src/solver/solver.ts +2 -2
@@ -1,80 +1,4 @@
1
- import type {
2
- BindingRegistry,
3
- GateAtom,
4
- OutputScope,
5
- ResolvedOutput,
6
- ResolvedToken,
7
- } from "../bindings/index.js";
8
1
  import { outputGate } from "../bindings/index.js";
9
2
 
10
3
  // Re-export the core helper so backends have a single entry point.
11
4
  export { outputGate };
12
-
13
- /**
14
- * Compact consecutive literal tokens. Backends that emit string concatenation
15
- * benefit from a shorter token stream; backends that template each token
16
- * individually can ignore this and use `output.tokens` directly.
17
- */
18
- export function compactTokens(tokens: ResolvedToken[]): ResolvedToken[] {
19
- const out: ResolvedToken[] = [];
20
- for (const tok of tokens) {
21
- const last = out[out.length - 1];
22
- if (tok.kind === "literal" && last && last.kind === "literal") {
23
- out[out.length - 1] = { kind: "literal", value: last.value + tok.value };
24
- } else {
25
- out.push(tok);
26
- }
27
- }
28
- return out;
29
- }
30
-
31
- /**
32
- * One output ready for codegen. `gate` is the wrapper sequence (outermost
33
- * first); the backend renders each atom as the appropriate scope-introducing
34
- * statement, then emits the path expression inside the innermost layer.
35
- */
36
- export interface OutputEmitPlan {
37
- name: string;
38
- gate: GateAtom[];
39
- tokens: ResolvedToken[];
40
- resolved: ResolvedOutput;
41
- }
42
-
43
- export function planOutput(
44
- scopeGate: GateAtom[],
45
- output: ResolvedOutput,
46
- bindings: BindingRegistry,
47
- ): OutputEmitPlan {
48
- return {
49
- name: output.name,
50
- gate: outputGate(scopeGate, output, bindings),
51
- tokens: compactTokens(output.tokens),
52
- resolved: output,
53
- };
54
- }
55
-
56
- /**
57
- * Does the output have any conditional wrapper? Equivalent to "is at least one
58
- * atom a `present` or `variant`?". `iter` alone means the output emits a list
59
- * and is not conditionally absent.
60
- */
61
- export function isGated(plan: OutputEmitPlan): boolean {
62
- return plan.gate.some((a) => a.kind === "present" || a.kind === "variant");
63
- }
64
-
65
- /** Does the output iterate (emit zero-or-more values)? */
66
- export function isIterated(plan: OutputEmitPlan): boolean {
67
- return plan.gate.some((a) => a.kind === "iter");
68
- }
69
-
70
- /**
71
- * Convenience for backends emitting all outputs of a scope at once. The caller
72
- * provides the scope's gate (typically `bindings.get(scope.scope)?.gate ?? []`).
73
- */
74
- export function planScope(
75
- scope: OutputScope,
76
- scopeGate: GateAtom[],
77
- bindings: BindingRegistry,
78
- ): OutputEmitPlan[] {
79
- return scope.outputs.map((output) => planOutput(scopeGate, output, bindings));
80
- }
@@ -1,5 +1,5 @@
1
1
  import type { Binding, BindingId, BoundType, BoundVariant } from "../../bindings/index.js";
2
- import { collectFieldInfo } from "../collect-field-info.js";
2
+ import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
3
3
  import type { Expr } from "../../ir/index.js";
4
4
  import type { CodegenContext } from "../../manifest/index.js";
5
5
  import { CodeBuilder } from "../code-builder.js";
@@ -92,14 +92,6 @@ function accessOf(binding: Binding, arg: ArgContext): string {
92
92
  * carrying a Boutiques default. Restricted to single-segment (root) field access
93
93
  * so a nested field can never accidentally pick up a same-named root default.
94
94
  */
95
- function rootFieldDefault(
96
- binding: Binding,
97
- defaults: ReadonlyMap<string, string>,
98
- ): string | undefined {
99
- const a = binding.access;
100
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
101
- return undefined;
102
- }
103
95
 
104
96
  /**
105
97
  * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
@@ -113,20 +105,6 @@ function readAccess(binding: Binding, arg: ArgContext): string {
113
105
  return def !== undefined ? `(${accessOf(binding, arg)} ?? ${def})` : accessOf(binding, arg);
114
106
  }
115
107
 
116
- /** Build the field-name -> rendered-default map for a struct root (else empty).
117
- * Includes only non-optional defaulted fields (optional fields are
118
- * presence-guarded; their default comes from the factory's kwarg signature). */
119
- function collectDefaults(ctx: CodegenContext, rootType?: BoundType): Map<string, string> {
120
- const out = new Map<string, string>();
121
- if (rootType?.kind !== "struct") return out;
122
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
123
- if (fi.defaultValue === undefined) continue;
124
- if (rootType.fields[name]?.kind === "optional") continue;
125
- out.set(name, renderTsLiteral(fi.defaultValue));
126
- }
127
- return out;
128
- }
129
-
130
108
  // -- Recursive descent --
131
109
 
132
110
  let loopVarCounter = 0;
@@ -142,7 +120,7 @@ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundT
142
120
  const initialCtx: ArgContext = {
143
121
  joinDepth: 0,
144
122
  loopVars: new Map(),
145
- defaults: collectDefaults(ctx, rootType),
123
+ defaults: collectDefaults(ctx, renderTsLiteral, rootType),
146
124
  };
147
125
  return walk(rootExpr, ctx, initialCtx);
148
126
  }
@@ -10,7 +10,10 @@ import { collectFieldInfo, resolveTypeName } from "./types.js";
10
10
 
11
11
  export function emitJsDoc(cb: CodeBuilder, description?: string): void {
12
12
  if (!description) return;
13
- const lines = description.split("\n");
13
+ // Escape `*/` so a description containing it can't terminate the block comment
14
+ // early (`*\/` is not a comment terminator; the backslash is inert in comments).
15
+ const safe = description.replace(/\*\//g, "*\\/");
16
+ const lines = safe.split("\n");
14
17
  if (lines.length === 1) {
15
18
  cb.line(`/** ${lines[0]} */`);
16
19
  } else {
@@ -22,9 +25,9 @@ export function emitJsDoc(cb: CodeBuilder, description?: string): void {
22
25
  }
23
26
  }
24
27
 
25
- export function emitImports(cb: CodeBuilder, emitOutputs: boolean): void {
26
- const inputs = ["Runner", "Execution", "Metadata", "InputPathType"];
27
- if (emitOutputs) inputs.push("OutputPathType");
28
+ export function emitImports(cb: CodeBuilder): void {
29
+ // Every tool emits an Outputs object, so OutputPathType is always needed.
30
+ const inputs = ["Runner", "Execution", "Metadata", "InputPathType", "OutputPathType"];
28
31
  cb.line(`import type { ${inputs.join(", ")} } from "styxdefs";`);
29
32
  cb.line('import { getGlobalRunner, StyxValidationError } from "styxdefs";');
30
33
  }
@@ -1,12 +1,6 @@
1
- import type {
2
- Binding,
3
- BindingId,
4
- BoundType,
5
- GateAtom,
6
- ResolvedToken,
7
- } from "../../bindings/index.js";
1
+ import type { BindingId, BoundType, GateAtom, ResolvedToken } from "../../bindings/index.js";
8
2
  import { outputGate } from "../../bindings/index.js";
9
- import { collectFieldInfo } from "../collect-field-info.js";
3
+ import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
10
4
  import type { CodegenContext } from "../../manifest/index.js";
11
5
  import { CodeBuilder } from "../code-builder.js";
12
6
  import {
@@ -18,6 +12,7 @@ import {
18
12
  rootOutput,
19
13
  streamFields,
20
14
  } from "../collect-output-fields.js";
15
+ import { unionIsMixed, variantAtomUnion } from "../union-variants.js";
21
16
  import { emitJsDoc, renderAccess, tsPropAccess } from "./emit.js";
22
17
  import { renderTsLiteral } from "./typemap.js";
23
18
 
@@ -97,32 +92,6 @@ interface OutputEmitCtx {
97
92
  defaults: ReadonlyMap<string, string>;
98
93
  }
99
94
 
100
- /** The rendered default for a binding iff it is a root-level defaulted field. */
101
- function rootFieldDefault(
102
- binding: Binding | undefined,
103
- defaults: ReadonlyMap<string, string>,
104
- ): string | undefined {
105
- if (!binding) return undefined;
106
- const a = binding.access;
107
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
108
- return undefined;
109
- }
110
-
111
- /** Build the field-name -> rendered-default map for the struct root (else empty).
112
- * Includes only non-optional defaulted fields (optional fields are
113
- * presence-guarded; their default comes from the factory's kwarg signature). */
114
- function collectDefaults(ctx: CodegenContext): Map<string, string> {
115
- const out = new Map<string, string>();
116
- const rootType = ctx.resolve(ctx.expr)?.type;
117
- if (rootType?.kind !== "struct") return out;
118
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
119
- if (fi.defaultValue === undefined) continue;
120
- if (rootType.fields[name]?.kind === "optional") continue;
121
- out.set(name, renderTsLiteral(fi.defaultValue));
122
- }
123
- return out;
124
- }
125
-
126
95
  /**
127
96
  * Render one output's wrapper stack and emit the assignment inside the
128
97
  * innermost wrapper. Nesting is done via recursive callbacks so the
@@ -201,10 +170,17 @@ function renderWrapperOpen(atom: GateAtom, ec: OutputEmitCtx): WrapperRender {
201
170
  }
202
171
  if (atom.kind === "variant") {
203
172
  const access = bindingAccess(atom.binding, ec);
204
- return {
205
- open: `if (${access}["@type"] === ${JSON.stringify(atom.variant)}) {`,
206
- close: `}`,
207
- };
173
+ const check = `${access}["@type"] === ${JSON.stringify(atom.variant)}`;
174
+ // A mixed union (`0 | Struct`) only carries `@type` on its struct arms; guard
175
+ // on the object shape first so TS narrows off the bare-literal arm before the
176
+ // subscript (which would otherwise be a "property does not exist" error).
177
+ // Mirrors the cargs builder and validator. Pure-struct unions need no guard.
178
+ const union = variantAtomUnion(atom, ec.ctx.bindings);
179
+ const cond =
180
+ union && unionIsMixed(union)
181
+ ? `typeof ${access} === "object" && ${access} !== null && ${check}`
182
+ : check;
183
+ return { open: `if (${cond}) {`, close: `}` };
208
184
  }
209
185
  // present
210
186
  const binding = ec.ctx.bindings.get(atom.binding);
@@ -321,7 +297,7 @@ export function emitBuildOutputs(
321
297
  ctx,
322
298
  iter: new Map(),
323
299
  fieldShapes: new Map(fields.map((f) => [f.id, f.shape])),
324
- defaults: collectDefaults(ctx),
300
+ defaults: collectDefaults(ctx, renderTsLiteral),
325
301
  };
326
302
 
327
303
  // Initialize the outputs object with defaults so wrapper code can assign or
@@ -1,5 +1,13 @@
1
1
  import type { BoundType } from "../../bindings/index.js";
2
2
 
3
+ // A valid TS identifier can be an unquoted object key; anything else (e.g. a
4
+ // wire key like `4d_input`) must be quoted. Kept local to avoid a cycle with
5
+ // emit.ts (which imports this module).
6
+ const TS_IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
7
+ function objKey(key: string): string {
8
+ return TS_IDENT_RE.test(key) ? key : JSON.stringify(key);
9
+ }
10
+
3
11
  export function mapType(type: BoundType, resolve: (type: BoundType) => string | undefined): string {
4
12
  switch (type.kind) {
5
13
  case "scalar":
@@ -27,7 +35,7 @@ export function mapType(type: BoundType, resolve: (type: BoundType) => string |
27
35
  if (name) return name;
28
36
  const fields = Object.entries(type.fields)
29
37
  .filter(([, v]) => v.kind !== "literal")
30
- .map(([k, v]) => `${k}: ${mapType(v, resolve)}`)
38
+ .map(([k, v]) => `${objKey(k)}: ${mapType(v, resolve)}`)
31
39
  .join("; ");
32
40
  return `{ ${fields} }`;
33
41
  }
@@ -256,6 +256,19 @@ export function buildEmitModel(
256
256
  }
257
257
 
258
258
  export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): string {
259
+ return generateTypeScriptModule(ctx, packageScope).code;
260
+ }
261
+
262
+ /**
263
+ * Emit the module and, alongside it, the dispatch entrypoint carrying the
264
+ * *scope-registered* execute-function name. Computing the entrypoint here (not
265
+ * via the scope-blind `appEntrypoint`) keeps the suite dispatcher in sync with
266
+ * the actual emitted symbol when a shared package scope suffix-bumps a collision.
267
+ */
268
+ function generateTypeScriptModule(
269
+ ctx: CodegenContext,
270
+ packageScope?: Scope,
271
+ ): { code: string; entrypoint: AppEntrypoint | undefined } {
259
272
  const cb = new CodeBuilder(" ");
260
273
  // A package-shared scope keeps top-level names unique across every tool in the
261
274
  // suite barrel; without one (standalone emit) a per-tool scope is enough.
@@ -282,9 +295,7 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
282
295
  // Every tool emits an Outputs object: at minimum the synthetic `root` output
283
296
  // directory (outputFile(".")), plus any declared file/mutable outputs and
284
297
  // stdout/stderr stream fields. OutputPathType is therefore always imported.
285
- const emitOutputs = true;
286
-
287
- emitImports(cb, true);
298
+ emitImports(cb);
288
299
  cb.blank();
289
300
 
290
301
  emitMetadata(ctx, names.metadata, cb);
@@ -292,12 +303,10 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
292
303
 
293
304
  emitTypeDeclarations(typeDecls, namedTypes, ctx, names.params, appId, pkg, cb);
294
305
 
295
- if (emitOutputs) {
296
- emitOutputsInterface(ctx, names.outputs, cb);
297
- cb.blank();
298
- }
306
+ emitOutputsInterface(ctx, names.outputs, cb);
307
+ cb.blank();
299
308
 
300
- if (emitOutputs && needsStripExtensionsHelper(ctx)) {
309
+ if (needsStripExtensionsHelper(ctx)) {
301
310
  emitStripExtensionsHelper(cb);
302
311
  cb.blank();
303
312
  }
@@ -327,10 +336,8 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
327
336
  emitBuildCargs(ctx, rootType, paramsType, names.cargs, cb);
328
337
  cb.blank();
329
338
 
330
- if (emitOutputs) {
331
- emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
332
- cb.blank();
333
- }
339
+ emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
340
+ cb.blank();
334
341
 
335
342
  // Dict-style execute function. For struct roots it's the internal
336
343
  // `<tool>Execute`; for other roots it doubles as the user-facing wrapper.
@@ -341,8 +348,8 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
341
348
  executeName,
342
349
  names.metadata,
343
350
  names.cargs,
344
- emitOutputs ? names.outputsFn : undefined,
345
- emitOutputs ? names.outputs : undefined,
351
+ names.outputsFn,
352
+ names.outputs,
346
353
  names.validate,
347
354
  streamFieldIds(ctx),
348
355
  cb,
@@ -357,13 +364,24 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
357
364
  names.wrapper,
358
365
  names.paramsFn,
359
366
  names.execute,
360
- emitOutputs ? names.outputs : undefined,
367
+ names.outputs,
361
368
  cb,
362
369
  );
363
370
  cb.blank();
364
371
  }
365
372
 
366
- return cb.toString();
373
+ // `executeName` is the scope-registered symbol actually emitted above, so the
374
+ // dispatcher references the real name even after a collision suffix-bump. Guard
375
+ // on `ctx.package?.name` (not the destructured `pkg`, which falls back to
376
+ // "unknown") so a package-less app yields no entrypoint, as before.
377
+ const entryAppId = ctx.app?.id;
378
+ const entryPkg = ctx.package?.name;
379
+ const entrypoint: AppEntrypoint | undefined =
380
+ entryAppId && entryPkg
381
+ ? { type: `${entryPkg}/${entryAppId}`, executeFn: executeName }
382
+ : undefined;
383
+
384
+ return { code: cb.toString(), entrypoint };
367
385
  }
368
386
 
369
387
  /**
@@ -381,6 +399,9 @@ export function appModuleName(meta: AppMeta | undefined): string {
381
399
  * the dict-style execute function name. Returns undefined when the id or package
382
400
  * is unknown (no stable `@type`), so the app is left out of the suite dispatcher.
383
401
  */
402
+ // Note: this recomputes the execute name without a package scope, so it reflects
403
+ // the un-bumped name. The emitter (`emitApp`) instead takes the entrypoint from
404
+ // the scope-aware emit pass; use that path when a shared package scope is in play.
384
405
  export function appEntrypoint(ctx: CodegenContext): AppEntrypoint | undefined {
385
406
  const appId = ctx.app?.id;
386
407
  const pkg = ctx.package?.name;
@@ -469,11 +490,13 @@ export class TypeScriptBackend implements Backend {
469
490
  readonly target = "typescript";
470
491
 
471
492
  emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
472
- const code = generateTypeScript(ctx, scope);
493
+ // Take the entrypoint from the same pass that emitted the module, so its
494
+ // execute-function name reflects any shared-scope suffix-bump.
495
+ const { code, entrypoint } = generateTypeScriptModule(ctx, scope);
473
496
  const fileName = `${appModuleName(ctx.app)}.ts`;
474
497
  return {
475
498
  meta: ctx.app,
476
- entrypoint: appEntrypoint(ctx),
499
+ entrypoint,
477
500
  files: new Map([[fileName, code]]),
478
501
  errors: [],
479
502
  warnings: [],
@@ -136,7 +136,10 @@ function emitValue(
136
136
  const itemNode = findRepeatNode(node)?.attrs.node;
137
137
  const elem = e.scope.add("el");
138
138
  e.cb.line(`for (const ${elem} of ${access}) {`);
139
- e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
139
+ // Report the element type (e.g. `int`), not the list type (`int[]`).
140
+ e.cb.indent(() =>
141
+ emitValue(e, type.item, itemNode, wireKey, elem, expectedType(e, type.item)),
142
+ );
140
143
  e.cb.line("}");
141
144
  return;
142
145
  }
@@ -1,4 +1,44 @@
1
- import type { BoundType, BoundVariant } from "../bindings/index.js";
1
+ import type { BindingRegistry, BoundType, BoundVariant, GateAtom } from "../bindings/index.js";
2
+
3
+ /**
4
+ * Whether a union is "mixed": it has at least one non-struct (bare-literal) arm
5
+ * alongside its struct variants (e.g. ants `n4_correction = Literal[0] | N4On`).
6
+ *
7
+ * The `@type` discriminator only exists on the struct arms, so indexing
8
+ * `value["@type"]` on the union value is unsound (a type error, and a runtime
9
+ * KeyError if the literal arm is ever hit) until the value is narrowed to a
10
+ * struct at runtime. Backends that dispatch on `@type` must first emit a shape
11
+ * guard (Python `isinstance(value, dict)`, TS `typeof value === "object"`) when
12
+ * this is true; a pure-struct union (every arm a struct) needs no guard.
13
+ */
14
+ export function unionIsMixed(unionType: Extract<BoundType, { kind: "union" }>): boolean {
15
+ return unionType.variants.some((v) => v.type.kind !== "struct");
16
+ }
17
+
18
+ /**
19
+ * The union type bound by a `variant` gate atom, if the atom's binding resolves
20
+ * to a union - used by the outputs emitters to decide whether the variant gate
21
+ * needs a mixed-union shape guard before its `@type` check. Returns `undefined`
22
+ * for a well-formed non-union (defensive; a variant atom should always name a
23
+ * union binding).
24
+ */
25
+ export function variantAtomUnion(
26
+ atom: Extract<GateAtom, { kind: "variant" }>,
27
+ bindings: BindingRegistry,
28
+ ): Extract<BoundType, { kind: "union" }> | undefined {
29
+ return resolveUnion(bindings.get(atom.binding)?.type);
30
+ }
31
+
32
+ function resolveUnion(
33
+ type: BoundType | undefined,
34
+ ): Extract<BoundType, { kind: "union" }> | undefined {
35
+ if (!type) return undefined;
36
+ // An `optional<union>` binding can carry a variant gate (the optional wrapper
37
+ // and the union collapse onto one access path); unwrap to reach the union.
38
+ if (type.kind === "optional") return resolveUnion(type.inner);
39
+ if (type.kind === "union") return type;
40
+ return undefined;
41
+ }
2
42
 
3
43
  /** A union's struct variant paired with its index into the union's `variants`
4
44
  * array (which stays parallel to the IR `alts` and the per-arm results a caller
@@ -270,10 +270,12 @@ export class ArgdumpParser implements Frontend {
270
270
 
271
271
  if (isArgparseMarker(nargs)) {
272
272
  if (nargs.__argparse__ === "REMAINDER") {
273
- // REMAINDER -> rep(str())
273
+ // REMAINDER -> rep(node), preserving the terminal's meta (name/doc/
274
+ // default) so the positional's `dest` name survives instead of being
275
+ // dropped for a solver-derived placeholder.
274
276
  const rep: Repeat = {
275
277
  kind: "repeat",
276
- attrs: { node: { kind: "str", attrs: {} }, countMin: 0 },
278
+ attrs: { node, countMin: 0 },
277
279
  };
278
280
  return rep;
279
281
  }
@@ -700,32 +702,33 @@ export class ArgdumpParser implements Frontend {
700
702
  return null;
701
703
  }
702
704
 
703
- if (alts.length === 1) {
704
- return alts[0]!;
705
- }
706
-
707
- const alt: Alternative = { kind: "alternative", attrs: { alts } };
705
+ // A single choice needs no `alternative` wrapper, but must still honor the
706
+ // subparsers' required-ness (a lone-choice non-required subparser is still
707
+ // optional) rather than being returned bare as a mandatory node.
708
+ const node: Expr =
709
+ alts.length === 1
710
+ ? alts[0]!
711
+ : ({ kind: "alternative", attrs: { alts } } satisfies Alternative);
708
712
 
709
713
  const isRequired = action.subparsers_required === true || action.required === true;
710
- if (!isRequired) {
711
- const opt: Optional = { kind: "optional", attrs: { node: alt } };
714
+ const meta = this.buildNodeMeta(action);
712
715
 
713
- const meta = this.buildNodeMeta(action);
716
+ if (!isRequired) {
717
+ const opt: Optional = { kind: "optional", attrs: { node } };
714
718
  if (meta) opt.meta = meta;
715
-
716
719
  return opt;
717
720
  }
718
721
 
719
- const meta = this.buildNodeMeta(action);
720
- if (meta) alt.meta = meta;
721
-
722
- return alt;
722
+ // Required: hang the subparsers meta on the alternative wrapper when there is
723
+ // one; a single bare arm keeps its own sub-command meta instead of having it
724
+ // clobbered by the action's dest name.
725
+ if (node.kind === "alternative" && meta) node.meta = meta;
726
+ return node;
723
727
  }
724
728
 
725
729
  // Mutual exclusion
726
730
 
727
731
  private applyMutualExclusion(
728
- actionsByDest: Map<string, AdAction>,
729
732
  groups: unknown[],
730
733
  nodes: Expr[],
731
734
  nodesByDest: Map<string, Expr>,
@@ -825,7 +828,6 @@ export class ArgdumpParser implements Frontend {
825
828
 
826
829
  const positionals: Expr[] = [];
827
830
  const optionals: Expr[] = [];
828
- const actionsByDest = new Map<string, AdAction>();
829
831
  const nodesByDest = new Map<string, Expr>();
830
832
 
831
833
  for (const rawAction of actions) {
@@ -836,7 +838,6 @@ export class ArgdumpParser implements Frontend {
836
838
 
837
839
  const dest = rawAction.dest;
838
840
  if (isString(dest)) {
839
- actionsByDest.set(dest, rawAction);
840
841
  nodesByDest.set(dest, node);
841
842
  }
842
843
 
@@ -853,7 +854,7 @@ export class ArgdumpParser implements Frontend {
853
854
  // Apply mutual exclusion groups
854
855
  const mutexGroups = descriptor.mutually_exclusive_groups;
855
856
  if (isArray(mutexGroups) && mutexGroups.length > 0) {
856
- allNodes = this.applyMutualExclusion(actionsByDest, mutexGroups, allNodes, nodesByDest);
857
+ allNodes = this.applyMutualExclusion(mutexGroups, allNodes, nodesByDest);
857
858
  }
858
859
 
859
860
  return { kind: "sequence", attrs: { nodes: allNodes } };
@@ -0,0 +1,41 @@
1
+ ---
2
+ exe: "3dTstat"
3
+ version: "24.2.06"
4
+ authors:
5
+ - "RW Cox"
6
+ urls:
7
+ - "https://afni.nimh.nih.gov/"
8
+ ---
9
+
10
+ /// Compute voxelwise statistics across the time axis of a 3D+time dataset.
11
+ ///
12
+ /// All of AFNI is named with a leading digit (3d*, 1d*, 2d*), so the tool
13
+ /// label has to be quoted - a bare identifier cannot start with a digit.
14
+ "3dTstat": seq(
15
+ set(
16
+ /// Output dataset prefix (the view and extension are added automatically).
17
+ opt("-prefix", prefix: str = "stat"),
18
+
19
+ /// Compute the mean across time (the default when no statistic is given).
20
+ mean: opt("-mean"),
21
+
22
+ /// Compute the standard deviation across time.
23
+ stdev: opt("-stdev"),
24
+
25
+ /// Compute the median across time.
26
+ median: opt("-median"),
27
+
28
+ /// Percentiles to compute, emitted as a single colon-joined argument
29
+ /// (for example `25:50:75`).
30
+ opt("-percentile", percentiles: rep(int.min(0).max(100)).join(":")),
31
+
32
+ /// Number of leading time points to ignore.
33
+ opt("-ignore", ignore_count: int.min(0)),
34
+ ),
35
+
36
+ /// Input 3D+time dataset.
37
+ input: path,
38
+ ).output(
39
+ /// The statistics dataset header written by `-prefix`.
40
+ statfile: `{prefix}+orig.HEAD`,
41
+ )