@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,131 @@
1
+ import type { AccessPath, BindingId, BoundType } from "../../bindings/index.js";
2
+
3
+ /**
4
+ * Map a BoundType to its Python type expression.
5
+ *
6
+ * `resolve` is the named-type resolver from `collectNamedTypes` - returns the
7
+ * declared name for struct/union types so they can be referenced symbolically
8
+ * rather than inlined.
9
+ *
10
+ * Python target: 3.10+ (uses `X | None`, `list[T]`, `int`/`str`/etc.).
11
+ */
12
+ export function mapType(type: BoundType, resolve: (type: BoundType) => string | undefined): string {
13
+ switch (type.kind) {
14
+ case "scalar":
15
+ return { int: "int", float: "float", str: "str", path: "InputPathType" }[type.scalar];
16
+ case "bool":
17
+ return "bool";
18
+ case "count":
19
+ return "int";
20
+ case "literal":
21
+ return typeof type.value === "string"
22
+ ? `typing.Literal[${pyStr(type.value)}]`
23
+ : `typing.Literal[${type.value}]`;
24
+ case "optional":
25
+ // The solver has no nullable type: `optional` means "omittable" (the key
26
+ // may be absent), never "the value may be None". Omittability is expressed
27
+ // structurally at the field level (`typing.NotRequired[...]`); the value
28
+ // type itself is just the inner type. So in any value position (nested
29
+ // list/union arm, validator messages) we render the inner type with no
30
+ // `| None`.
31
+ return mapType(type.inner, resolve);
32
+ case "list":
33
+ return `list[${mapType(type.item, resolve)}]`;
34
+ case "struct": {
35
+ const name = resolve(type);
36
+ if (name) return name;
37
+ // Fallback: inline as Mapping[str, object]. Real struct types should always
38
+ // resolve to a declared TypedDict.
39
+ return "typing.Mapping[str, object]";
40
+ }
41
+ case "union": {
42
+ const name = resolve(type);
43
+ if (name) return name;
44
+ return type.variants.map((v) => mapType(v.type, resolve)).join(" | ");
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Render a JS default value as a Python literal (signatures, `.get(k, default)`). */
50
+ export function renderPyLiteral(value: string | number | boolean): string {
51
+ if (typeof value === "boolean") return value ? "True" : "False";
52
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "float('nan')";
53
+ return pyStr(value);
54
+ }
55
+
56
+ /** Python double-quoted string literal with minimal escaping. */
57
+ export function pyStr(value: string): string {
58
+ // For any value containing control characters, JSON encoding produces a valid
59
+ // Python double-quoted literal (with the same `\n`/`\t`/`\uXXXX` escapes).
60
+ // Otherwise we hand-escape backslashes and double quotes only.
61
+ for (let i = 0; i < value.length; i++) {
62
+ if (value.charCodeAt(i) < 0x20) return JSON.stringify(value);
63
+ }
64
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
65
+ }
66
+
67
+ /** Options controlling how `renderAccess` renders an access path. */
68
+ export interface RenderAccessOptions {
69
+ /**
70
+ * Render the LAST segment - if it is a `field` - as `base.get(key)` instead of
71
+ * `base[key]`. Used when binding the value of an optional field to a narrowed
72
+ * local: the params factory omits optional-without-default keys entirely (they
73
+ * are `typing.NotRequired`), so a bare subscript would raise `KeyError`. `.get()`
74
+ * yields `None` for an absent key, matching the present-and-None case. (Unlike
75
+ * JS, where `obj[missing]` is `undefined`, Python subscript on a missing key
76
+ * raises - so the structurally-mirrored TS walk needs this read in Python.)
77
+ */
78
+ finalFieldGet?: boolean;
79
+ /**
80
+ * Render the LAST segment - if it is a `field` - as `base.get(key, <default>)`,
81
+ * supplying a fallback value for an absent key. Used for fields that carry a
82
+ * Boutiques default and are read unconditionally (e.g. an output-basename like
83
+ * `maskfile="img_bet"`): the field is `NotRequired`, so a bare subscript would
84
+ * raise `KeyError`, and a bare `.get()` would yield `None` (wrong - the default
85
+ * must be substituted). The string is an already-rendered Python literal.
86
+ * Takes precedence over `finalFieldGet`.
87
+ */
88
+ finalFieldDefault?: string;
89
+ /**
90
+ * Prefix substitutions: maps a rendered access prefix (e.g. `params["cfg"]`) to
91
+ * a local variable name. After each segment is appended, if the rendered prefix
92
+ * so far matches a key, it is replaced by the local. This lets reads of an
93
+ * optional field (and anything nested under it) resolve to the `.get()`-narrowed
94
+ * local bound by the enclosing presence guard - one lookup, absent-safe, and
95
+ * mypy can narrow it (a re-subscript or a fresh `.get()` cannot be narrowed).
96
+ */
97
+ subst?: ReadonlyMap<string, string>;
98
+ }
99
+
100
+ /**
101
+ * Render a solver-assigned `AccessPath` to a Python expression. Starts from
102
+ * `params`; each `field` segment subscripts a key, and each `iter` segment
103
+ * resets the base to the loop variable bound to that repeat binding (resolved
104
+ * via `lookupLoopVar`). Mirrors the TypeScript `renderAccess`.
105
+ */
106
+ export function renderAccess(
107
+ path: AccessPath,
108
+ lookupLoopVar: (binding: BindingId) => string,
109
+ options: RenderAccessOptions = {},
110
+ ): string {
111
+ const { finalFieldGet = false, finalFieldDefault, subst } = options;
112
+ let cur = "params";
113
+ path.forEach((seg, i) => {
114
+ if (seg.kind !== "field") {
115
+ cur = lookupLoopVar(seg.binding);
116
+ } else {
117
+ const isLast = i === path.length - 1;
118
+ if (isLast && finalFieldDefault !== undefined) {
119
+ cur = `${cur}.get(${pyStr(seg.name)}, ${finalFieldDefault})`;
120
+ } else if (isLast && finalFieldGet) {
121
+ cur = `${cur}.get(${pyStr(seg.name)})`;
122
+ } else {
123
+ cur = `${cur}[${pyStr(seg.name)}]`;
124
+ }
125
+ }
126
+ // Swap in a narrowed local if this prefix was bound by an enclosing guard.
127
+ const sub = subst?.get(cur);
128
+ if (sub !== undefined) cur = sub;
129
+ });
130
+ return cur;
131
+ }
@@ -0,0 +1,8 @@
1
+ // Re-export shared utilities for convenience. All logic lives in shared
2
+ // backend modules.
3
+
4
+ export { structKey, unionKey } from "../type-keys.js";
5
+ export type { NamedType } from "../collect-named-types.js";
6
+ export { collectNamedTypes, resolveTypeName } from "../collect-named-types.js";
7
+ export type { FieldInfo } from "../collect-field-info.js";
8
+ export { collectFieldInfo } from "../collect-field-info.js";
@@ -0,0 +1,356 @@
1
+ import type { BoundType } from "../../bindings/index.js";
2
+ import type { Expr } from "../../ir/index.js";
3
+ import type { CodegenContext } from "../../manifest/index.js";
4
+ import type { CodeBuilder } from "../code-builder.js";
5
+ import type { Scope } from "../scope.js";
6
+ import {
7
+ findAlternativeNode,
8
+ findRangeNode,
9
+ findRepeatNode,
10
+ structFields,
11
+ } from "../validate-walk.js";
12
+ import { structVariants } from "../union-variants.js";
13
+ import { emitDocstring } from "./emit.js";
14
+ import { mapType, pyStr } from "./typemap.js";
15
+
16
+ /**
17
+ * Emit a `<tool>_validate(params)` function that walks the solved root
18
+ * `BoundType` and raises `StyxValidationError` on invalid input. Mirrors the
19
+ * runtime validation v1 niwrap emits, but as a single inlined function (the v2
20
+ * backends inline `_cargs`/`_outputs` too, rather than v1's per-struct
21
+ * dispatch).
22
+ *
23
+ * Constraints checked: presence (required fields), `isinstance`, int/float
24
+ * range, list length, union `@type` membership + per-variant recursion, and
25
+ * nested struct recursion.
26
+ */
27
+
28
+ type Resolve = (t: BoundType) => string | undefined;
29
+
30
+ interface Emit {
31
+ ctx: CodegenContext;
32
+ resolve: Resolve;
33
+ /** Per-function scope for generated locals (loop vars), reserving `params`. */
34
+ scope: Scope;
35
+ cb: CodeBuilder;
36
+ }
37
+
38
+ export function emitValidate(
39
+ ctx: CodegenContext,
40
+ rootType: BoundType,
41
+ rootNode: Expr,
42
+ paramsType: string,
43
+ funcName: string,
44
+ resolve: Resolve,
45
+ scope: Scope,
46
+ cb: CodeBuilder,
47
+ ): void {
48
+ const e: Emit = { ctx, resolve, scope: scope.child(["params"]), cb };
49
+ // Untyped input: a boundary guard usable on a parsed dict / config blob, not
50
+ // just an already-typed value (mirrors styx v1's `typing.Any` validator).
51
+ cb.line(`def ${funcName}(params: typing.Any) -> None:`);
52
+ cb.indent(() => {
53
+ emitDocstring(
54
+ cb,
55
+ `Validate untrusted parameters. Raises StyxValidationError if \`params\` is not a valid ${paramsType}.`,
56
+ );
57
+ emitRoot(e, rootType, rootNode);
58
+ });
59
+ }
60
+
61
+ function emitRoot(e: Emit, rootType: BoundType, rootNode: Expr): void {
62
+ if (rootType.kind === "struct") {
63
+ e.cb.line("if params is None or not isinstance(params, dict):");
64
+ e.cb.indent(() => raise(e, wrongObjectTypeMsg("params")));
65
+ for (const f of structFields(e.ctx, rootType, rootNode)) {
66
+ emitField(e, f.name, f.type, f.node, f.hasDefault, "params");
67
+ }
68
+ } else if (rootType.kind === "union") {
69
+ emitUnion(e, rootType, rootNode, "params", "params");
70
+ } else {
71
+ emitValue(e, rootType, rootNode, "params", "params", expectedType(e, rootType));
72
+ }
73
+ }
74
+
75
+ /** Validate one struct field, handling required-presence vs optional gating. */
76
+ function emitField(
77
+ e: Emit,
78
+ name: string,
79
+ fieldType: BoundType,
80
+ node: Expr | undefined,
81
+ hasDefault: boolean,
82
+ base: string,
83
+ ): void {
84
+ // `@type` and other fixed literals have no user-supplied runtime value.
85
+ if (fieldType.kind === "literal") return;
86
+
87
+ const getExpr = `${base}.get(${pyStr(name)}, None)`;
88
+ const idxExpr = `${base}[${pyStr(name)}]`;
89
+ const expected = expectedType(e, fieldType);
90
+ const valueType = fieldType.kind === "optional" ? fieldType.inner : fieldType;
91
+
92
+ // Optionals and defaulted fields/flags accept None (None = "use default"), so
93
+ // gate the body instead of requiring presence.
94
+ if (fieldType.kind === "optional" || hasDefault) {
95
+ e.cb.line(`if ${getExpr} is not None:`);
96
+ e.cb.indent(() => emitValue(e, valueType, node, name, idxExpr, expected));
97
+ } else {
98
+ e.cb.line(`if ${getExpr} is None:`);
99
+ e.cb.indent(() => raise(e, str("`" + name + "` must not be None")));
100
+ emitValue(e, valueType, node, name, idxExpr, expected);
101
+ }
102
+ }
103
+
104
+ /** Validate a known-non-null value at `valueExpr` against `type`. */
105
+ function emitValue(
106
+ e: Emit,
107
+ type: BoundType,
108
+ node: Expr | undefined,
109
+ wireKey: string,
110
+ valueExpr: string,
111
+ expected: string,
112
+ ): void {
113
+ switch (type.kind) {
114
+ case "optional":
115
+ emitValue(e, type.inner, node, wireKey, valueExpr, expected);
116
+ return;
117
+ case "literal":
118
+ return;
119
+ case "scalar":
120
+ switch (type.scalar) {
121
+ case "str":
122
+ checkType(e, valueExpr, "str", wireKey, expected);
123
+ return;
124
+ case "int":
125
+ checkType(e, valueExpr, "int", wireKey, expected);
126
+ emitRange(e, node, wireKey, valueExpr);
127
+ return;
128
+ case "float":
129
+ checkType(e, valueExpr, "(float, int)", wireKey, expected);
130
+ emitRange(e, node, wireKey, valueExpr);
131
+ return;
132
+ case "path":
133
+ checkType(e, valueExpr, "(pathlib.Path, str)", wireKey, expected);
134
+ return;
135
+ }
136
+ return;
137
+ case "bool":
138
+ checkType(e, valueExpr, "bool", wireKey, expected);
139
+ return;
140
+ case "count":
141
+ checkType(e, valueExpr, "int", wireKey, expected);
142
+ return;
143
+ case "list": {
144
+ checkType(e, valueExpr, "list", wireKey, expected);
145
+ emitListLength(e, node, wireKey, valueExpr);
146
+ const itemNode = findRepeatNode(node)?.attrs.node;
147
+ const elem = e.scope.add("e");
148
+ e.cb.line(`for ${elem} in ${valueExpr}:`);
149
+ e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
150
+ return;
151
+ }
152
+ case "struct": {
153
+ e.cb.line(`if not isinstance(${valueExpr}, dict):`);
154
+ e.cb.indent(() => raise(e, wrongObjectTypeMsg(valueExpr)));
155
+ for (const f of structFields(e.ctx, type, node)) {
156
+ emitField(e, f.name, f.type, f.node, f.hasDefault, valueExpr);
157
+ }
158
+ return;
159
+ }
160
+ case "union":
161
+ emitUnion(e, type, node, wireKey, valueExpr);
162
+ return;
163
+ }
164
+ }
165
+
166
+ function emitUnion(
167
+ e: Emit,
168
+ unionType: Extract<BoundType, { kind: "union" }>,
169
+ node: Expr | undefined,
170
+ wireKey: string,
171
+ valueExpr: string,
172
+ ): void {
173
+ const litVariants = unionType.variants.filter((v) => v.type.kind === "literal");
174
+ const hasStruct = unionType.variants.some((v) => v.type.kind === "struct");
175
+
176
+ // Pure enum/choice: no struct variants, just literal values (no `@type`).
177
+ if (!hasStruct) {
178
+ const values = litVariants.map(
179
+ (v) => (v.type as Extract<BoundType, { kind: "literal" }>).value,
180
+ );
181
+ const allStr = values.every((x) => typeof x === "string");
182
+ checkType(e, valueExpr, allStr ? "str" : "(float, int)", wireKey, expectedType(e, unionType));
183
+ emitLiteralMembership(e, values, wireKey, valueExpr);
184
+ return;
185
+ }
186
+
187
+ const altNode = findAlternativeNode(node);
188
+ // Struct variants with their indices; throws if two share an `@type` (a
189
+ // duplicate-tagged variant is unreachable and a mypy `comparison-overlap` -
190
+ // frontends must dodge duplicate tags before codegen). The index keeps each
191
+ // arm aligned with the IR `alts`.
192
+ const structVars = structVariants(unionType);
193
+ const emitStructArm = (): void => {
194
+ // `valueExpr` is known to be a dict here.
195
+ e.cb.line(`if "@type" not in ${valueExpr}:`);
196
+ e.cb.indent(() => raise(e, str("Params object is missing `@type`")));
197
+ const names = structVars
198
+ .map(({ variant }) => variant.name)
199
+ .filter((n): n is string => n !== undefined)
200
+ .map((n) => pyStr(n))
201
+ .join(", ");
202
+ e.cb.line(`if ${valueExpr}["@type"] not in [${names}]:`);
203
+ e.cb.indent(() =>
204
+ raise(e, str("Parameter `" + wireKey + "`s `@type` must be one of [" + names + "]")),
205
+ );
206
+ structVars.forEach(({ variant, i }, k) => {
207
+ const vt = variant.type as Extract<BoundType, { kind: "struct" }>;
208
+ const keyword = k === 0 ? "if" : "elif";
209
+ e.cb.line(`${keyword} ${valueExpr}["@type"] == ${pyStr(variant.name ?? "")}:`);
210
+ e.cb.indent(() => {
211
+ const fields = structFields(e.ctx, vt, altNode?.attrs.alts[i]).filter(
212
+ (f) => f.type.kind !== "literal",
213
+ );
214
+ if (fields.length === 0) {
215
+ e.cb.line("pass");
216
+ } else {
217
+ for (const f of fields) emitField(e, f.name, f.type, f.node, f.hasDefault, valueExpr);
218
+ }
219
+ });
220
+ });
221
+ };
222
+
223
+ // Pure discriminated union: every variant is a struct with an `@type`.
224
+ if (litVariants.length === 0) {
225
+ e.cb.line(`if not isinstance(${valueExpr}, dict):`);
226
+ e.cb.indent(() => raise(e, wrongObjectTypeMsg(valueExpr)));
227
+ emitStructArm();
228
+ return;
229
+ }
230
+
231
+ // Mixed union: a value is either a struct (dict with `@type`) or a bare
232
+ // literal. Branch on the runtime shape.
233
+ e.cb.line(`if isinstance(${valueExpr}, dict):`);
234
+ e.cb.indent(emitStructArm);
235
+ e.cb.line("else:");
236
+ e.cb.indent(() => {
237
+ const values = litVariants.map(
238
+ (v) => (v.type as Extract<BoundType, { kind: "literal" }>).value,
239
+ );
240
+ emitLiteralMembership(e, values, wireKey, valueExpr);
241
+ });
242
+ }
243
+
244
+ /** Emit a `not in [...]` membership check over literal values. */
245
+ function emitLiteralMembership(
246
+ e: Emit,
247
+ values: (string | number)[],
248
+ wireKey: string,
249
+ valueExpr: string,
250
+ ): void {
251
+ const rendered = values.map((x) => (typeof x === "string" ? pyStr(x) : pyNum(x))).join(", ");
252
+ e.cb.line(`if ${valueExpr} not in [${rendered}]:`);
253
+ e.cb.indent(() => raise(e, str("Parameter `" + wireKey + "` must be one of [" + rendered + "]")));
254
+ }
255
+
256
+ function checkType(
257
+ e: Emit,
258
+ valueExpr: string,
259
+ pyType: string,
260
+ wireKey: string,
261
+ expected: string,
262
+ ): void {
263
+ e.cb.line(`if not isinstance(${valueExpr}, ${pyType}):`);
264
+ e.cb.indent(() => raise(e, wrongTypeMsg(wireKey, valueExpr, expected)));
265
+ }
266
+
267
+ function emitRange(e: Emit, node: Expr | undefined, wireKey: string, valueExpr: string): void {
268
+ const term = findRangeNode(node);
269
+ if (!term) return;
270
+ const { minValue, maxValue } = term.attrs;
271
+ if (minValue !== undefined && maxValue !== undefined) {
272
+ e.cb.line(`if not (${pyNum(minValue)} <= ${valueExpr} <= ${pyNum(maxValue)}):`);
273
+ e.cb.indent(() =>
274
+ raise(
275
+ e,
276
+ str(`Parameter \`${wireKey}\` must be between ${minValue} and ${maxValue} (inclusive)`),
277
+ ),
278
+ );
279
+ } else if (minValue !== undefined) {
280
+ e.cb.line(`if ${valueExpr} < ${pyNum(minValue)}:`);
281
+ e.cb.indent(() => raise(e, str(`Parameter \`${wireKey}\` must be at least ${minValue}`)));
282
+ } else if (maxValue !== undefined) {
283
+ e.cb.line(`if ${valueExpr} > ${pyNum(maxValue)}:`);
284
+ e.cb.indent(() => raise(e, str(`Parameter \`${wireKey}\` must be at most ${maxValue}`)));
285
+ }
286
+ }
287
+
288
+ function emitListLength(e: Emit, node: Expr | undefined, wireKey: string, valueExpr: string): void {
289
+ const rep = findRepeatNode(node);
290
+ if (!rep) return;
291
+ const { countMin, countMax } = rep.attrs;
292
+ if (countMin !== undefined && countMax !== undefined) {
293
+ e.cb.line(`if not (${countMin} <= len(${valueExpr}) <= ${countMax}):`);
294
+ e.cb.indent(() =>
295
+ raise(
296
+ e,
297
+ str(
298
+ `Parameter \`${wireKey}\` must contain between ${countMin} and ${countMax} elements (inclusive)`,
299
+ ),
300
+ ),
301
+ );
302
+ } else if (countMin !== undefined) {
303
+ e.cb.line(`if len(${valueExpr}) < ${countMin}:`);
304
+ e.cb.indent(() =>
305
+ raise(
306
+ e,
307
+ str(
308
+ `Parameter \`${wireKey}\` must contain at least ${countMin} ${plural("element", countMin)}`,
309
+ ),
310
+ ),
311
+ );
312
+ } else if (countMax !== undefined) {
313
+ e.cb.line(`if len(${valueExpr}) > ${countMax}:`);
314
+ e.cb.indent(() =>
315
+ raise(
316
+ e,
317
+ str(
318
+ `Parameter \`${wireKey}\` must contain at most ${countMax} ${plural("element", countMax)}`,
319
+ ),
320
+ ),
321
+ );
322
+ }
323
+ }
324
+
325
+ // -- Message + literal rendering --
326
+
327
+ function raise(e: Emit, messageExpr: string): void {
328
+ e.cb.line(`raise StyxValidationError(${messageExpr})`);
329
+ }
330
+
331
+ /** A plain double-quoted Python string message. */
332
+ function str(text: string): string {
333
+ return pyStr(text);
334
+ }
335
+
336
+ /** A single-quoted f-string "wrong type" message referencing the runtime type. */
337
+ function wrongTypeMsg(wireKey: string, valueExpr: string, expected: string): string {
338
+ return `f'\`${wireKey}\` has the wrong type: Received \`{type(${valueExpr})}\` expected \`${expected}\`'`;
339
+ }
340
+
341
+ /** The generic "Params object has the wrong type" f-string message. */
342
+ function wrongObjectTypeMsg(valueExpr: string): string {
343
+ return `f'Params object has the wrong type \\'{type(${valueExpr})}\\''`;
344
+ }
345
+
346
+ function expectedType(e: Emit, type: BoundType): string {
347
+ return mapType(type, e.resolve);
348
+ }
349
+
350
+ function pyNum(n: number): string {
351
+ return Number.isFinite(n) ? String(n) : "float('nan')";
352
+ }
353
+
354
+ function plural(word: string, count: number): string {
355
+ return count === 1 ? word : `${word}s`;
356
+ }
@@ -0,0 +1,41 @@
1
+ import type { Binding, BoundType } from "../bindings/index.js";
2
+ import type { Expr } from "../ir/index.js";
3
+ import type { CodegenContext } from "../manifest/index.js";
4
+
5
+ /**
6
+ * Resolve a struct child node to its field binding, handling collapsed sequences.
7
+ *
8
+ * When the solver's simplify pass collapses `seq(lit("--flag"), terminal)` into
9
+ * just the terminal, the binding ends up on the inner node while metadata (doc,
10
+ * defaultValue) may remain on the outermost wrapper. This function recursively
11
+ * descends through collapsed sequences to find the binding, tracking the outermost
12
+ * node for metadata recovery.
13
+ *
14
+ * Uses type identity (`===`) to verify the binding matches the struct's field type,
15
+ * preventing cross-nesting name collisions where an inner struct has a field with
16
+ * the same name as the outer struct.
17
+ */
18
+ export function resolveFieldBinding(
19
+ node: Expr,
20
+ ctx: CodegenContext,
21
+ structType: Extract<BoundType, { kind: "struct" }>,
22
+ outermost?: Expr,
23
+ ): { binding: Binding; wrapperNode: Expr } | undefined {
24
+ const wrapper = outermost ?? node;
25
+ const binding = ctx.resolve(node);
26
+ if (
27
+ binding &&
28
+ binding.name in structType.fields &&
29
+ binding.type === structType.fields[binding.name]
30
+ ) {
31
+ return { binding, wrapperNode: wrapper };
32
+ }
33
+ // Recurse into collapsed sequences to find the binding deeper
34
+ if (node.kind === "sequence") {
35
+ for (const inner of node.attrs.nodes) {
36
+ const result = resolveFieldBinding(inner, ctx, structType, wrapper);
37
+ if (result) return result;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
@@ -0,0 +1,80 @@
1
+ import type {
2
+ BindingRegistry,
3
+ GateAtom,
4
+ OutputScope,
5
+ ResolvedOutput,
6
+ ResolvedToken,
7
+ } from "../bindings/index.js";
8
+ import { outputGate } from "../bindings/index.js";
9
+
10
+ // Re-export the core helper so backends have a single entry point.
11
+ export { outputGate };
12
+
13
+ /**
14
+ * Compact consecutive literal tokens. Backends that emit string concatenation
15
+ * benefit from a shorter token stream; backends that template each token
16
+ * individually can ignore this and use `output.tokens` directly.
17
+ */
18
+ export function compactTokens(tokens: ResolvedToken[]): ResolvedToken[] {
19
+ const out: ResolvedToken[] = [];
20
+ for (const tok of tokens) {
21
+ const last = out[out.length - 1];
22
+ if (tok.kind === "literal" && last && last.kind === "literal") {
23
+ out[out.length - 1] = { kind: "literal", value: last.value + tok.value };
24
+ } else {
25
+ out.push(tok);
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * One output ready for codegen. `gate` is the wrapper sequence (outermost
33
+ * first); the backend renders each atom as the appropriate scope-introducing
34
+ * statement, then emits the path expression inside the innermost layer.
35
+ */
36
+ export interface OutputEmitPlan {
37
+ name: string;
38
+ gate: GateAtom[];
39
+ tokens: ResolvedToken[];
40
+ resolved: ResolvedOutput;
41
+ }
42
+
43
+ export function planOutput(
44
+ scopeGate: GateAtom[],
45
+ output: ResolvedOutput,
46
+ bindings: BindingRegistry,
47
+ ): OutputEmitPlan {
48
+ return {
49
+ name: output.name,
50
+ gate: outputGate(scopeGate, output, bindings),
51
+ tokens: compactTokens(output.tokens),
52
+ resolved: output,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Does the output have any conditional wrapper? Equivalent to "is at least one
58
+ * atom a `present` or `variant`?". `iter` alone means the output emits a list
59
+ * and is not conditionally absent.
60
+ */
61
+ export function isGated(plan: OutputEmitPlan): boolean {
62
+ return plan.gate.some((a) => a.kind === "present" || a.kind === "variant");
63
+ }
64
+
65
+ /** Does the output iterate (emit zero-or-more values)? */
66
+ export function isIterated(plan: OutputEmitPlan): boolean {
67
+ return plan.gate.some((a) => a.kind === "iter");
68
+ }
69
+
70
+ /**
71
+ * Convenience for backends emitting all outputs of a scope at once. The caller
72
+ * provides the scope's gate (typically `bindings.get(scope.scope)?.gate ?? []`).
73
+ */
74
+ export function planScope(
75
+ scope: OutputScope,
76
+ scopeGate: GateAtom[],
77
+ bindings: BindingRegistry,
78
+ ): OutputEmitPlan[] {
79
+ return scope.outputs.map((output) => planOutput(scopeGate, output, bindings));
80
+ }
@@ -0,0 +1,2 @@
1
+ export type { JsonSchema } from "./jsonschema.js";
2
+ export { generateOutputsSchema, generateSchema, JsonSchemaBackend } from "./jsonschema.js";