behavior-contracts 0.8.2 → 0.8.4

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 (36) hide show
  1. package/dist/authoring.d.ts +1 -1
  2. package/dist/authoring.d.ts.map +1 -1
  3. package/dist/authoring.js +41 -2
  4. package/dist/authoring.js.map +1 -1
  5. package/dist/behavior.d.ts +0 -24
  6. package/dist/behavior.d.ts.map +1 -1
  7. package/dist/behavior.js +24 -2
  8. package/dist/behavior.js.map +1 -1
  9. package/dist/generator/emit-shared-go-typed.d.ts.map +1 -1
  10. package/dist/generator/emit-shared-go-typed.js +7 -7
  11. package/dist/generator/emit-shared-go-typed.js.map +1 -1
  12. package/dist/generator/emit-shared-rust-typed.d.ts +8 -0
  13. package/dist/generator/emit-shared-rust-typed.d.ts.map +1 -1
  14. package/dist/generator/emit-shared-rust-typed.js +20 -6
  15. package/dist/generator/emit-shared-rust-typed.js.map +1 -1
  16. package/dist/generator/emit-shared-typescript.d.ts.map +1 -1
  17. package/dist/generator/emit-shared-typescript.js +18 -2
  18. package/dist/generator/emit-shared-typescript.js.map +1 -1
  19. package/dist/generator/emit-typed-native-go.d.ts.map +1 -1
  20. package/dist/generator/emit-typed-native-go.js +185 -85
  21. package/dist/generator/emit-typed-native-go.js.map +1 -1
  22. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  23. package/dist/generator/emit-typed-native-rust.js +231 -101
  24. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  25. package/dist/generator/native-expr.d.ts +68 -5
  26. package/dist/generator/native-expr.d.ts.map +1 -1
  27. package/dist/generator/native-expr.js +94 -18
  28. package/dist/generator/native-expr.js.map +1 -1
  29. package/dist/generator/typed.d.ts +26 -0
  30. package/dist/generator/typed.d.ts.map +1 -1
  31. package/dist/generator/typed.js +52 -0
  32. package/dist/generator/typed.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/package.json +2 -2
@@ -2,7 +2,7 @@ import { GeneratorFailure } from "./core.js";
2
2
  import { isControlGate } from "../behavior.js";
3
3
  import { goStraightlineInternals } from "./emit-shared-go.js";
4
4
  import { goTypedInternals } from "./emit-shared-go-typed.js";
5
- import { buildTypePlan, deriveTypeRef, findDecl } from "./typed.js";
5
+ import { buildTypePlan, deriveTypeRef, findDecl, inputPortIsOptional, inputPortTypeRef } from "./typed.js";
6
6
  import { concurrencyPlan, execPlan, analyzeSequentialOps, classifyExpr, buildComponentPlan, } from "./straightline.js";
7
7
  import { NativeExprCompiler } from "./native-expr.js";
8
8
  const PKG = "dslcontracts";
@@ -72,18 +72,13 @@ function inferPortType(node, inputPorts, asBinding) {
72
72
  }
73
73
  return "Value";
74
74
  }
75
+ // a ref to a REQUIRED scalar input port → that scalar. An OPTIONAL port has no required-scalar
76
+ // kind (its native type is *T) and is lowered by the opt lane ahead of this — reaching here with
77
+ // one means the shape is uncovered, and "Value" routes it to the caller's loud fail-closed.
75
78
  if (e.path.length === 1) {
76
- const s = inputPorts[e.path[0]];
77
- if (s) {
78
- if (s.type === "string" || s.type === "literal")
79
- return "string"; // literal = constrained string.
80
- if (s.type === "int")
81
- return "int64";
82
- if (s.type === "float" || s.type === "number")
83
- return "float64"; // `"number"` = the float channel.
84
- if (s.type === "bool")
85
- return "bool";
86
- }
79
+ const sc = inputScalarKind(inputPorts[e.path[0]]);
80
+ if (sc !== undefined)
81
+ return sc;
87
82
  }
