@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,454 @@
1
+ import type { Binding, BindingId, BoundType, BoundVariant } from "../../bindings/index.js";
2
+ import { collectFieldInfo } from "../collect-field-info.js";
3
+ import type { Expr } from "../../ir/index.js";
4
+ import type { CodegenContext } from "../../manifest/index.js";
5
+ import { CodeBuilder } from "../code-builder.js";
6
+ import { structVariants } from "../union-variants.js";
7
+ import { pyStr, renderAccess, renderPyLiteral } from "./typemap.js";
8
+
9
+ // -- Result types --
10
+
11
+ interface Expr_ {
12
+ expr: string;
13
+ }
14
+ interface Stmt {
15
+ stmt: string;
16
+ }
17
+ export type ArgResult = Expr_ | Stmt;
18
+
19
+ function isExpr(r: ArgResult): r is Expr_ {
20
+ return "expr" in r;
21
+ }
22
+
23
+ export function resultToStmt(r: ArgResult): string {
24
+ return isExpr(r) ? `cargs.append(${r.expr})` : r.stmt;
25
+ }
26
+
27
+ function appendLines(cb: CodeBuilder, code: string): void {
28
+ for (const line of code.split("\n")) cb.line(line);
29
+ }
30
+
31
+ // -- Type helpers --
32
+
33
+ function toStringExpr(node: Expr, type: BoundType, expr: string): string {
34
+ if (type.kind === "scalar") {
35
+ if (type.scalar === "str") return expr;
36
+ if (type.scalar === "path") return pathArg(node, expr);
37
+ }
38
+ return `str(${expr})`;
39
+ }
40
+
41
+ /**
42
+ * Command-line value for a path input via `execution.input_file`, threading the
43
+ * path node's `resolve_parent` / `mutable` attrs as keyword args. `mutable=True`
44
+ * tells the runner to stage a writable COPY (original untouched) and return the
45
+ * copy's command-line path; the outputs builder surfaces that same copy's host
46
+ * path via `execution.mutable_copy`.
47
+ */
48
+ function pathArg(node: Expr, expr: string): string {
49
+ if (node.kind !== "path") return `execution.input_file(${expr})`;
50
+ let extra = "";
51
+ if (node.attrs.resolveParent) extra += ", resolve_parent=True";
52
+ if (node.attrs.mutable) extra += ", mutable=True";
53
+ return `execution.input_file(${expr}${extra})`;
54
+ }
55
+
56
+ // -- Context passed down through recursion --
57
+
58
+ interface ArgContext {
59
+ /** Join nesting depth (controls ternary vs if-statement for optionals). */
60
+ joinDepth: number;
61
+ /**
62
+ * Loop variables bound by enclosing `repeat`-of-list nodes, keyed by the
63
+ * repeat binding's id. `renderAccess` consults this to resolve the `iter`
64
+ * segments in a binding's solver-assigned access path.
65
+ */
66
+ loopVars: ReadonlyMap<BindingId, string>;
67
+ /**
68
+ * Prefix substitutions for optional fields narrowed by an enclosing presence
69
+ * guard: maps a rendered access prefix to the `.get()`-narrowed local that
70
+ * holds it. Threaded into `renderAccess` so inner reads use the local (one
71
+ * lookup, absent-safe, mypy-narrowable) instead of re-subscripting.
72
+ */
73
+ valueSubst: ReadonlyMap<string, string>;
74
+ /**
75
+ * Rendered Python default literals for root-level NON-OPTIONAL fields that
76
+ * carry a Boutiques default (e.g. `maskfile -> "img_bet"`), keyed by field
77
+ * name. Such a field is `NotRequired` (a hand-authored config may omit it) yet
78
+ * is read unconditionally - so every read of its value becomes
79
+ * `.get(key, <default>)` to substitute the default instead of raising
80
+ * `KeyError`. Optional fields are excluded: they are presence-guarded (their
81
+ * default, if any, is supplied by the factory's kwarg signature), so reading
82
+ * them with a default here would both be wrong and collide with `valueSubst`.
83
+ */
84
+ defaults: ReadonlyMap<string, string>;
85
+ }
86
+
87
+ /**
88
+ * The rendered default for a binding iff it is a root-level NON-OPTIONAL field
89
+ * carrying a Boutiques default. Restricted to single-segment (root) field access
90
+ * so a nested field can never accidentally pick up a same-named root default.
91
+ */
92
+ function rootFieldDefault(
93
+ binding: Binding,
94
+ defaults: ReadonlyMap<string, string>,
95
+ ): string | undefined {
96
+ const a = binding.access;
97
+ if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
103
+ * loop, alternative dispatch): substitutes the field's default via
104
+ * `.get(key, default)` when it is a defaulted non-optional field, else the plain
105
+ * access. Returns plain access for every non-defaulted field, so the emitted
106
+ * code is byte-identical to before for the common case. Not used by
107
+ * `walkOptional` (its bare access is the `valueSubst` key and its guard reads
108
+ * via `.get()`).
109
+ */
110
+ function readAccess(binding: Binding, arg: ArgContext): string {
111
+ const def = rootFieldDefault(binding, arg.defaults);
112
+ return accessOf(binding, arg, def !== undefined ? { finalDefault: def } : {});
113
+ }
114
+
115
+ interface AccessOpts {
116
+ /** Render the final field segment as `.get(key)` (absent key -> None). */
117
+ finalGet?: boolean;
118
+ /** Render the final field segment as `.get(key, default)` (absent key -> default). */
119
+ finalDefault?: string;
120
+ }
121
+
122
+ /**
123
+ * Render a binding's solver-assigned access path in the current loop scope.
124
+ * `finalGet` renders the final field segment as `.get(key)` (used when binding
125
+ * an optional's value to a narrowed local); `finalDefault` renders it as
126
+ * `.get(key, default)` (used for absent-safe reads of a defaulted field).
127
+ */
128
+ function accessOf(binding: Binding, arg: ArgContext, opts: AccessOpts = {}): string {
129
+ return renderAccess(
130
+ binding.access,
131
+ (b) => {
132
+ const v = arg.loopVars.get(b);
133
+ if (v === undefined) throw new Error(`arg-builder: unbound loop variable for binding ${b}`);
134
+ return v;
135
+ },
136
+ { finalFieldGet: opts.finalGet, finalFieldDefault: opts.finalDefault, subst: arg.valueSubst },
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Build the field-name -> rendered-default map for a struct root (else empty).
142
+ * Includes only non-optional defaulted fields (optional fields are
143
+ * presence-guarded; their default comes from the factory's kwarg signature).
144
+ */
145
+ function collectDefaults(ctx: CodegenContext, rootType?: BoundType): Map<string, string> {
146
+ const out = new Map<string, string>();
147
+ if (rootType?.kind !== "struct") return out;
148
+ for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
149
+ if (fi.defaultValue === undefined) continue;
150
+ if (rootType.fields[name]?.kind === "optional") continue;
151
+ out.set(name, renderPyLiteral(fi.defaultValue));
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // -- Recursive descent --
157
+
158
+ let loopVarCounter = 0;
159
+ let optVarCounter = 0;
160
+
161
+ /**
162
+ * Build arg-building code for an IR tree via recursive descent.
163
+ *
164
+ * Mirrors the TypeScript backend's `walk` structurally so the emitted Python
165
+ * has the same shape (and the same correctness story) as the TS output.
166
+ */
167
+ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundType): ArgResult {
168
+ loopVarCounter = 0;
169
+ optVarCounter = 0;
170
+ const initialCtx: ArgContext = {
171
+ joinDepth: 0,
172
+ loopVars: new Map(),
173
+ valueSubst: new Map(),
174
+ defaults: collectDefaults(ctx, rootType),
175
+ };
176
+ return walk(rootExpr, ctx, initialCtx);
177
+ }
178
+
179
+ function walk(node: Expr, ctx: CodegenContext, arg: ArgContext): ArgResult {
180
+ switch (node.kind) {
181
+ case "literal":
182
+ return { expr: pyStr(node.attrs.str) };
183
+
184
+ case "int":
185
+ case "float":
186
+ case "str":
187
+ case "path":
188
+ return walkTerminal(node, ctx, arg);
189
+
190
+ case "sequence":
191
+ return walkSequence(node, ctx, arg);
192
+
193
+ case "optional":
194
+ return walkOptional(node, ctx, arg);
195
+
196
+ case "repeat":
197
+ return walkRepeat(node, ctx, arg);
198
+
199
+ case "alternative":
200
+ return walkAlternative(node, ctx, arg);
201
+ }
202
+ }
203
+
204
+ function walkTerminal(node: Expr, ctx: CodegenContext, arg: ArgContext): ArgResult {
205
+ const binding = ctx.resolve(node);
206
+ if (!binding) throw new Error(`Missing binding for terminal node: ${node.kind}`);
207
+ // A root-level defaulted field (e.g. an output basename `maskfile="img_bet"`)
208
+ // is read here unconditionally but is `NotRequired`, so `readAccess`
209
+ // substitutes the default for an absent key via `.get(key, default)`.
210
+ return { expr: toStringExpr(node, binding.type, readAccess(binding, arg)) };
211
+ }
212
+
213
+ function walkSequence(
214
+ node: Extract<Expr, { kind: "sequence" }>,
215
+ ctx: CodegenContext,
216
+ arg: ArgContext,
217
+ ): ArgResult {
218
+ // A non-join sequence inside an outer join must concatenate (rather than
219
+ // push separate args) so it can stand in as a single Expr element of the
220
+ // outer join. Boutiques produces this shape for `command-line-flag` inputs
221
+ // nested under a parent join template (e.g. `[OUTPUT][FLAG]` -> seqJoin('')
222
+ // around an opt(seq(lit(FLAG), value))).
223
+ const join = node.attrs.join ?? (arg.joinDepth > 0 ? "" : undefined);
224
+
225
+ // Struct scoping is already baked into each child's access path by the
226
+ // solver; here we only thread join depth (a codegen concern).
227
+ const childArg: ArgContext = join !== undefined ? { ...arg, joinDepth: arg.joinDepth + 1 } : arg;
228
+
229
+ const parts = node.attrs.nodes.map((child) => walk(child, ctx, childArg));
230
+
231
+ if (join !== undefined) {
232
+ const exprs = parts.map((p) => (isExpr(p) ? p.expr : p.stmt));
233
+ if (exprs.length === 1) return { expr: exprs[0]! };
234
+ return { expr: `${pyStr(join)}.join([${exprs.join(", ")}])` };
235
+ }
236
+
237
+ return { stmt: parts.map(resultToStmt).join("\n") };
238
+ }
239
+
240
+ function walkOptional(
241
+ node: Extract<Expr, { kind: "optional" }>,
242
+ ctx: CodegenContext,
243
+ arg: ArgContext,
244
+ ): ArgResult {
245
+ const binding = ctx.resolve(node);
246
+ if (!binding) throw new Error("Missing binding for optional node");
247
+ const isOpt = binding.type.kind === "optional";
248
+ const access = accessOf(binding, arg);
249
+
250
+ // For a nullable optional, bind the value to a narrowed local read via `.get()`
251
+ // (the key is `NotRequired` - the factory omits it when None, so a bare
252
+ // subscript would KeyError). Inner reads of this access (and anything nested
253
+ // under it) are redirected to the local via `valueSubst`: one lookup, absent-
254
+ // safe, and mypy can narrow the local (it cannot narrow a re-subscript or a
255
+ // fresh `.get()`). Bool-flag optionals are also `NotRequired` (default false),
256
+ // so the truthy guard reads via `.get()` too (absent key -> None -> flag off).
257
+ let childArg = arg;
258
+ let local: string | undefined;
259
+ let getAccess: string | undefined;
260
+ if (isOpt) {
261
+ local = `v_${optVarCounter++}`;
262
+ getAccess = accessOf(binding, arg, { finalGet: true });
263
+ childArg = { ...arg, valueSubst: new Map(arg.valueSubst).set(access, local) };
264
+ }
265
+ // Absent-safe truthy guard for the bool-flag case.
266
+ const boolGuard = accessOf(binding, arg, { finalGet: true });
267
+
268
+ // The inner node's access path is solver-assigned (it either inherits this
269
+ // optional's path on a collapse, or scopes into it for a struct); we thread the
270
+ // loop scope, join depth, and the optional's value substitution.
271
+ const inner = walk(node.attrs.node, ctx, childArg);
272
+
273
+ // Inside a join context, emit as ternary expression.
274
+ if (arg.joinDepth > 0 && isExpr(inner)) {
275
+ if (isOpt) {
276
+ // Walrus binds the narrowed local inside the lazy ternary; the inner expr
277
+ // (which references `local`) only evaluates when the key is present.
278
+ return { expr: `(${inner.expr} if (${local} := ${getAccess}) is not None else "")` };
279
+ }
280
+ return { expr: `(${inner.expr} if ${boolGuard} else "")` };
281
+ }
282
+
283
+ const cb = new CodeBuilder(" ");
284
+ const innerStmt = resultToStmt(inner);
285
+ if (isOpt) {
286
+ cb.line(`${local} = ${getAccess}`);
287
+ cb.line(`if ${local} is not None:`);
288
+ cb.indent(() => appendLines(cb, innerStmt));
289
+ } else {
290
+ cb.line(`if ${boolGuard}:`);
291
+ cb.indent(() => appendLines(cb, innerStmt));
292
+ }
293
+ return { stmt: cb.toString() };
294
+ }
295
+
296
+ function walkRepeat(
297
+ node: Extract<Expr, { kind: "repeat" }>,
298
+ ctx: CodegenContext,
299
+ arg: ArgContext,
300
+ ): ArgResult {
301
+ const binding = ctx.resolve(node);
302
+ if (!binding) throw new Error("Missing binding for repeat node");
303
+ // A non-join repeat inside an outer join concatenates rather than pushing
304
+ // separate args, mirroring walkSequence's handling of bare non-join seqs.
305
+ const join = node.attrs.join ?? (arg.joinDepth > 0 ? "" : undefined);
306
+ // Unconditional read (the count/list value) - `readAccess` substitutes a
307
+ // defaulted non-optional field's default for an absent key.
308
+ const access = readAccess(binding, arg);
309
+
310
+ // Count repeat: emit a counted for-loop. Inside a join the for-loop would
311
+ // be dropped into a list literal as raw text, so emit a comprehension.
312
+ if (binding.type.kind === "count") {
313
+ const inner = walk(node.attrs.node, ctx, arg);
314
+ const v = `_i${loopVarCounter++}`;
315
+ if (join !== undefined && isExpr(inner)) {
316
+ return { expr: `${pyStr(join)}.join([${inner.expr} for ${v} in range(${access})])` };
317
+ }
318
+ const cb = new CodeBuilder(" ");
319
+ cb.line(`for ${v} in range(${access}):`);
320
+ cb.indent(() => appendLines(cb, resultToStmt(inner)));
321
+ return { stmt: cb.toString() };
322
+ }
323
+
324
+ // List repeat: emit a for-in loop or generator-join. The loop variable is
325
+ // registered under this repeat's binding id so inner bindings' `iter`
326
+ // segments resolve to it via `renderAccess`.
327
+ const loopVar = `item${loopVarCounter++}`;
328
+ const childArg: ArgContext = {
329
+ ...arg,
330
+ loopVars: new Map(arg.loopVars).set(binding.id, loopVar),
331
+ };
332
+
333
+ const inner = walk(node.attrs.node, ctx, childArg);
334
+
335
+ if (join !== undefined && isExpr(inner)) {
336
+ return {
337
+ expr: `${pyStr(join)}.join([${inner.expr} for ${loopVar} in ${access}])`,
338
+ };
339
+ }
340
+
341
+ const cb = new CodeBuilder(" ");
342
+ cb.line(`for ${loopVar} in ${access}:`);
343
+ cb.indent(() => appendLines(cb, resultToStmt(inner)));
344
+ return { stmt: cb.toString() };
345
+ }
346
+
347
+ function walkAlternative(
348
+ node: Extract<Expr, { kind: "alternative" }>,
349
+ ctx: CodegenContext,
350
+ arg: ArgContext,
351
+ ): ArgResult {
352
+ const binding = ctx.resolve(node);
353
+ if (!binding) throw new Error("Missing binding for alternative node");
354
+ // Unconditional read (the enum value / bool guard / union discriminator) -
355
+ // `readAccess` substitutes a defaulted non-optional field's default for an
356
+ // absent key (e.g. a `value-choices` String with a default).
357
+ const access = readAccess(binding, arg);
358
+
359
+ // Complex-union variant fields already carry the union's path in their
360
+ // solver-assigned access, so arms walk with the current context unchanged.
361
+ const variants = node.attrs.alts.map((alt) => walk(alt, ctx, arg));
362
+
363
+ if (
364
+ binding.type.kind === "union" &&
365
+ binding.type.variants.every((v: BoundVariant) => v.type.kind === "literal")
366
+ ) {
367
+ return { expr: `str(${access})` };
368
+ }
369
+
370
+ if (binding.type.kind === "bool") {
371
+ // Inside a join, the alternative's output must be an expression, not a
372
+ // statement: an `if/else` block dropped into a `"".join([...])` list
373
+ // literal is not valid Python. Emit a ternary when both arms are exprs.
374
+ if (arg.joinDepth > 0) {
375
+ if (!variants[1]) {
376
+ throw new Error(
377
+ "single-arm bool alternative inside a join: cannot produce an expression " +
378
+ "without ambiguous semantics (omitting the entry vs emitting empty string)",
379
+ );
380
+ }
381
+ if (!variants.every(isExpr)) {
382
+ throw new Error(
383
+ "bool alternative inside a join has statement-shaped variants; " +
384
+ "expected all arms to fold to expressions",
385
+ );
386
+ }
387
+ const v0 = (variants[0] as Expr_).expr;
388
+ const v1 = (variants[1] as Expr_).expr;
389
+ return { expr: `(${v0} if ${access} else ${v1})` };
390
+ }
391
+ const cb = new CodeBuilder(" ");
392
+ cb.line(`if ${access}:`);
393
+ cb.indent(() => appendLines(cb, resultToStmt(variants[0]!)));
394
+ if (variants[1]) {
395
+ cb.line(`else:`);
396
+ cb.indent(() => appendLines(cb, resultToStmt(variants[1]!)));
397
+ }
398
+ return { stmt: cb.toString() };
399
+ }
400
+
401
+ if (binding.type.kind === "union") {
402
+ const unionType = binding.type;
403
+ // A union may be pure-discriminated (every variant a struct with `@type`) or
404
+ // mixed (struct variants plus bare-literal variants, e.g. ants
405
+ // `Interpolation = "Linear" | MultiLabel | ...`). Pure-enum unions returned
406
+ // above. Dispatch struct variants on `@type`; a bare literal is its own value.
407
+ // Struct variants with their indices; throws if two share an `@type` (a
408
+ // duplicate-tagged variant is unreachable and a mypy `comparison-overlap` -
409
+ // frontends must dodge duplicate tags before codegen).
410
+ const structVars = structVariants(unionType);
411
+ const hasLiteral = unionType.variants.some((v) => v.type.kind === "literal");
412
+
413
+ // Inside a join: chained ternary, same reason as bool above.
414
+ if (arg.joinDepth > 0) {
415
+ if (!variants.every(isExpr)) {
416
+ throw new Error(
417
+ "union alternative inside a join has statement-shaped variants; " +
418
+ "expected all arms to fold to expressions",
419
+ );
420
+ }
421
+ let structExpr = pyStr("");
422
+ for (let k = structVars.length - 1; k >= 0; k--) {
423
+ const { variant, i } = structVars[k]!;
424
+ const v = (variants[i] as Expr_).expr;
425
+ structExpr = `(${v} if ${access}["@type"] == ${pyStr(variant.name ?? "")} else ${structExpr})`;
426
+ }
427
+ if (!hasLiteral) return { expr: structExpr };
428
+ // Mixed: a dict value dispatches by `@type`; a bare literal is itself.
429
+ return { expr: `(${structExpr} if isinstance(${access}, dict) else str(${access}))` };
430
+ }
431
+
432
+ const cb = new CodeBuilder(" ");
433
+ const emitStructDispatch = (): void => {
434
+ structVars.forEach(({ variant, i }, k) => {
435
+ const keyword = k === 0 ? "if" : "elif";
436
+ cb.line(`${keyword} ${access}["@type"] == ${pyStr(variant.name ?? "")}:`);
437
+ cb.indent(() => appendLines(cb, resultToStmt(variants[i]!)));
438
+ });
439
+ };
440
+ if (!hasLiteral) {
441
+ emitStructDispatch();
442
+ return { stmt: cb.toString() };
443
+ }
444
+ // Mixed union: branch on runtime shape (dict -> `@type` dispatch; else a
445
+ // bare literal used directly), mirroring the validator.
446
+ cb.line(`if isinstance(${access}, dict):`);
447
+ cb.indent(emitStructDispatch);
448
+ cb.line(`else:`);
449
+ cb.indent(() => appendLines(cb, resultToStmt({ expr: `str(${access})` })));
450
+ return { stmt: cb.toString() };
451
+ }
452
+
453
+ return { stmt: variants.map(resultToStmt).join("\n") };
454
+ }