@styx-api/core 0.7.0 → 0.8.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 (44) hide show
  1. package/dist/index.cjs +430 -356
  2. package/dist/index.d.cts +1 -34
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +1 -34
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +431 -352
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +0 -2
  10. package/src/backend/argtype/emit.ts +2 -51
  11. package/src/backend/boutiques/boutiques.ts +55 -47
  12. package/src/backend/field-defaults.ts +40 -0
  13. package/src/backend/find-struct-node.ts +7 -0
  14. package/src/backend/index.ts +1 -9
  15. package/src/backend/nipype/emit.ts +11 -3
  16. package/src/backend/python/arg-builder.ts +2 -27
  17. package/src/backend/python/emit.ts +18 -6
  18. package/src/backend/python/outputs-emit.ts +27 -42
  19. package/src/backend/python/python.ts +46 -22
  20. package/src/backend/python/validate-emit.ts +4 -1
  21. package/src/backend/resolve-output-tokens.ts +0 -76
  22. package/src/backend/typescript/arg-builder.ts +2 -24
  23. package/src/backend/typescript/emit.ts +7 -4
  24. package/src/backend/typescript/outputs-emit.ts +15 -39
  25. package/src/backend/typescript/typemap.ts +9 -1
  26. package/src/backend/typescript/typescript.ts +41 -18
  27. package/src/backend/typescript/validate-emit.ts +4 -1
  28. package/src/backend/union-variants.ts +41 -1
  29. package/src/frontend/argdump/parser.ts +20 -19
  30. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  31. package/src/frontend/argtype/__fixtures__/bet.argtype +0 -2
  32. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  33. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  34. package/src/frontend/argtype/ast.ts +14 -1
  35. package/src/frontend/argtype/frontmatter.ts +49 -5
  36. package/src/frontend/argtype/lexer.ts +20 -0
  37. package/src/frontend/argtype/lower.ts +115 -75
  38. package/src/frontend/argtype/parser-frontend.ts +2 -2
  39. package/src/frontend/argtype/parser.ts +70 -24
  40. package/src/frontend/boutiques/destruct-template.ts +24 -17
  41. package/src/frontend/boutiques/parser.ts +27 -8
  42. package/src/ir/passes/canonicalize.ts +18 -5
  43. package/src/ir/passes/simplify.ts +17 -2
  44. package/src/solver/solver.ts +2 -2
@@ -378,6 +378,19 @@ export function buildEmitModel(
378
378
  }
379
379
 
