@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,233 @@
1
+ import type {
2
+ Binding,
3
+ BindingId,
4
+ BoundType,
5
+ OutputDiagnostic,
6
+ OutputScope,
7
+ OutputValidationResult,
8
+ ResolvedOutput,
9
+ ResolvedToken,
10
+ SolveResult,
11
+ } from "../bindings/index.js";
12
+ import type { Expr, Output } from "../ir/index.js";
13
+ import { effectiveOutputName } from "../ir/index.js";
14
+
15
+ export interface OutputResolution {
16
+ scopes: OutputScope[];
17
+ diagnostics: OutputValidationResult;
18
+ }
19
+
20
+ /**
21
+ * Per-binding map keyed by `Binding.name`. Frontends attach outputs to the
22
+ * declaring struct, so refs name fields visible in that scope (the resolver
23
+ * accepts a single global name index because optimization can move bindings
24
+ * around but never duplicates names within a scope).
25
+ */
26
+ interface NameIndex {
27
+ byName: Map<string, Binding>;
28
+ }
29
+
30
+ /**
31
+ * Whether a binding resolves to a value that can be interpolated into an output
32
+ * path token. Scalars/bool/count/literals (and optional/list wrappers of them)
33
+ * carry a concrete value; structs and complex unions do not.
34
+ *
35
+ * This drives the name-tie preference below: an unnamed multi-field struct
36
+ * inherits its first field's name (`findDeepName`), so a struct and its scalar
37
+ * field can share a name. An output ref always means the value, so the
38
+ * interpolable binding must win regardless of which sits shallower.
39
+ */
40
+ function isInterpolable(type: BoundType): boolean {
41
+ switch (type.kind) {
42
+ case "optional":
43
+ return isInterpolable(type.inner);
44
+ case "list":
45
+ return isInterpolable(type.item);
46
+ case "struct":
47
+ return false;
48
+ case "union":
49
+ // Pure-enum unions are interpolable (the value is a literal); a union with
50
+ // any struct variant is not.
51
+ return type.variants.every((v) => isInterpolable(v.type));
52
+ default:
53
+ return true;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `includeRoot` controls whether the binding on `root` itself (depth 0) is
59
+ * indexed. The global index excludes it (a ref must never resolve to the root
60
+ * struct, which shares its name with a deep field via `findDeepName`). A
61
+ * scope-local index includes it: a collapsed single-field union arm boxes its
62
+ * lone field's binding onto the scope node itself (e.g. ants DenoiseImage's
63
+ * `correctedOutputFileName` arm), and that binding - with the field's access
64
+ * path and the arm's variant gate - is exactly what the arm's output ref needs.
65
+ */
66
+ function indexBindingsByName(
67
+ root: Expr,
68
+ resolve: (n: Expr) => Binding | undefined,
69
+ includeRoot = false,
70
+ ): NameIndex {
71
+ const byNameDepth = new Map<string, { binding: Binding; depth: number }>();
72
+ function walk(node: Expr, depth: number): void {
73
+ const binding = resolve(node);
74
+ if (binding && (depth > 0 || includeRoot)) {
75
+ const existing = byNameDepth.get(binding.name);
76
+ if (!existing || preferCandidate({ binding, depth }, existing)) {
77
+ byNameDepth.set(binding.name, { binding, depth });
78
+ }
79
+ }
80
+ switch (node.kind) {
81
+ case "sequence":
82
+ for (const child of node.attrs.nodes) walk(child, depth + 1);
83
+ break;
84
+ case "optional":
85
+ case "repeat":
86
+ walk(node.attrs.node, depth + 1);
87
+ break;
88
+ case "alternative":
89
+ for (const alt of node.attrs.alts) walk(alt, depth + 1);
90
+ break;
91
+ }
92
+ }
93
+ walk(root, 0);
94
+ const byName = new Map<string, Binding>();
95
+ for (const [name, { binding }] of byNameDepth) byName.set(name, binding);
96
+ return { byName };
97
+ }
98
+
99
+ /**
100
+ * Decide whether `cand` should replace `existing` for a shared name. An
101
+ * interpolable binding always beats a structured one (the struct/scalar name
102
+ * collision); otherwise the shallowest binding wins, as before.
103
+ */
104
+ function preferCandidate(
105
+ cand: { binding: Binding; depth: number },
106
+ existing: { binding: Binding; depth: number },
107
+ ): boolean {
108
+ const candLeaf = isInterpolable(cand.binding.type);
109
+ const exLeaf = isInterpolable(existing.binding.type);
110
+ if (candLeaf !== exLeaf) return candLeaf;
111
+ return cand.depth < existing.depth;
112
+ }
113
+
114
+ /**
115
+ * Walk the IR collecting `(node, outputs)` pairs in tree order. Outputs attach
116
+ * to struct/sequence nodes by frontend convention.
117
+ */
118
+ function collectScopes(root: Expr): { node: Expr; outputs: Output[] }[] {
119
+ const out: { node: Expr; outputs: Output[] }[] = [];
120
+ function walk(node: Expr): void {
121
+ if (node.meta?.outputs?.length) out.push({ node, outputs: node.meta.outputs });
122
+ switch (node.kind) {
123
+ case "sequence":
124
+ for (const child of node.attrs.nodes) walk(child);
125
+ break;
126
+ case "optional":
127
+ case "repeat":
128
+ walk(node.attrs.node);
129
+ break;
130
+ case "alternative":
131
+ for (const alt of node.attrs.alts) walk(alt);
132
+ break;
133
+ }
134
+ }
135
+ walk(root);
136
+ return out;
137
+ }
138
+
139
+ function resolveOne(
140
+ output: Output,
141
+ index: number,
142
+ localNames: NameIndex,
143
+ globalNames: NameIndex,
144
+ ): { resolved: ResolvedOutput; errors: OutputDiagnostic[] } {
145
+ const errors: OutputDiagnostic[] = [];
146
+ const name = effectiveOutputName(output, index);
147
+ const tokens: ResolvedToken[] = [];
148
+ for (const token of output.tokens) {
149
+ if (token.kind === "literal") {
150
+ tokens.push({ kind: "literal", value: token.value });
151
+ continue;
152
+ }
153
+ // Prefer a binding declared within the output's own scope subtree. Union
154
+ // arms duplicate field names across scopes (each arm is its own scope), so
155
+ // the global index alone would resolve an arm's output ref to a sibling
156
+ // arm's binding, mixing contradictory variant gates into one output. Fall
157
+ // back to the global index for refs to bindings outside the local subtree.
158
+ const binding =
159
+ localNames.byName.get(token.target.name) ?? globalNames.byName.get(token.target.name);
160
+ if (!binding) {
161
+ errors.push({
162
+ output: name,
163
+ level: "error",
164
+ message: `token ref '${token.target.name}' has no binding with that name (frontend produced an inconsistent output spec)`,
165
+ });
166
+ continue;
167
+ }
168
+ tokens.push({
169
+ kind: "ref",
170
+ binding: binding.id,
171
+ ...(token.stripExtensions && { stripExtensions: token.stripExtensions }),
172
+ ...(token.fallback !== undefined && { fallback: token.fallback }),
173
+ });
174
+ }
175
+ const resolved: ResolvedOutput = {
176
+ name,
177
+ ...(output.doc && { doc: output.doc }),
178
+ tokens,
179
+ ...(output.mediaTypes && { mediaTypes: output.mediaTypes }),
180
+ };
181
+ return { resolved, errors };
182
+ }
183
+
184
+ /**
185
+ * Translate each `NodeMeta.outputs` entry against the binding registry,
186
+ * grouped by the declaring struct binding (the "scope"). The solver forces a
187
+ * binding on every output-carrying sequence, so an output without a scope
188
+ * binding indicates a frontend bug (outputs attached to a non-sequence
189
+ * node) - it is reported as a diagnostic and dropped.
190
+ */
191
+ export function resolveOutputs(root: Expr, solved: SolveResult): OutputResolution {
192
+ const names = indexBindingsByName(root, solved.resolve);
193
+ const collected = collectScopes(root);
194
+
195
+ const byScope = new Map<BindingId, OutputScope>();
196
+ const errors: OutputDiagnostic[] = [];
197
+ const warnings: OutputDiagnostic[] = [];
198
+
199
+ let outputIndex = 0;
200
+ for (const { node, outputs } of collected) {
201
+ const scopeBinding = solved.resolve(node);
202
+ if (!scopeBinding) {
203
+ for (const output of outputs) {
204
+ const name = effectiveOutputName(output, outputIndex++);
205
+ errors.push({
206
+ output: name,
207
+ level: "error",
208
+ message: `output '${name}' is attached to a node without a binding (frontends should attach outputs to struct sequences)`,
209
+ });
210
+ }
211
+ continue;
212
+ }
213
+ let bucket = byScope.get(scopeBinding.id);
214
+ if (!bucket) {
215
+ bucket = { scope: scopeBinding.id, outputs: [] };
216
+ byScope.set(scopeBinding.id, bucket);
217
+ }
218
+ // Index only the bindings within this scope's subtree (including the scope
219
+ // node's own binding), so an output ref resolves to the field declared in
220
+ // the same scope (union arm) before falling back to the global index.
221
+ const localNames = indexBindingsByName(node, solved.resolve, true);
222
+ for (const output of outputs) {
223
+ const { resolved, errors: outErrors } = resolveOne(output, outputIndex++, localNames, names);
224
+ errors.push(...outErrors);
225
+ bucket.outputs.push(resolved);
226
+ }
227
+ }
228
+
229
+ return {
230
+ scopes: Array.from(byScope.values()),
231
+ diagnostics: { errors, warnings },
232
+ };
233
+ }
@@ -0,0 +1,319 @@
1
+ import type { Binding, BindingId, BoundType, GateAtom, SolveResult } from "../bindings/index.js";
2
+ import { createRegistry } from "../bindings/index.js";
3
+ import type { Expr, Literal } from "../ir/index.js";
4
+ import { assignAccessPaths } from "./assign-access.js";
5
+
6
+ export interface SolveOptions {
7
+ namingStrategy?: NamingStrategy;
8
+ }
9
+
10
+ export interface NamingStrategy {
11
+ getName: (node: Expr, path: string[]) => string;
12
+ generateId: () => BindingId;
13
+ }
14
+
15
+ // Shared helper for deep name search
16
+ function findDeepName(node: Expr): string | undefined {
17
+ if (node.meta?.name) return node.meta.name;
18
+
19
+ if (node.kind === "optional" || node.kind === "repeat") {
20
+ return findDeepName(node.attrs.node);
21
+ }
22
+
23
+ if (node.kind === "sequence") {
24
+ return node.attrs.nodes
25
+ .filter((n) => n.kind !== "literal")
26
+ .map(findDeepName)
27
+ .find(Boolean);
28
+ }
29
+
30
+ return undefined;
31
+ }
32
+
33
+ export function defaultNamingStrategy(): NamingStrategy {
34
+ let counter = 0;
35
+
36
+ return {
37
+ getName: (node, path) => findDeepName(node) ?? path[path.length - 1] ?? `param_${counter++}`,
38
+ generateId: () => `binding_${counter++}`,
39
+ };
40
+ }
41
+
42
+ // Helper to check if alternative should collapse to bool
43
+ function isBooleanLiteralPair(variants: Array<{ type: BoundType }>): boolean {
44
+ if (variants.length !== 2 || !variants.every((v) => v.type.kind === "literal")) {
45
+ return false;
46
+ }
47
+ const [a, b] = variants.map((v) => (v.type.kind === "literal" ? v.type.value : null));
48
+ return (
49
+ (a === 0 && b === 1) ||
50
+ (a === 1 && b === 0) ||
51
+ (a === "0" && b === "1") ||
52
+ (a === "1" && b === "0") ||
53
+ (a === "false" && b === "true") ||
54
+ (a === "true" && b === "false")
55
+ );
56
+ }
57
+
58
+ // Helper to create literal bound type from IR literal
59
+ function literalFromNode(node: Literal): BoundType {
60
+ const str = node.attrs.str;
61
+ const num = Number(str);
62
+ const isCleanInt = Number.isInteger(num) && !Number.isNaN(num) && String(num) === str;
63
+ return { kind: "literal", value: isCleanInt ? num : str };
64
+ }
65
+
66
+ /**
67
+ * Name to give the single wrapped field when a non-struct variant gets boxed
68
+ * into a discriminated struct. For a sequence arm the wrapped value is the
69
+ * inner parameter (`seq(lit("convert"), path{src})` -> `src`), so look past the
70
+ * arm's own (variant) name; otherwise use the value's own deep name.
71
+ */
72
+ function innerParamName(armNode: Expr): string {
73
+ if (armNode.kind === "sequence") {
74
+ const inner = armNode.attrs.nodes
75
+ .filter((n) => n.kind !== "literal")
76
+ .map(findDeepName)
77
+ .find(Boolean);
78
+ if (inner) return inner;
79
+ }
80
+ return findDeepName(armNode) ?? "value";
81
+ }
82
+
83
+ /**
84
+ * Discriminated form of a variant's type: literal variants discriminate by
85
+ * value (returned unchanged); struct variants get an `@type` field prepended;
86
+ * anything else is boxed into `{ "@type": <name>, <field>: <type> }`. Pure -
87
+ * never mutates `type`.
88
+ */
89
+ function taggedVariantType(name: string, type: BoundType, armNode: Expr): BoundType {
90
+ if (type.kind === "literal") return type;
91
+ const tag: BoundType = { kind: "literal", value: name };
92
+ if (type.kind === "struct") return { kind: "struct", fields: { "@type": tag, ...type.fields } };
93
+ return { kind: "struct", fields: { "@type": tag, [innerParamName(armNode)]: type } };
94
+ }
95
+
96
+ export function solve(expr: Expr, options?: SolveOptions): SolveResult {
97
+ const strategy = options?.namingStrategy ?? defaultNamingStrategy();
98
+ const registry = createRegistry();
99
+ const nodeToBinding = new WeakMap<Expr, Binding>();
100
+
101
+ // Wrapper bindings (optional/repeat/alternative) need an id BEFORE recursing
102
+ // into children so the child's `gate` can reference it. Pre-allocate the id
103
+ // here, then materialize the binding with its computed type after the
104
+ // recursion settles.
105
+ function preallocate(): BindingId {
106
+ return strategy.generateId();
107
+ }
108
+
109
+ function registerBinding(
110
+ id: BindingId,
111
+ node: Expr,
112
+ name: string,
113
+ type: BoundType,
114
+ gate: GateAtom[],
115
+ ): Binding {
116
+ // `access` is filled by `assignAccessPaths` once all types have settled
117
+ // (sequence collapse-vs-struct and union arm retyping are only known after
118
+ // the full recursion). Start empty so the field is always present.
119
+ const binding: Binding = { id, node, name, type, gate, access: [] };
120
+ registry.set(id, binding);
121
+ nodeToBinding.set(node, binding);
122
+ return binding;
123
+ }
124
+
125
+ function createBinding(node: Expr, name: string, type: BoundType, gate: GateAtom[]): Binding {
126
+ return registerBinding(strategy.generateId(), node, name, type, gate);
127
+ }
128
+
129
+ function solveNode(node: Expr, path: string[], gate: GateAtom[]): BoundType | null {
130
+ const name = strategy.getName(node, path);
131
+
132
+ switch (node.kind) {
133
+ case "literal":
134
+ return null;
135
+
136
+ case "optional": {
137
+ const id = preallocate();
138
+ const childGate = [...gate, { kind: "present" as const, binding: id }];
139
+ const inner = solveNode(node.attrs.node, [...path, name], childGate);
140
+ const type: BoundType = inner === null ? { kind: "bool" } : { kind: "optional", inner };
141
+ registerBinding(id, node, name, type, gate);
142
+ return type;
143
+ }
144
+
145
+ case "repeat": {
146
+ const id = preallocate();
147
+ // We don't yet know if the inner collapses to a count (no inner
148
+ // binding -> repeat-of-literal) or to a list. Optimistically tag the
149
+ // child gate as `iter`; if the inner is null we replace the wrapper
150
+ // type with `count` and the iter atom never reaches a real binding
151
+ // (no inner binding consumes the gate).
152
+ const childGate = [...gate, { kind: "iter" as const, binding: id }];
153
+ const inner = solveNode(node.attrs.node, [...path, name], childGate);
154
+ const type: BoundType = inner === null ? { kind: "count" } : { kind: "list", item: inner };
155
+ registerBinding(id, node, name, type, gate);
156
+ return type;
157
+ }
158
+
159
+ case "sequence": {
160
+ const fields: Record<string, BoundType> = {};
161
+ // Track the single binding-bearing child so the collapse check below can
162
+ // inspect its node kind (only meaningful when exactly one field exists).
163
+ let soleFieldChild: Expr | undefined;
164
+ for (const child of node.attrs.nodes) {
165
+ const childName = strategy.getName(child, path);
166
+ const childType = solveNode(child, [...path, childName], gate);
167
+ if (childType !== null) {
168
+ fields[childName] = childType;
169
+ soleFieldChild = child;
170
+ }
171
+ }
172
+ // A sequence that carries `meta.outputs` must always produce a binding,
173
+ // even when it would otherwise collapse - that binding is the scope key
174
+ // for the outputs declared on it. Empty- and single-field collapses
175
+ // would otherwise leave the scope unbound and the outputs orphaned.
176
+ const hasOutputs = node.meta?.outputs && node.meta.outputs.length > 0;
177
+ if (Object.keys(fields).length === 0) {
178
+ if (hasOutputs) {
179
+ const type: BoundType = { kind: "struct", fields: {} };
180
+ createBinding(node, name, type, gate);
181
+ return type;
182
+ }
183
+ return null;
184
+ }
185
+ // Collapse a single-field sequence to that field - e.g. `-x <val>`
186
+ // (`seq(lit("-x"), str)`) becomes just the value, so the flag and its
187
+ // one value read as a single optional/required parameter.
188
+ //
189
+ // EXCEPTION: when the field comes from an `optional` child, the
190
+ // sequence's own literal (e.g. `-whole-file`) can be present while the
191
+ // optional sub-field (e.g. `-demean`) is absent. Collapsing would
192
+ // conflate those two independent optional states and drop the sub-field
193
+ // (it would have no struct to live on). Keep such a sequence a struct so
194
+ // the sub-field stays addressable. A `repeat`/scalar/alternative child
195
+ // is tied 1:1 to the flag's presence, so collapsing those stays correct.
196
+ if (
197
+ Object.keys(fields).length === 1 &&
198
+ !hasOutputs &&
199
+ soleFieldChild?.kind !== "optional"
200
+ ) {
201
+ return Object.values(fields)[0]!;
202
+ }
203
+ const type: BoundType = { kind: "struct", fields };
204
+ createBinding(node, name, type, gate);
205
+ return type;
206
+ }
207
+
208
+ case "alternative": {
209
+ const id = preallocate();
210
+ // Resolve each arm's variant name first so child gates can carry it.
211
+ // `variantTag` (the sub-command id) is preferred over `name`: a
212
+ // single-field sub-command collapses onto its inner field, whose `name`
213
+ // wins, so `name` alone would derive the tag from the inner field's id
214
+ // (two distinct sub-commands wrapping a same-named field would collide).
215
+ const armNames = node.attrs.alts.map((alt, i) => {
216
+ if (alt.meta?.variantTag) return alt.meta.variantTag;
217
+ if (alt.meta?.name) return alt.meta.name;
218
+ if (alt.kind === "literal") return alt.attrs.str.replace(/^-+/, "");
219
+ return `variant_${i}`;
220
+ });
221
+
222
+ const variants = node.attrs.alts.map((alt, i) => {
223
+ const variantName = armNames[i]!;
224
+ const childGate = [
225
+ ...gate,
226
+ { kind: "variant" as const, binding: id, variant: variantName },
227
+ ];
228
+ const childType =
229
+ solveNode(alt, [...path, `variant_${i}`], childGate) ??
230
+ (alt.kind === "literal" ? literalFromNode(alt) : { kind: "bool" as const });
231
+ return { name: variantName, type: childType, node: alt };
232
+ });
233
+
234
+ // Pattern: boolean pair -> bool. The pre-allocated variant atoms in
235
+ // child gates are unreached (literal arms produce no bindings).
236
+ if (isBooleanLiteralPair(variants)) {
237
+ const type: BoundType = { kind: "bool" };
238
+ registerBinding(id, node, name, type, gate);
239
+ return type;
240
+ }
241
+
242
+ // Discriminate each variant. When an arm carries its own binding (a
243
+ // multi-field struct), retype it so that binding and the union agree;
244
+ // collapsed single-field arms keep their inner binding and the boxed
245
+ // form lives only in the union's `variants`.
246
+ for (const v of variants) {
247
+ v.type = taggedVariantType(v.name, v.type, v.node);
248
+ const armBinding = nodeToBinding.get(v.node);
249
+ if (armBinding) armBinding.type = v.type;
250
+ }
251
+
252
+ const type: BoundType = {
253
+ kind: "union",
254
+ variants: variants.map(({ name, type }) => ({ name, type })),
255
+ };
256
+ registerBinding(id, node, name, type, gate);
257
+ return type;
258
+ }
259
+
260
+ case "int":
261
+ case "float":
262
+ case "str":
263
+ case "path": {
264
+ const type: BoundType = { kind: "scalar", scalar: node.kind };
265
+ createBinding(node, name, type, gate);
266
+ return type;
267
+ }
268
+ }
269
+ }
270
+
271
+ const rootType = solveNode(expr, [], []);
272
+
273
+ // Ensure a root binding always exists, even when the sequence collapsed
274
+ // (0 fields -> empty struct, 1 field that's not already a struct -> wrap in single-field struct)
275
+ if (!nodeToBinding.has(expr) && expr.kind === "sequence") {
276
+ const name = strategy.getName(expr, []);
277
+ if (rootType === null) {
278
+ createBinding(expr, name, { kind: "struct", fields: {} }, []);
279
+ } else if (rootType.kind === "struct") {
280
+ // Already a struct (e.g. joined seq with 2+ fields) - use it directly
281
+ createBinding(expr, name, rootType, []);
282
+ } else {
283
+ // Single scalar/optional/list field was collapsed - wrap it in a struct.
284
+ // The field's binding may not be a direct child: a nested sequence that
285
+ // collapsed (e.g. one preserved by flatten to keep its `meta.doc`) leaves
286
+ // the binding buried one or more levels down. Search through collapsed
287
+ // sequences for it. Using `binding.name` as the field name keeps the
288
+ // struct field aligned with the access path the backends render
289
+ // (`params.<binding.name>`).
290
+ const findCollapsedBinding = (node: Expr): Binding | undefined => {
291
+ const b = nodeToBinding.get(node);
292
+ if (b) return b;
293
+ // Only sequences collapse without leaving a binding; optional/repeat/
294
+ // alternative always register one, so no need to descend into them.
295
+ if (node.kind === "sequence") {
296
+ for (const child of node.attrs.nodes) {
297
+ const found = findCollapsedBinding(child);
298
+ if (found) return found;
299
+ }
300
+ }
301
+ return undefined;
302
+ };
303
+ const childName = expr.attrs.nodes.map(findCollapsedBinding).find(Boolean)?.name;
304
+ if (childName) {
305
+ createBinding(expr, name, { kind: "struct", fields: { [childName]: rootType } }, []);
306
+ }
307
+ }
308
+ }
309
+
310
+ const resolve = (node: Expr) => nodeToBinding.get(node);
311
+
312
+ // Now that every binding's type has settled (sequence collapse, union arm
313
+ // retyping, root fixup), walk the IR once more to attach each binding's
314
+ // access path relative to top-level `params`. Backends render these paths
315
+ // instead of re-deriving them.
316
+ assignAccessPaths(expr, resolve);
317
+
318
+ return { bindings: registry, resolve };
319
+ }