@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,76 @@
1
+ import type { Expr } from "../node.js";
2
+ import { PassStatus, type Pass, type PassResult } from "./pass.js";
3
+
4
+ /**
5
+ * Remove empty nodes:
6
+ * - seq() → removed
7
+ * - alt() → removed
8
+ * - opt(seq()) → removed
9
+ * - rep(alt()) → removed
10
+ */
11
+ export const removeEmpty: Pass = {
12
+ name: "remove-empty",
13
+ apply(expr: Expr): PassResult {
14
+ let changed = false;
15
+
16
+ function isEmpty(node: Expr): boolean {
17
+ return (
18
+ (node.kind === "sequence" && node.attrs.nodes.length === 0) ||
19
+ (node.kind === "alternative" && node.attrs.alts.length === 0)
20
+ );
21
+ }
22
+
23
+ function isRemovable(node: Expr): boolean {
24
+ if (node.meta) return false; // Preserve metadata
25
+ return (
26
+ isEmpty(node) ||
27
+ ((node.kind === "optional" || node.kind === "repeat") && isEmpty(node.attrs.node))
28
+ );
29
+ }
30
+
31
+ function visit(node: Expr): Expr {
32
+ switch (node.kind) {
33
+ case "sequence": {
34
+ const nodes = node.attrs.nodes.map(visit).filter((child) => {
35
+ const removable = isRemovable(child);
36
+ if (removable) changed = true;
37
+ return !removable;
38
+ });
39
+ return { ...node, attrs: { ...node.attrs, nodes } };
40
+ }
41
+
42
+ case "alternative": {
43
+ const alts = node.attrs.alts.map(visit).filter((child) => {
44
+ const removable = isRemovable(child);
45
+ if (removable) changed = true;
46
+ return !removable;
47
+ });
48
+ return { ...node, attrs: { ...node.attrs, alts } };
49
+ }
50
+
51
+ case "optional":
52
+ return { ...node, attrs: { node: visit(node.attrs.node) } };
53
+
54
+ case "repeat":
55
+ return { ...node, attrs: { ...node.attrs, node: visit(node.attrs.node) } };
56
+
57
+ case "literal":
58
+ case "int":
59
+ case "float":
60
+ case "str":
61
+ case "path":
62
+ return node;
63
+
64
+ default: {
65
+ const _exhaustive: never = node;
66
+ return node;
67
+ }
68
+ }
69
+ }
70
+
71
+ return {
72
+ expr: visit(expr),
73
+ status: changed ? PassStatus.Changed : PassStatus.Unchanged,
74
+ };
75
+ },
76
+ };
@@ -0,0 +1,179 @@
1
+ import type { NodeMeta } from "../meta.js";
2
+ import type { Expr } from "../node.js";
3
+ import { PassStatus, type Pass, type PassResult } from "./pass.js";
4
+
5
+ /**
6
+ * Merge two `NodeMeta` when collapsing a wrapper layer (the child is the
7
+ * inner/surviving node). Name, doc fields and defaultValue take the innermost
8
+ * value; `outputs` from both layers are concatenated so an output attached to
9
+ * a collapsed wrapper survives on whatever node absorbs it.
10
+ */
11
+ function mergeMeta(parent?: NodeMeta, child?: NodeMeta): NodeMeta | undefined {
12
+ if (!parent && !child) return undefined;
13
+ if (!parent) return child;
14
+ if (!child) return parent;
15
+
16
+ const merged: NodeMeta = {};
17
+
18
+ const name = child.name ?? parent.name;
19
+ if (name !== undefined) merged.name = name;
20
+
21
+ // The variant tag is the union discriminator (the sub-command id); unlike
22
+ // `name` it must survive a single-field sub-command collapsing onto its inner
23
+ // field (whose `name` wins above), so the `@type` stays the sub-command id
24
+ // rather than the inner field's id. The parent (the sub-command wrapper)
25
+ // carries it; the inner field has none.
26
+ const variantTag = parent.variantTag ?? child.variantTag;
27
+ if (variantTag !== undefined) merged.variantTag = variantTag;
28
+
29
+ const defaultValue = child.defaultValue ?? parent.defaultValue;
30
+ if (defaultValue !== undefined) merged.defaultValue = defaultValue;
31
+
32
+ if (parent.doc || child.doc) {
33
+ merged.doc = {
34
+ title: child.doc?.title ?? parent.doc?.title,
35
+ description: child.doc?.description ?? parent.doc?.description,
36
+ authors: [...(parent.doc?.authors ?? []), ...(child.doc?.authors ?? [])],
37
+ literature: [...(parent.doc?.literature ?? []), ...(child.doc?.literature ?? [])],
38
+ urls: [...(parent.doc?.urls ?? []), ...(child.doc?.urls ?? [])],
39
+ comment: child.doc?.comment ?? parent.doc?.comment,
40
+ };
41
+ }
42
+
43
+ const outputs = [...(parent.outputs ?? []), ...(child.outputs ?? [])];
44
+ if (outputs.length > 0) merged.outputs = outputs;
45
+
46
+ return merged;
47
+ }
48
+
49
+ /**
50
+ * Simplify passes:
51
+ * - optional(optional(T)) -> optional(T)
52
+ * - repeat(repeat(T)) -> repeat(T) with merged constraints
53
+ * - seq(T) -> T, alt(T) -> T (singleton unwrapping)
54
+ * - seq(lit("a"), lit("b")) -> seq(lit("ab")) (merge consecutive literals)
55
+ *
56
+ * Every collapse that drops a wrapper layer merges that layer's `NodeMeta`
57
+ * into the surviving node via `mergeMeta`, so names and attached outputs are
58
+ * never lost.
59
+ */
60
+ export const simplify: Pass = {
61
+ name: "simplify",
62
+ apply(expr: Expr): PassResult {
63
+ let changed = false;
64
+
65
+ function visit(node: Expr): Expr {
66
+ switch (node.kind) {
67
+ case "optional": {
68
+ const inner = visit(node.attrs.node);
69
+
70
+ // optional(optional(T)) -> optional(T)
71
+ if (inner.kind === "optional") {
72
+ changed = true;
73
+ return {
74
+ ...node,
75
+ attrs: { node: inner.attrs.node },
76
+ meta: mergeMeta(node.meta, inner.meta),
77
+ };
78
+ }
79
+
80
+ return { ...node, attrs: { node: inner } };
81
+ }
82
+
83
+ case "repeat": {
84
+ const inner = visit(node.attrs.node);
85
+
86
+ // repeat(repeat(T)) -> repeat(T) with merged constraints
87
+ if (inner.kind === "repeat") {
88
+ changed = true;
89
+ return {
90
+ ...node,
91
+ attrs: {
92
+ node: inner.attrs.node,
93
+ join: node.attrs.join ?? inner.attrs.join,
94
+ countMin: Math.max(node.attrs.countMin ?? 0, inner.attrs.countMin ?? 0),
95
+ countMax:
96
+ node.attrs.countMax === undefined || inner.attrs.countMax === undefined
97
+ ? undefined
98
+ : Math.min(node.attrs.countMax, inner.attrs.countMax),
99
+ },
100
+ meta: mergeMeta(node.meta, inner.meta),
101
+ };
102
+ }
103
+
104
+ return { ...node, attrs: { ...node.attrs, node: inner } };
105
+ }
106
+
107
+ case "sequence": {
108
+ const children = node.attrs.nodes.map(visit);
109
+
110
+ // Merge consecutive metadata-less literals, but only in an explicit
111
+ // concatenation context (`join === ""`). The merge is separator-free
112
+ // (`prev.str += child.str`), which only matches the semantics of an
113
+ // empty join. A sequence with no join joins its children with a space
114
+ // (they become separate argv tokens at the top level), so merging
115
+ // adjacent literals there would wrongly fuse two arguments into one
116
+ // (e.g. `seq(lit("wb_command"), lit("-foo"))` -> `"wb_command-foo"`).
117
+ // Inside a join context the backend concatenates anyway, so leaving
118
+ // such literals unmerged stays correct (just one node larger).
119
+ const nodes: Expr[] = [];
120
+ for (const child of children) {
121
+ const prev = nodes[nodes.length - 1];
122
+ if (
123
+ prev?.kind === "literal" &&
124
+ child.kind === "literal" &&
125
+ !prev.meta &&
126
+ !child.meta &&
127
+ node.attrs.join === ""
128
+ ) {
129
+ changed = true;
130
+ prev.attrs.str += child.attrs.str;
131
+ } else {
132
+ nodes.push(child);
133
+ }
134
+ }
135
+
136
+ // seq(T) -> T, carrying the seq's meta down onto the child.
137
+ if (nodes.length === 1) {
138
+ const child = nodes[0]!;
139
+ changed = true;
140
+ const mergedMeta = mergeMeta(node.meta, child.meta);
141
+ return mergedMeta ? { ...child, meta: mergedMeta } : child;
142
+ }
143
+
144
+ return { ...node, attrs: { ...node.attrs, nodes } };
145
+ }
146
+
147
+ case "alternative": {
148
+ const alts = node.attrs.alts.map(visit);
149
+
150
+ // alt(T) -> T. Only when the alt carries no metadata of its own
151
+ // (otherwise the collapse would orphan it).
152
+ if (alts.length === 1 && !node.meta) {
153
+ changed = true;
154
+ return alts[0]!;
155
+ }
156
+
157
+ return { ...node, attrs: { ...node.attrs, alts } };
158
+ }
159
+
160
+ case "literal":
161
+ case "int":
162
+ case "float":
163
+ case "str":
164
+ case "path":
165
+ return node;
166
+
167
+ default: {
168
+ const _exhaustive: never = node;
169
+ return node;
170
+ }
171
+ }
172
+ }
173
+
174
+ return {
175
+ expr: visit(expr),
176
+ status: changed ? PassStatus.Changed : PassStatus.Unchanged,
177
+ };
178
+ },
179
+ };
@@ -0,0 +1,15 @@
1
+ /** Scalar type kinds for terminal nodes */
2
+ export type ScalarKind = "int" | "float" | "str" | "path";
3
+
4
+ /** Media type identifier (MIME type) */
5
+ export type MediaTypeIdentifier = string;
6
+
7
+ /** Human-facing documentation metadata */
8
+ export interface Documentation {
9
+ title?: string;
10
+ description?: string;
11
+ authors?: string[];
12
+ literature?: string[];
13
+ urls?: string[];
14
+ comment?: string;
15
+ }
@@ -0,0 +1,36 @@
1
+ import type {
2
+ BindingRegistry,
3
+ OutputScope,
4
+ OutputValidationResult,
5
+ SolveResult,
6
+ } from "../bindings/index.js";
7
+ import type { AppMeta, Expr } from "../ir/index.js";
8
+ import type { OutputResolution } from "../solver/index.js";
9
+ import type { PackageMeta, ProjectMeta } from "./types.js";
10
+
11
+ export interface CodegenContext {
12
+ expr: Expr;
13
+ bindings: BindingRegistry;
14
+ resolve: SolveResult["resolve"];
15
+ outputScopes: OutputScope[];
16
+ outputDiagnostics: OutputValidationResult;
17
+ app?: AppMeta;
18
+ package?: PackageMeta;
19
+ project?: ProjectMeta;
20
+ }
21
+
22
+ export function createContext(
23
+ expr: Expr,
24
+ solveResult: SolveResult,
25
+ outputs: OutputResolution,
26
+ meta?: { app?: AppMeta; package?: PackageMeta; project?: ProjectMeta },
27
+ ): CodegenContext {
28
+ return {
29
+ expr,
30
+ bindings: solveResult.bindings,
31
+ resolve: solveResult.resolve,
32
+ outputScopes: outputs.scopes,
33
+ outputDiagnostics: outputs.diagnostics,
34
+ ...meta,
35
+ };
36
+ }
@@ -0,0 +1,3 @@
1
+ export type { CodegenContext } from "./context.js";
2
+ export { createContext } from "./context.js";
3
+ export type { PackageMeta, ProjectMeta } from "./types.js";
@@ -0,0 +1,15 @@
1
+ import type { Documentation } from "../ir/index.js";
2
+
3
+ export interface ProjectMeta {
4
+ name?: string;
5
+ version?: string;
6
+ doc?: Documentation;
7
+ license?: Documentation;
8
+ }
9
+
10
+ export interface PackageMeta {
11
+ name?: string;
12
+ version?: string;
13
+ docker?: string;
14
+ doc?: Documentation;
15
+ }
@@ -0,0 +1,218 @@
1
+ import type { AccessPath, AccessSegment, Binding, BoundType } from "../bindings/index.js";
2
+ import type { Expr } from "../ir/index.js";
3
+
4
+ /**
5
+ * Attach `Binding.access` to every binding by walking the IR once, threading
6
+ * the same scope state the backend walkers used to re-derive independently
7
+ * (`arg-builder.walk` and `outputs-emit.walkAccess`). Producing the paths here,
8
+ * after types settle, collapses both walkers into pure `renderAccess` lookups
9
+ * and removes the drift class between them.
10
+ *
11
+ * The walk faithfully mirrors the (post-`ca61dc8`) arg-builder/outputs-emit
12
+ * scope handling for sequence/optional/alternative, and adopts the
13
+ * arg-builder's `repeat` recursion (which `walkAccess` lacked) so bindings
14
+ * inside a `repeat`-of-list get an `iter`-rooted path instead of being absent.
15
+ */
16
+ export function assignAccessPaths(expr: Expr, resolve: (node: Expr) => Binding | undefined): void {
17
+ const rootBinding = resolve(expr);
18
+ walk(expr, resolve, { path: [], currentStructType: rootBinding?.type });
19
+ }
20
+
21
+ /**
22
+ * Scope state threaded down the walk, mirroring the arg-builder's `ArgContext`
23
+ * but carrying structured paths instead of rendered strings.
24
+ */
25
+ interface AccessCtx {
26
+ /** Access path of the enclosing struct scope (the base for child fields). */
27
+ path: AccessPath;
28
+ /**
29
+ * When set, the next binding's value lives at this exact path rather than at
30
+ * `path + field(name)` - the wrapper collapsed the inner value onto its own
31
+ * path (`optional<scalar>`/`optional<bool>`, scalar lists). Mirrors the
32
+ * arg-builder's `directValue`. Not a rendered segment: it is "inherit the
33
+ * parent's path, append nothing".
34
+ */
35
+ directPath?: AccessPath;
36
+ /** The struct type at the current scope level (prevents double-scoping). */
37
+ currentStructType?: BoundType;
38
+ }
39
+
40
+ function field(name: string): AccessSegment {
41
+ return { kind: "field", name };
42
+ }
43
+
44
+ function iter(binding: string): AccessSegment {
45
+ return { kind: "iter", binding };
46
+ }
47
+
48
+ /** The path at which a binding's own value lives in the current scope. */
49
+ function ownAccess(arg: AccessCtx, name: string): AccessPath {
50
+ return arg.directPath ?? [...arg.path, field(name)];
51
+ }
52
+
53
+ /** Whether a BoundType contains a struct that requires scoping when entered. */
54
+ function hasStructScope(type: BoundType): boolean {
55
+ switch (type.kind) {
56
+ case "optional":
57
+ return hasStructScope(type.inner);
58
+ case "list":
59
+ return hasStructScope(type.item);
60
+ case "struct":
61
+ return true;
62
+ default:
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /** Unwrap optional/list to find the inner struct type, if any. */
68
+ function unwrapToStruct(type: BoundType): Extract<BoundType, { kind: "struct" }> | undefined {
69
+ switch (type.kind) {
70
+ case "optional":
71
+ return unwrapToStruct(type.inner);
72
+ case "list":
73
+ return unwrapToStruct(type.item);
74
+ case "struct":
75
+ return type;
76
+ default:
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ function walk(node: Expr, resolve: (node: Expr) => Binding | undefined, arg: AccessCtx): void {
82
+ const binding = resolve(node);
83
+
84
+ switch (node.kind) {
85
+ case "literal":
86
+ return;
87
+
88
+ case "int":
89
+ case "float":
90
+ case "str":
91
+ case "path": {
92
+ if (binding) binding.access = ownAccess(arg, binding.name);
93
+ return;
94
+ }
95
+
96
+ case "sequence": {
97
+ let childArg = arg;
98
+ if (binding && hasStructScope(binding.type) && binding.type !== arg.currentStructType) {
99
+ // A nested struct: children access fields under this binding's path.
100
+ const access: AccessPath = [...arg.path, field(binding.name)];
101
+ binding.access = access;
102
+ childArg = {
103
+ path: access,
104
+ currentStructType: unwrapToStruct(binding.type) ?? arg.currentStructType,
105
+ };
106
+ } else if (binding) {
107
+ // Root struct or a struct already scoped by an enclosing wrapper: the
108
+ // binding reuses the current path (collapse / already-scoped).
109
+ binding.access = arg.directPath ?? arg.path;
110
+ }
111
+ for (const child of node.attrs.nodes) walk(child, resolve, childArg);
112
+ return;
113
+ }
114
+
115
+ case "optional": {
116
+ if (!binding) {
117
+ walk(node.attrs.node, resolve, arg);
118
+ return;
119
+ }
120
+ const access: AccessPath = [...arg.path, field(binding.name)];
121
+ binding.access = access;
122
+ // The inner node, after unwrapping the optional from the binding type.
123
+ const innerType = binding.type.kind === "optional" ? binding.type.inner : binding.type;
124
+ let childArg: AccessCtx;
125
+ if (binding.type === arg.currentStructType) {
126
+ // This optional IS the boxed single-field union variant: a collapsed
127
+ // single-input arm whose type the solver retyped to the variant's
128
+ // struct (so `binding.type === currentStructType`). It maps to the
129
+ // variant's lone field, so the inner value collapses onto this
130
+ // optional's own field access. Without this, the struct-scope branch
131
+ // below would add a spurious extra segment named after the inner IR
132
+ // node (e.g. `params.opts.exponential_options.smoothing_standard_deviation`)
133
+ // instead of the boxed field (`params.opts.exponential_options`). The
134
+ // sequence case guards the same way via its `!== currentStructType` test.
135
+ childArg = { ...arg, directPath: access };
136
+ } else if (innerType.kind === "list") {
137
+ // optional<list>: the inner node is a `repeat` that represents this same
138
+ // value and assigns its own (iter-based) child scope. It must reuse this
139
+ // optional's path rather than append its field again - otherwise a
140
+ // struct-list field whose name matches the optional's collides into a
141
+ // doubled path (`params.config.config`). `directPath` = "inherit this
142
+ // path, append nothing". Scalar lists already reached here via the
143
+ // optional/bool branch below; struct lists previously fell into the
144
+ // `hasStructScope` branch and got the doubled path.
145
+ childArg = { ...arg, directPath: access };
146
+ } else if (hasStructScope(binding.type)) {
147
+ childArg = {
148
+ path: access,
149
+ currentStructType: unwrapToStruct(binding.type) ?? arg.currentStructType,
150
+ };
151
+ } else if (binding.type.kind === "optional" || binding.type.kind === "bool") {
152
+ // Collapsed non-struct: the inner value lives at the optional's path.
153
+ childArg = { ...arg, directPath: access };
154
+ } else {
155
+ childArg = arg;
156
+ }
157
+ walk(node.attrs.node, resolve, childArg);
158
+ return;
159
+ }
160
+
161
+ case "repeat": {
162
+ // The solver always binds repeat nodes, so this is defensive only; unlike
163
+ // optional/alternative there are no children to recurse into without it.
164
+ if (!binding) return;
165
+ // The list/count binding lives at its own access path.
166
+ binding.access = ownAccess(arg, binding.name);
167
+
168
+ if (binding.type.kind === "count") {
169
+ // Count repeats wrap a literal (no inner binding); recurse harmlessly
170
+ // with the scope unchanged, mirroring the arg-builder.
171
+ walk(node.attrs.node, resolve, arg);
172
+ return;
173
+ }
174
+
175
+ // List repeat: inner bindings are iteration-scoped. Reset the base to an
176
+ // `iter` segment bound to this repeat; the renderer substitutes the loop
177
+ // variable. Scalar lists collapse the element onto the loop var directly
178
+ // (directPath), struct lists scope into the element type.
179
+ const itemType = binding.type.kind === "list" ? binding.type.item : undefined;
180
+ const isScalar = !itemType || !hasStructScope(itemType);
181
+ const elementPath: AccessPath = [iter(binding.id)];
182
+ const childArg: AccessCtx = {
183
+ path: elementPath,
184
+ directPath: isScalar ? elementPath : undefined,
185
+ currentStructType:
186
+ !isScalar && itemType?.kind === "struct" ? itemType : arg.currentStructType,
187
+ };
188
+ walk(node.attrs.node, resolve, childArg);
189
+ return;
190
+ }
191
+
192
+ case "alternative": {
193
+ if (!binding) {
194
+ for (const alt of node.attrs.alts) walk(alt, resolve, arg);
195
+ return;
196
+ }
197
+ const access = ownAccess(arg, binding.name);
198
+ binding.access = access;
199
+ const isComplexUnion =
200
+ binding.type.kind === "union" &&
201
+ !binding.type.variants.every((v) => v.type.kind === "literal");
202
+ node.attrs.alts.forEach((alt, i) => {
203
+ if (isComplexUnion && binding.type.kind === "union") {
204
+ // A complex-union variant's fields are accessed via the union's own
205
+ // path (the `@type` discriminant narrows it), so scope into `access`.
206
+ const variantType = binding.type.variants[i]?.type;
207
+ walk(alt, resolve, {
208
+ path: access,
209
+ currentStructType: variantType?.kind === "struct" ? variantType : arg.currentStructType,
210
+ });
211
+ } else {
212
+ walk(alt, resolve, arg);
213
+ }
214
+ });
215
+ return;
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,4 @@
1
+ export type { OutputResolution } from "./resolve-outputs.js";
2
+ export { resolveOutputs } from "./resolve-outputs.js";
3
+ export type { NamingStrategy, SolveOptions } from "./solver.js";
4
+ export { defaultNamingStrategy, solve } from "./solver.js";