380
380
  export function generatePython(ctx: CodegenContext, packageScope?: Scope): string {
381
+ return generatePythonModule(ctx, packageScope).code;
382
+ }
383
+
384
+ /**
385
+ * Emit the module and, alongside it, the dispatch entrypoint carrying the
386
+ * *scope-registered* execute-function name. Computing the entrypoint here (not
387
+ * via the scope-blind `appEntrypoint`) keeps the suite dispatcher in sync with
388
+ * the actual emitted symbol when a shared package scope suffix-bumps a collision.
389
+ */
390
+ function generatePythonModule(
391
+ ctx: CodegenContext,
392
+ packageScope?: Scope,
393
+ ): { code: string; entrypoint: AppEntrypoint | undefined } {
381
394
  const cb = new CodeBuilder(" ");
382
395
  // A package-shared scope keeps top-level names unique across every tool in the
383
396
  // suite's `from .x import *` re-exports; without one a per-tool scope suffices.
@@ -403,9 +416,7 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
403
416
  // Every tool emits an Outputs object: at minimum the synthetic `root` output
404
417
  // directory (output_file(".")), plus any declared file/mutable outputs and
405
418
  // stdout/stderr stream fields. OutputPathType is therefore always imported.
406
- const emitOutputs = true;
407
-
408
- emitImports(cb, true);
419
+ emitImports(cb);
409
420
  cb.blank();
410
421
 
411
422
  emitMetadata(ctx, names.metadata, cb);
@@ -413,12 +424,10 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
413
424
 
414
425
  emitTypeDeclarations(typeDecls, namedTypes, ctx, cb, names.params, rootTypeTag);
415
426
 
416
- if (emitOutputs) {
417
- emitOutputsClass(ctx, names.outputs, cb);
418
- cb.blank();
419
- }
427
+ emitOutputsClass(ctx, names.outputs, cb);
428
+ cb.blank();
420
429
 
421
- if (emitOutputs && needsStripExtensionsHelper(ctx)) {
430
+ if (needsStripExtensionsHelper(ctx)) {
422
431
  emitStripExtensionsHelper(cb);
423
432
  cb.blank();
424
433
  }
@@ -457,10 +466,8 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
457
466
  emitBuildCargs(ctx, rootType, paramsType, names.cargs, cb);
458
467
  cb.blank();
459
468
 
460
- if (emitOutputs) {
461
- emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
462
- cb.blank();
463
- }
469
+ emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
470
+ cb.blank();
464
471
 
465
472
  // Dict-style execute function. For struct roots it's the internal
466
473
  // `<tool>_execute`; for other roots it doubles as the user-facing wrapper.
@@ -471,8 +478,8 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
471
478
  executeName,
472
479
  names.metadata,
473
480
  names.cargs,
474
- emitOutputs ? names.outputsFn : undefined,
475
- emitOutputs ? names.outputs : undefined,
481
+ names.outputsFn,
482
+ names.outputs,
476
483
  names.validate,
477
484
  streamFieldIds(ctx),
478
485
  cb,
@@ -487,7 +494,7 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
487
494
  names.wrapper,
488
495
  names.paramsFn,
489
496
  names.execute,
490
- emitOutputs ? names.outputs : undefined,
497
+ names.outputs,
491
498
  cb,
492
499
  );
493
500
  cb.blank();
@@ -497,10 +504,10 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
497
504
  // module's stdlib/styxdefs imports.
498
505
  const publicSymbols = [
499
506
  names.params,
500
- ...(emitOutputs ? [names.outputs] : []),
507
+ names.outputs,
501
508
  names.metadata,
502
509
  names.cargs,
503
- ...(emitOutputs ? [names.outputsFn] : []),
510
+ names.outputsFn,
504
511
  ...(rootIsStruct ? [names.paramsFn, names.execute] : []),
505
512
  ...nestedFactories.map((nf) => nf.funcName),
506
513
  names.validate,
@@ -512,7 +519,14 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
512
519
  });
513
520
  cb.line("]");
514
521
 
515
- return cb.toString();
522
+ // `executeName` is the scope-registered symbol actually emitted above, so the
523
+ // dispatcher references the real name even after a collision suffix-bump.
524
+ const appId = ctx.app?.id;
525
+ const pkg = ctx.package?.name;
526
+ const entrypoint: AppEntrypoint | undefined =
527
+ appId && pkg ? { type: `${pkg}/${appId}`, executeFn: executeName } : undefined;
528
+
529
+ return { code: cb.toString(), entrypoint };
516
530
  }
517
531
 
518
532
  /**
@@ -529,6 +543,10 @@ export function appModuleName(meta: AppMeta | undefined): string {
529
543
  * The dispatch entrypoint for one app: its root `@type` (`<package>/<app>`) and
530
544
  * the dict-style execute function name. Returns undefined when the id or package
531
545
  * is unknown (no stable `@type`), so the app is left out of the suite dispatcher.
546
+ *
547
+ * Note: this recomputes the execute name without a package scope, so it reflects
548
+ * the un-bumped name. The emitter (`emitApp`) instead takes the entrypoint from
549
+ * the scope-aware emit pass; use that path when a shared package scope is in play.
532
550
  */
533
551
  export function appEntrypoint(ctx: CodegenContext): AppEntrypoint | undefined {
534
552
  const appId = ctx.app?.id;
@@ -599,10 +617,14 @@ function emitPackageDispatch(cb: CodeBuilder, dispatch: AppEntrypoint[]): void {
599
617
  }
600
618
  });
601
619
  cb.line("}");