88
83
  return "Value";
89
84
  }
@@ -121,6 +116,12 @@ function staticStringArrElems(node) {
121
116
  * `dslcontracts.Value` fallback on the native module). `where` names the emission site for the error.
122
117
  */
123
118
  function portFieldGoType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
119
+ // A port reading an OPTIONAL input port lowers through the shared native-expr compiler (the *T
120
+ // pass-through / the coalesce default). It runs FIRST so an optional input can never fall into the
121
+ // required-scalar inference below (which has no way to say "absent").
122
+ const optPort = optPortCompileG(node, inputPorts, where, plan);
123
+ if (optPort !== null)
124
+ return renderTypeRef(optPort.ref);
124
125
  if (staticStringArrElems(node) !== null)
125
126
  return "[]string";
126
127
  // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → []ElemT.
@@ -141,7 +142,7 @@ function portFieldGoType(node, inputPorts, where = "port", asBinding, plan, type
141
142
  return renderTypeRef(composite);
142
143
  const ft = inferPortType(node, inputPorts, asBinding);
143
144
  if (ft === "Value") {
144
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Go type (would need a boxed dslcontracts.Value). The covered read plane is runtime-free and admits NO boxed escape the covered corpus ports are string / bool / static-string-array (projection) / bare-number (limit) only. Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
145
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Go type (would need a boxed dslcontracts.Value). The covered read plane is runtime-free and admits NO boxed escape. ${GO_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
145
146
  }
146
147
  return ft;
147
148
  }
@@ -279,14 +280,20 @@ function numLiteralGoExpr(node) {
279
280
  // fractional / exponent literal → checked float64 (finite, already guaranteed above).
280
281
  return `${PKG}.Value(float64(${node}))`;
281
282
  }
282
- /** A port node is static (literal / ref / concat of those / static arr / bare number literal)
283
- * resolvable to a native Go Value with no NewObj / no evaluate interpreter. */
284
- function portIsStatic(node) {
283
+ /** A port node is static (literal / ref / concat of those / static arr / bare number literal / an
284
+ * optional-input read) — resolvable to a native Go value with no NewObj / no evaluate interpreter. This
285
+ * is the SHAPE gate that decides native ELIGIBILITY; the lowering itself (portFieldGoType / emitPortInit)
286
+ * is what proves each shape, and fails closed loudly on one it cannot lower. */
287
+ function portIsStatic(node, inputPorts) {
285
288
  const arr = staticArrElems(node);
286
289
  if (arr !== null)
287
- return arr.every(portIsStatic);
290
+ return arr.every((el) => portIsStatic(el, inputPorts));
288
291
  if (numLiteralGoExpr(node) !== null)
289
292
  return true; // bare number literal (e.g. Query `limit`)
293
+ // An optional-input read (`opt($.x)` / `coalesce(opt($.x), 20)` / `ne(opt($.x), null)`) is a
294
+ // statically-resolvable port — the opt lane lowers it (or fails closed with the specific reason).
295
+ if (inputPorts !== undefined && exprReadsOptionalInput(node, inputPorts))
296
+ return true;
290
297
  const e = classifyExpr(node);
291
298
  switch (e.kind) {
292
299
  case "str":
@@ -295,7 +302,7 @@ function portIsStatic(node) {
295
302
  case "ref":
296
303
  return true;
297
304
  case "concat":
298
- return e.parts.every((p) => portIsStatic(reconstructG(p)));
305
+ return e.parts.every((p) => portIsStatic(reconstructG(p), inputPorts));
299
306
  default:
300
307
  return false;
301
308
  }
@@ -399,7 +406,7 @@ function coveredMapNode(n, bodyIds, comp) {
399
406
  }
400
407
  const ports = m.ports;
401
408
  for (const p of Object.keys(ports))
402
- if (!portIsStatic(ports[p]))
409
+ if (!portIsStatic(ports[p], comp.inputPorts))
403
410
  return false;
404
411
  return true;
405
412
  }
@@ -432,7 +439,7 @@ function coveredFanoutNode(n, bodyIds, comp) {
432
439
  }
433
440
  const ports = f.ports;
434
441
  for (const p of Object.keys(ports))
435
- if (!portIsStatic(ports[p]))
442
+ if (!portIsStatic(ports[p], comp.inputPorts))
436
443
  return false;
437
444
  return true;
438
445
  }
@@ -539,7 +546,7 @@ function coverageRejectReason(comp) {
539
546
  if (cr.relationKind !== undefined && cr.relationKind !== "single" && cr.relationKind !== "connection")
540
547
  return `relationKind '${cr.relationKind}' not native-covered (node '${n.id}')`;
541
548
  for (const p of Object.keys(cr.ports))
542
- if (portIsStatic(cr.ports[p]) === false)
549
+ if (portIsStatic(cr.ports[p], comp.inputPorts) === false)
543
550
  return `node '${n.id}' port '${p}' is not statically resolvable`;
544
551
  }
545
552
  if (!outputIsNativeLowerable(comp))
@@ -1030,43 +1037,31 @@ function reconstructG(e) {
1030
1037
  function inStructName(compName) {
1031
1038
  return `In_${sanitize(compName)}`;
1032
1039
  }
1033
- /** Go type for an input port's declared type (scalar → concrete; array+elemType → []ElemT; else Value). */
1034
- function inputPortGoType(schema, plan) {
1035
- switch (schema?.type) {
1036
- case "string":
1037
- // a `"literal"` port is a constrained STRING union (`param.literal(…)`, stored 'S') → native string.
1038
- case "literal":
1039
- return "string";
1040
- case "int":
1041
- return "int64";
1042
- case "float":
1043
- // a `"number"` port is the FLOAT channel (run_behavior: number float / f64 / float64).
1044
- case "number":
1045
- return "float64";
1046
- case "bool":
1047
- return "bool";
1048
- case "array":
1049
- case "arr": {
1050
- // #108: an input ARRAY port with a declared elemType lowers to a native []ElemT (the map-over-input
1051
- // $as element type source). Without elemType or a plan, it stays a boxed Value (uncovered / fail-closed).
1052
- const et = schema.elemType;
1053
- if (et !== undefined && plan !== undefined)
1054
- return `[]${renderTypeRef(deriveTypeRef(et, plan))}`;
1055
- return `${PKG}.Value`;
1056
- }
1057
- case "map": {
1058
- // an input MAP port (dynamic string keys, declared value elemType) lowers to a native map[string]ElemT.
1059
- const et = schema.elemType;
1060
- if (et !== undefined && plan !== undefined)
1061
- return `map[string]${renderTypeRef(deriveTypeRef(et, plan))}`;
1062
- return `${PKG}.Value`;
1063
- }
1064
- default:
1065
- return `${PKG}.Value`;
1066
- }
1067
- }
1068
- /** The scalar kind an input port declares, or undefined if it is not a native scalar. */
1040
+ /**
1041
+ * inputPortGoType the native Go type for an input port's declared type. Scalars lower to the concrete
1042
+ * scalar; `array`/`map` with a declared elemType to `[]ElemT` / `map[string]ElemT`; an OPTIONAL port
1043
+ * (`{opt:T}` → `required:false`) to `*T` — the native representation of "the value is absent" (nil).
1044
+ * A declared type with no native lowering stays the boxed Value.
1045
+ *
1046
+ * An OPTIONAL port whose inner type has no native lowering FAILS CLOSED: a boxed `Value` cannot carry
1047
+ * the presence distinction on the covered plane, and emitting the bare inner type would silently turn
1048
+ * "absent" into a zero value. A port whose optionality cannot be represented is never silently coerced.
1049
+ */
1050
+ function inputPortGoType(schema, plan, where = "input port") {
1051
+ const ref = inputPortTypeRef(schema, plan);
1052
+ if (ref !== undefined)
1053
+ return renderTypeRef(ref);
1054
+ if (inputPortIsOptional(schema))
1055
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go typed-native (runtime-free): ${where} is OPTIONAL (declared {opt:…}) but its inner type ${JSON.stringify(schema?.type)} has no native Go lowering, so "absent" has no native representation (a boxed Value would be an escape, and the bare inner type would silently read absent as a zero value). Declare an elemType for an array/map, or use a natively-lowerable scalar.`);
1056
+ return `${PKG}.Value`;
1057
+ }
1058
+ /** The scalar kind a REQUIRED input port declares, or undefined when it is not a required native scalar.
1059
+ * An OPTIONAL port is deliberately NOT a scalar kind here: its native type is `*T`, and the callers of
1060
+ * this function build REQUIRED scalar slots (`in.<Field>` read straight into a non-opt port field).
1061
+ * Answering "int64" for an `{opt:"int"}` port is exactly how "absent" would silently become `0`. */
1069
1062
  function inputScalarKind(schema) {
1063
+ if (inputPortIsOptional(schema))
1064
+ return undefined;
1070
1065
  switch (schema?.type) {
1071
1066
  case "string":
1072
1067
  case "literal": // a `"literal"` union is a constrained string (see inputPortGoType).
@@ -1091,7 +1086,7 @@ function emitInStruct(comp, plan) {
1091
1086
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\ntype ${name} struct{}`;
1092
1087
  }
1093
1088
  const names = ports.map((p) => goFieldName(p));
1094
- const types = ports.map((p) => inputPortGoType(comp.inputPorts[p], plan));
1089
+ const types = ports.map((p) => inputPortGoType(comp.inputPorts[p], plan, `input port '${p}' of component '${comp.name}'`));
1095
1090
  const nameW = Math.max(0, ...names.map((n) => n.length));
1096
1091
  const typeW = Math.max(0, ...types.map((t) => t.length));
1097
1092
  const fields = ports
@@ -1132,6 +1127,15 @@ function emitInDecoder(comp, plan) {
1132
1127
  const scalar = inputScalarKind(schema);
1133
1128
  const et = schema.elemType;
1134
1129
  lines.push(`\tif v, ok := input.Get(${JSON.stringify(p)}); ok {`);
1130
+ if (inputPortIsOptional(schema)) {
1131
+ // An OPTIONAL port decodes into its native *T. materializeExpr's opt de-box IS the wire rule:
1132
+ // a nil Value (null) → nil (absent), any other value → a pointer to the materialized inner. A key
1133
+ // that never appears leaves the struct's zero value (nil) — the same absent value. Checked FIRST:
1134
+ // optionality wraps every inner type (an optional array is *[]T, not []T).
1135
+ lines.push(`\t\tin.${field} = ${goTypedInternals.materializeExpr("v", inputPortTypeRef(schema, plan), plan)}`);
1136
+ lines.push(`\t}`);
1137
+ continue;
1138
+ }
1135
1139
  if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
1136
1140
  // #108: decode the boxed Value array into the native []ElemT (test glue — materialize each element
1137
1141
  // via the typed materializer; a non-array or non-conforming element is a fail-closed decode error).
@@ -1174,6 +1178,9 @@ function emitInDecoder(comp, plan) {
1174
1178
  lines.push(`}`);
1175
1179
  return lines.join("\n");
1176
1180
  }
1181
+ /** The port forms the STATIC (non-optional) lane lowers — named in its reject message so the message
1182
+ * describes what is actually covered rather than a stale corpus list. */
1183
+ const GO_STATIC_PORT_FORMS = "This port does not read an optional input, so it lowers through the STATIC port forms: a string / bool / number literal, a static string-array (projection), a ref to a REQUIRED scalar input port, a ref to an input array/map port or a prior node's array (declared elemType), a ref to a `$as` element field, or a concat of native strings. A dynamic operator at port position is not covered on this lane. (A port that DOES read an optional input lowers through the full native expression compiler, which admits the whole closed operator set — so the covered operator set at port position currently depends on the lane, not on the operator.)";
1177
1184
  /**
1178
1185
  * emitNativeScalar — lower a static port expr to a NATIVE Go expression of the concrete `want` scalar
1179
1186
  * type, or null if it cannot be statically proven that type. Literals: a string literal is native for
@@ -1231,6 +1238,11 @@ function emitNativeScalar(node, want, resolveRef) {
1231
1238
  */
1232
1239
  function emitPortInit(pn, expr, fieldGoType, resolveRef, inputPorts, plan, typedNodes, asBinding, elemBaseExpr) {
1233
1240
  const fieldName = goPortFieldName(pn);
1241
+ // The OPTIONAL-input lane (*T pass-through / coalesce default) — same shared compile as
1242
+ // portFieldGoType, so the field type and its initializer are derived from ONE lowering.
1243
+ const optPort = optPortCompileG(expr, inputPorts ?? {}, `port '${pn}'`, plan);
1244
+ if (optPort !== null)
1245
+ return `${fieldName}: ${optPort.expr}`;
1234
1246
  // #108-map: a port that is a ref to a `$as` element MAP/NAMED field (or an input map port) → the typed
1235
1247
  // composite value directly off the element/input (native map[string]V / struct — ZERO boxed Value).
1236
1248
  if (plan !== undefined) {
@@ -1283,7 +1295,7 @@ function emitPortInit(pn, expr, fieldGoType, resolveRef, inputPorts, plan, typed
1283
1295
  // FAIL CLOSED: the covered read plane is runtime-free — no boxed Value fallback (that would
1284
1296
  // re-couple the covered module to bc-runtime). If a genuinely-dynamic port reaches here, the
1285
1297
  // component is not native-eligible and must regenerate on the boxed straight-line path.
1286
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Go value of type '${fieldGoType}'. The covered read plane admits NO boxed dslcontracts.Value escape regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1298
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Go value of type '${fieldGoType}'. The covered read plane admits NO boxed dslcontracts.Value escape. ${GO_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1287
1299
  }
1288
1300
  /**
1289
1301
  * emitParallelStageArm (bc#87) — EXPLICIT static parallel orchestration for a real-concurrency stage.
@@ -2019,23 +2031,72 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, zeroOut
2019
2031
  lines.push(c);
2020
2032
  return lines.join("\n");
2021
2033
  }
2022
- /** #108: resolve an INPUT port head for a native-expr ref (cond `if` / map `when` / branches). A scalar
2023
- * port `in.<Field>` of the scalar type; an array port WITH elemType `in.<Field>` of `[]ElemT` (so
2024
- * `len(in.<Field>)` works); anything else null (fail-closed). */
2034
+ /**
2035
+ * goInputHeadRef (#108) resolve an INPUT port head for a native-expr ref (cond `if` / map `when` /
2036
+ * branches / an opt-lane port). The port's declared type comes from the shared `inputPortTypeRef` SSoT,
2037
+ * so an OPTIONAL port resolves as `{opt:…}` (native `*T`) and the compiler can see its optionality: a
2038
+ * null-compare becomes a real presence test and a coalesce a real deref-or-default, while a
2039
+ * REQUIRED-scalar slot fed by an opt fails closed. Go values are read directly off the input struct (no
2040
+ * ownership ceremony); a type with no native lowering → null (fail-closed).
2041
+ */
2025
2042
  function goInputHeadRef(head, comp, plan) {
2026
- const schema = comp.inputPorts?.[head];
2027
- if (schema === undefined)
2043
+ const ref = inputPortTypeRef(comp.inputPorts?.[head], plan);
2044
+ if (ref === undefined)
2028
2045
  return null;
2029
- const sc = inputScalarKind(schema);
2030
- if (sc !== undefined) {
2031
- const scalarRef = { kind: "scalar", scalar: sc === "int64" ? "int" : sc === "float64" ? "float" : sc };
2032
- return { expr: `in.${goFieldName(head)}`, ref: scalarRef };
2033
- }
2034
- const et = schema.elemType;
2035
- if ((schema.type === "array" || schema.type === "arr") && et !== undefined) {
2036
- return { expr: `in.${goFieldName(head)}`, ref: { kind: "arr", elem: deriveTypeRef(et, plan) } };
2046
+ return { expr: `in.${goFieldName(head)}`, ref };
2047
+ }
2048
+ /**
2049
+ * optPortCompileG — the OPTIONAL-input port lane (go). The rust twin's mirror: a component may
2050
+ * declare an input port `{opt:T}` (PortSchema `required:false`), and the only native representation of
2051
+ * "the value is absent" is `*T`, so such a port never lowers through the required-scalar inference. The
2052
+ * port expression compiles with the SHARED runtime-free native-expr compiler (native-expr.ts — the same
2053
+ * traversal, type-inference and fail-closed discipline rust uses, so the two agree by construction):
2054
+ *
2055
+ * - `opt($.x)` / `$.x` at port position → `*T` (the leaf receives the absent value as null, exactly
2056
+ * as run_behavior's `evalPorts` passes it).
2057
+ * - `coalesce(opt($.x), <literal>)` → the native deref-or-default — a native `T`, no boxed Value.
2058
+ * - `ne(opt($.x), null)` / `eq(…, null)` → the native presence test (`!= nil` / `== nil`).
2059
+ *
2060
+ * Returns null when the port expression reads NO optional input (the caller keeps its existing lowering).
2061
+ * A port that DOES read one but does not compile — or that compiles to a fallible expression, which this
2062
+ * position cannot hoist — FAILS CLOSED loudly rather than degrade to a required type or a zero value.
2063
+ */
2064
+ function optPortCompileG(node, inputPorts, where, plan) {
2065
+ if (plan === undefined)
2066
+ return null;
2067
+ if (!exprReadsOptionalInput(node, inputPorts))
2068
+ return null;
2069
+ const resolveHead = (head) => goInputHeadRef(head, { inputPorts }, plan);
2070
+ let c;
2071
+ try {
2072
+ // zeroOut is unreachable here: a port expression that needs an error return is rejected below
2073
+ // (stmts.length > 0), so the backend never renders an early-return with it.
2074
+ c = new NativeExprCompiler(makeGoExprBackend("", resolveHead, plan)).compilePort(node);
2075
+ }
2076
+ catch (e) {
2077
+ if (!(e instanceof GeneratorFailure))
2078
+ throw e;
2079
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port but does not lower to a native Go value: ${e.message}`);
2080
+ }
2081
+ if (c.stmts.length > 0)
2082
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port through a FALLIBLE expression (one that can raise INT_OVERFLOW / NAN_OR_INF / MOD_ZERO / PRECISION_LOSS). Such an expression lowers to STATEMENTS carrying an early error-return, and a port is built inline in the ports-struct literal — a position that cannot host them. (A cond/guard position CAN host them, so the same expression is covered there — the boundary is the POSITION, not the operator.) Use a non-fallible port expression.`);
2083
+ return c;
2084
+ }
2085
+ /** exprReadsOptionalInput — does this port expression reference an OPTIONAL (`required:false`) input
2086
+ * port? Language-neutral scan for `ref`/`refOpt` heads bound to an optional port. Decides ONLY which
2087
+ * lane a port takes; the lane itself then proves the lowering (or fails closed). */
2088
+ function exprReadsOptionalInput(node, inputPorts) {
2089
+ if (node === null || typeof node !== "object")
2090
+ return false;
2091
+ if (Array.isArray(node))
2092
+ return node.some((el) => exprReadsOptionalInput(el, inputPorts));
2093
+ const rec = node;
2094
+ const keys = Object.keys(rec);
2095
+ if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
2096
+ const path = rec[keys[0]];
2097
+ return Array.isArray(path) && typeof path[0] === "string" && inputPortIsOptional(inputPorts?.[path[0]]);
2037
2098
  }
2038
- return null;
2099
+ return keys.some((k) => exprReadsOptionalInput(rec[k], inputPorts));
2039
2100
  }
2040
2101
  /**
2041
2102
  * emitCondArm — the struct-native exec of a covered cond node (#108). Mirrors run_behavior's cond
@@ -2281,6 +2342,23 @@ function makeGoExprBackend(zeroOut, resolveHead, plan) {
2281
2342
  // opt (`*T`) null-presence test: present = `(expr != nil)`, absent = `(expr == nil)`.
2282
2343
  optIsSome: (expr) => `(${expr} != nil)`,
2283
2344
  optIsNone: (expr) => `(${expr} == nil)`,
2345
+ // opt (`*T`) defaulted to a PURE default. Go has no `unwrap_or`, so this is the same immediately-
2346
+ // invoked func literal the `ternary` rendering uses (the established shape in this emitter): bind the
2347
+ // pointer ONCE, deref it when present, else yield the default. The default sits in the else branch,
2348
+ // so it is evaluated only when absent — run_behavior's lazy right side exactly.
2349
+ optUnwrapOr: (optExpr, defaultExpr, innerTy) => `func() ${innerTy} { if v := ${optExpr}; v != nil { return *v }; return ${defaultExpr} }()`,
2350
+ // opt defaulted to a FALLIBLE default — a statement if/else, NOT a func literal: the default's
2351
+ // hoisted `return zeroOut, err` early returns must leave the ENCLOSING fn, which a func literal
2352
+ // would swallow (it would return from the literal). Only the nil branch evaluates the default.
2353
+ optCoalesceGuard: (temp, ty, optExpr, bindTemp, dStmts, dExpr) => [
2354
+ `var ${temp} ${ty}`,
2355
+ `if ${bindTemp} := ${optExpr}; ${bindTemp} != nil {`,
2356
+ ` ${temp} = *${bindTemp}`,
2357
+ `} else {`,
2358
+ ...dStmts.map((x) => ` ${x}`),
2359
+ ` ${temp} = ${dExpr}`,
2360
+ `}`,
2361
+ ],
2284
2362
  ternary: (cond, t, e, ty) => `func() ${ty} { if ${cond} { return ${t} }; return ${e} }()`,
2285
2363
  shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
2286
2364
  // and: temp=false; if left { <rStmts>; temp=rExpr }. or: temp=true; if !left { <rStmts>; temp=rExpr }.
@@ -2713,17 +2791,20 @@ ${native.map((c) => `\t${handlerIfaceName(c.name)}`).join("\n")}
2713
2791
  const rowT = rawRowStructName(c.name, node.id);
2714
2792
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
2715
2793
  const nodeBoundT = boundGoType(node, c, typedNodes, plan);
2716
- // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
2717
- // reference scriptedHandlers' `echo === "items"` the scripted handler ECHOES the NATIVE `items`
2718
- // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
2719
- // observed result is exactly the native array that flowed into its ports struct, so a dropped or
2720
- // boxed native array would diverge from run_behavior. `ports` is named only when the echo path
2721
- // uses it (else the param stays `_` so a non-echo node compiles clean / unused-param-free).
2722
- const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
2723
- const portsParam = canEcho ? "ports" : "_";
2724
- const echoBranch = canEcho
2725
- ? `\tif src.echo == "items" {\n\t\treturn ${rowT}{Val: ports.${goPortFieldName("items")}}, true\n\t}\n`
2726
- : "";
2794
+ // #129 (TEST glue): support the reference scriptedHandlers' echo forms (PROTOCOL.md §3.5) the
2795
+ // adapter normalizes `{"echo":"port","port":"X"}` / `{"echo":"items"}` to the port NAME, and the
2796
+ // scripted handler
2797
+ // ECHOES a NATIVE port back as its result. This makes a port-plane pin non-vacuous: the node's
2798
+ // observed result IS the native value that flowed into its ports struct, so a dropped / re-boxed /
2799
+ // zero-defaulted port diverges from run_behavior instead of passing. A port is echoable when its
2800
+ // native type IS the node's outType (that is exactly when the row's Val can hold it); `ports` is
2801
+ // named only when at least one port qualifies (else the param stays `_` so a non-echo node
2802
+ // compiles clean / unused-param-free).
2803
+ const echoable = echoablePorts(c, node, ref, plan, typedNodes);
2804
+ const portsParam = echoable.length > 0 ? "ports" : "_";
2805
+ const echoBranch = echoable
2806
+ .map((ep) => `\tif src.echo == ${JSON.stringify(ep)} {\n\t\treturn ${rowT}{Val: ports.${goPortFieldName(ep)}}, true\n\t}\n`)
2807
+ .join("");
2727
2808
  methodImpls.push(`func (s *ScriptedNativeRaw) ${nodeMethodName(c.name, node.id)}(${portsParam} ${portsStructName(c.name, node.id)}, _ ${nodeBoundT}) (${rowT}, bool) {
2728
2809
  src, ok := s.next(${JSON.stringify(node.component)})
2729
2810
  if !ok {
@@ -2775,10 +2856,19 @@ func NewScriptedNativeRaw(spec *${PKG}.JObj) (*ScriptedNativeRaw, error) {
2775
2856
  for _, e := range entries {
2776
2857
  eo, _ := e.(*${PKG}.JObj)
2777
2858
  // #129: an \`echo\` directive names an input port to echo back (mirrors the reference
2778
- // scriptedHandlers' \`echo === "items"\`). It is drained together with the ok payload.
2859
+ // scriptedHandlers' PROTOCOL.md §3.5 echo forms). It is drained together with the ok
2860
+ // payload. \`{"echo":"port","port":"X"}\` is normalized to the port NAME here, so the
2861
+ // generated node method just compares \`src.echo\` against its echoable port names —
2862
+ // \`{"echo":"items"}\` is already that shape.
2779
2863
  echo := ""
2780
2864
  if echoNode, has := eo.Get("echo"); has {
2781
2865
  echo, _ = echoNode.(string)
2866
+ if echo == "port" {
2867
+ echo = ""
2868
+ if portNode, hasPort := eo.Get("port"); hasPort {
2869
+ echo, _ = portNode.(string)
2870
+ }
2871
+ }
2782
2872
  }
2783
2873
  if okNode, has := eo.Get("ok"); has {
2784
2874
  val, derr := ${PKG}.DecodeValue(okNode)
@@ -2862,6 +2952,16 @@ ${arms}
2862
2952
  }
2863
2953
  `;
2864
2954
  }
2955
+ /** echoablePorts (TEST glue) — the node's ports whose NATIVE type is exactly the node's outType, in
2956
+ * declaration order. Those are the ports a scripted `echo: "<port>"` can return as the row's Val. A port
2957
+ * with a different type is not echoable (the row could not hold it), and a NAMED outType has no Val at
2958
+ * all (its row carries the struct's fields flattened — see emitDecodeRow). */
2959
+ function echoablePorts(comp, node, ref, plan, typedNodes) {
2960
+ if (ref.kind === "named")
2961
+ return [];
2962
+ const want = renderTypeRef(ref);
2963
+ return Object.keys(node.ports).filter((p) => portFieldGoType(node.ports[p], comp.inputPorts, `port '${p}'`, undefined, plan, typedNodes) === want);
2964
+ }
2865
2965
  /** emitDecodeRow — emit (once, deduped) a decoder `decodeRow_<comp>_<node><suffix>(v Value) <rowT>`
2866
2966
  * that materializes the scripted ok Value into the concrete row struct (typed fields = the outType /
2867
2967
  * elem-row type). Named: reuse the Value->struct marshaller + copy fields; scalar/arr/opt: materialize