@styx-api/core 0.3.0 → 0.5.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.
package/dist/index.mjs CHANGED
@@ -14,7 +14,7 @@ function isObject$3(x) {
14
14
  function isString$3(x) {
15
15
  return typeof x === "string";
16
16
  }
17
- function isNumber$1(x) {
17
+ function isNumber$2(x) {
18
18
  return typeof x === "number";
19
19
  }
20
20
  function isArray$3(x) {
@@ -99,7 +99,7 @@ var ArgdumpParser = class {
99
99
  const choices = action.choices;
100
100
  if (isArray$3(choices) && choices.length > 0) {
101
101
  const alts = [];
102
- for (const choice of choices) if (isString$3(choice) || isNumber$1(choice)) alts.push({
102
+ for (const choice of choices) if (isString$3(choice) || isNumber$2(choice)) alts.push({
103
103
  kind: "literal",
104
104
  attrs: { str: String(choice) }
105
105
  });
@@ -232,7 +232,7 @@ var ArgdumpParser = class {
232
232
  countMin: 1
233
233
  }
234
234
  };
235
- if (isNumber$1(nargs) && Number.isInteger(nargs) && nargs >= 0) {
235
+ if (isNumber$2(nargs) && Number.isInteger(nargs) && nargs >= 0) {
236
236
  if (nargs === 1) return node;
237
237
  return {
238
238
  kind: "repeat",
@@ -252,7 +252,7 @@ var ArgdumpParser = class {
252
252
  const name = this.preferredName(action) ?? (isString$3(dest) ? dest : void 0);
253
253
  const hasName = name !== void 0;
254
254
  const hasHelp = isString$3(help) && !isSuppressed(help);
255
- const hasDefault = (isString$3(defaultVal) || isNumber$1(defaultVal) || typeof defaultVal === "boolean") && !isSuppressed(defaultVal);
255
+ const hasDefault = (isString$3(defaultVal) || isNumber$2(defaultVal) || typeof defaultVal === "boolean") && !isSuppressed(defaultVal);
256
256
  if (!hasName && !hasHelp && !hasDefault) return void 0;
257
257
  return {
258
258
  ...hasName && { name },
@@ -853,7 +853,7 @@ function isObject$2(x) {
853
853
  function isString$2(x) {
854
854
  return typeof x === "string";
855
855
  }
856
- function isNumber(x) {
856
+ function isNumber$1(x) {
857
857
  return typeof x === "number";
858
858
  }
859
859
  function isArray$2(x) {
@@ -951,7 +951,7 @@ var BoutiquesParser = class {
951
951
  const title = btInput.name;
952
952
  const description = btInput.description;
953
953
  const defaultValue = btInput["default-value"];
954
- const hasDefault = isString$2(defaultValue) || isNumber(defaultValue) || typeof defaultValue === "boolean";
954
+ const hasDefault = isString$2(defaultValue) || isNumber$1(defaultValue) || typeof defaultValue === "boolean";
955
955
  if (!isString$2(name) && !isString$2(title) && !isString$2(description) && !hasDefault) return;
956
956
  return {
957
957
  ...isString$2(name) && { name },
@@ -1075,7 +1075,7 @@ var BoutiquesParser = class {
1075
1075
  kind: "literal",
1076
1076
  attrs: { str: choice }
1077
1077
  });
1078
- else if (isNumber(choice)) alts.push({
1078
+ else if (isNumber$1(choice)) alts.push({
1079
1079
  kind: "literal",
1080
1080
  attrs: { str: String(choice) }
1081
1081
  });
@@ -1112,11 +1112,11 @@ var BoutiquesParser = class {
1112
1112
  kind: "int",
1113
1113
  attrs: {}
1114
1114
  };
1115
- if (isNumber(btInput.minimum)) {
1115
+ if (isNumber$1(btInput.minimum)) {
1116
1116
  node.attrs.minValue = Math.floor(btInput.minimum);
1117
1117
  if (btInput["exclusive-minimum"] === true) node.attrs.minValue += 1;
1118
1118
  }
1119
- if (isNumber(btInput.maximum)) {
1119
+ if (isNumber$1(btInput.maximum)) {
1120
1120
  node.attrs.maxValue = Math.floor(btInput.maximum);
1121
1121
  if (btInput["exclusive-maximum"] === true) node.attrs.maxValue -= 1;
1122
1122
  }
@@ -1128,8 +1128,8 @@ var BoutiquesParser = class {
1128
1128
  kind: "float",
1129
1129
  attrs: {}
1130
1130
  };
1131
- if (isNumber(btInput.minimum)) node.attrs.minValue = btInput.minimum;
1132
- if (isNumber(btInput.maximum)) node.attrs.maxValue = btInput.maximum;
1131
+ if (isNumber$1(btInput.minimum)) node.attrs.minValue = btInput.minimum;
1132
+ if (isNumber$1(btInput.maximum)) node.attrs.maxValue = btInput.maximum;
1133
1133
  if (meta) node.meta = meta;
1134
1134
  return node;
1135
1135
  }
@@ -1234,8 +1234,8 @@ var BoutiquesParser = class {
1234
1234
  attrs: {
1235
1235
  node,
1236
1236
  ...isString$2(btInput["list-separator"]) && { join: btInput["list-separator"] },
1237
- ...isNumber(btInput["min-list-entries"]) && { countMin: btInput["min-list-entries"] },
1238
- ...isNumber(btInput["max-list-entries"]) && { countMax: btInput["max-list-entries"] }
1237
+ ...isNumber$1(btInput["min-list-entries"]) && { countMin: btInput["min-list-entries"] },
1238
+ ...isNumber$1(btInput["max-list-entries"]) && { countMax: btInput["max-list-entries"] }
1239
1239
  }
1240
1240
  };
1241
1241
  }
@@ -1485,6 +1485,9 @@ function isObject$1(x) {
1485
1485
  function isString$1(x) {
1486
1486
  return typeof x === "string";
1487
1487
  }
1488
+ function isNumber(x) {
1489
+ return typeof x === "number" && Number.isFinite(x);
1490
+ }
1488
1491
  function isArray$1(x) {
1489
1492
  return Array.isArray(x);
1490
1493
  }
@@ -1531,9 +1534,10 @@ function emptyExpr$1() {
1531
1534
  * - option, 1 arg -> opt(seq(lit(-switch), value)) (flat optional)
1532
1535
  * - option, >1 arg / multi -> opt|rep(seq(lit(-switch), ...)) (sub-struct)
1533
1536
  *
1534
- * Type mapping mirrors the v1 `mrt2bt.js` converter's `set_type`. The dump does
1535
- * not carry choice values, so a `choice` argument degrades to a plain string
1536
- * (as it did in v1). Per-command quirks v1 hand-coded that the flat dump cannot
1537
+ * Type mapping mirrors the v1 `mrt2bt.js` converter's `set_type`, plus `choice`
1538
+ * values: when the dump carries `choices` it lowers to an enum (an alternative
1539
+ * of literals), else a plain string (older dumps that omit the values - as v1
1540
+ * always did). Per-command quirks v1 hand-coded that the flat dump cannot
1537
1541
  * express (e.g. dwi2fod/mtnormalise paired in/out args) are intentionally NOT
1538
1542
  * special-cased here - they are patched on the niwrap side post-dump, keeping
1539
1543
  * this frontend format-general.
@@ -1616,6 +1620,19 @@ var MrtrixParser = class {
1616
1620
  };
1617
1621
  }
1618
1622
  /**
1623
+ * Integer/float bounds, when the dump carries them. The C++ hook serializes
1624
+ * `Argument::limits.{i,f}.{min,max}` as `min`/`max`, omitting the unbounded
1625
+ * sentinels - so a present value is a real, tool-enforced bound.
1626
+ */
1627
+ numericBounds(arg) {
1628
+ const lo = arg.min;
1629
+ const hi = arg.max;
1630
+ return {
1631
+ ...isNumber(lo) && { minValue: lo },
1632
+ ...isNumber(hi) && { maxValue: hi }
1633
+ };
1634
+ }
1635
+ /**
1619
1636
  * Lower one MRtrix argument to its terminal node (carrying name + doc) and,
1620
1637
  * for output types, an accompanying `Output`.
1621
1638
  */
@@ -1627,11 +1644,31 @@ var MrtrixParser = class {
1627
1644
  }
1628
1645
  const name = meta.name;
1629
1646
  switch (argType) {
1630
- case "integer": return { node: int(meta) };
1631
- case "float": return { node: float(meta) };
1647
+ case "integer": {
1648
+ const node = int(meta);
1649
+ Object.assign(node.attrs, this.numericBounds(arg));
1650
+ return { node };
1651
+ }
1652
+ case "float": {
1653
+ const node = float(meta);
1654
+ Object.assign(node.attrs, this.numericBounds(arg));
1655
+ return { node };
1656
+ }
1632
1657
  case "text":
1633
- case "choice":
1634
- case "undefined": return { node: str(meta) };
1658
+ case "undefined":
1659
+ case "boolean": return { node: str(meta) };
1660
+ case "choice": {
1661
+ const choices = arg.choices;
1662
+ if (isArray$1(choices)) {
1663
+ const alts = choices.filter(isString$1).map((c) => lit(c));
1664
+ if (alts.length > 0) {
1665
+ const node = alt(...alts);
1666
+ node.meta = meta;
1667
+ return { node };
1668
+ }
1669
+ }
1670
+ return { node: str(meta) };
1671
+ }
1635
1672
  case "int seq": {
1636
1673
  const node = repJoin(",", int());
1637
1674
  node.meta = meta;
@@ -3350,196 +3387,6 @@ function resolveTypeName(namedTypes) {
3350
3387
  };
3351
3388
  }
3352
3389
 
3353
- //#endregion
3354
- //#region src/backend/styxdefs-compat.ts
3355
- /**
3356
- * Runtime version floors baked into generated dependency metadata.
3357
- *
3358
- * styx2-generated code calls `mutable_copy` / `mutableCopy` (introduced in the
3359
- * styxdefs 0.7.0 / styxdefs-js 0.2.0 release), so emitted packages genuinely
3360
- * require that runtime floor. This is the single source of truth: bump here and
3361
- * both the Python and TypeScript backends pick it up.
3362
- */
3363
- const STYXDEFS_COMPAT = {
3364
- python: ">=0.7.0,<0.8.0",
3365
- npm: "^0.2.0"
3366
- };
3367
- /**
3368
- * Extra Python runtime packages the root distribution pulls in (container +
3369
- * graph runners). Left unpinned - styxdefs's floor constrains them transitively
3370
- * via their own inter-package pins.
3371
- */
3372
- const PYTHON_RUNNER_DEPS = [
3373
- "styxdocker",
3374
- "styxsingularity",
3375
- "styxgraph"
3376
- ];
3377
-
3378
- //#endregion
3379
- //#region src/backend/python/packaging.ts
3380
- const REQUIRES_PYTHON = ">=3.10";
3381
- const BUILD_SYSTEM = `[build-system]
3382
- requires = ["setuptools>=61"]
3383
- build-backend = "setuptools.build_meta"`;
3384
- /** Escape a value for embedding in a TOML basic string. */
3385
- function tomlStr(s) {
3386
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
3387
- }
3388
- /** Suite directory / importable package name; matches the CLI's `pkgDir` fallback. */
3389
- function pkgDir(pkg) {
3390
- return pkg.name ?? "package";
3391
- }
3392
- /** Distribution (PyPI) name for a package: `<project>_<package>`, or just `<package>`. */
3393
- function pyDistName(proj, pkg) {
3394
- const name = pkgDir(pkg);
3395
- return proj.name ? `${proj.name}_${name}` : name;
3396
- }
3397
- const SUMMARY_MAX_LEN = 512;
3398
- /**
3399
- * Clamp a description to a <=512-char one-line summary. Prefer cutting at the
3400
- * last complete sentence that fits (clean, no dangling fragment); if there is no
3401
- * sentence boundary early enough, cut at a word boundary and mark the elision.
3402
- */
3403
- function clampSummary(s) {
3404
- if (s.length <= SUMMARY_MAX_LEN) return s;
3405
- const window = s.slice(0, SUMMARY_MAX_LEN);
3406
- const lastSentence = window.lastIndexOf(". ");
3407
- if (lastSentence >= 0) return s.slice(0, lastSentence + 1);
3408
- const ellipsis = "...";
3409
- const body = window.slice(0, SUMMARY_MAX_LEN - 3);
3410
- const lastSpace = body.lastIndexOf(" ");
3411
- return (lastSpace > 0 ? body.slice(0, lastSpace) : body).replace(/[.,;:\s]+$/, "") + ellipsis;
3412
- }
3413
- function description$1(doc, fallbackName) {
3414
- return clampSummary(doc?.description ?? `Styx generated wrappers for ${doc?.title ?? fallbackName ?? "tools"}.`);
3415
- }
3416
- function authorsField(doc) {
3417
- return `[${(doc?.authors?.length ? doc.authors : ["unknown"]).map((a) => `{ name = "${tomlStr(a)}" }`).join(", ")}]`;
3418
- }
3419
- function licenseField(proj) {
3420
- return `{ text = "${tomlStr(proj.license?.description ?? "unknown")}" }`;
3421
- }
3422
- /**
3423
- * Per-suite `pyproject.toml`. The flat layout (`python/<pkg>/bet.py`) makes the
3424
- * directory itself the importable package, so setuptools' `package-dir` maps the
3425
- * import name (`<pkg>`) onto the distribution's root directory. The styxdefs
3426
- * floor is the only runtime dependency.
3427
- *
3428
- * Precondition: `pkg.name` must be a valid Python identifier - the flat layout's
3429
- * relative imports (`from .bet import *`) already require this, and the CLI uses
3430
- * it verbatim as the directory name, so this stays consistent with that.
3431
- */
3432
- function generateSubPyproject(proj, pkg) {
3433
- const importName = pkgDir(pkg);
3434
- const cb = new CodeBuilder(" ");
3435
- cb.line("[project]");
3436
- cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
3437
- cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
3438
- cb.line(`description = "${tomlStr(description$1(pkg.doc, pkg.name))}"`);
3439
- cb.line(`readme = "README.md"`);
3440
- cb.line(`license = ${licenseField(proj)}`);
3441
- cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
3442
- cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
3443
- cb.line("dependencies = [");
3444
- cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
3445
- cb.line("]");
3446
- cb.blank();
3447
- cb.line("[tool.setuptools]");
3448
- cb.line(`packages = ["${importName}"]`);
3449
- cb.line(`package-dir = { "${importName}" = "." }`);
3450
- cb.blank();
3451
- cb.line("[tool.setuptools.package-data]");
3452
- cb.line(`"${importName}" = ["py.typed"]`);
3453
- cb.blank();
3454
- cb.line(BUILD_SYSTEM);
3455
- return cb.toString() + "\n";
3456
- }
3457
- /**
3458
- * Root `pyproject.toml`: a metapackage depending on each per-suite distribution
3459
- * plus the container/graph runner packages. `packages = []` keeps setuptools
3460
- * from sweeping the sibling suite directories into this distribution.
3461
- */
3462
- function generateRootPyproject(proj, distNames) {
3463
- const cb = new CodeBuilder(" ");
3464
- cb.line("[project]");
3465
- cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
3466
- cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
3467
- cb.line(`description = "${tomlStr(description$1(proj.doc, proj.name))}"`);
3468
- cb.line(`readme = "README.md"`);
3469
- cb.line(`license = ${licenseField(proj)}`);
3470
- cb.line(`authors = ${authorsField(proj.doc)}`);
3471
- cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
3472
- cb.line("dependencies = [");
3473
- for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
3474
- for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
3475
- cb.line("]");
3476
- cb.blank();
3477
- cb.line("[tool.setuptools]");
3478
- cb.line("packages = []");
3479
- cb.blank();
3480
- cb.line(BUILD_SYSTEM);
3481
- return cb.toString() + "\n";
3482
- }
3483
- /** Per-suite README crediting the upstream tool authors. */
3484
- function generateSubReadme(proj, pkg) {
3485
- const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
3486
- const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
3487
- const url = pkg.doc?.urls?.[0];
3488
- const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
3489
- const credits = pkg.doc?.authors?.length ? pkg.doc.authors.join(", ") : pkg.doc?.urls?.join(", ") ?? "unknown";
3490
- return `# ${projectTitle} wrappers for ${titleMd}${pkg.doc?.description ? `\n\n${pkg.doc.description}` : ""}\n\n${packageTitle} is made by ${credits}.\n\nThis package contains wrappers only and has no affiliation with the original authors.\n`;
3491
- }
3492
- /** Root README listing the bundled per-suite distributions. */
3493
- function generateRootReadme(proj, distNames) {
3494
- return `# ${proj.doc?.title ?? proj.name ?? "Styx"}\n${proj.doc?.description ? `\n${proj.doc.description}\n` : ""}\nAuto-generated Styx wrappers. This project bundles the following packages:\n\n${distNames.map((d) => `- ${d}`).join("\n")}\n`;
3495
- }
3496
- /** Local-install manifest: each suite directory first, then the root metapackage. */
3497
- function generateRequirementsTxt(pkgDirs) {
3498
- return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
3499
- }
3500
-
3501
- //#endregion
3502
- //#region src/backend/sig-entries.ts
3503
- /**
3504
- * Build per-field signature entries for the kwarg wrapper and params factory.
3505
- * Skips `@type` (the factory injects it as a constant). Required-no-default
3506
- * entries are placed before defaulted ones so the resulting signature is
3507
- * syntactically valid in both Python and TS.
3508
- *
3509
- * `registerLocal` is called once per field with the wire key; it must return
3510
- * a scrubbed, unique host identifier (typically by combining a language-aware
3511
- * scrub function with `Scope.add()`). The caller's scope should already have
3512
- * the function's other locals (`params`, `runner`, ...) reserved so this
3513
- * registration cannot collide with them.
3514
- */
3515
- function buildSigEntries(rootType, fieldInfo, registerLocal, opts) {
3516
- const entries = [];
3517
- for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
3518
- if (fieldType.kind === "literal") continue;
3519
- const fi = fieldInfo.get(fieldName);
3520
- const isOptional = fieldType.kind === "optional";
3521
- const inner = isOptional ? fieldType.inner : fieldType;
3522
- let sigType = opts.renderType(inner);
3523
- if (isOptional) sigType += opts.nullableSuffix;
3524
- let sigDefault;
3525
- const hasExplicitDefault = fi?.defaultValue !== void 0;
3526
- if (hasExplicitDefault) sigDefault = opts.renderDefault(fi.defaultValue);
3527
- else if (isOptional) sigDefault = opts.nullableDefault;
3528
- entries.push({
3529
- name: registerLocal(fieldName),
3530
- wireKey: fieldName,
3531
- sigType,
3532
- sigDefault,
3533
- isOptional,
3534
- hasExplicitDefault,
3535
- doc: fi?.doc
3536
- });
3537
- }
3538
- const required = entries.filter((e) => e.sigDefault === void 0);
3539
- const defaulted = entries.filter((e) => e.sigDefault !== void 0);
3540
- return [...required, ...defaulted];
3541
- }
3542
-
3543
3390
  //#endregion
3544
3391
  //#region src/backend/union-variants.ts
3545
3392
  /**
@@ -4359,65 +4206,455 @@ function emitKwargWrapper$1(ctx, entries, funcName, paramsFnName, executeFnName,
4359
4206
  }
4360
4207
 
4361
4208
  //#endregion
4362
- //#region src/backend/validate-walk.ts
4209
+ //#region src/backend/nipype/emit.ts
4210
+ function call(ctor, args) {
4211
+ return `${ctor}(${args.join(", ")})`;
4212
+ }
4363
4213
  /**
4364
- * Enumerate a struct type's fields in declaration order, each paired with the
4365
- * IR node its binding resolved from. `searchRoot` is the IR subtree known to
4366
- * contain the struct (the root expr for the top-level struct, or a field /
4367
- * union-arm node for nested ones).
4214
+ * Render a numeric literal, forcing a float form (e.g. `0.0`) for a float field.
4215
+ * `traits.Range` infers its numeric type from the bound literals, so integer
4216
+ * bounds on a float field (e.g. `low=0, high=1`) would build an *integer* range
4217
+ * that rejects `0.5`. Emitting `0.0`/`1.0` keeps it a float range.
4368
4218
  */
4369
- function structFields(ctx, structType, searchRoot) {
4370
- const nodeByName = /* @__PURE__ */ new Map();
4371
- if (searchRoot) {
4372
- const structNode = findStructNode(searchRoot, ctx, structType);
4373
- if (structNode) for (const child of structNode.attrs.nodes) {
4374
- const match = resolveFieldBinding(child, ctx, structType);
4375
- if (match) nodeByName.set(match.binding.name, match.binding.node);
4219
+ function renderNum(value, asFloat) {
4220
+ if (asFloat && typeof value === "number" && Number.isInteger(value)) return `${value}.0`;
4221
+ return renderPyLiteral(value);
4222
+ }
4223
+ /** Human-readable `desc=` text: doc + degrade/media-type notes. */
4224
+ function descText(p) {
4225
+ const parts = [];
4226
+ if (p.doc) parts.push(p.doc);
4227
+ if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
4228
+ if (p.mediaTypes && p.mediaTypes.length > 0) parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
4229
+ return parts.length > 0 ? parts.join(" ") : void 0;
4230
+ }
4231
+ /** Map a list element descriptor to a (bare) nipype inner trait. */
4232
+ function renderItemTrait(item) {
4233
+ if (!item) return "traits.Any()";
4234
+ switch (item.kind) {
4235
+ case "path": return "File(exists=True)";
4236
+ case "int":
4237
+ case "count": return "traits.Int()";
4238
+ case "float": return "traits.Float()";
4239
+ case "str": return "traits.Str()";
4240
+ case "bool": return "traits.Bool()";
4241
+ case "enum": return call("traits.Enum", (item.choices ?? []).map((c) => renderPyLiteral(c)));
4242
+ default: return "traits.Any()";
4243
+ }
4244
+ }
4245
+ /** Map a parameter to its nipype input trait expression, carrying rich constraints. */
4246
+ function renderInputTrait(p) {
4247
+ const tail = [];
4248
+ if (p.mandatory) tail.push("mandatory=True");
4249
+ const desc = descText(p);
4250
+ if (desc) tail.push(`desc=${pyStr(desc)}`);
4251
+ const hasDef = p.hasDefault && p.default !== void 0;
4252
+ const def = p.default;
4253
+ switch (p.kind) {
4254
+ case "path": return call("File", ["exists=True", ...tail]);
4255
+ case "bool": return call("traits.Bool", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4256
+ case "count": return call("traits.Int", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4257
+ case "int":
4258
+ case "float": {
4259
+ const asFloat = p.kind === "float";
4260
+ if (p.range) {
4261
+ const a = [];
4262
+ if (hasDef) a.push(`value=${renderNum(def, asFloat)}`);
4263
+ if (p.range.min !== void 0) a.push(`low=${renderNum(p.range.min, asFloat)}`);
4264
+ if (p.range.max !== void 0) a.push(`high=${renderNum(p.range.max, asFloat)}`);
4265
+ if (hasDef) a.push("usedefault=True");
4266
+ return call("traits.Range", [...a, ...tail]);
4267
+ }
4268
+ return call(p.kind === "int" ? "traits.Int" : "traits.Float", [...hasDef ? [renderNum(def, asFloat), "usedefault=True"] : [], ...tail]);
4269
+ }
4270
+ case "str": return call("traits.Str", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4271
+ case "enum": {
4272
+ const choices = p.choices ?? [];
4273
+ return call("traits.Enum", [
4274
+ ...(hasDef ? [def, ...choices.filter((c) => c !== def)] : choices).map((c) => renderPyLiteral(c)),
4275
+ ...hasDef ? ["usedefault=True"] : [],
4276
+ ...tail
4277
+ ]);
4278
+ }
4279
+ case "list": {
4280
+ const inner = renderItemTrait(p.itemType);
4281
+ const bounds = [];
4282
+ if (p.listBounds?.min !== void 0) bounds.push(`minlen=${p.listBounds.min}`);
4283
+ if (p.listBounds?.max !== void 0) bounds.push(`maxlen=${p.listBounds.max}`);
4284
+ return call("traits.List", [
4285
+ inner,
4286
+ ...bounds,
4287
+ ...tail
4288
+ ]);
4376
4289
  }
4290
+ case "struct":
4291
+ case "union": return call("traits.Any", tail);
4377
4292
  }
4378
- const fieldInfo = collectFieldInfo(ctx, structType);
4379
- return Object.entries(structType.fields).map(([name, type]) => ({
4380
- name,
4381
- type,
4382
- node: nodeByName.get(name),
4383
- hasDefault: fieldInfo.get(name)?.defaultValue !== void 0
4384
- }));
4385
4293
  }
4386
4294
  /**
4387
- * Depth-first search for the first node satisfying `pred`, descending through
4388
- * the transparent structural wrappers the solver may bury a binding under
4389
- * (sequence/optional/repeat/alternative).
4295
+ * Map an output field to its nipype output trait expression. `isRoot` is the
4296
+ * synthetic output directory, typed as a `Directory` rather than a `File`.
4390
4297
  */
4391
- function findNode(node, pred) {
4392
- if (!node) return void 0;
4393
- if (pred(node)) return node;
4394
- switch (node.kind) {
4395
- case "sequence":
4396
- for (const child of node.attrs.nodes) {
4397
- const r = findNode(child, pred);
4398
- if (r) return r;
4399
- }
4400
- return;
4401
- case "optional": return findNode(node.attrs.node, pred);
4402
- case "repeat": return findNode(node.attrs.node, pred);
4403
- case "alternative":
4404
- for (const alt of node.attrs.alts) {
4405
- const r = findNode(alt, pred);
4406
- if (r) return r;
4407
- }
4408
- return;
4409
- default: return;
4410
- }
4411
- }
4412
- /** Locate the int/float node carrying a scalar field's numeric range. */
4413
- function findRangeNode(node) {
4414
- return findNode(node, (n) => n.kind === "int" || n.kind === "float");
4415
- }
4416
- /** Locate the repeat node carrying a list field's length bounds and item. */
4417
- function findRepeatNode(node) {
4418
- return findNode(node, (n) => n.kind === "repeat");
4298
+ function renderOutputTrait(f, isRoot) {
4299
+ const tail = f.doc ? [`desc=${pyStr(f.doc)}`] : [];
4300
+ if (f.shape.kind === "list") return call("traits.List", ["File()", ...tail]);
4301
+ if (isRoot) return call("Directory", tail);
4302
+ return call("File", tail);
4419
4303
  }
4420
- /** Locate the alternative node backing a union field, to map arms to variants. */
4304
+ /**
4305
+ * Emit the nipype interface module for one tool: a typed InputSpec/OutputSpec and
4306
+ * a BaseInterface that delegates execution to the co-emitted styx Python wrapper.
4307
+ */
4308
+ function emitNipypeInterface(ctx, spec, names) {
4309
+ const cb = new CodeBuilder(" ");
4310
+ cb.comment("This file was auto generated by Styx.", "# ");
4311
+ cb.comment("Do not edit this file directly.", "# ");
4312
+ cb.blank();
4313
+ cb.line("from nipype.interfaces.base import (");
4314
+ cb.indent(() => {
4315
+ cb.line("BaseInterface,");
4316
+ cb.line("BaseInterfaceInputSpec,");
4317
+ cb.line("Directory,");
4318
+ cb.line("File,");
4319
+ cb.line("TraitedSpec,");
4320
+ cb.line("isdefined,");
4321
+ cb.line("traits,");
4322
+ });
4323
+ cb.line(")");
4324
+ cb.blank();
4325
+ cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}, ${spec.delegation.outputsClass}`);
4326
+ cb.blank();
4327
+ cb.blank();
4328
+ cb.line(`class ${names.inputSpec}(BaseInterfaceInputSpec):`);
4329
+ cb.indent(() => {
4330
+ if (!spec.rootIsStruct || spec.params.length === 0) {
4331
+ cb.line("pass");
4332
+ return;
4333
+ }
4334
+ for (const p of spec.params) cb.line(`${p.hostName} = ${renderInputTrait(p)}`);
4335
+ });
4336
+ cb.blank();
4337
+ cb.blank();
4338
+ cb.line(`class ${names.outputSpec}(TraitedSpec):`);
4339
+ cb.indent(() => {
4340
+ if (spec.outputs.length === 0 && spec.streams.length === 0) {
4341
+ cb.line("pass");
4342
+ return;
4343
+ }
4344
+ spec.outputs.forEach((f, i) => cb.line(`${f.id} = ${renderOutputTrait(f, i === 0)}`));
4345
+ for (const s of spec.streams) {
4346
+ const tail = s.doc ? `, desc=${pyStr(s.doc)}` : "";
4347
+ cb.line(`${s.id} = traits.List(traits.Str()${tail})`);
4348
+ }
4349
+ });
4350
+ cb.blank();
4351
+ cb.blank();
4352
+ cb.line(`class ${names.cls}(BaseInterface):`);
4353
+ cb.indent(() => {
4354
+ emitDocstring(cb, [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n") || void 0);
4355
+ cb.line(`input_spec = ${names.inputSpec}`);
4356
+ cb.line(`output_spec = ${names.outputSpec}`);
4357
+ cb.blank();
4358
+ cb.line("def _run_interface(self, runtime):");
4359
+ cb.indent(() => {
4360
+ if (!spec.rootIsStruct) {
4361
+ cb.line("raise NotImplementedError(");
4362
+ cb.indent(() => cb.line(pyStr("styx nipype backend: tools with a non-struct root are not supported.")));
4363
+ cb.line(")");
4364
+ return;
4365
+ }
4366
+ cb.line("kwargs = {}");
4367
+ for (const p of spec.params) if (p.mandatory) cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`);
4368
+ else {
4369
+ cb.line(`if isdefined(self.inputs.${p.hostName}):`);
4370
+ cb.indent(() => cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`));
4371
+ }
4372
+ cb.line(`self._result: ${spec.delegation.outputsClass} = ${spec.delegation.wrapperFn}(**kwargs)`);
4373
+ cb.line("return runtime");
4374
+ });
4375
+ cb.blank();
4376
+ cb.line("def _list_outputs(self):");
4377
+ cb.indent(() => {
4378
+ if (!spec.rootIsStruct) {
4379
+ cb.line("return self._outputs().get()");
4380
+ return;
4381
+ }
4382
+ cb.line("result = self._result");
4383
+ cb.line("outputs = self._outputs().get()");
4384
+ for (const f of spec.outputs) if (f.shape.kind === "single" && f.shape.optional) {
4385
+ cb.line(`if result.${f.id} is not None:`);
4386
+ cb.indent(() => cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`));
4387
+ } else cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`);
4388
+ for (const s of spec.streams) cb.line(`outputs[${pyStr(s.id)}] = result.${s.id}`);
4389
+ cb.line("return outputs");
4390
+ });
4391
+ });
4392
+ return cb.toString();
4393
+ }
4394
+
4395
+ //#endregion
4396
+ //#region src/backend/styxdefs-compat.ts
4397
+ /**
4398
+ * Runtime version floors baked into generated dependency metadata.
4399
+ *
4400
+ * styx2-generated code calls `mutable_copy` / `mutableCopy` (introduced in the
4401
+ * styxdefs 0.7.0 / styxdefs-js 0.2.0 release), so emitted packages genuinely
4402
+ * require that runtime floor. This is the single source of truth: bump here and
4403
+ * both the Python and TypeScript backends pick it up.
4404
+ */
4405
+ const STYXDEFS_COMPAT = {
4406
+ python: ">=0.7.0,<0.8.0",
4407
+ npm: "^0.2.0"
4408
+ };
4409
+ /**
4410
+ * Extra Python runtime packages the root metapackage pulls in. `styxkit[all]`
4411
+ * provides the cross-backend runner-selection helpers (`use_docker`, `use_auto`,
4412
+ * ...) that the metapackage's `__init__` re-exports, and transitively installs
4413
+ * every container/graph runner backend. Left unpinned - styxkit's own styxdefs
4414
+ * floor constrains the stack.
4415
+ */
4416
+ const PYTHON_RUNNER_DEPS = ["styxkit[all]"];
4417
+
4418
+ //#endregion
4419
+ //#region src/backend/python/packaging.ts
4420
+ const REQUIRES_PYTHON = ">=3.10";
4421
+ const BUILD_SYSTEM = `[build-system]
4422
+ requires = ["setuptools>=61"]
4423
+ build-backend = "setuptools.build_meta"`;
4424
+ /** Escape a value for embedding in a TOML basic string. */
4425
+ function tomlStr(s) {
4426
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
4427
+ }
4428
+ /** Suite directory / importable package name; matches the CLI's `pkgDir` fallback. */
4429
+ function pkgDir(pkg) {
4430
+ return pkg.name ?? "package";
4431
+ }
4432
+ /** Distribution (PyPI) name for a package: `<project>_<package>`, or just `<package>`. */
4433
+ function pyDistName(proj, pkg) {
4434
+ const name = pkgDir(pkg);
4435
+ return proj.name ? `${proj.name}_${name}` : name;
4436
+ }
4437
+ const SUMMARY_MAX_LEN = 512;
4438
+ /**
4439
+ * Clamp a description to a <=512-char one-line summary. Prefer cutting at the
4440
+ * last complete sentence that fits (clean, no dangling fragment); if there is no
4441
+ * sentence boundary early enough, cut at a word boundary and mark the elision.
4442
+ */
4443
+ function clampSummary(s) {
4444
+ if (s.length <= SUMMARY_MAX_LEN) return s;
4445
+ const window = s.slice(0, SUMMARY_MAX_LEN);
4446
+ const lastSentence = window.lastIndexOf(". ");
4447
+ if (lastSentence >= 0) return s.slice(0, lastSentence + 1);
4448
+ const ellipsis = "...";
4449
+ const body = window.slice(0, SUMMARY_MAX_LEN - 3);
4450
+ const lastSpace = body.lastIndexOf(" ");
4451
+ return (lastSpace > 0 ? body.slice(0, lastSpace) : body).replace(/[.,;:\s]+$/, "") + ellipsis;
4452
+ }
4453
+ function description$1(doc, fallbackName) {
4454
+ return clampSummary(doc?.description ?? `Styx generated wrappers for ${doc?.title ?? fallbackName ?? "tools"}.`);
4455
+ }
4456
+ function authorsField(doc) {
4457
+ return `[${(doc?.authors?.length ? doc.authors : ["unknown"]).map((a) => `{ name = "${tomlStr(a)}" }`).join(", ")}]`;
4458
+ }
4459
+ function licenseField(proj) {
4460
+ return `{ text = "${tomlStr(proj.license?.description ?? "unknown")}" }`;
4461
+ }
4462
+ /**
4463
+ * Per-suite `pyproject.toml`. The flat layout (`python/<pkg>/bet.py`) makes the
4464
+ * directory itself the importable package, so setuptools' `package-dir` maps the
4465
+ * import name (`<pkg>`) onto the distribution's root directory. The styxdefs
4466
+ * floor is the only runtime dependency.
4467
+ *
4468
+ * Precondition: `pkg.name` must be a valid Python identifier - the flat layout's
4469
+ * relative imports (`from .bet import *`) already require this, and the CLI uses
4470
+ * it verbatim as the directory name, so this stays consistent with that.
4471
+ */
4472
+ function generateSubPyproject(proj, pkg) {
4473
+ const importName = pkgDir(pkg);
4474
+ const cb = new CodeBuilder(" ");
4475
+ cb.line("[project]");
4476
+ cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
4477
+ cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
4478
+ cb.line(`description = "${tomlStr(description$1(pkg.doc, pkg.name))}"`);
4479
+ cb.line(`readme = "README.md"`);
4480
+ cb.line(`license = ${licenseField(proj)}`);
4481
+ cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
4482
+ cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
4483
+ cb.line("dependencies = [");
4484
+ cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
4485
+ cb.line("]");
4486
+ cb.blank();
4487
+ cb.line("[tool.setuptools]");
4488
+ cb.line(`packages = ["${importName}"]`);
4489
+ cb.line(`package-dir = { "${importName}" = "." }`);
4490
+ cb.blank();
4491
+ cb.line("[tool.setuptools.package-data]");
4492
+ cb.line(`"${importName}" = ["py.typed"]`);
4493
+ cb.blank();
4494
+ cb.line(BUILD_SYSTEM);
4495
+ return cb.toString() + "\n";
4496
+ }
4497
+ /**
4498
+ * The metapackage's `__init__.py`. A thin re-export of styxkit so the runner
4499
+ * configuration helpers (`use_docker`, `use_local`, `set_global_runner`, ...)
4500
+ * are reachable as `<project>.<name>` - the v1 ergonomic that the v2 split into
4501
+ * per-suite distributions otherwise dropped. The logic lives in styxkit (pulled
4502
+ * in via `styxkit[all]`), so this stays a wildcard re-export and never re-emits
4503
+ * the runner-config code itself.
4504
+ */
4505
+ function generateRootInitPy() {
4506
+ return "# This file was auto generated by Styx.\n# Do not edit this file directly.\n\n# Re-export styxkit's runner-configuration helpers (use_docker, use_local,\n# use_auto, set_global_runner, get_global_runner, ...) so they are available\n# directly on this package, e.g. `import niwrap; niwrap.use_docker()`.\nfrom styxkit import * # noqa: F401,F403\n";
4507
+ }
4508
+ /**
4509
+ * Root `pyproject.toml`: a metapackage depending on `styxkit[all]` (the runner
4510
+ * stack it re-exports) plus each per-suite distribution. `packages` lists only
4511
+ * the metapackage's own module so setuptools ships the styxkit re-export without
4512
+ * sweeping the sibling suite directories into this distribution.
4513
+ */
4514
+ function generateRootPyproject(proj, distNames, moduleName) {
4515
+ const cb = new CodeBuilder(" ");
4516
+ cb.line("[project]");
4517
+ cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
4518
+ cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
4519
+ cb.line(`description = "${tomlStr(description$1(proj.doc, proj.name))}"`);
4520
+ cb.line(`readme = "README.md"`);
4521
+ cb.line(`license = ${licenseField(proj)}`);
4522
+ cb.line(`authors = ${authorsField(proj.doc)}`);
4523
+ cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
4524
+ cb.line("dependencies = [");
4525
+ for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
4526
+ for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
4527
+ cb.line("]");
4528
+ cb.blank();
4529
+ cb.line("[tool.setuptools]");
4530
+ cb.line(`packages = ["${moduleName}"]`);
4531
+ cb.blank();
4532
+ cb.line("[tool.setuptools.package-data]");
4533
+ cb.line(`"${moduleName}" = ["py.typed"]`);
4534
+ cb.blank();
4535
+ cb.line(BUILD_SYSTEM);
4536
+ return cb.toString() + "\n";
4537
+ }
4538
+ /** Per-suite README crediting the upstream tool authors. */
4539
+ function generateSubReadme(proj, pkg) {
4540
+ const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
4541
+ const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
4542
+ const url = pkg.doc?.urls?.[0];
4543
+ const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
4544
+ const credits = pkg.doc?.authors?.length ? pkg.doc.authors.join(", ") : pkg.doc?.urls?.join(", ") ?? "unknown";
4545
+ return `# ${projectTitle} wrappers for ${titleMd}${pkg.doc?.description ? `\n\n${pkg.doc.description}` : ""}\n\n${packageTitle} is made by ${credits}.\n\nThis package contains wrappers only and has no affiliation with the original authors.\n`;
4546
+ }
4547
+ /** Root README listing the bundled per-suite distributions. */
4548
+ function generateRootReadme(proj, distNames) {
4549
+ return `# ${proj.doc?.title ?? proj.name ?? "Styx"}\n${proj.doc?.description ? `\n${proj.doc.description}\n` : ""}\nAuto-generated Styx wrappers. This project bundles the following packages:\n\n${distNames.map((d) => `- ${d}`).join("\n")}\n`;
4550
+ }
4551
+ /** Local-install manifest: each suite directory first, then the root metapackage. */
4552
+ function generateRequirementsTxt(pkgDirs) {
4553
+ return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
4554
+ }
4555
+
4556
+ //#endregion
4557
+ //#region src/backend/sig-entries.ts
4558
+ /**
4559
+ * Build per-field signature entries for the kwarg wrapper and params factory.
4560
+ * Skips `@type` (the factory injects it as a constant). Required-no-default
4561
+ * entries are placed before defaulted ones so the resulting signature is
4562
+ * syntactically valid in both Python and TS.
4563
+ *
4564
+ * `registerLocal` is called once per field with the wire key; it must return
4565
+ * a scrubbed, unique host identifier (typically by combining a language-aware
4566
+ * scrub function with `Scope.add()`). The caller's scope should already have
4567
+ * the function's other locals (`params`, `runner`, ...) reserved so this
4568
+ * registration cannot collide with them.
4569
+ */
4570
+ function buildSigEntries(rootType, fieldInfo, registerLocal, opts) {
4571
+ const entries = [];
4572
+ for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
4573
+ if (fieldType.kind === "literal") continue;
4574
+ const fi = fieldInfo.get(fieldName);
4575
+ const isOptional = fieldType.kind === "optional";
4576
+ const inner = isOptional ? fieldType.inner : fieldType;
4577
+ let sigType = opts.renderType(inner);
4578
+ if (isOptional) sigType += opts.nullableSuffix;
4579
+ let sigDefault;
4580
+ const hasExplicitDefault = fi?.defaultValue !== void 0;
4581
+ if (hasExplicitDefault) sigDefault = opts.renderDefault(fi.defaultValue);
4582
+ else if (isOptional) sigDefault = opts.nullableDefault;
4583
+ entries.push({
4584
+ name: registerLocal(fieldName),
4585
+ wireKey: fieldName,
4586
+ sigType,
4587
+ sigDefault,
4588
+ isOptional,
4589
+ hasExplicitDefault,
4590
+ doc: fi?.doc
4591
+ });
4592
+ }
4593
+ const required = entries.filter((e) => e.sigDefault === void 0);
4594
+ const defaulted = entries.filter((e) => e.sigDefault !== void 0);
4595
+ return [...required, ...defaulted];
4596
+ }
4597
+
4598
+ //#endregion
4599
+ //#region src/backend/validate-walk.ts
4600
+ /**
4601
+ * Enumerate a struct type's fields in declaration order, each paired with the
4602
+ * IR node its binding resolved from. `searchRoot` is the IR subtree known to
4603
+ * contain the struct (the root expr for the top-level struct, or a field /
4604
+ * union-arm node for nested ones).
4605
+ */
4606
+ function structFields(ctx, structType, searchRoot) {
4607
+ const nodeByName = /* @__PURE__ */ new Map();
4608
+ if (searchRoot) {
4609
+ const structNode = findStructNode(searchRoot, ctx, structType);
4610
+ if (structNode) for (const child of structNode.attrs.nodes) {
4611
+ const match = resolveFieldBinding(child, ctx, structType);
4612
+ if (match) nodeByName.set(match.binding.name, match.binding.node);
4613
+ }
4614
+ }
4615
+ const fieldInfo = collectFieldInfo(ctx, structType);
4616
+ return Object.entries(structType.fields).map(([name, type]) => ({
4617
+ name,
4618
+ type,
4619
+ node: nodeByName.get(name),
4620
+ hasDefault: fieldInfo.get(name)?.defaultValue !== void 0
4621
+ }));
4622
+ }
4623
+ /**
4624
+ * Depth-first search for the first node satisfying `pred`, descending through
4625
+ * the transparent structural wrappers the solver may bury a binding under
4626
+ * (sequence/optional/repeat/alternative).
4627
+ */
4628
+ function findNode(node, pred) {
4629
+ if (!node) return void 0;
4630
+ if (pred(node)) return node;
4631
+ switch (node.kind) {
4632
+ case "sequence":
4633
+ for (const child of node.attrs.nodes) {
4634
+ const r = findNode(child, pred);
4635
+ if (r) return r;
4636
+ }
4637
+ return;
4638
+ case "optional": return findNode(node.attrs.node, pred);
4639
+ case "repeat": return findNode(node.attrs.node, pred);
4640
+ case "alternative":
4641
+ for (const alt of node.attrs.alts) {
4642
+ const r = findNode(alt, pred);
4643
+ if (r) return r;
4644
+ }
4645
+ return;
4646
+ default: return;
4647
+ }
4648
+ }
4649
+ /** Locate the int/float node carrying a scalar field's numeric range. */
4650
+ function findRangeNode(node) {
4651
+ return findNode(node, (n) => n.kind === "int" || n.kind === "float");
4652
+ }
4653
+ /** Locate the repeat node carrying a list field's length bounds and item. */
4654
+ function findRepeatNode(node) {
4655
+ return findNode(node, (n) => n.kind === "repeat");
4656
+ }
4657
+ /** Locate the alternative node backing a union field, to map arms to variants. */
4421
4658
  function findAlternativeNode(node) {
4422
4659
  return findNode(node, (n) => n.kind === "alternative");
4423
4660
  }
@@ -5119,7 +5356,7 @@ function computePublicNames$1(appId) {
5119
5356
  * (the `reg` registrations and the `sigScope` child), so passing the same scope
5120
5357
  * the emitter continues with keeps later local registrations consistent.
5121
5358
  */
5122
- function buildEmitModel$1(ctx, scope = new Scope(PY_RESERVED)) {
5359
+ function buildEmitModel(ctx, scope = new Scope(PY_RESERVED)) {
5123
5360
  const appId = ctx.app?.id;
5124
5361
  const pkg = ctx.package?.name;
5125
5362
  const publicNames = computePublicNames$1(appId);
@@ -5162,7 +5399,7 @@ function buildEmitModel$1(ctx, scope = new Scope(PY_RESERVED)) {
5162
5399
  function generatePython(ctx, packageScope) {
5163
5400
  const cb = new CodeBuilder(" ");
5164
5401
  const scope = packageScope ?? new Scope(PY_RESERVED);
5165
- const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
5402
+ const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
5166
5403
  cb.comment("This file was auto generated by Styx.", "# ");
5167
5404
  cb.comment("Do not edit this file directly.", "# ");
5168
5405
  cb.blank();
@@ -5309,6 +5546,7 @@ var PythonBackend = class {
5309
5546
  const files = /* @__PURE__ */ new Map();
5310
5547
  const distNames = [];
5311
5548
  const pkgDirs = [];
5549
+ const warnings = [];
5312
5550
  for (const p of packages) {
5313
5551
  const pkg = p.meta ?? {};
5314
5552
  const dir = pkg.name ?? "package";
@@ -5317,13 +5555,21 @@ var PythonBackend = class {
5317
5555
  files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg));
5318
5556
  files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
5319
5557
  }
5320
- files.set("pyproject.toml", generateRootPyproject(proj, distNames));
5558
+ let rootMod = pyScrubIdent(proj.name && proj.name.trim() ? proj.name : "project", PY_RESERVED);
5559
+ if (pkgDirs.includes(rootMod)) {
5560
+ const collided = rootMod;
5561
+ while (pkgDirs.includes(rootMod)) rootMod += "_";
5562
+ warnings.push({ message: `metapackage module "${collided}" collides with a suite directory; emitting as "${rootMod}" instead` });
5563
+ }
5564
+ files.set(`${rootMod}/__init__.py`, generateRootInitPy());
5565
+ files.set(`${rootMod}/py.typed`, "");
5566
+ files.set("pyproject.toml", generateRootPyproject(proj, distNames, rootMod));
5321
5567
  files.set("README.md", generateRootReadme(proj, distNames));
5322
5568
  files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
5323
5569
  return {
5324
5570
  files,
5325
5571
  errors: [],
5326
- warnings: []
5572
+ warnings
5327
5573
  };
5328
5574
  }
5329
5575
  };
@@ -5469,7 +5715,7 @@ const pyDialect = {
5469
5715
  * @param opts - Import and package-root options.
5470
5716
  */
5471
5717
  function renderPythonCall(ctx, config, opts = {}) {
5472
- const model = buildEmitModel$1(ctx);
5718
+ const model = buildEmitModel(ctx);
5473
5719
  const pkg = ctx.package?.name;
5474
5720
  const callee = pkg ? `${pkg}.${model.names.wrapper}` : model.names.wrapper;
5475
5721
  const call = model.rootIsStruct && model.rootType.kind === "struct" ? renderKwargCall(callee, model.sigEntries, model.rootType, config) : `${callee}(${renderValue(config, model.rootType, "", pyDialect)})`;
@@ -5492,6 +5738,495 @@ function renderKwargCall(callee, sigEntries, rootType, config) {
5492
5738
  return `${callee}(\n${lines.join("\n")}\n)`;
5493
5739
  }
5494
5740
 
5741
+ //#endregion
5742
+ //#region src/backend/typed-spec.ts
5743
+ /** Project a tool's solved tree into the flat typed spec for delegation backends. */
5744
+ function buildTypedSpec(ctx) {
5745
+ const model = buildEmitModel(ctx);
5746
+ const delegation = {
5747
+ moduleName: appModuleName$1(ctx.app),
5748
+ wrapperFn: model.names.wrapper,
5749
+ outputsClass: model.names.outputs
5750
+ };
5751
+ const outputs = collectOutputFields(ctx, pyId);
5752
+ const streams = streamFields(ctx, pyId);
5753
+ if (!model.rootIsStruct || model.rootType.kind !== "struct") return {
5754
+ rootIsStruct: false,
5755
+ params: [],
5756
+ outputs,
5757
+ streams,
5758
+ delegation
5759
+ };
5760
+ const rootType = model.rootType;
5761
+ const fieldByName = new Map(structFields(ctx, rootType, ctx.expr).map((f) => [f.name, f]));
5762
+ const fieldInfo = collectFieldInfo(ctx, rootType);
5763
+ return {
5764
+ rootIsStruct: true,
5765
+ params: model.sigEntries.map((e) => {
5766
+ const fe = fieldByName.get(e.wireKey);
5767
+ const fi = fieldInfo.get(e.wireKey);
5768
+ return describeParam(e, fe?.type, fe?.node, fi?.defaultValue, fi?.doc);
5769
+ }),
5770
+ outputs,
5771
+ streams,
5772
+ delegation
5773
+ };
5774
+ }
5775
+ function describeParam(e, type, node, defaultValue, fallbackDoc) {
5776
+ const optional = e.isOptional;
5777
+ const hasDefault = e.hasExplicitDefault;
5778
+ const base = {
5779
+ hostName: e.name,
5780
+ wireKey: e.wireKey,
5781
+ optional,
5782
+ hasDefault,
5783
+ mandatory: !optional && !hasDefault,
5784
+ default: defaultValue,
5785
+ doc: e.doc ?? fallbackDoc
5786
+ };
5787
+ const inner = type && type.kind === "optional" ? type.inner : type;
5788
+ const choices = inner ? literalChoices(inner, node) : void 0;
5789
+ if (choices) return {
5790
+ ...base,
5791
+ kind: "enum",
5792
+ choices
5793
+ };
5794
+ switch (inner?.kind) {
5795
+ case "scalar":
5796
+ if (inner.scalar === "path") return {
5797
+ ...base,
5798
+ kind: "path",
5799
+ scalarKind: "path",
5800
+ mediaTypes: pathMediaTypes(node)
5801
+ };
5802
+ if (inner.scalar === "int" || inner.scalar === "float") return {
5803
+ ...base,
5804
+ kind: inner.scalar,
5805
+ scalarKind: inner.scalar,
5806
+ range: numericRange(node)
5807
+ };
5808
+ return {
5809
+ ...base,
5810
+ kind: "str",
5811
+ scalarKind: "str"
5812
+ };
5813
+ case "bool": return {
5814
+ ...base,
5815
+ kind: "bool"
5816
+ };
5817
+ case "count": return {
5818
+ ...base,
5819
+ kind: "count"
5820
+ };
5821
+ case "list": return {
5822
+ ...base,
5823
+ kind: "list",
5824
+ listBounds: listBounds(node),
5825
+ itemType: describeItem(inner.item, node)
5826
+ };
5827
+ case "struct": return {
5828
+ ...base,
5829
+ kind: "struct"
5830
+ };
5831
+ case "union": return {
5832
+ ...base,
5833
+ kind: "union"
5834
+ };
5835
+ default: return {
5836
+ ...base,
5837
+ kind: "str"
5838
+ };
5839
+ }
5840
+ }
5841
+ function describeItem(item, node) {
5842
+ const inner = item.kind === "optional" ? item.inner : item;
5843
+ const choices = literalChoices(inner, node);
5844
+ if (choices) return {
5845
+ kind: "enum",
5846
+ choices
5847
+ };
5848
+ switch (inner.kind) {
5849
+ case "scalar":
5850
+ if (inner.scalar === "path") return {
5851
+ kind: "path",
5852
+ scalarKind: "path",
5853
+ mediaTypes: pathMediaTypes(node)
5854
+ };
5855
+ return {
5856
+ kind: inner.scalar,
5857
+ scalarKind: inner.scalar
5858
+ };
5859
+ case "bool": return { kind: "bool" };
5860
+ case "count": return { kind: "count" };
5861
+ case "struct": return { kind: "struct" };
5862
+ case "union": return { kind: "union" };
5863
+ default: return { kind: "str" };
5864
+ }
5865
+ }
5866
+ /** Allowed values when `inner` is a literal or a union of literals (else undefined). */
5867
+ function literalChoices(inner, node) {
5868
+ if (inner.kind === "literal") return [inner.value];
5869
+ if (inner.kind === "union" && inner.variants.length > 0 && inner.variants.every((v) => v.type.kind === "literal")) return inner.variants.map((v) => v.type.value);
5870
+ const altNode = node ? findNode(node, (n) => n.kind === "alternative") : void 0;
5871
+ if (altNode && altNode.kind === "alternative") {
5872
+ const alts = altNode.attrs.alts;
5873
+ if (alts.length > 0 && alts.every((a) => a.kind === "literal")) return alts.map((a) => a.attrs.str);
5874
+ }
5875
+ }
5876
+ function numericRange(node) {
5877
+ const r = findRangeNode(node);
5878
+ if (!r) return void 0;
5879
+ const { minValue, maxValue } = r.attrs;
5880
+ if (minValue === void 0 && maxValue === void 0) return void 0;
5881
+ return {
5882
+ min: minValue,
5883
+ max: maxValue
5884
+ };
5885
+ }
5886
+ function listBounds(node) {
5887
+ const r = findRepeatNode(node);
5888
+ if (!r) return void 0;
5889
+ const { countMin, countMax } = r.attrs;
5890
+ if (countMin === void 0 && countMax === void 0) return void 0;
5891
+ return {
5892
+ min: countMin,
5893
+ max: countMax
5894
+ };
5895
+ }
5896
+ function pathMediaTypes(node) {
5897
+ const p = node ? findNode(node, (n) => n.kind === "path") : void 0;
5898
+ if (p && p.kind === "path") {
5899
+ const mt = p.attrs.mediaTypes;
5900
+ if (mt && mt.length > 0) return mt;
5901
+ }
5902
+ }
5903
+
5904
+ //#endregion
5905
+ //#region src/backend/nipype/nipype.ts
5906
+ /** Derive the per-tool nipype module/class names. */
5907
+ function nipypeNames(ctx) {
5908
+ const mod = appModuleName$1(ctx.app);
5909
+ const rawId = ctx.app?.id ?? "tool";
5910
+ const cls = pascalCase(/^[0-9]/.test(rawId) ? "v_" + rawId : rawId);
5911
+ return {
5912
+ styxStem: `_${mod}`,
5913
+ ifaceStem: mod,
5914
+ cls,
5915
+ inputSpec: `${cls}InputSpec`,
5916
+ outputSpec: `${cls}OutputSpec`
5917
+ };
5918
+ }
5919
+ /** Generate the nipype interface module source for one tool. */
5920
+ function generateNipype(ctx) {
5921
+ return emitNipypeInterface(ctx, buildTypedSpec(ctx), nipypeNames(ctx));
5922
+ }
5923
+ /**
5924
+ * Emits nipype `Interface` definitions whose typed InputSpec/OutputSpec carry
5925
+ * rich constraints (numeric ranges, list bounds, enum choices, file types) and
5926
+ * which delegate execution to the styx Python wrapper (Option B): no command-line
5927
+ * arg-building or output-path resolution is re-implemented here.
5928
+ *
5929
+ * Per tool, two co-located files are emitted so the output is a self-contained,
5930
+ * importable Python package: `_<tool>.py` (the styx Python module) and
5931
+ * `<tool>.py` (the interface, importing the wrapper via a relative import).
5932
+ */
5933
+ var NipypeBackend = class {
5934
+ name = "nipype";
5935
+ target = "nipype";
5936
+ emitApp(ctx, _scope) {
5937
+ const names = nipypeNames(ctx);
5938
+ const spec = buildTypedSpec(ctx);
5939
+ const warnings = [];
5940
+ if (!spec.rootIsStruct) warnings.push({ message: `nipype: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted interface has no typed inputs and raises on run.` });
5941
+ const styxCode = generatePython(ctx);
5942
+ const ifaceCode = emitNipypeInterface(ctx, spec, names);
5943
+ return {
5944
+ meta: ctx.app,
5945
+ files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, ifaceCode]]),
5946
+ errors: [],
5947
+ warnings
5948
+ };
5949
+ }
5950
+ };
5951
+
5952
+ //#endregion
5953
+ //#region src/backend/pydra/emit.ts
5954
+ /** A scalar enum's Python type: int when every choice is numeric, else str. */
5955
+ function enumScalar(choices) {
5956
+ return (choices ?? []).every((c) => typeof c === "number") ? "int" : "str";
5957
+ }
5958
+ /** True when a parameter is a file path (a path scalar or a list of paths). */
5959
+ function isPathParam(p) {
5960
+ return p.kind === "path" || p.kind === "list" && p.itemType?.kind === "path";
5961
+ }
5962
+ function itemTypeStr(item, imp) {
5963
+ if (!item) {
5964
+ imp.typing = true;
5965
+ return "ty.Any";
5966
+ }
5967
+ switch (item.kind) {
5968
+ case "path":
5969
+ imp.file = true;
5970
+ return "File";
5971
+ case "int":
5972
+ case "count": return "int";
5973
+ case "float": return "float";
5974
+ case "str": return "str";
5975
+ case "bool": return "bool";
5976
+ case "enum": return enumScalar(item.choices);
5977
+ default:
5978
+ imp.typing = true;
5979
+ return "ty.Any";
5980
+ }
5981
+ }
5982
+ /** Base (non-optional) Python type expression for a parameter. */
5983
+ function baseType(p, imp) {
5984
+ switch (p.kind) {
5985
+ case "path":
5986
+ imp.file = true;
5987
+ return "File";
5988
+ case "int":
5989
+ case "count": return "int";
5990
+ case "float": return "float";
5991
+ case "str": return "str";
5992
+ case "bool": return "bool";
5993
+ case "enum": return enumScalar(p.choices);
5994
+ case "list": return `list[${itemTypeStr(p.itemType, imp)}]`;
5995
+ case "struct":
5996
+ case "union":
5997
+ imp.typing = true;
5998
+ return "ty.Any";
5999
+ }
6000
+ }
6001
+ /** Full input type, wrapping in `ty.Optional[...]` for an omittable-no-default field. */
6002
+ function inputType(p, imp) {
6003
+ const base = baseType(p, imp);
6004
+ if (p.optional && !p.hasDefault) {
6005
+ imp.typing = true;
6006
+ return `ty.Optional[${base}]`;
6007
+ }
6008
+ return base;
6009
+ }
6010
+ /** An attrs validator enforcing numeric range / list-length bounds, or undefined. */
6011
+ function validatorExpr(p, imp) {
6012
+ const checks = [];
6013
+ if (p.range) {
6014
+ if (p.range.min !== void 0) checks.push(`attrs.validators.ge(${renderPyLiteral(p.range.min)})`);
6015
+ if (p.range.max !== void 0) checks.push(`attrs.validators.le(${renderPyLiteral(p.range.max)})`);
6016
+ }
6017
+ if (p.listBounds) {
6018
+ if (p.listBounds.min !== void 0) checks.push(`attrs.validators.min_len(${p.listBounds.min})`);
6019
+ if (p.listBounds.max !== void 0) checks.push(`attrs.validators.max_len(${p.listBounds.max})`);
6020
+ }
6021
+ if (checks.length === 0) return void 0;
6022
+ imp.attrs = true;
6023
+ return `_styx_optional(${checks.length === 1 ? checks[0] : `attrs.validators.and_(${checks.join(", ")})`})`;
6024
+ }
6025
+ /** Help text: doc + degrade / media-type notes. */
6026
+ function helpText(p) {
6027
+ const parts = [];
6028
+ if (p.doc) parts.push(p.doc);
6029
+ if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
6030
+ if (p.mediaTypes && p.mediaTypes.length > 0) parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
6031
+ return parts.length > 0 ? parts.join(" ") : void 0;
6032
+ }
6033
+ /** Render a `python.arg(...)` field for one parameter. */
6034
+ function renderInputArg(p, imp) {
6035
+ const args = [`type=${inputType(p, imp)}`];
6036
+ if (p.mandatory) {} else if (p.optional && !p.hasDefault) args.push("default=None");
6037
+ else args.push(`default=${renderPyLiteral(p.default)}`);
6038
+ if (p.kind === "enum") args.push(`allowed_values=[${(p.choices ?? []).map((c) => renderPyLiteral(c)).join(", ")}]`);
6039
+ const v = validatorExpr(p, imp);
6040
+ if (v) args.push(`validator=${v}`);
6041
+ const h = helpText(p);
6042
+ if (h) args.push(`help=${pyStr(h)}`);
6043
+ return `python.arg(${args.join(", ")})`;
6044
+ }
6045
+ /** Python type for an output field. `isRoot` is the synthetic output directory. */
6046
+ function outputType(f, isRoot, imp) {
6047
+ if (f.shape.kind === "list") {
6048
+ imp.file = true;
6049
+ return "list[File]";
6050
+ }
6051
+ if (isRoot) {
6052
+ imp.directory = true;
6053
+ return "Directory";
6054
+ }
6055
+ imp.file = true;
6056
+ if (f.shape.optional) {
6057
+ imp.typing = true;
6058
+ return "ty.Optional[File]";
6059
+ }
6060
+ return "File";
6061
+ }
6062
+ function renderOutputArg(f, isRoot, imp) {
6063
+ const args = [`type=${outputType(f, isRoot, imp)}`];
6064
+ if (f.doc) args.push(`help=${pyStr(f.doc)}`);
6065
+ return `python.out(${args.join(", ")})`;
6066
+ }
6067
+ /**
6068
+ * Emit the pydra task module for one tool: a `@python.define` task whose typed
6069
+ * inputs/outputs carry rich constraints and whose body delegates execution to the
6070
+ * co-emitted styx Python wrapper (Option B). Targets the post-rewrite
6071
+ * `pydra.compose` API (pydra >= 1.0a, Python >= 3.11).
6072
+ */
6073
+ function emitPydraTask(ctx, spec, names) {
6074
+ const imp = {
6075
+ typing: false,
6076
+ attrs: false,
6077
+ file: false,
6078
+ directory: false
6079
+ };
6080
+ const inputEntries = spec.params.map((p) => ({
6081
+ key: p.hostName,
6082
+ expr: renderInputArg(p, imp)
6083
+ }));
6084
+ const outputEntries = spec.outputs.map((f, i) => ({
6085
+ key: f.id,
6086
+ expr: renderOutputArg(f, i === 0, imp)
6087
+ }));
6088
+ for (const s of spec.streams) outputEntries.push({
6089
+ key: s.id,
6090
+ expr: `python.out(type=list[str]${s.doc ? `, help=${pyStr(s.doc)}` : ""})`
6091
+ });
6092
+ const needsPath = spec.params.some(isPathParam);
6093
+ const cb = new CodeBuilder(" ");
6094
+ cb.comment("This file was auto generated by Styx.", "# ");
6095
+ cb.comment("Do not edit this file directly.", "# ");
6096
+ cb.comment("Targets the pydra.compose API (pydra >= 1.0a, Python >= 3.11).", "# ");
6097
+ cb.blank();
6098
+ if (needsPath) cb.line("import os");
6099
+ if (imp.typing) cb.line("import typing as ty");
6100
+ if (imp.attrs) cb.line("import attrs.validators");
6101
+ cb.line("from pydra.compose import python");
6102
+ const ff = [];
6103
+ if (imp.directory) ff.push("Directory");
6104
+ if (imp.file) ff.push("File");
6105
+ if (ff.length > 0) cb.line(`from fileformats.generic import ${ff.join(", ")}`);
6106
+ cb.blank();
6107
+ cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}`);
6108
+ cb.blank();
6109
+ cb.blank();
6110
+ if (imp.attrs) {
6111
+ cb.line("def _styx_optional(validator):");
6112
+ cb.indent(() => {
6113
+ cb.line("\"\"\"Apply an attrs validator only to a set (non-NOTHING), non-None value.\"\"\"");
6114
+ cb.line("def _check(instance, attribute, value):");
6115
+ cb.indent(() => {
6116
+ cb.line("if value is attrs.NOTHING or value is None:");
6117
+ cb.indent(() => cb.line("return"));
6118
+ cb.line("validator(instance, attribute, value)");
6119
+ });
6120
+ cb.line("return _check");
6121
+ });
6122
+ cb.blank();
6123
+ cb.blank();
6124
+ }
6125
+ if (needsPath) {
6126
+ cb.line("def _styx_path(value):");
6127
+ cb.indent(() => {
6128
+ cb.line("\"\"\"Convert fileformats / Path inputs into a styxdefs-accepted path.\"\"\"");
6129
+ cb.line("if value is None:");
6130
+ cb.indent(() => cb.line("return None"));
6131
+ cb.line("if isinstance(value, (list, tuple)):");
6132
+ cb.indent(() => cb.line("return [os.fspath(v) for v in value]"));
6133
+ cb.line("return os.fspath(value)");
6134
+ });
6135
+ cb.blank();
6136
+ cb.blank();
6137
+ }
6138
+ cb.line("@python.define(");
6139
+ cb.indent(() => {
6140
+ if (inputEntries.length === 0) cb.line("inputs={},");
6141
+ else {
6142
+ cb.line("inputs={");
6143
+ cb.indent(() => {
6144
+ for (const e of inputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
6145
+ });
6146
+ cb.line("},");
6147
+ }
6148
+ cb.line("outputs={");
6149
+ cb.indent(() => {
6150
+ for (const e of outputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
6151
+ });
6152
+ cb.line("},");
6153
+ });
6154
+ cb.line(")");
6155
+ const paramList = spec.params.map((p) => p.hostName);
6156
+ cb.line(`def ${names.cls}(${paramList.join(", ")}):`);
6157
+ cb.indent(() => {
6158
+ emitDocstring(cb, [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n") || void 0);
6159
+ if (!spec.rootIsStruct) {
6160
+ cb.line("raise NotImplementedError(");
6161
+ cb.indent(() => cb.line(pyStr("styx pydra backend: tools with a non-struct root are not supported.")));
6162
+ cb.line(")");
6163
+ return;
6164
+ }
6165
+ if (spec.params.length === 0) cb.line(`result = ${spec.delegation.wrapperFn}()`);
6166
+ else {
6167
+ cb.line(`result = ${spec.delegation.wrapperFn}(`);
6168
+ cb.indent(() => {
6169
+ for (const p of spec.params) {
6170
+ const expr = isPathParam(p) ? `_styx_path(${p.hostName})` : p.hostName;
6171
+ cb.line(`${p.hostName}=${expr},`);
6172
+ }
6173
+ });
6174
+ cb.line(")");
6175
+ }
6176
+ const returns = [...spec.outputs.map((f) => `result.${f.id}`), ...spec.streams.map((s) => `result.${s.id}`)];
6177
+ if (returns.length === 1) cb.line(`return ${returns[0]}`);
6178
+ else cb.line(`return ${returns.join(", ")}`);
6179
+ });
6180
+ return cb.toString();
6181
+ }
6182
+
6183
+ //#endregion
6184
+ //#region src/backend/pydra/pydra.ts
6185
+ /** Derive the per-tool pydra module/class names. */
6186
+ function pydraNames(ctx) {
6187
+ const mod = appModuleName$1(ctx.app);
6188
+ const rawId = ctx.app?.id ?? "tool";
6189
+ const safeId = /^[0-9]/.test(rawId) ? "v_" + rawId : rawId;
6190
+ return {
6191
+ styxStem: `_${mod}`,
6192
+ ifaceStem: mod,
6193
+ cls: pascalCase(safeId)
6194
+ };
6195
+ }
6196
+ /** Generate the pydra task module source for one tool. */
6197
+ function generatePydra(ctx) {
6198
+ return emitPydraTask(ctx, buildTypedSpec(ctx), pydraNames(ctx));
6199
+ }
6200
+ /**
6201
+ * Emits pydra tasks (`@python.define`, the post-rewrite pydra.compose API) whose
6202
+ * typed inputs/outputs carry rich constraints (numeric ranges and list bounds via
6203
+ * attrs validators, enum choices, file types, defaults) and which delegate
6204
+ * execution to the styx Python wrapper (Option B): no command-line arg-building
6205
+ * or output-path resolution is re-implemented here.
6206
+ *
6207
+ * Per tool, two co-located files are emitted so the output is a self-contained,
6208
+ * importable Python package: `_<tool>.py` (the styx Python module) and
6209
+ * `<tool>.py` (the task, importing the wrapper via a relative import).
6210
+ */
6211
+ var PydraBackend = class {
6212
+ name = "pydra";
6213
+ target = "pydra";
6214
+ emitApp(ctx, _scope) {
6215
+ const names = pydraNames(ctx);
6216
+ const spec = buildTypedSpec(ctx);
6217
+ const warnings = [];
6218
+ if (!spec.rootIsStruct) warnings.push({ message: `pydra: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted task has no typed inputs and raises on run.` });
6219
+ const styxCode = generatePython(ctx);
6220
+ const taskCode = emitPydraTask(ctx, spec, names);
6221
+ return {
6222
+ meta: ctx.app,
6223
+ files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, taskCode]]),
6224
+ errors: [],
6225
+ warnings
6226
+ };
6227
+ }
6228
+ };
6229
+
5495
6230
  //#endregion
5496
6231
  //#region src/backend/schema/jsonschema.ts
5497
6232
  var SchemaBuilder = class {
@@ -5537,7 +6272,7 @@ var SchemaBuilder = class {
5537
6272
  case "literal": return { const: type.value };
5538
6273
  case "optional": return this.fromType(type.inner, node?.kind === "optional" ? node.attrs.node : void 0);
5539
6274
  case "list": {
5540
- const repeat = node?.kind === "repeat" ? node : void 0;
6275
+ const repeat = findRepeatNode(node);
5541
6276
  const schema = {
5542
6277
  type: "array",
5543
6278
  items: this.fromType(type.item, repeat?.attrs.node)
@@ -5547,7 +6282,7 @@ var SchemaBuilder = class {
5547
6282
  return schema;
5548
6283
  }
5549
6284
  case "struct": return this.structSchema(type, node);
5550
- case "union": return this.unionSchema(type);
6285
+ case "union": return this.unionSchema(type, node);
5551
6286
  }
5552
6287
  }
5553
6288
  findTerminal(node) {
@@ -5608,9 +6343,20 @@ var SchemaBuilder = class {
5608
6343
  if (required.length > 0) schema.required = required;
5609
6344
  return schema;
5610
6345
  }
5611
- unionSchema(type) {
6346
+ unionSchema(type, node) {
5612
6347
  if (type.variants.every((v) => v.type.kind === "literal")) return { enum: type.variants.map((v) => v.type.kind === "literal" ? v.type.value : "") };
5613
- return { oneOf: type.variants.map((v) => this.fromType(v.type)) };
6348
+ const altNode = findAlternativeNode(node);
6349
+ return { oneOf: type.variants.map((v, i) => {
6350
+ const schema = this.fromType(v.type, altNode?.attrs.alts[i]);
6351
+ if (v.type.kind === "struct" && "@type" in v.type.fields && schema.properties && !("@type" in schema.properties)) {
6352
+ schema.properties = {
6353
+ "@type": this.fromType(v.type.fields["@type"]),
6354
+ ...schema.properties
6355
+ };
6356
+ schema.required = ["@type", ...schema.required ?? []];
6357
+ }
6358
+ return schema;
6359
+ }) };
5614
6360
  }
5615
6361
  };
5616
6362
  function generateSchema(ctx) {
@@ -6862,7 +7608,7 @@ function computePublicNames(appId) {
6862
7608
  * (the `reg` registrations and the `sigScope` child), so passing the same scope
6863
7609
  * the emitter continues with keeps later local registrations consistent.
6864
7610
  */
6865
- function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
7611
+ function buildEmitModel$1(ctx, scope = new Scope(TS_RESERVED)) {
6866
7612
  const appId = ctx.app?.id;
6867
7613
  const pkg = ctx.package?.name ?? "unknown";
6868
7614
  const publicNames = computePublicNames(appId);
@@ -6905,7 +7651,7 @@ function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
6905
7651
  function generateTypeScript(ctx, packageScope) {
6906
7652
  const cb = new CodeBuilder(" ");
6907
7653
  const scope = packageScope ?? new Scope(TS_RESERVED);
6908
- const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
7654
+ const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
6909
7655
  cb.comment("This file was auto generated by Styx.");
6910
7656
  cb.comment("Do not edit this file directly.");
6911
7657
  cb.blank();
@@ -7088,7 +7834,7 @@ const tsDialect = {
7088
7834
  * @param opts - Import and package-root options.
7089
7835
  */
7090
7836
  function renderTypeScriptCall(ctx, config, opts = {}) {
7091
- const model = buildEmitModel(ctx);
7837
+ const model = buildEmitModel$1(ctx);
7092
7838
  const pkg = ctx.package?.name;
7093
7839
  const fnName = model.rootIsStruct ? model.names.execute : model.names.wrapper;
7094
7840
  const call = `${pkg ? `${pkg}.${fnName}` : fnName}(${model.rootIsStruct && model.rootType.kind === "struct" ? renderStructLiteral(config, model.rootType, "", tsDialect, model.rootTypeTag) : renderValue(config, model.rootType, "", tsDialect)})`;
@@ -8250,5 +8996,5 @@ function compile(source, filenameOrOptions) {
8250
8996
  }
8251
8997
 
8252
8998
  //#endregion
8253
- export { BoutiquesBackend, CodeBuilder, JsonSchemaBackend, PYTHON_RUNNER_DEPS, PassStatus, PythonBackend, STYXDEFS_COMPAT, Scope, TypeScriptBackend, alt, appEntrypoint, atomKey, buildSigEntries, camelCase, canonicalize, collectFieldInfo, collectNamedTypes, compactTokens, compile, compose, createContext, createPipeline, createRegistry, defaultNamingStrategy, defaultPipeline, detectFormat, effectiveOutputName, findDoc, findStructNode, fixpoint, flatten, float, format, formatSolveResult, generateBoutiques, generateOutputsSchema, generatePython, generateSchema, generateTypeScript, int, isGated, isIterated, isStructural, isTerminal, lit, nodeRef, opt, outputGate, pascalCase, path, planOutput, planScope, removeEmpty, renderPythonCall, renderStructLiteral, renderTypeScriptCall, renderValue, rep, repJoin, resolveFieldBinding, resolveOutputs, resolveTypeName, screamingSnakeCase, seq, seqJoin, simplify, snakeCase, solve, str, structKey, typeKey, unionKey };
8999
+ export { BoutiquesBackend, CodeBuilder, JsonSchemaBackend, NipypeBackend, PYTHON_RUNNER_DEPS, PassStatus, PydraBackend, PythonBackend, STYXDEFS_COMPAT, Scope, TypeScriptBackend, alt, appEntrypoint, atomKey, buildEmitModel, buildSigEntries, buildTypedSpec, camelCase, canonicalize, collectFieldInfo, collectNamedTypes, compactTokens, compile, compose, createContext, createPipeline, createRegistry, defaultNamingStrategy, defaultPipeline, detectFormat, effectiveOutputName, findDoc, findStructNode, fixpoint, flatten, float, format, formatSolveResult, generateBoutiques, generateNipype, generateOutputsSchema, generatePydra, generatePython, generateSchema, generateTypeScript, int, isGated, isIterated, isStructural, isTerminal, lit, nipypeNames, nodeRef, opt, outputGate, pascalCase, path, planOutput, planScope, pydraNames, removeEmpty, renderPythonCall, renderStructLiteral, renderTypeScriptCall, renderValue, rep, repJoin, resolveFieldBinding, resolveOutputs, resolveTypeName, screamingSnakeCase, seq, seqJoin, simplify, snakeCase, solve, str, structKey, typeKey, unionKey };
8254
9000
  //# sourceMappingURL=index.mjs.map