@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,52 @@
1
+ import type { BoundType } from "../bindings/index.js";
2
+
3
+ /**
4
+ * Stable, fully structural identity key for any BoundType.
5
+ *
6
+ * Two types share a key iff they are structurally identical - same shape AND
7
+ * same leaf types/literal values, recursively. This is what `collectNamedTypes`
8
+ * dedups on: distinct nominal types must get distinct keys so each gets its own
9
+ * generated name.
10
+ *
11
+ * Keying on field/variant *names* alone is too coarse: discriminated-union
12
+ * variants that differ only by their `@type` literal (e.g. ANTs' `transform_*`
13
+ * variants, all `{ "@type": <literal>, gradient_step: float }`) would collapse
14
+ * to one identity, emitting `Transform = TransformRigid | TransformRigid | ...`
15
+ * and breaking discriminated-union narrowing. Including the field types (and
16
+ * thus the `@type` literal value) keeps them distinct.
17
+ */
18
+ export function typeKey(type: BoundType): string {
19
+ switch (type.kind) {
20
+ case "scalar":
21
+ return `scalar:${type.scalar}`;
22
+ case "bool":
23
+ return "bool";
24
+ case "count":
25
+ return "count";
26
+ case "literal":
27
+ // JSON.stringify disambiguates the value's type too (e.g. "2" vs 2).
28
+ return `literal:${JSON.stringify(type.value)}`;
29
+ case "optional":
30
+ return `optional(${typeKey(type.inner)})`;
31
+ case "list":
32
+ return `list(${typeKey(type.item)})`;
33
+ case "struct":
34
+ return structKey(type);
35
+ case "union":
36
+ return unionKey(type);
37
+ }
38
+ }
39
+
40
+ /** Stable identity key for a struct type (field names + field types). */
41
+ export function structKey(type: Extract<BoundType, { kind: "struct" }>): string {
42
+ const fields = Object.entries(type.fields)
43
+ .map(([name, fieldType]) => `${name}=${typeKey(fieldType)}`)
44
+ .join(",");
45
+ return `struct{${fields}}`;
46
+ }
47
+
48
+ /** Stable identity key for a union type (variant names + variant types). */
49
+ export function unionKey(type: Extract<BoundType, { kind: "union" }>): string {
50
+ const variants = type.variants.map((v) => `${v.name ?? "?"}=${typeKey(v.type)}`).join("|");
51
+ return `union[${variants}]`;
52
+ }
@@ -0,0 +1,420 @@
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 { renderAccess } from "./emit.js";
8
+ import { renderTsLiteral } from "./typemap.js";
9
+
10
+ // -- Result types --
11
+
12
+ interface Expr_ {
13
+ expr: string;
14
+ }
15
+ interface Stmt {
16
+ stmt: string;
17
+ }
18
+ export type ArgResult = Expr_ | Stmt;
19
+
20
+ function isExpr(r: ArgResult): r is Expr_ {
21
+ return "expr" in r;
22
+ }
23
+
24
+ export function resultToStmt(r: ArgResult): string {
25
+ return isExpr(r) ? `cargs.push(${r.expr});` : r.stmt;
26
+ }
27
+
28
+ function appendLines(cb: CodeBuilder, code: string): void {
29
+ for (const line of code.split("\n")) cb.line(line);
30
+ }
31
+
32
+ // -- Type helpers --
33
+
34
+ function toStringExpr(node: Expr, type: BoundType, expr: string): string {
35
+ if (type.kind === "scalar") {
36
+ if (type.scalar === "str") return expr;
37
+ if (type.scalar === "path") return pathArg(node, expr);
38
+ }
39
+ return `String(${expr})`;
40
+ }
41
+
42
+ /**
43
+ * Command-line value for a path input via `execution.inputFile`. The styxdefs
44
+ * signature is `inputFile(hostFile, resolveParent?, mutable?)`, so `mutable`
45
+ * requires supplying `resolveParent` positionally first. `mutable=true` tells
46
+ * the runner to stage a writable COPY (original untouched) and return the copy's
47
+ * command-line path; the outputs builder surfaces that same copy's host path via
48
+ * `execution.mutableCopy`.
49
+ */
50
+ function pathArg(node: Expr, expr: string): string {
51
+ if (node.kind !== "path") return `execution.inputFile(${expr})`;
52
+ const { resolveParent, mutable } = node.attrs;
53
+ if (mutable) return `execution.inputFile(${expr}, ${resolveParent ? "true" : "false"}, true)`;
54
+ if (resolveParent) return `execution.inputFile(${expr}, true)`;
55
+ return `execution.inputFile(${expr})`;
56
+ }
57
+
58
+ // -- Context passed down through recursion --
59
+
60
+ interface ArgContext {
61
+ /** Join nesting depth (controls ternary vs if-statement for optionals). */
62
+ joinDepth: number;
63
+ /**
64
+ * Loop variables bound by enclosing `repeat`-of-list nodes, keyed by the
65
+ * repeat binding's id. `renderAccess` consults this to resolve the `iter`
66
+ * segments in a binding's solver-assigned access path.
67
+ */
68
+ loopVars: ReadonlyMap<BindingId, string>;
69
+ /**
70
+ * Rendered TS default literals for root-level NON-OPTIONAL fields that carry a
71
+ * Boutiques default (e.g. `maskfile -> "img_bet"`), keyed by field name. Such a
72
+ * field is `?:` (a hand-authored config may omit it) yet is read
73
+ * unconditionally - so every read of its value becomes `(access ?? <default>)`
74
+ * to substitute the default instead of stringifying `undefined`. Optional
75
+ * fields are excluded: they are presence-guarded (their default, if any, comes
76
+ * from the factory's kwarg signature).
77
+ */
78
+ defaults: ReadonlyMap<string, string>;
79
+ }
80
+
81
+ /** Render a binding's solver-assigned access path in the current loop scope. */
82
+ function accessOf(binding: Binding, arg: ArgContext): string {
83
+ return renderAccess(binding.access, (b) => {
84
+ const v = arg.loopVars.get(b);
85
+ if (v === undefined) throw new Error(`arg-builder: unbound loop variable for binding ${b}`);
86
+ return v;
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The rendered default for a binding iff it is a root-level NON-OPTIONAL field
92
+ * carrying a Boutiques default. Restricted to single-segment (root) field access
93
+ * so a nested field can never accidentally pick up a same-named root default.
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
+
104
+ /**
105
+ * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
106
+ * loop, alternative dispatch): substitutes the field's default via
107
+ * `(access ?? default)` when it is a defaulted non-optional field, else the
108
+ * plain access. Returns plain access for every non-defaulted field, so the
109
+ * emitted code is byte-identical to before for the common case.
110
+ */
111
+ function readAccess(binding: Binding, arg: ArgContext): string {
112
+ const def = rootFieldDefault(binding, arg.defaults);
113
+ return def !== undefined ? `(${accessOf(binding, arg)} ?? ${def})` : accessOf(binding, arg);
114
+ }
115
+
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
+ // -- Recursive descent --
131
+
132
+ let loopVarCounter = 0;
133
+
134
+ /**
135
+ * Build arg-building code for an IR tree via recursive descent.
136
+ *
137
+ * Context flows down via the immutable `arg` parameter (access paths, join depth, etc.).
138
+ * Results flow up via return values (expressions or statement blocks).
139
+ */
140
+ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundType): ArgResult {
141
+ loopVarCounter = 0;
142
+ const initialCtx: ArgContext = {
143
+ joinDepth: 0,
144
+ loopVars: new Map(),
145
+ defaults: collectDefaults(ctx, rootType),
146
+ };
147
+ return walk(rootExpr, ctx, initialCtx);
148
+ }
149
+
150
+ function walk(node: Expr, ctx: CodegenContext, arg: ArgContext): ArgResult {
151
+ switch (node.kind) {
152
+ case "literal":
153
+ return { expr: JSON.stringify(node.attrs.str) };
154
+
155
+ case "int":
156
+ case "float":
157
+ case "str":
158
+ case "path":
159
+ return walkTerminal(node, ctx, arg);
160
+
161
+ case "sequence":
162
+ return walkSequence(node, ctx, arg);
163
+
164
+ case "optional":
165
+ return walkOptional(node, ctx, arg);
166
+
167
+ case "repeat":
168
+ return walkRepeat(node, ctx, arg);
169
+
170
+ case "alternative":
171
+ return walkAlternative(node, ctx, arg);
172
+ }
173
+ }
174
+
175
+ function walkTerminal(node: Expr, ctx: CodegenContext, arg: ArgContext): ArgResult {
176
+ const binding = ctx.resolve(node);
177
+ if (!binding) throw new Error(`Missing binding for terminal node: ${node.kind}`);
178
+ // A root-level defaulted field (e.g. an output basename `maskfile="img_bet"`)
179
+ // is read here unconditionally but is `?:`, so `readAccess` substitutes the
180
+ // default for an absent key via `(access ?? default)`.
181
+ return { expr: toStringExpr(node, binding.type, readAccess(binding, arg)) };
182
+ }
183
+
184
+ function walkSequence(
185
+ node: Extract<Expr, { kind: "sequence" }>,
186
+ ctx: CodegenContext,
187
+ arg: ArgContext,
188
+ ): ArgResult {
189
+ // A non-join sequence inside an outer join must concatenate (rather than
190
+ // push separate args) so it can stand in as a single Expr element of the
191
+ // outer join. Boutiques produces this shape for `command-line-flag` inputs
192
+ // nested under a parent join template (e.g. `[OUTPUT][FLAG]` -> seqJoin('')
193
+ // around an opt(seq(lit(FLAG), value))).
194
+ const join = node.attrs.join ?? (arg.joinDepth > 0 ? "" : undefined);
195
+
196
+ // Struct scoping is already baked into each child's access path by the
197
+ // solver; here we only thread join depth (a codegen concern).
198
+ const childArg: ArgContext = join !== undefined ? { ...arg, joinDepth: arg.joinDepth + 1 } : arg;
199
+
200
+ const parts = node.attrs.nodes.map((child) => walk(child, ctx, childArg));
201
+
202
+ if (join !== undefined) {
203
+ const exprs = parts.map((p) => (isExpr(p) ? p.expr : p.stmt));
204
+ if (exprs.length === 1) return { expr: exprs[0]! };
205
+ return { expr: `[${exprs.join(", ")}].join(${JSON.stringify(join)})` };
206
+ }
207
+
208
+ return { stmt: parts.map(resultToStmt).join("\n") };
209
+ }
210
+
211
+ function walkOptional(
212
+ node: Extract<Expr, { kind: "optional" }>,
213
+ ctx: CodegenContext,
214
+ arg: ArgContext,
215
+ ): ArgResult {
216
+ const binding = ctx.resolve(node);
217
+ if (!binding) throw new Error("Missing binding for optional node");
218
+ const access = accessOf(binding, arg);
219
+
220
+ // The inner node's access path is solver-assigned (it either inherits this
221
+ // optional's path on a collapse, or scopes into it for a struct), so no scope
222
+ // context needs threading - only the existing loop scope and join depth.
223
+ const inner = walk(node.attrs.node, ctx, arg);
224
+
225
+ // Inside a join context, emit as ternary expression
226
+ if (arg.joinDepth > 0 && isExpr(inner)) {
227
+ if (binding.type.kind === "optional") {
228
+ return { expr: `(${access} != null ? ${inner.expr} : "")` };
229
+ }
230
+ return { expr: `(${access} ? ${inner.expr} : "")` };
231
+ }
232
+
233
+ const cb = new CodeBuilder(" ");
234
+ const innerStmt = resultToStmt(inner);
235
+ if (binding.type.kind === "optional") {
236
+ cb.line(`if (${access} != null) {`);
237
+ cb.indent(() => appendLines(cb, innerStmt));
238
+ cb.line("}");
239
+ } else {
240
+ cb.line(`if (${access}) {`);
241
+ cb.indent(() => appendLines(cb, innerStmt));
242
+ cb.line("}");
243
+ }
244
+ return { stmt: cb.toString() };
245
+ }
246
+
247
+ function walkRepeat(
248
+ node: Extract<Expr, { kind: "repeat" }>,
249
+ ctx: CodegenContext,
250
+ arg: ArgContext,
251
+ ): ArgResult {
252
+ const binding = ctx.resolve(node);
253
+ if (!binding) throw new Error("Missing binding for repeat node");
254
+ // A non-join repeat inside an outer join concatenates rather than pushing
255
+ // separate args, mirroring walkSequence's handling of bare non-join seqs.
256
+ const join = node.attrs.join ?? (arg.joinDepth > 0 ? "" : undefined);
257
+ // Unconditional read (the count/list value) - `readAccess` substitutes a
258
+ // defaulted non-optional field's default for an absent key.
259
+ const access = readAccess(binding, arg);
260
+
261
+ // Count repeat: emit a counted for-loop. Inside a join the for-loop would
262
+ // be dropped into a list literal as raw text, so emit `Array.from` instead.
263
+ if (binding.type.kind === "count") {
264
+ const inner = walk(node.attrs.node, ctx, arg);
265
+ const v = `i${loopVarCounter++}`;
266
+ if (join !== undefined && isExpr(inner)) {
267
+ return {
268
+ expr: `Array.from({length: ${access}}, (_, ${v}) => ${inner.expr}).join(${JSON.stringify(join)})`,
269
+ };
270
+ }
271
+ const cb = new CodeBuilder(" ");
272
+ cb.line(`for (let ${v} = 0; ${v} < ${access}; ${v}++) {`);
273
+ cb.indent(() => appendLines(cb, resultToStmt(inner)));
274
+ cb.line("}");
275
+ return { stmt: cb.toString() };
276
+ }
277
+
278
+ // List repeat: emit a for-of loop or .map().join(). The loop variable is
279
+ // registered under this repeat's binding id so inner bindings' `iter`
280
+ // segments resolve to it via `renderAccess`.
281
+ const loopVar = `item${loopVarCounter++}`;
282
+ const childArg: ArgContext = {
283
+ ...arg,
284
+ loopVars: new Map(arg.loopVars).set(binding.id, loopVar),
285
+ };
286
+
287
+ const inner = walk(node.attrs.node, ctx, childArg);
288
+
289
+ if (join !== undefined && isExpr(inner)) {
290
+ return { expr: `${access}.map((${loopVar}) => ${inner.expr}).join(${JSON.stringify(join)})` };
291
+ }
292
+
293
+ const cb = new CodeBuilder(" ");
294
+ cb.line(`for (const ${loopVar} of ${access}) {`);
295
+ cb.indent(() => appendLines(cb, resultToStmt(inner)));
296
+ cb.line("}");
297
+ return { stmt: cb.toString() };
298
+ }
299
+
300
+ function walkAlternative(
301
+ node: Extract<Expr, { kind: "alternative" }>,
302
+ ctx: CodegenContext,
303
+ arg: ArgContext,
304
+ ): ArgResult {
305
+ const binding = ctx.resolve(node);
306
+ if (!binding) throw new Error("Missing binding for alternative node");
307
+ // Unconditional read (the enum value / bool guard / union discriminator) -
308
+ // `readAccess` substitutes a defaulted non-optional field's default for an
309
+ // absent key (e.g. a `value-choices` String with a default).
310
+ const access = readAccess(binding, arg);
311
+
312
+ // Complex-union variant fields already carry the union's path in their
313
+ // solver-assigned access, so arms walk with the current context unchanged.
314
+ const variants = node.attrs.alts.map((alt) => walk(alt, ctx, arg));
315
+
316
+ if (
317
+ binding.type.kind === "union" &&
318
+ binding.type.variants.every((v: BoundVariant) => v.type.kind === "literal")
319
+ ) {
320
+ return { expr: `String(${access})` };
321
+ }
322
+
323
+ if (binding.type.kind === "bool") {
324
+ // Inside a join, the alternative's output must be an expression, not a
325
+ // statement: dropping an `if/else` block into a `[...].join("")` list
326
+ // literal is not valid TypeScript. Emit a ternary when both arms are exprs.
327
+ if (arg.joinDepth > 0) {
328
+ if (!variants[1]) {
329
+ throw new Error(
330
+ "single-arm bool alternative inside a join: cannot produce an expression " +
331
+ "without ambiguous semantics (omitting the entry vs emitting empty string)",
332
+ );
333
+ }
334
+ if (!variants.every(isExpr)) {
335
+ throw new Error(
336
+ "bool alternative inside a join has statement-shaped variants; " +
337
+ "expected all arms to fold to expressions",
338
+ );
339
+ }
340
+ const v0 = (variants[0] as Expr_).expr;
341
+ const v1 = (variants[1] as Expr_).expr;
342
+ return { expr: `(${access} ? ${v0} : ${v1})` };
343
+ }
344
+ const cb = new CodeBuilder(" ");
345
+ cb.line(`if (${access}) {`);
346
+ cb.indent(() => appendLines(cb, resultToStmt(variants[0]!)));
347
+ if (variants[1]) {
348
+ cb.line("} else {");
349
+ cb.indent(() => appendLines(cb, resultToStmt(variants[1]!)));
350
+ }
351
+ cb.line("}");
352
+ return { stmt: cb.toString() };
353
+ }
354
+
355
+ if (binding.type.kind === "union") {
356
+ const unionType = binding.type;
357
+ // A union may be pure-discriminated (every variant a struct with `@type`) or
358
+ // mixed (struct variants plus bare-literal variants, e.g. ants
359
+ // `Interpolation = "Linear" | MultiLabel | ...`). Pure-enum unions returned
360
+ // above. Dispatch struct variants on `@type`; a bare literal is its own value.
361
+ // `structVariants` throws if two share an `@type` (an unreachable, dead
362
+ // branch - frontends must dodge duplicate tags before codegen).
363
+ const structVars = structVariants(unionType);
364
+ const hasLiteral = unionType.variants.some((v) => v.type.kind === "literal");
365
+
366
+ // Inside a join: chained ternary, same reason as bool above.
367
+ if (arg.joinDepth > 0) {
368
+ if (!variants.every(isExpr)) {
369
+ throw new Error(
370
+ "union alternative inside a join has statement-shaped variants; " +
371
+ "expected all arms to fold to expressions",
372
+ );
373
+ }
374
+ let structExpr = '""';
375
+ for (let k = structVars.length - 1; k >= 0; k--) {
376
+ const { variant, i } = structVars[k]!;
377
+ const v = (variants[i] as Expr_).expr;
378
+ structExpr = `(${access}["@type"] === ${JSON.stringify(variant.name ?? "")} ? ${v} : ${structExpr})`;
379
+ }
380
+ if (!hasLiteral) return { expr: structExpr };
381
+ // Mixed: an object value dispatches by `@type`; a bare literal is itself.
382
+ return {
383
+ expr: `(typeof ${access} === "object" && ${access} !== null ? ${structExpr} : String(${access}))`,
384
+ };
385
+ }
386
+
387
+ const cb = new CodeBuilder(" ");
388
+ // `switch` (not an `if`/`else if` ===-chain): each `case` narrows the
389
+ // discriminated union, whereas a chain accumulates narrowing and TS rejects
390
+ // later arms (TS2367).
391
+ const emitStructSwitch = (): void => {
392
+ cb.line(`switch (${access}["@type"]) {`);
393
+ cb.indent(() => {
394
+ for (const { variant, i } of structVars) {
395
+ cb.line(`case ${JSON.stringify(variant.name ?? "")}: {`);
396
+ cb.indent(() => {
397
+ appendLines(cb, resultToStmt(variants[i]!));
398
+ cb.line("break;");
399
+ });
400
+ cb.line("}");
401
+ }
402
+ });
403
+ cb.line("}");
404
+ };
405
+ if (!hasLiteral) {
406
+ emitStructSwitch();
407
+ return { stmt: cb.toString() };
408
+ }
409
+ // Mixed union: branch on runtime shape. `typeof === "object"` narrows to the
410
+ // struct variants; the `else` to the bare-literal members.
411
+ cb.line(`if (typeof ${access} === "object" && ${access} !== null) {`);
412
+ cb.indent(emitStructSwitch);
413
+ cb.line(`} else {`);
414
+ cb.indent(() => appendLines(cb, resultToStmt({ expr: `String(${access})` })));
415
+ cb.line(`}`);
416
+ return { stmt: cb.toString() };
417
+ }
418
+
419
+ return { stmt: variants.map(resultToStmt).join("\n") };
420
+ }