@styx-api/core 0.7.0 → 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 (44) hide show
  1. package/dist/index.cjs +430 -356
  2. package/dist/index.d.cts +1 -34
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +1 -34
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +431 -352
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +0 -2
  10. package/src/backend/argtype/emit.ts +2 -51
  11. package/src/backend/boutiques/boutiques.ts +55 -47
  12. package/src/backend/field-defaults.ts +40 -0
  13. package/src/backend/find-struct-node.ts +7 -0
  14. package/src/backend/index.ts +1 -9
  15. package/src/backend/nipype/emit.ts +11 -3
  16. package/src/backend/python/arg-builder.ts +2 -27
  17. package/src/backend/python/emit.ts +18 -6
  18. package/src/backend/python/outputs-emit.ts +27 -42
  19. package/src/backend/python/python.ts +46 -22
  20. package/src/backend/python/validate-emit.ts +4 -1
  21. package/src/backend/resolve-output-tokens.ts +0 -76
  22. package/src/backend/typescript/arg-builder.ts +2 -24
  23. package/src/backend/typescript/emit.ts +7 -4
  24. package/src/backend/typescript/outputs-emit.ts +15 -39
  25. package/src/backend/typescript/typemap.ts +9 -1
  26. package/src/backend/typescript/typescript.ts +41 -18
  27. package/src/backend/typescript/validate-emit.ts +4 -1
  28. package/src/backend/union-variants.ts +41 -1
  29. package/src/frontend/argdump/parser.ts +20 -19
  30. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  31. package/src/frontend/argtype/__fixtures__/bet.argtype +0 -2
  32. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  33. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  34. package/src/frontend/argtype/ast.ts +14 -1
  35. package/src/frontend/argtype/frontmatter.ts +49 -5
  36. package/src/frontend/argtype/lexer.ts +20 -0
  37. package/src/frontend/argtype/lower.ts +115 -75
  38. package/src/frontend/argtype/parser-frontend.ts +2 -2
  39. package/src/frontend/argtype/parser.ts +70 -24
  40. package/src/frontend/boutiques/destruct-template.ts +24 -17
  41. package/src/frontend/boutiques/parser.ts +27 -8
  42. package/src/ir/passes/canonicalize.ts +18 -5
  43. package/src/ir/passes/simplify.ts +17 -2
  44. package/src/solver/solver.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styx-api/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Styx compiler core: parses CLI tool descriptors (e.g. Boutiques), optimizes an IR, solves typed parameter bindings, and generates type-safe wrappers (TypeScript, Python, JSON Schema). Part of the Styx/NiWrap ecosystem.",
5
5
  "keywords": [
6
6
  "styx",
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^25.0.3",
51
+ "fast-check": "^4.9.0",
51
52
  "rolldown": "1.0.0-rc.1",
52
53
  "styxdefs": "^0.2.0",
53
54
  "tsdown": "^0.20.1"
@@ -1,8 +1,6 @@
1
1
  ---
2
2
  exe: "cut"
3
3
  version: "9.0"
4
- extensions:
5
- - mediatypes
6
4
  ---
7
5
 
8
6
  /// Cut fields from each line, exercising join micro-syntax and media types
@@ -191,34 +191,6 @@ function syntheticWrap(root: Expr): { child: Expr; outputs?: Output[] } | undefi
191
191
  return { child, ...(meta?.outputs && { outputs: meta.outputs }) };
192
192
  }
193
193
 
194
- function hasOutputs(expr: Expr): boolean {
195
- return walkSome(expr, (n) => (n.meta?.outputs?.length ?? 0) > 0);
196
- }
197
-
198
- function hasMediaTypes(expr: Expr): boolean {
199
- return walkSome(expr, (n) => n.kind === "path" && (n.attrs.mediaTypes?.length ?? 0) > 0);
200
- }
201
-
202
- function hasPaths(expr: Expr): boolean {
203
- return walkSome(expr, (n) => n.kind === "path" && (!!n.attrs.mutable || !!n.attrs.resolveParent));
204
- }
205
-
206
- function walkSome(node: Expr, pred: (n: Expr) => boolean): boolean {
207
- if (pred(node)) return true;
208
- switch (node.kind) {
209
- case "sequence":
210
- return node.attrs.nodes.some((n) => walkSome(n, pred));
211
- case "alternative":
212
- return node.attrs.alts.some((n) => walkSome(n, pred));
213
- case "optional":
214
- return walkSome(node.attrs.node, pred);
215
- case "repeat":
216
- return walkSome(node.attrs.node, pred);
217
- default:
218
- return false;
219
- }
220
- }
221
-
222
194
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
223
195
 