602
- cb.line('_fn = _dispatch.get(params["@type"])');
620
+ // `.get` (not `params["@type"]`) so a missing discriminant surfaces the
621
+ // clean ValueError below instead of a bare KeyError. The `is not None` guard
622
+ // also narrows `_type` away from None for the typed `_dispatch.get`.
623
+ cb.line('_type = params.get("@type")');
624
+ cb.line("_fn = _dispatch.get(_type) if _type is not None else None");
603
625
  cb.line("if _fn is None:");
604
626
  cb.indent(() => {
605
- cb.line(`raise ValueError(f"No tool registered for @type {params['@type']!r}")`);
627
+ cb.line(`raise ValueError(f"No tool registered for @type {_type!r}")`);
606
628
  });
607
629
  cb.line("return _fn(params, runner)");
608
630
  });
@@ -613,11 +635,13 @@ export class PythonBackend implements Backend {
613
635
  readonly target = "python";
614
636
 
615
637
  emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
616
- const code = generatePython(ctx, scope);
638
+ // Take the entrypoint from the same pass that emitted the module, so its
639
+ // execute-function name reflects any shared-scope suffix-bump.
640
+ const { code, entrypoint } = generatePythonModule(ctx, scope);
617
641
  const fileName = `${appModuleName(ctx.app)}.py`;
618
642
  return {
619
643
  meta: ctx.app,
620
- entrypoint: appEntrypoint(ctx),
644
+ entrypoint,
621
645
  files: new Map([[fileName, code]]),
622
646
  errors: [],
623
647
  warnings: [],
@@ -146,7 +146,10 @@ function emitValue(
146
146
  const itemNode = findRepeatNode(node)?.attrs.node;
147
147
  const elem = e.scope.add("e");
148
148
  e.cb.line(`for ${elem} in ${valueExpr}:`);
149
- e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
149
+ // Report the element type (e.g. `int`), not the list type (`list[int]`).
150
+ e.cb.indent(() =>
151
+ emitValue(e, type.item, itemNode, wireKey, elem, expectedType(e, type.item)),
152
+ );
150
153
  return;
151
154
  }
152
155
  case "struct": {
@@ -1,80 +1,4 @@
1
- import type {
2
- BindingRegistry,
3
- GateAtom,
4
- OutputScope,
5
- ResolvedOutput,
6
- ResolvedToken,
7
- } from "../bindings/index.js";
8
1
  import { outputGate } from "../bindings/index.js";
9
2
 
10
3
  // Re-export the core helper so backends have a single entry point.
11
4
  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
- }
@@ -1,5 +1,5 @@
1
1
  import type { Binding, BindingId, BoundType, BoundVariant } from "../../bindings/index.js";
2
- import { collectFieldInfo } from "../collect-field-info.js";
2
+ import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
3
3
  import type { Expr } from "../../ir/index.js";
4
4
  import type { CodegenContext } from "../../manifest/index.js";
5
5
  import { CodeBuilder } from "../code-builder.js";
@@ -92,14 +92,6 @@ function accessOf(binding: Binding, arg: ArgContext): string {
92
92
  * carrying a Boutiques default. Restricted to single-segment (root) field access
93
93
  * so a nested field can never accidentally pick up a same-named root default.
94
94
  */
95
- function rootFieldDefault(
96
- binding: Binding,
97
- defaults: ReadonlyMap<string, string>,
98
- ): string | undefined {
99
- const a = binding.access;
100
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
101
- return undefined;
102
- }
103
95
 
104
96
  /**
105
97
  * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
@@ -113,20 +105,6 @@ function readAccess(binding: Binding, arg: ArgContext): string {
113
105
  return def !== undefined ? `(${accessOf(binding, arg)} ?? ${def})` : accessOf(binding, arg);
114
106
  }
115
107
 
116
- /** Build the field-name -> rendered-default map for a struct root (else empty).
117
- * Includes only non-optional defaulted fields (optional fields are
118
- * presence-guarded; their default comes from the factory's kwarg signature). */
119
- function collectDefaults(ctx: CodegenContext, rootType?: BoundType): Map<string, string> {
120
- const out = new Map<string, string>();
121
- if (rootType?.kind !== "struct") return out;
122
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
123
- if (fi.defaultValue === undefined) continue;
124
- if (rootType.fields[name]?.kind === "optional") continue;
125
- out.set(name, renderTsLiteral(fi.defaultValue));
126
- }
127
- return out;
128
- }
129
-
130
108
  // -- Recursive descent --
131
109
 
132
110
  let loopVarCounter = 0;
@@ -142,7 +120,7 @@ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundT
142
120
  const initialCtx: ArgContext = {
143
121
  joinDepth: 0,
144
122
  loopVars: new Map(),
145
- defaults: collectDefaults(ctx, rootType),
123
+ defaults: collectDefaults(ctx, renderTsLiteral, rootType),
146
124
  };
147
125
  return walk(rootExpr, ctx, initialCtx);
148
126
  }
@@ -10,7 +10,10 @@ import { collectFieldInfo, resolveTypeName } from "./types.js";
10
10
 
11
11
  export function emitJsDoc(cb: CodeBuilder, description?: string): void {
12
12
  if (!description) return;
13
- const lines = description.split("\n");
13
+ // Escape `*/` so a description containing it can't terminate the block comment
14
+ // early (`*\/` is not a comment terminator; the backslash is inert in comments).
15
+ const safe = description.replace(/\*\//g, "*\\/");
16
+ const lines = safe.split("\n");
14
17
  if (lines.length === 1) {
15
18
  cb.line(`/** ${lines[0]} */`);
16
19
  } else {
@@ -22,9 +25,9 @@ export function emitJsDoc(cb: CodeBuilder, description?: string): void {
22
25
  }
23
26
  }
24
27
 
25
- export function emitImports(cb: CodeBuilder, emitOutputs: boolean): void {
26
- const inputs = ["Runner", "Execution", "Metadata", "InputPathType"];
27
- if (emitOutputs) inputs.push("OutputPathType");
28
+ export function emitImports(cb: CodeBuilder): void {
29
+ // Every tool emits an Outputs object, so OutputPathType is always needed.
30
+ const inputs = ["Runner", "Execution", "Metadata", "InputPathType", "OutputPathType"];
28
31
  cb.line(`import type { ${inputs.join(", ")} } from "styxdefs";`);
29
32
  cb.line('import { getGlobalRunner, StyxValidationError } from "styxdefs";');
30
33
  }
@@ -1,12 +1,6 @@
1
- import type {
2
- Binding,
3
- BindingId,
4
- BoundType,
5
- GateAtom,
6
- ResolvedToken,
7
- } from "../../bindings/index.js";
1
+ import type { BindingId, BoundType, GateAtom, ResolvedToken } from "../../bindings/index.js";
8
2
  import { outputGate } from "../../bindings/index.js";
9
- import { collectFieldInfo } from "../collect-field-info.js";
3
+ import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
10
4
  import type { CodegenContext } from "../../manifest/index.js";
11
5
  import { CodeBuilder } from "../code-builder.js";
12
6
  import {
@@ -18,6 +12,7 @@ import {
18
12
  rootOutput,
19
13
  streamFields,
20
14
  } from "../collect-output-fields.js";
15
+ import { unionIsMixed, variantAtomUnion } from "../union-variants.js";
21
16
  import { emitJsDoc, renderAccess, tsPropAccess } from "./emit.js";
22
17
  import { renderTsLiteral } from "./typemap.js";
23
18
 
@@ -97,32 +92,6 @@ interface OutputEmitCtx {
97
92
  defaults: ReadonlyMap<string, string>;
98
93
  }
99
94
 
100
- /** The rendered default for a binding iff it is a root-level defaulted field. */
101
- function rootFieldDefault(
102
- binding: Binding | undefined,
103
- defaults: ReadonlyMap<string, string>,
104
- ): string | undefined {
105
- if (!binding) return undefined;
106
- const a = binding.access;
107
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
108
- return undefined;
109
- }
110
-
111
- /** Build the field-name -> rendered-default map for the struct root (else empty).
112
- * Includes only non-optional defaulted fields (optional fields are
113
- * presence-guarded; their default comes from the factory's kwarg signature). */
114
- function collectDefaults(ctx: CodegenContext): Map<string, string> {
115
- const out = new Map<string, string>();
116
- const rootType = ctx.resolve(ctx.expr)?.type;
117
- if (rootType?.kind !== "struct") return out;
118
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
119
- if (fi.defaultValue === undefined) continue;
120
- if (rootType.fields[name]?.kind === "optional") continue;
121
- out.set(name, renderTsLiteral(fi.defaultValue));
122
- }
123
- return out;
124
- }
125
-
126
95
  /**
127
96
  * Render one output's wrapper stack and emit the assignment inside the
128
97
  * innermost wrapper. Nesting is done via recursive callbacks so the
@@ -201,10 +170,17 @@ function renderWrapperOpen(atom: GateAtom, ec: OutputEmitCtx): WrapperRender {
201
170
  }
202
171
  if (atom.kind === "variant") {
203
172
  const access = bindingAccess(atom.binding, ec);
204
- return {
205
- open: `if (${access}["@type"] === ${JSON.stringify(atom.variant)}) {`,
206
- close: `}`,
207
- };
173
+ const check = `${access}["@type"] === ${JSON.stringify(atom.variant)}`;
174
+ // A mixed union (`0 | Struct`) only carries `@type` on its struct arms; guard
175
+ // on the object shape first so TS narrows off the bare-literal arm before the
176
+ // subscript (which would otherwise be a "property does not exist" error).
177
+ // Mirrors the cargs builder and validator. Pure-struct unions need no guard.
178
+ const union = variantAtomUnion(atom, ec.ctx.bindings);
179
+ const cond =
180
+ union && unionIsMixed(union)
181
+ ? `typeof ${access} === "object" && ${access} !== null && ${check}`
182
+ : check;
183
+ return { open: `if (${cond}) {`, close: `}` };
208
184
  }
209
185
  // present
210
186
  const binding = ec.ctx.bindings.get(atom.binding);
@@ -321,7 +297,7 @@ export function emitBuildOutputs(
321
297
  ctx,
322
298
  iter: new Map(),
323
299
  fieldShapes: new Map(fields.map((f) => [f.id, f.shape])),
324
- defaults: collectDefaults(ctx),
300
+ defaults: collectDefaults(ctx, renderTsLiteral),
325
301
  };
326
302
 
327
303
  // Initialize the outputs object with defaults so wrapper code can assign or
@@ -1,5 +1,13 @@
1
1
  import type { BoundType } from "../../bindings/index.js";
2
2
 
3
+ // A valid TS identifier can be an unquoted object key; anything else (e.g. a
4
+ // wire key like `4d_input`) must be quoted. Kept local to avoid a cycle with
5
+ // emit.ts (which imports this module).
6
+ const TS_IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
7
+ function objKey(key: string): string {
8
+ return TS_IDENT_RE.test(key) ? key : JSON.stringify(key);
9
+ }
10
+
3
11
  export function mapType(type: BoundType, resolve: (type: BoundType) => string | undefined): string {
4
12
  switch (type.kind) {
5
13
  case "scalar":
@@ -27,7 +35,7 @@ export function mapType(type: BoundType, resolve: (type: BoundType) => string |
27
35
  if (name) return name;
28
36
  const fields = Object.entries(type.fields)
29
37
  .filter(([, v]) => v.kind !== "literal")
30
- .map(([k, v]) => `${k}: ${mapType(v, resolve)}`)
38
+ .map(([k, v]) => `${objKey(k)}: ${mapType(v, resolve)}`)
31
39
  .join("; ");
32
40
  return `{ ${fields} }`;
33
41
  }
@@ -256,6 +256,19 @@ export function buildEmitModel(
256
256
  }
257
257
 
258
258
  export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): string {
259
+ return generateTypeScriptModule(ctx, packageScope).code;
260
+ }
261
+
262
+ /**
263
+ * Emit the module and, alongside it, the dispatch entrypoint carrying the
264
+ * *scope-registered* execute-function name. Computing the entrypoint here (not
265
+ * via the scope-blind `appEntrypoint`) keeps the suite dispatcher in sync with
266
+ * the actual emitted symbol when a shared package scope suffix-bumps a collision.
267
+ */
268
+ function generateTypeScriptModule(
269
+ ctx: CodegenContext,
270
+ packageScope?: Scope,
271
+ ): { code: string; entrypoint: AppEntrypoint | undefined } {
259
272
  const cb = new CodeBuilder(" ");
260
273
  // A package-shared scope keeps top-level names unique across every tool in the
261
274
  // suite barrel; without one (standalone emit) a per-tool scope is enough.
@@ -282,9 +295,7 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
282
295
  // Every tool emits an Outputs object: at minimum the synthetic `root` output
283
296
  // directory (outputFile(".")), plus any declared file/mutable outputs and
284
297
  // stdout/stderr stream fields. OutputPathType is therefore always imported.
285
- const emitOutputs = true;
286
-
287
- emitImports(cb, true);
298
+ emitImports(cb);
288
299
  cb.blank();
289
300
 
290
301
  emitMetadata(ctx, names.metadata, cb);
@@ -292,12 +303,10 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
292
303
 
293
304
  emitTypeDeclarations(typeDecls, namedTypes, ctx, names.params, appId, pkg, cb);
294
305
 
295
- if (emitOutputs) {
296
- emitOutputsInterface(ctx, names.outputs, cb);
297
- cb.blank();
298
- }
306
+ emitOutputsInterface(ctx, names.outputs, cb);
307
+ cb.blank();
299
308
 
300
- if (emitOutputs && needsStripExtensionsHelper(ctx)) {
309
+ if (needsStripExtensionsHelper(ctx)) {
301
310
  emitStripExtensionsHelper(cb);
302
311
  cb.blank();
303
312
  }
@@ -327,10 +336,8 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
327
336
  emitBuildCargs(ctx, rootType, paramsType, names.cargs, cb);
328
337
  cb.blank();
329
338
 
330
- if (emitOutputs) {
331
- emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
332
- cb.blank();
333
- }
339
+ emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
340
+ cb.blank();
334
341
 
335
342
  // Dict-style execute function. For struct roots it's the internal
336
343
  // `<tool>Execute`; for other roots it doubles as the user-facing wrapper.
@@ -341,8 +348,8 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
341
348
  executeName,
342
349
  names.metadata,
343
350
  names.cargs,
344
- emitOutputs ? names.outputsFn : undefined,
345
- emitOutputs ? names.outputs : undefined,
351
+ names.outputsFn,
352
+ names.outputs,
346
353
  names.validate,
347
354
  streamFieldIds(ctx),
348
355
  cb,
@@ -357,13 +364,24 @@ export function generateTypeScript(ctx: CodegenContext, packageScope?: Scope): s
357
364
  names.wrapper,
358
365
  names.paramsFn,
359
366
  names.execute,
360
- emitOutputs ? names.outputs : undefined,
367
+ names.outputs,
361
368
  cb,
362
369
  );
363
370
  cb.blank();
364
371
  }
365
372
 
366
- return cb.toString();
373
+ // `executeName` is the scope-registered symbol actually emitted above, so the
374
+ // dispatcher references the real name even after a collision suffix-bump. Guard
375
+ // on `ctx.package?.name` (not the destructured `pkg`, which falls back to
376
+ // "unknown") so a package-less app yields no entrypoint, as before.
377
+ const entryAppId = ctx.app?.id;
378
+ const entryPkg = ctx.package?.name;
379
+ const entrypoint: AppEntrypoint | undefined =
380
+ entryAppId && entryPkg
381
+ ? { type: `${entryPkg}/${entryAppId}`, executeFn: executeName }
382
+ : undefined;
383
+
384
+ return { code: cb.toString(), entrypoint };
367
385
  }
368
386
 
369
387
  /**
@@ -381,6 +399,9 @@ export function appModuleName(meta: AppMeta | undefined): string {
381
399
  * the dict-style execute function name. Returns undefined when the id or package
382
400
  * is unknown (no stable `@type`), so the app is left out of the suite dispatcher.
383
401
  */
402
+ // Note: this recomputes the execute name without a package scope, so it reflects
403
+ // the un-bumped name. The emitter (`emitApp`) instead takes the entrypoint from
404
+ // the scope-aware emit pass; use that path when a shared package scope is in play.
384
405
  export function appEntrypoint(ctx: CodegenContext): AppEntrypoint | undefined {
385
406
  const appId = ctx.app?.id;
386
407
  const pkg = ctx.package?.name;
@@ -469,11 +490,13 @@ export class TypeScriptBackend implements Backend {
469
490
  readonly target = "typescript";
470
491
 
471
492
  emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
472
- const code = generateTypeScript(ctx, scope);
493
+ // Take the entrypoint from the same pass that emitted the module, so its
494
+ // execute-function name reflects any shared-scope suffix-bump.
495
+ const { code, entrypoint } = generateTypeScriptModule(ctx, scope);
473
496
  const fileName = `${appModuleName(ctx.app)}.ts`;
474
497
  return {
475
498
  meta: ctx.app,
476
- entrypoint: appEntrypoint(ctx),
499
+ entrypoint,
477
500
  files: new Map([[fileName, code]]),
478
501
  errors: [],
479
502
  warnings: [],
@@ -136,7 +136,10 @@ function emitValue(
136
136
  const itemNode = findRepeatNode(node)?.attrs.node;
137
137
  const elem = e.scope.add("el");
138
138
  e.cb.line(`for (const ${elem} of ${access}) {`);
139
- e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
139
+ // Report the element type (e.g. `int`), not the list type (`int[]`).
140
+ e.cb.indent(() =>
141
+ emitValue(e, type.item, itemNode, wireKey, elem, expectedType(e, type.item)),
142
+ );
140
143
  e.cb.line("}");
141
144
  return;
142
145
  }
@@ -1,4 +1,44 @@
1
- import type { BoundType, BoundVariant } from "../bindings/index.js";
1
+ import type { BindingRegistry, BoundType, BoundVariant, GateAtom } from "../bindings/index.js";
2
+
3
+ /**
4
+ * Whether a union is "mixed": it has at least one non-struct (bare-literal) arm
5
+ * alongside its struct variants (e.g. ants `n4_correction = Literal[0] | N4On`).
6
+ *
7
+ * The `@type` discriminator only exists on the struct arms, so indexing
8
+ * `value["@type"]` on the union value is unsound (a type error, and a runtime
9
+ * KeyError if the literal arm is ever hit) until the value is narrowed to a
10
+ * struct at runtime. Backends that dispatch on `@type` must first emit a shape
11
+ * guard (Python `isinstance(value, dict)`, TS `typeof value === "object"`) when
12
+ * this is true; a pure-struct union (every arm a struct) needs no guard.
13
+ */
14
+ export function unionIsMixed(unionType: Extract<BoundType, { kind: "union" }>): boolean {
15
+ return unionType.variants.some((v) => v.type.kind !== "struct");
16
+ }
17
+
18
+ /**
19
+ * The union type bound by a `variant` gate atom, if the atom's binding resolves
20
+ * to a union - used by the outputs emitters to decide whether the variant gate
21
+ * needs a mixed-union shape guard before its `@type` check. Returns `undefined`
22
+ * for a well-formed non-union (defensive; a variant atom should always name a
23
+ * union binding).
24
+ */
25
+ export function variantAtomUnion(
26
+ atom: Extract<GateAtom, { kind: "variant" }>,
27
+ bindings: BindingRegistry,
28
+ ): Extract<BoundType, { kind: "union" }> | undefined {
29
+ return resolveUnion(bindings.get(atom.binding)?.type);
30
+ }
31
+
32
+ function resolveUnion(
33
+ type: BoundType | undefined,
34
+ ): Extract<BoundType, { kind: "union" }> | undefined {
35
+ if (!type) return undefined;
36
+ // An `optional<union>` binding can carry a variant gate (the optional wrapper
37
+ // and the union collapse onto one access path); unwrap to reach the union.
38
+ if (type.kind === "optional") return resolveUnion(type.inner);
39
+ if (type.kind === "union") return type;
40
+ return undefined;
41
+ }
2
42
 
3
43
  /** A union's struct variant paired with its index into the union's `variants`
4
44
  * array (which stays parallel to the IR `alts` and the per-arm results a caller