224
196
  /** A name rendered where an identifier is expected: bare when it is a valid
@@ -342,13 +314,7 @@ class ArgtypeEmitter {
342
314
  if (raw.meta) root.meta = raw.meta;
343
315
  }
344
316
 
345
- const frontmatter = this.emitFrontmatter(
346
- app,
347
- exe,
348
- hasOutputs(root),
349
- hasMediaTypes(root),
350
- hasPaths(root),
351
- );
317
+ const frontmatter = this.emitFrontmatter(app, exe);
352
318
  if (frontmatter) {
353
319
  lines.push(frontmatter, "");
354
320
  }
@@ -641,13 +607,7 @@ class ArgtypeEmitter {
641
607
  return quote(value);
642
608
  }
643
609
 
644
- private emitFrontmatter(
645
- app: AppMeta | undefined,
646
- exe: string | undefined,
647
- outputs: boolean,
648
- mediaTypes: boolean,
649
- paths: boolean,
650
- ): string | undefined {
610
+ private emitFrontmatter(app: AppMeta | undefined, exe: string | undefined): string | undefined {
651
611
  const lines: string[] = [];
652
612
 
653
613
  // `exe` is the executable stripped from the command (argv[0]); emit it only
@@ -690,15 +650,6 @@ class ArgtypeEmitter {
690
650
  }
691
651
  }
692
652
 
693
- const extensions: string[] = [];
694
- if (outputs) extensions.push("outputs");
695
- if (mediaTypes) extensions.push("mediatypes");
696
- if (paths) extensions.push("paths");
697
- if (extensions.length > 0) {
698
- lines.push("extensions:");
699
- for (const e of extensions) lines.push(` - ${e}`);
700
- }
701
-
702
653
  if (lines.length === 0) return undefined;
703
654
  return ["---", ...lines, "---"].join("\n");
704
655
  }
@@ -404,28 +404,20 @@ class BoutiquesEmitter {
404
404
  wrapperNode,
405
405
  );
406
406
 
407
- // Add flag to command line if present, then value-key
408
- if (peeled.flag) {
409
- if (
410
- fieldType.kind === "bool" ||
411
- (fieldType.kind === "optional" && this.isBool(fieldType))
412
- ) {
413
- // Bool flags: the value-key IS the flag
414
- commandParts.push(valueKeyStr);
415
- } else {
416
- // Value flags: flag then value-key as separate args
417
- commandParts.push(valueKeyStr);
418
- }
419
- } else {
420
- commandParts.push(valueKeyStr);
421
- }
407
+ // The value-key alone: a peeled flag rides on the input's
408
+ // `command-line-flag`, and Boutiques renders it ahead of the value.
409
+ commandParts.push(valueKeyStr);
422
410
 
423
411
  inputs.push(input);
424
412
  }
425
413
 
426
414
  bt["command-line"] = commandParts.join(" ");
427
415
  bt.inputs = inputs;
428
- this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
416
+ // Resolve the output scope from `structNode` (which carries `meta.outputs`
417
+ // and is what the solver keyed the outputs under), not `expr`: for a plain
418
+ // or list subcommand `findStructNode` descends past `expr`, so resolving from
419
+ // `expr` would miss the bucket and silently drop the subcommand's outputs.
420
+ this.emitOutputFiles(bt, structNode, valueKeyByBinding, idScope);
429
421
  }
430
422
 
431
423
  // Emit `output-files` entries declared on this struct binding. `optional`
@@ -579,6 +571,7 @@ class BoutiquesEmitter {
579
571
  // had to fall back to String.
580
572
  private finalizeInput(input: BtInput): void {
581
573
  if (input.name === undefined) input.name = input.id;
574
+ this.finalizeSubDescriptors(input);
582
575
 
583
576
  // A String with a bool default and no choices is really a Flag.
584
577
  if (
@@ -624,6 +617,24 @@ class BoutiquesEmitter {
624
617
  this.mergeDefaultIntoDescription(input);
625
618
  }
626
619
 
620
+ // A sub-descriptor used as an input `type` needs an `id`: it is required by
621
+ // the schema, and for union variants it doubles as the `@type` discriminator.
622
+ // Anonymous IR nodes (a struct the frontend never named) leave it unset, so
623
+ // fall back to the owning input's id, which is already unique in this scope.
624
+ private finalizeSubDescriptors(input: BtInput): void {
625
+ const type = input.type;
626
+ if (Array.isArray(type)) {
627
+ type.forEach((sub, i) => this.ensureSubDescriptorId(sub, `${input.id}_${i + 1}`));
628
+ } else if (typeof type === "object" && type !== null) {
629
+ this.ensureSubDescriptorId(type, input.id);
630
+ }
631
+ }
632
+
633
+ private ensureSubDescriptorId(sub: BtDescriptor, fallback: string): void {
634
+ if (sub.id === undefined) sub.id = this.sanitizeId(fallback);
635
+ if (sub.name === undefined) sub.name = sub.id;
636
+ }
637
+
627
638
  // Peel wrapper layers from an IR node to extract Boutiques input properties.
628
639
  // Walks from outermost to innermost, detecting optional/repeat/flag patterns.
629
640
  private peelNode(node: Expr, type: BoundType): PeeledInput {
@@ -652,6 +663,23 @@ class BoutiquesEmitter {
652
663
  break;
653
664
 
654
665
  case "sequence": {
666
+ // Two shapes both present as `seq(lit, x)` with a struct-typed field,
667
+ // and only the node tells them apart:
668
+ //
669
+ // struct body seq(lit("--cifti-output"), optional(choice))
670
+ // - the literal is the sub-descriptor's own `command-line`, which
671
+ // `buildFromStruct` serializes by walking these same children.
672
+ // Peeling it would emit it twice.
673
+ // flag wrapper seq(lit("-i"), seq(fixed, moving))
674
+ // - the struct body is `nodes[1]`; the literal is outside it and
675
+ // nothing else serializes it. Not peeling it would drop it.
676
+ //
677
+ // `findStructNode` returns the body, so identity separates the cases.
678
+ const structType = this.unwrapType(type);
679
+ if (structType.kind === "struct" && findStructNode(node, this.ctx, structType) === node) {
680
+ break;
681
+ }
682
+
655
683
  // Detect flag pattern: seq(lit(flag), inner)
656
684
  const nodes = node.attrs.nodes;
657
685
  if (nodes.length === 2 && nodes[0]!.kind === "literal") {
@@ -820,14 +848,9 @@ class BoutiquesEmitter {
820
848
  };
821
849
  }
822
850
 
823
- // All-struct union -> SubCommandUnion
824
- const allStruct = type.variants.every((v: BoundVariant) => v.type.kind === "struct");
825
- if (allStruct) {
826
- return { type: this.buildSubCommandUnion(type, node) };
827
- }
828
-
829
- // Mixed union -> wrap each variant as a SubCommand descriptor
830
- return { type: this.buildMixedUnionAsSubCommands(type, node) };
851
+ // Any other union (all-struct or mixed) -> one SubCommand descriptor per
852
+ // variant: struct arms serialize recursively, non-struct arms are wrapped.
853
+ return { type: this.buildUnionSubCommands(type, node) };
831
854
  }
832
855
 
833
856
  private buildSubCommand(type: Extract<BoundType, { kind: "struct" }>, node: Expr): BtDescriptor {
@@ -844,29 +867,10 @@ class BoutiquesEmitter {
844
867
  return bt;
845
868
  }
846
869
 
847
- private buildSubCommandUnion(
848
- type: Extract<BoundType, { kind: "union" }>,
849
- node: Expr,
850
- ): BtDescriptor[] {
851
- const alts = node.kind === "alternative" ? node.attrs.alts : [node];
852
-
853
- return type.variants.map((variant: BoundVariant, i: number) => {
854
- const altNode = alts[i] ?? node;
855
- if (variant.type.kind === "struct") {
856
- const bt = this.buildSubCommand(variant.type, altNode);
857
- // The variant has its own name; the wrapperNode's name is the
858
- // parent (mutex group) name and would otherwise leak down.
859
- if (variant.name) {
860
- bt.name = variant.name;
861
- bt.id = this.sanitizeId(variant.name);
862
- }
863
- return bt;
864
- }
865
- return this.wrapAsDescriptor(variant, altNode);
866
- });
867
- }
868
-
869
- private buildMixedUnionAsSubCommands(
870
+ // One SubCommand descriptor per union variant. Handles both all-struct and
871
+ // mixed unions: struct arms serialize recursively, non-struct arms are wrapped
872
+ // as a trivial single-input descriptor.
873
+ private buildUnionSubCommands(
870
874
  type: Extract<BoundType, { kind: "union" }>,
871
875
  node: Expr,
872
876
  ): BtDescriptor[] {
@@ -1012,6 +1016,10 @@ class BoutiquesEmitter {
1012
1016
  type: subBt,
1013
1017
  list: true,
1014
1018
  minListEntries: countMin,
1019
+ // A count that tolerates zero occurrences may be omitted entirely.
1020
+ // Without this, Boutiques defaults `optional` to false and rejects an
1021
+ // invocation that leaves the flag out. Mirrors the bounded path above.
1022
+ ...(countMin === 0 && { optional: true }),
1015
1023
  };
1016
1024
  }
1017
1025
  }
@@ -0,0 +1,40 @@
1
+ import type { Binding, BoundType } from "../bindings/index.js";
2
+ import type { CodegenContext } from "../manifest/index.js";
3
+ import { collectFieldInfo } from "./collect-field-info.js";
4
+
5
+ /** Renders a default value as a target-language literal. */
6
+ export type RenderLiteral = (value: string | number | boolean) => string;
7
+
8
+ /**
9
+ * Build the field-name -> rendered-default map for a struct root (else empty).
10
+ * Includes only non-optional defaulted fields (optional fields are
11
+ * presence-guarded; their default comes from the factory's kwarg signature).
12
+ *
13
+ * `rootType` defaults to the resolved root type; callers that already have it
14
+ * (the arg-builders) pass it to avoid re-resolving.
15
+ */
16
+ export function collectDefaults(
17
+ ctx: CodegenContext,
18
+ renderLiteral: RenderLiteral,
19
+ rootType: BoundType | undefined = ctx.resolve(ctx.expr)?.type,
20
+ ): Map<string, string> {
21
+ const out = new Map<string, string>();
22
+ if (rootType?.kind !== "struct") return out;
23
+ for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
24
+ if (fi.defaultValue === undefined) continue;
25
+ if (rootType.fields[name]?.kind === "optional") continue;
26
+ out.set(name, renderLiteral(fi.defaultValue));
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** The rendered default for a binding iff it is a root-level defaulted field. */
32
+ export function rootFieldDefault(
33
+ binding: Binding | undefined,
34
+ defaults: ReadonlyMap<string, string>,
35
+ ): string | undefined {
36
+ if (!binding) return undefined;
37
+ const a = binding.access;
38
+ if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
39
+ return undefined;
40
+ }
@@ -24,6 +24,13 @@ export function findStructNode(
24
24
  ctx: CodegenContext,
25
25
  structType: Extract<BoundType, { kind: "struct" }>,
26
26
  ): Extract<Expr, { kind: "sequence" }> | undefined {
27
+ // An empty struct (no fields) has no field bindings to match, but it is still a
28
+ // real scope: a parameterless output-bearing command like `seq(lit("run"))`
29
+ // with an output-file. Its scope node is simply the sequence carrying it, so a
30
+ // backend can emit the (field-less) command line and its outputs.
31
+ if (node.kind === "sequence" && Object.keys(structType.fields).length === 0) {
32
+ return node;
33
+ }
27
34
  switch (node.kind) {
28
35
  case "sequence": {
29
36
  // Phase 1: Check if any direct child has a binding matching a struct field
@@ -22,15 +22,7 @@ export { generateNipype, NipypeBackend, nipypeNames } from "./nipype/index.js";
22
22
  export type { PydraNames } from "./pydra/index.js";
23
23
  export { generatePydra, PydraBackend, pydraNames } from "./pydra/index.js";
24
24
  export { resolveFieldBinding } from "./resolve-field-binding.js";
25
- export type { OutputEmitPlan } from "./resolve-output-tokens.js";
26
- export {
27
- compactTokens,
28
- isGated,
29
- isIterated,
30
- outputGate,
31
- planOutput,
32
- planScope,
33
- } from "./resolve-output-tokens.js";
25
+ export { outputGate } from "./resolve-output-tokens.js";
34
26
  export { buildEmitModel, generatePython, PythonBackend, renderPythonCall } from "./python/index.js";
35
27
  export { Scope } from "./scope.js";
36
28
  export type { JsonSchema } from "./schema/index.js";
@@ -116,11 +116,19 @@ export function renderInputTrait(p: TypedParam): string {
116
116
  ]);
117
117
  case "enum": {
118
118
  const choices = p.choices ?? [];
119
- // nipype's first Enum arg is the default; put the styx default first.
120
- const ordered = hasDef ? [def!, ...choices.filter((c) => c !== def)] : choices;
119
+ // nipype's first Enum arg is the default; put the styx default first, but
120
+ // only when it is actually one of the choices - prepending an out-of-spec
121
+ // default would silently widen the allowed set. (Enum choices are never
122
+ // booleans, so a boolean default can't be one.)
123
+ const defChoice =
124
+ hasDef && def !== undefined && typeof def !== "boolean" && choices.includes(def)
125
+ ? def
126
+ : undefined;
127
+ const ordered =
128
+ defChoice !== undefined ? [defChoice, ...choices.filter((c) => c !== defChoice)] : choices;
121
129
  return call("traits.Enum", [
122
130
  ...ordered.map((c) => renderPyLiteral(c)),
123
- ...(hasDef ? ["usedefault=True"] : []),
131
+ ...(defChoice !== undefined ? ["usedefault=True"] : []),
124
132
  ...tail,
125
133
  ]);
126
134
  }
@@ -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";
@@ -97,15 +97,6 @@ interface ArgContext {
97
97
  * carrying a Boutiques default. Restricted to single-segment (root) field access
98
98
  * so a nested field can never accidentally pick up a same-named root default.
99
99
  */
100
- function rootFieldDefault(
101
- binding: Binding,
102
- defaults: ReadonlyMap<string, string>,
103
- ): string | undefined {
104
- const a = binding.access;
105
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
106
- return undefined;
107
- }
108
-
109
100
  /**
110
101
  * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
111
102
  * loop, alternative dispatch): substitutes the field's default via
@@ -145,22 +136,6 @@ function accessOf(binding: Binding, arg: ArgContext, opts: AccessOpts = {}): str
145
136
  );
146
137
  }
147
138
 
148
- /**
149
- * Build the field-name -> rendered-default map for a struct root (else empty).
150
- * Includes only non-optional defaulted fields (optional fields are
151
- * presence-guarded; their default comes from the factory's kwarg signature).
152
- */
153
- function collectDefaults(ctx: CodegenContext, rootType?: BoundType): Map<string, string> {
154
- const out = new Map<string, string>();
155
- if (rootType?.kind !== "struct") return out;
156
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
157
- if (fi.defaultValue === undefined) continue;
158
- if (rootType.fields[name]?.kind === "optional") continue;
159
- out.set(name, renderPyLiteral(fi.defaultValue));
160
- }
161
- return out;
162
- }
163
-
164
139
  // -- Recursive descent --
165
140
 
166
141
  let loopVarCounter = 0;
@@ -179,7 +154,7 @@ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundT
179
154
  joinDepth: 0,
180
155
  loopVars: new Map(),
181
156
  valueSubst: new Map(),
182
- defaults: collectDefaults(ctx, rootType),
157
+ defaults: collectDefaults(ctx, renderPyLiteral, rootType),
183
158
  };
184
159
  return walk(rootExpr, ctx, initialCtx);
185
160
  }
@@ -17,8 +17,13 @@ import { collectFieldInfo, resolveTypeName } from "./types.js";
17
17
  */
18
18
  export function emitDocstring(cb: CodeBuilder, text?: string): void {
19
19
  if (!text) return;
20
- const lines = text.split("\n");
21
- if (lines.length === 1 && !lines[0]!.includes('"')) {
20
+ // Escape embedded triple-quotes so a `"""` in the text can't close the
21
+ // docstring early.
22
+ const escaped = text.replace(/"""/g, '\\"\\"\\"');
23
+ const lines = escaped.split("\n");
24
+ // Single-line form only when safe: no embedded quote, and no trailing
25
+ // backslash (which would escape the closing quotes, e.g. `"""x\"""`).
26
+ if (lines.length === 1 && !lines[0]!.includes('"') && !lines[0]!.endsWith("\\")) {
22
27
  cb.line(`"""${lines[0]}"""`);
23
28
  return;
24
29
  }
@@ -27,13 +32,20 @@ export function emitDocstring(cb: CodeBuilder, text?: string): void {
27
32
  cb.line(`"""`);
28
33
  }
29
34
 
30
- export function emitImports(cb: CodeBuilder, emitOutputs: boolean): void {
31
- cb.line("import dataclasses");
35
+ export function emitImports(cb: CodeBuilder): void {
32
36
  cb.line("import pathlib");
33
37
  cb.line("import typing");
34
38
  cb.blank();
35
- const fromStyxdefs = ["Execution", "InputPathType", "Metadata", "Runner", "StyxValidationError"];
36
- if (emitOutputs) fromStyxdefs.push("OutputPathType");
39
+ // Every tool emits an Outputs object, so OutputPathType is always needed.
40
+ // (Kept last to preserve the previously emitted import order.)
41
+ const fromStyxdefs = [
42
+ "Execution",
43
+ "InputPathType",
44
+ "Metadata",
45
+ "Runner",
46
+ "StyxValidationError",
47
+ "OutputPathType",
48
+ ];
37
49
  cb.line(`from styxdefs import ${fromStyxdefs.join(", ")}, get_global_runner`);
38
50
  }
39
51
 
@@ -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 {
@@ -17,6 +11,7 @@ import {
17
11
  rootOutput,
18
12
  streamFields,
19
13
  } from "../collect-output-fields.js";
14
+ import { unionIsMixed, variantAtomUnion } from "../union-variants.js";
20
15
  import { PY_KEYWORDS, emitDocstring } from "./emit.js";
21
16
  import { pyStr, renderAccess, renderPyLiteral } from "./typemap.js";
22
17
 
@@ -40,10 +35,11 @@ export function streamFieldIds(ctx: CodegenContext): { stdout?: string; stderr?:
40
35
  return res;
41
36
  }
42
37
 
43
- /** Emit `@dataclasses.dataclass\nclass <outputsType>:` declaration. */
38
+ /** Emit `class <outputsType>(typing.NamedTuple):` declaration. */
44
39
  export function emitOutputsClass(ctx: CodegenContext, outputsType: string, cb: CodeBuilder): void {
45
- cb.line("@dataclasses.dataclass");
46
- cb.line(`class ${outputsType}:`);
40
+ // A NamedTuple (not a dataclass) matches the styx1 / NiWrap convention: outputs
41
+ // are an immutable, tuple-shaped result built once from keyword args.
42
+ cb.line(`class ${outputsType}(typing.NamedTuple):`);
47
43
  cb.indent(() => {
48
44
  emitDocstring(cb, "Output paths produced by the tool.");
49
45
  const fields = collectOutputFields(ctx, pyId);
@@ -89,32 +85,6 @@ interface OutputEmitCtx {
89
85
  defaults: ReadonlyMap<string, string>;
90
86
  }
91
87
 
92
- /** The rendered default for a binding iff it is a root-level defaulted field. */
93
- function rootFieldDefault(
94
- binding: Binding | undefined,
95
- defaults: ReadonlyMap<string, string>,
96
- ): string | undefined {
97
- if (!binding) return undefined;
98
- const a = binding.access;
99
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
100
- return undefined;
101
- }
102
-
103
- /** Build the field-name -> rendered-default map for the struct root (else empty).
104
- * Includes only non-optional defaulted fields (optional fields are
105
- * presence-guarded; their default comes from the factory's kwarg signature). */
106
- function collectDefaults(ctx: CodegenContext): Map<string, string> {
107
- const out = new Map<string, string>();
108
- const rootType = ctx.resolve(ctx.expr)?.type;
109
- if (rootType?.kind !== "struct") return out;
110
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
111
- if (fi.defaultValue === undefined) continue;
112
- if (rootType.fields[name]?.kind === "optional") continue;
113
- out.set(name, renderPyLiteral(fi.defaultValue));
114
- }
115
- return out;
116
- }
117
-
118
88
  interface WrapperRender {
119
89
  open: string;
120
90
  loopVar?: string;
@@ -134,7 +104,17 @@ function renderWrapperOpen(atom: GateAtom, ec: OutputEmitCtx): WrapperRender {
134
104
  }
135
105
  if (atom.kind === "variant") {
136
106
  const access = bindingAccess(atom.binding, ec);
137
- return { open: `if ${access}["@type"] == ${pyStr(atom.variant)}:` };
107
+ const check = `${access}["@type"] == ${pyStr(atom.variant)}`;
108
+ // A mixed union (`Literal[0] | Struct`) only carries `@type` on its struct
109
+ // arms; guard on the dict shape first so mypy narrows off the bare-literal
110
+ // arm before the subscript (which would otherwise be a non-indexable type
111
+ // error, and a runtime KeyError on the literal). Mirrors the cargs builder
112
+ // and validator. Pure-struct unions are always indexable - no guard needed.
113
+ const union = variantAtomUnion(atom, ec.ctx.bindings);
114
+ if (union && unionIsMixed(union)) {
115
+ return { open: `if isinstance(${access}, dict) and ${check}:` };
116
+ }
117
+ return { open: `if ${check}:` };
138
118
  }
139
119
  // present
140
120
  const binding = ec.ctx.bindings.get(atom.binding);
@@ -339,7 +319,7 @@ export function emitBuildOutputs(
339
319
  ctx,
340
320
  iter: new Map(),
341
321
  subst: new Map(),
342
- defaults: collectDefaults(ctx),
322
+ defaults: collectDefaults(ctx, renderPyLiteral),
343
323
  };
344
324
 
345
325
  const fields = collectOutputFields(ctx, pyId);
@@ -393,11 +373,16 @@ export function emitBuildOutputs(
393
373
  });
394
374
  }
395
375
 
396
- /** Sanitize an output name to a valid Python identifier. */
376
+ /**
377
+ * Sanitize an output name to a valid Python identifier. Uses a *letter*-leading
378
+ * prefix (`v_`) for digit-leading / empty names, never a leading underscore:
379
+ * the Outputs type is a `typing.NamedTuple`, which raises `ValueError` at import
380
+ * time for a field whose name starts with `_`. Matches styx1 and `pyScrubIdent`.
381
+ * (A trailing underscore for keywords is fine - only leading underscores fail.)
382
+ */
397
383
  export function pyId(name: string): string {
398
384
  let s = name.replace(/[^a-zA-Z0-9_]/g, "_");
399
- if (/^\d/.test(s)) s = "_" + s;
400
- if (s === "") s = "_";
385
+ if (/^\d/.test(s) || s === "") s = "v_" + s;
401
386
  if (PY_KEYWORDS.has(s)) s = s + "_";
402
387
  return s;
403
388
  }