behavior-contracts 0.8.3 → 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 +222 -96
  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,12 +2,12 @@ import { GeneratorFailure } from "./core.js";
2
2
  import { isControlGate } from "../behavior.js";
3
3
  import { rustStraightlineInternals } from "./emit-shared-rust.js";
4
4
  import { rustTypedInternals } from "./emit-shared-rust-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 } from "./straightline.js";
7
7
  import { NativeExprCompiler } from "./native-expr.js";
8
8
  import { buildAsyncPlans } from "./async-plan.js";
9
9
  const { sanitize, rustStrLit } = rustStraightlineInternals;
10
- const { rustFieldName, renderTypeRef, typedCell, typedFieldAccess, serializeTyped, emitTypeDecls, emitDefaults, emitSerializers, emitTypedValue, emitMarshallers, emitMarshalHelpers, materializeExpr, } = rustTypedInternals;
10
+ const { typeRefIsCopy, rustFieldName, renderTypeRef, typedCell, typedFieldAccess, serializeTyped, emitTypeDecls, emitDefaults, emitSerializers, emitTypedValue, emitMarshallers, emitMarshalHelpers, materializeExpr, } = rustTypedInternals;
11
11
  /** rustErr — build the LOCAL concrete error value for a behavior/plan failure on the covered read
12
12
  * plane (runtime-free). The covered module declares a local `BehaviorError` (see emitBehaviorErrorType);
13
13
  * the runner returns `Result<_, BehaviorError>` over THAT local type. This REPLACES the old
@@ -135,18 +135,13 @@ function inferPortType(node, inputPorts, asBinding) {
135
135
  }
136
136
  return "Value";
137
137
  }
138
+ // a ref to a REQUIRED scalar input port → that scalar. An OPTIONAL port has no required-scalar
139
+ // kind (its native type is Option<T>) and is lowered by the opt lane ahead of this — reaching here
140
+ // with one means the shape is uncovered, and "Value" routes it to the caller's loud fail-closed.
138
141
  if (e.path.length === 1) {
139
- const s = inputPorts[e.path[0]];
140
- if (s) {
141
- if (s.type === "string" || s.type === "literal")
142
- return "String"; // literal = constrained String.
143
- if (s.type === "int")
144
- return "i64";
145
- if (s.type === "float" || s.type === "number")
146
- return "f64"; // `"number"` = the float channel.
147
- if (s.type === "bool")
148
- return "bool";
149
- }
142
+ const sc = inputScalarKind(inputPorts[e.path[0]]);
143
+ if (sc !== undefined)
144
+ return sc;
150
145
  }
151
146
  return "Value";
152
147
  }
@@ -180,6 +175,12 @@ function staticStringArrElems(node) {
180
175
  * fallback on the native module). `where` names the emission site for the error.
181
176
  */
182
177
  function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
178
+ // A port reading an OPTIONAL input port lowers through the shared native-expr compiler (the
179
+ // Option<T> pass-through / the coalesce default). It runs FIRST so an optional input can never fall
180
+ // into the required-scalar inference below (which has no way to say "absent").
181
+ const optPort = optPortCompileR(node, inputPorts, where, plan);
182
+ if (optPort !== null)
183
+ return renderTypeRef(optPort.ref);
183
184
  if (staticStringArrElems(node) !== null)
184
185
  return "Vec<&'static str>";
185
186
  // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → Vec<ElemT>.
@@ -202,10 +203,61 @@ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, ty
202
203
  return renderTypeRef(composite);
203
204
  const ft = inferPortType(node, inputPorts, asBinding);
204
205
  if (ft === "Value") {
205
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Rust type (would need a boxed 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.`);
206
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Rust type (would need a boxed Value). The covered read plane is runtime-free and admits NO boxed escape. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
206
207
  }
207
208
  return ft;
208
209
  }
210
+ /**
211
+ * optPortCompileR — the OPTIONAL-input port lane (rust). A component may declare an input port
212
+ * `{opt:T}` (PortSchema `required:false`); the only native representation of "the value is absent" is
213
+ * `Option<T>`, so such a port never lowers through the required-scalar inference. This lane compiles the
214
+ * port expression with the SHARED runtime-free native-expr compiler (native-expr.ts — the same traversal,
215
+ * type-inference and fail-closed discipline the go twin uses, so the two agree by construction):
216
+ *
217
+ * - `opt($.x)` / `$.x` at port position → `Option<T>` (the leaf receives the absent value as null,
218
+ * exactly as run_behavior's `evalPorts` passes it).
219
+ * - `coalesce(opt($.x), <literal>)` → `in_.x.unwrap_or(<literal>)` — a native `T`, no boxed Value.
220
+ * - `ne(opt($.x), null)` / `eq(…, null)` → the native presence test (`is_some()` / `is_none()`).
221
+ *
222
+ * Returns null when the port expression reads NO optional input (the caller keeps its existing lowering).
223
+ * A port that DOES read one but does not compile — or that compiles to a fallible expression, which this
224
+ * position cannot hoist — FAILS CLOSED loudly rather than degrade to a required type or a zero value.
225
+ */
226
+ function optPortCompileR(node, inputPorts, where, plan) {
227
+ if (plan === undefined)
228
+ return null;
229
+ if (!exprReadsOptionalInput(node, inputPorts))
230
+ return null;
231
+ const resolveHead = (head) => rustInputHeadRef(head, { inputPorts }, plan);
232
+ let c;
233
+ try {
234
+ c = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compilePort(node);
235
+ }
236
+ catch (e) {
237
+ if (!(e instanceof GeneratorFailure))
238
+ throw e;
239
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port but does not lower to a native Rust value: ${e.message}`);
240
+ }
241
+ if (c.stmts.length > 0)
242
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust 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.`);
243
+ return c;
244
+ }
245
+ /** exprReadsOptionalInput — does this port expression reference an OPTIONAL (`required:false`) input
246
+ * port? Language-neutral scan for `ref`/`refOpt` heads bound to an optional port. Decides ONLY which
247
+ * lane a port takes; the lane itself then proves the lowering (or fails closed). */
248
+ function exprReadsOptionalInput(node, inputPorts) {
249
+ if (node === null || typeof node !== "object")
250
+ return false;
251
+ if (Array.isArray(node))
252
+ return node.some((el) => exprReadsOptionalInput(el, inputPorts));
253
+ const rec = node;
254
+ const keys = Object.keys(rec);
255
+ if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
256
+ const path = rec[keys[0]];
257
+ return Array.isArray(path) && typeof path[0] === "string" && inputPortIsOptional(inputPorts?.[path[0]]);
258
+ }
259
+ return keys.some((k) => exprReadsOptionalInput(rec[k], inputPorts));
260
+ }
209
261
  /** portCompositeRef — the full TypeRef of a port that is a `{ref:[...]}` into a `$as` element field OR
210
262
  * an input port whose type is a MAP or NAMED struct (a non-scalar covered field). Returns null when the
211
263
  * node is not such a ref (so the caller falls through to the scalar/array inference). #108-map: enables a
@@ -393,21 +445,40 @@ function makeRustExprBackend(resolveHead, plan) {
393
445
  // position (a bare `None` can be ambiguous; the explicit turbofish keeps it a fully-typed value).
394
446
  optNone: (innerTy) => `Option::<${innerTy}>::None`,
395
447
  // opt (`Option<T>`) null-presence test: present = `expr.is_some()`, absent = `expr.is_none()`.
396
- optIsSome: (expr) => `${expr}.is_some()`,
397
- optIsNone: (expr) => `${expr}.is_none()`,
448
+ // A presence test only READS the discriminant, so an owning clone of the operand (which a head
449
+ // resolver adds so the value can be moved into a port) is dropped here — testing `Option<String>`
450
+ // must not heap-allocate. Mirrors the go twin's zero-cost `!= nil`.
451
+ optIsSome: (expr) => `${rustStripTrailingClone(expr)}.is_some()`,
452
+ optIsNone: (expr) => `${rustStripTrailingClone(expr)}.is_none()`,
453
+ // opt (`Option<T>`) defaulted to a PURE default — it cannot fail and has no effect, so its
454
+ // evaluation order is UNOBSERVABLE and both forms below are correct (a FALLIBLE default, where
455
+ // laziness IS observable, goes to optCoalesceGuard instead). `unwrap_or` is EAGER — fine for a plain
456
+ // literal/copy. A default that allocates (an owned `String`, a struct/vec literal) uses
457
+ // `unwrap_or_else`, whose closure defers the allocation to the absent branch: not a semantic
458
+ // requirement here, but pointless work otherwise — and what clippy `or_fun_call` asks for.
459
+ optUnwrapOr: (optExpr, defaultExpr, _innerTy) => /^[A-Za-z0-9_.:]+$/.test(defaultExpr) ? `${optExpr}.unwrap_or(${defaultExpr})` : `${optExpr}.unwrap_or_else(|| ${defaultExpr})`,
460
+ // opt defaulted to a FALLIBLE default — a `match`, NOT a closure: the default's hoisted `?` early
461
+ // returns must propagate out of the ENCLOSING fn, which `unwrap_or_else`'s closure would swallow
462
+ // (it would try to return from the closure). Only the None arm evaluates the default.
463
+ optCoalesceGuard: (temp, ty, optExpr, bindTemp, dStmts, dExpr) => [
464
+ `let ${temp}: ${ty} = match ${optExpr} {`,
465
+ ` Some(${bindTemp}) => ${bindTemp},`,
466
+ ` None => { ${rustBlockBody(dStmts, dExpr)} }`,
467
+ `};`,
468
+ ],
398
469
  ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
399
470
  shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
400
471
  // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
401
472
  // build a block that runs rStmts then yields rExpr (parens stripped — block-tail is not a sub-expr).
402
- const rblock = `{ ${rStmts.join(" ")} ${rustStripOuterParens(rExpr)} }`;
473
+ const rblock = `{ ${rustBlockBody(rStmts, rExpr)} }`;
403
474
  if (op === "and") {
404
475
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} ${rblock} else { false };`];
405
476
  }
406
477
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} { true } else ${rblock};`];
407
478
  },
408
479
  condGuard: (temp, ty, cond, tStmts, tExpr, eStmts, eExpr) => {
409
- const tblock = `{ ${tStmts.join(" ")} ${rustStripOuterParens(tExpr)} }`;
410
- const eblock = `{ ${eStmts.join(" ")} ${rustStripOuterParens(eExpr)} }`;
480
+ const tblock = `{ ${rustBlockBody(tStmts, tExpr)} }`;
481
+ const eblock = `{ ${rustBlockBody(eStmts, eExpr)} }`;
411
482
  return [`let ${temp}: ${ty} = if ${rustStripOuterParens(cond)} ${tblock} else ${eblock};`];
412
483
  },
413
484
  };
@@ -416,6 +487,17 @@ function makeRustExprBackend(resolveHead, plan) {
416
487
  function rustSnake(name) {
417
488
  return name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replace(/([0-9])([a-z])/g, "$1_$2").toLowerCase();
418
489
  }
490
+ /** rustBlockBody — the body of a block-expression that runs `stmts` then yields `expr`. A trailing
491
+ * `let t = E; t` is collapsed to `E`: binding a temp only to return it on the next line is clippy's
492
+ * `let_and_return` (an error under `-D warnings`), and the hoisted-fallible shape produces exactly that
493
+ * whenever the block's tail IS the hoisted temp. */
494
+ function rustBlockBody(stmts, expr) {
495
+ const last = stmts[stmts.length - 1];
496
+ const m = last !== undefined ? /^let\s+([A-Za-z0-9_]+)(?::\s*[^=]+)?\s*=\s*(.+);$/.exec(last) : null;
497
+ if (m !== null && m[1] === expr)
498
+ return [...stmts.slice(0, -1), m[2]].join(" ");
499
+ return [...stmts, rustStripOuterParens(expr)].join(" ");
500
+ }
419
501
  /** strip ONE redundant outer paren pair from an expr (clippy `if (cond)` unused_parens). Only when the
420
502
  * whole string is a single balanced `( … )` — a `(a) && (b)` stays untouched. */
421
503
  function rustStripOuterParens(expr) {
@@ -433,6 +515,12 @@ function rustStripOuterParens(expr) {
433
515
  }
434
516
  return expr.slice(1, -1);
435
517
  }
518
+ /** Drop a TRAILING `.clone()` from an expression — for a position that only reads the value (no move),
519
+ * so an owning clone added by a head resolver is pure waste. Only a clone at the very END is dropped: a
520
+ * `.clone()` mid-path (`t_x.borrow().clone().field`) still owns the field access that follows it. */
521
+ function rustStripTrailingClone(expr) {
522
+ return expr.endsWith(".clone()") ? expr.slice(0, -".clone()".length) : expr;
523
+ }
436
524
  /** Rust float literal (finite; integral gets `.0`). */
437
525
  function rustFloatLitExpr(n) {
438
526
  if (Number.isInteger(n))
@@ -859,14 +947,20 @@ function numLiteralRustExpr(node) {
859
947
  // fractional / exponent literal → checked f64 (finite, guaranteed above).
860
948
  return `Value::Float(${node}f64)`;
861
949
  }
862
- /** A port node is static (literal / ref / concat of those / static arr / bare number literal)
863
- * resolvable to a native Value with no Obj / no evaluate interpreter. */
864
- function portIsStatic(node) {
950
+ /** A port node is static (literal / ref / concat of those / static arr / bare number literal / an
951
+ * optional-input read) — resolvable to a native value with no Obj / no evaluate interpreter. This is the
952
+ * SHAPE gate that decides native ELIGIBILITY; the lowering itself (portFieldRustType / emitPortInit) is
953
+ * what proves each shape, and fails closed loudly on one it cannot lower. */
954
+ function portIsStatic(node, inputPorts) {
865
955
  const arr = staticArrElems(node);
866
956
  if (arr !== null)
867
- return arr.every(portIsStatic);
957
+ return arr.every((el) => portIsStatic(el, inputPorts));
868
958
  if (numLiteralRustExpr(node) !== null)
869
959
  return true; // bare number literal (e.g. Query `limit`)
960
+ // An optional-input read (`opt($.x)` / `coalesce(opt($.x), 20)` / `ne(opt($.x), null)`) is a
961
+ // statically-resolvable port — the opt lane lowers it (or fails closed with the specific reason).
962
+ if (inputPorts !== undefined && exprReadsOptionalInput(node, inputPorts))
963
+ return true;
870
964
  const e = classifyExpr(node);
871
965
  switch (e.kind) {
872
966
  case "str":
@@ -875,7 +969,7 @@ function portIsStatic(node) {
875
969
  case "ref":
876
970
  return true;
877
971
  case "concat":
878
- return e.parts.every((p) => portIsStatic(reconstructR(p)));
972
+ return e.parts.every((p) => portIsStatic(reconstructR(p), inputPorts));
879
973
  default:
880
974
  return false;
881
975
  }
@@ -944,7 +1038,7 @@ function coveredMapNode(n, bodyIds, comp) {
944
1038
  }
945
1039
  const ports = m.ports;
946
1040
  for (const p of Object.keys(ports))
947
- if (!portIsStatic(ports[p]))
1041
+ if (!portIsStatic(ports[p], comp.inputPorts))
948
1042
  return false;
949
1043
  return true;
950
1044
  }
@@ -971,7 +1065,7 @@ function coveredFanoutNode(n, bodyIds, comp) {
971
1065
  }
972
1066
  const ports = f.ports;
973
1067
  for (const p of Object.keys(ports))
974
- if (!portIsStatic(ports[p]))
1068
+ if (!portIsStatic(ports[p], comp.inputPorts))
975
1069
  return false;
976
1070
  return true;
977
1071
  }
@@ -1082,7 +1176,7 @@ function coverageRejectReason(comp) {
1082
1176
  if (cr.relationKind !== undefined && cr.relationKind !== "single" && cr.relationKind !== "connection")
1083
1177
  return `relationKind '${cr.relationKind}' not native-covered (node '${n.id}')`;
1084
1178
  for (const p of Object.keys(cr.ports))
1085
- if (portIsStatic(cr.ports[p]) === false)
1179
+ if (portIsStatic(cr.ports[p], comp.inputPorts) === false)
1086
1180
  return `node '${n.id}' port '${p}' is not statically resolvable`;
1087
1181
  }
1088
1182
  if (!outputIsNativeLowerable(comp))
@@ -1110,44 +1204,31 @@ function assertTypedCoverage(comp) {
1110
1204
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#77): component '${comp.name}' is a sequential componentRef read but lacks outType/outputType on every node — the combined emitter de-boxes the RESULT into the node's outType struct, so a covered read MUST be fully typed (bc#45). Add outType annotations, or regenerate on rust-straightline-native (boxed result).`);
1111
1205
  }
1112
1206
  // ── typed input struct (InNR<Comp>) + NATIVE scalar port lowering ────────────────────────────
1113
- /** Rust type for an input port's declared type (scalar → concrete; array+elemType → Vec<ElemT>; else Value). */
1114
- function inputPortRustType(schema, plan) {
1115
- switch (schema?.type) {
1116
- case "string":
1117
- // a `"literal"` port is a constrained STRING union (`param.literal('a','b',…)`, stored 'S') its
1118
- // native representation is a plain String (the union constraint is a validation concern, not a type).
1119
- case "literal":
1120
- return "String";
1121
- case "int":
1122
- return "i64";
1123
- case "float":
1124
- // a `"number"` port is a FLOAT (run_behavior: `typeName(number) === "float"`, expr-eval.ts §value):
1125
- // a JS number is IEEE754 → f64. (An i64 value is declared `"int"`; `"number"` is the float channel.)
1126
- case "number":
1127
- return "f64";
1128
- case "bool":
1129
- return "bool";
1130
- case "array":
1131
- case "arr": {
1132
- // #108: an input ARRAY port with a declared elemType lowers to a native Vec<ElemT>.
1133
- const et = schema.elemType;
1134
- if (et !== undefined && plan !== undefined)
1135
- return `Vec<${renderTypeRef(deriveTypeRef(et, plan))}>`;
1136
- return "Value";
1137
- }
1138
- case "map": {
1139
- // an input MAP port (dynamic string keys, declared value elemType) lowers to a native BTreeMap.
1140
- const et = schema.elemType;
1141
- if (et !== undefined && plan !== undefined)
1142
- return `std::collections::BTreeMap<String, ${renderTypeRef(deriveTypeRef(et, plan))}>`;
1143
- return "Value";
1144
- }
1145
- default:
1146
- return "Value";
1147
- }
1148
- }
1149
- /** The scalar kind an input port declares, or undefined if it is not a native scalar. */
1207
+ /**
1208
+ * inputPortRustType the native Rust type for an input port's declared type. Scalars lower to the
1209
+ * concrete scalar; `array`/`map` with a declared elemType to `Vec<ElemT>` / `BTreeMap<String, ElemT>`;
1210
+ * an OPTIONAL port (`{opt:T}` → `required:false`) to `Option<T>` — the native representation of "the
1211
+ * value is absent". A declared type with no native lowering stays the boxed `Value`.
1212
+ *
1213
+ * An OPTIONAL port whose inner type has no native lowering FAILS CLOSED: `Option<Value>` would be a
1214
+ * boxed escape, and emitting the bare inner type would silently turn "absent" into a zero value. A port
1215
+ * whose optionality cannot be represented is never silently coerced.
1216
+ */
1217
+ function inputPortRustType(schema, plan, where = "input port") {
1218
+ const ref = inputPortTypeRef(schema, plan);
1219
+ if (ref !== undefined)
1220
+ return renderTypeRef(ref);
1221
+ if (inputPortIsOptional(schema))
1222
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} is OPTIONAL (declared {opt:…}) but its inner type ${JSON.stringify(schema?.type)} has no native Rust lowering, so "absent" has no native representation (an Option<Value> would be a boxed 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.`);
1223
+ return "Value";
1224
+ }
1225
+ /** The scalar kind a REQUIRED input port declares, or undefined when it is not a required native scalar.
1226
+ * An OPTIONAL port is deliberately NOT a scalar kind here: its native type is `Option<T>`, and the
1227
+ * callers of this function build REQUIRED scalar slots (`in_.<field>` read straight into a non-opt port
1228
+ * field). Answering "i64" for an `{opt:"int"}` port is exactly how "absent" would silently become `0`. */
1150
1229
  function inputScalarKind(schema) {
1230
+ if (inputPortIsOptional(schema))
1231
+ return undefined;
1151
1232
  switch (schema?.type) {
1152
1233
  case "string":
1153
1234
  case "literal": // a `"literal"` union is a constrained String (see inputPortRustType).
@@ -1172,7 +1253,7 @@ function emitInStruct(comp, plan) {
1172
1253
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\n#[derive(Default)]\npub struct ${name};`;
1173
1254
  }
1174
1255
  const fields = ports
1175
- .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan)}, // ${JSON.stringify(p)}`)
1256
+ .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan, `input port '${p}' of component '${comp.name}'`)}, // ${JSON.stringify(p)}`)
1176
1257
  .join("\n");
1177
1258
  return `// ${name} — the CONCRETE input for '${comp.name}' (fields = inputPorts; typed, consumer-built —
1178
1259
  // NO generic Value slice, NO per-field boxing crosses the covered read boundary).
@@ -1181,6 +1262,9 @@ pub struct ${name} {
1181
1262
  ${fields}
1182
1263
  }`;
1183
1264
  }
1265
+ /** The port forms the STATIC (non-optional) lane lowers — named in its reject message so the message
1266
+ * describes what is actually covered rather than a stale corpus list. */
1267
+ const RUST_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.)";
1184
1268
  /**
1185
1269
  * emitNativeScalar — lower a static port expr to a NATIVE Rust expression of the concrete `want` scalar
1186
1270
  * type, or null if it cannot be statically proven that type. A string literal is native for
@@ -1254,6 +1338,11 @@ function emitNativeScalar(node, want, resolveRef) {
1254
1338
  */
1255
1339
  function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes, asBinding, elemBaseExpr) {
1256
1340
  const field = portFieldName(pn);
1341
+ // The OPTIONAL-input lane (Option<T> pass-through / coalesce default) — same shared compile as
1342
+ // portFieldRustType, so the field type and its initializer are derived from ONE lowering.
1343
+ const optPort = optPortCompileR(expr, inputPorts ?? {}, `port '${pn}'`, plan);
1344
+ if (optPort !== null)
1345
+ return `${field}: ${optPort.expr}`;
1257
1346
  // #108-map: a port that is a ref to a `$as` element MAP/NAMED field (or an input map port) → CLONE the
1258
1347
  // typed composite value directly off the element/input (native BTreeMap / struct — ZERO boxed Value).
1259
1348
  // Reuses portCompositeRef for the type; the value is the typed field access `.clone()`.
@@ -1303,7 +1392,7 @@ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typ
1303
1392
  // FAIL CLOSED: the covered read plane is runtime-free — no boxed Value fallback (that would re-couple
1304
1393
  // the covered module to bc-runtime). If a genuinely-dynamic port reaches here, the component is not
1305
1394
  // native-eligible and must regenerate on the boxed straight-line path.
1306
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Rust value of type '${fieldRustType}'. The covered read plane admits NO boxed Value escape regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1395
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Rust value of type '${fieldRustType}'. The covered read plane admits NO boxed Value escape. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1307
1396
  }
1308
1397
  // ── the combined runner (STRUCT-returning; INLINE sequential; native ports in; concrete row out) ──
1309
1398
  /** resolveRefAt for the top-level runner: a ref head is a PRIOR NODE (its typed scalar field) or an
@@ -2077,24 +2166,29 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync
2077
2166
  lines.push(c);
2078
2167
  return lines.join("\n");
2079
2168
  }
2080
- /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
2081
- * cloned, Copy as-is); array WITH elemType `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
2169
+ /**
2170
+ * rustInputHeadRef (#108) resolve an INPUT port head for a native-expr ref (rust). The port's declared
2171
+ * type comes from the shared `inputPortTypeRef` SSoT, so an OPTIONAL port resolves as `{opt:…}` (native
2172
+ * `Option<T>`) and the compiler can see its optionality: a null-compare becomes a real presence test and
2173
+ * a coalesce a real `unwrap_or`, while a REQUIRED-scalar slot fed by an opt fails closed. Exprs are OWNED
2174
+ * (String / Option<String> cloned; Copy scalars as-is), except an array — the only array use inside a
2175
+ * cond/guard expr is `len`, which BORROWS (`&in_.xs`).
2176
+ */
2082
2177
  function rustInputHeadRef(head, comp, plan) {
2083
2178
  const schema = comp.inputPorts?.[head];
2084
- if (schema === undefined)
2179
+ const ref = inputPortTypeRef(schema, plan);
2180
+ if (ref === undefined)
2085
2181
  return null;
2086
- const sc = inputScalarKind(schema);
2087
- if (sc !== undefined) {
2088
- const scalarRef = { kind: "scalar", scalar: sc === "i64" ? "int" : sc === "f64" ? "float" : sc === "String" ? "string" : "bool" };
2089
- const field = `in_.${rustFieldName(head)}`;
2090
- return { expr: sc === "String" ? `${field}.clone()` : field, ref: scalarRef };
2091
- }
2092
- const et = schema.elemType;
2093
- if ((schema.type === "array" || schema.type === "arr") && et !== undefined) {
2094
- // no clone: the only array use inside a cond/guard expr is `len`, which BORROWS (`&in_.xs`).
2095
- return { expr: `in_.${rustFieldName(head)}`, ref: { kind: "arr", elem: deriveTypeRef(et, plan) } };
2096
- }
2097
- return null;
2182
+ const field = `in_.${rustFieldName(head)}`;
2183
+ return { expr: rustOwnedHeadExpr(field, ref), ref };
2184
+ }
2185
+ /** The OWNED native expression for a head of type `ref` read off a by-value struct field. A String (or an
2186
+ * Option/collection containing one) is cloned; a Copy scalar (and an Option/array of Copy scalars) is
2187
+ * read directly — cloning a Copy value trips clippy's `clone_on_copy`. An array stays borrowed (`len`). */
2188
+ function rustOwnedHeadExpr(field, ref) {
2189
+ if (ref.kind === "arr")
2190
+ return field; // borrowed by `len`
2191
+ return typeRefIsCopy(ref) ? field : `${field}.clone()`;
2098
2192
  }
2099
2193
  /**
2100
2194
  * emitCondArm — the struct-native exec of a covered cond node (#108, rust). Mirrors run_behavior's cond
@@ -2469,17 +2563,22 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2469
2563
  const node = n;
2470
2564
  const rowT = rawRowStructName(c.name, node.id);
2471
2565
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
2472
- // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
2473
- // reference scriptedHandlers' `echo === "items"` the scripted handler ECHOES the NATIVE `f_items`
2474
- // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
2475
- // observed result is exactly the native array that flowed into its ports struct, so a dropped or
2476
- // boxed native array would diverge from run_behavior. `ports` is bound only on the echo path.
2477
- const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
2478
- const portsParam = canEcho ? "ports" : "_ports";
2479
- const echoBranch = canEcho
2480
- ? `\n if echo == "items" {\n return Some(${rowT} { val: ${portsParam}.${portFieldName("items")}.clone(), ..Default::default() });\n }`
2481
- : "";
2482
- const okBind = canEcho ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2566
+ // #129 (TEST glue): support the reference scriptedHandlers' echo forms (PROTOCOL.md §3.5) the
2567
+ // adapter normalizes `{"echo":"port","port":"X"}` / `{"echo":"items"}` to the port NAME, and the
2568
+ // scripted handler
2569
+ // ECHOES a NATIVE port back as its result. This makes a port-plane pin non-vacuous: the node's
2570
+ // observed result IS the native value that flowed into its ports struct, so a dropped / re-boxed /
2571
+ // zero-defaulted port diverges from run_behavior instead of passing. A port is echoable when its
2572
+ // native type IS the node's outType (that is exactly when the row's `val` can hold it); `ports` is
2573
+ // bound only when at least one port qualifies.
2574
+ const echoable = echoablePorts(c, node, ref, plan, typedNodes);
2575
+ const portsParam = echoable.length > 0 ? "ports" : "_ports";
2576
+ // a Copy port (i64/f64/bool/Option<scalar>) is read directly cloning it trips clippy clone_on_copy.
2577
+ const echoRead = (p) => `${portsParam}.${portFieldName(p)}${typeRefIsCopy(ref) ? "" : ".clone()"}`;
2578
+ const echoBranch = echoable
2579
+ .map((p) => `\n if echo == ${rustStrLit(p)} {\n return Some(${rowT} { val: ${echoRead(p)}, ..Default::default() });\n }`)
2580
+ .join("");
2581
+ const okBind = echoable.length > 0 ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2483
2582
  methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
2484
2583
  let ${okBind} = self.next(${rustStrLit(node.component)})?;
2485
2584
  if is_err {
@@ -2548,7 +2647,14 @@ impl ScriptedNativeRaw {
2548
2647
  let mut queues = self.queues.lock().unwrap();
2549
2648
  let (queue, single) = queues.get_mut(component)?;
2550
2649
  let raw = if *single || queue.len() <= 1 { queue[0].clone() } else { queue.remove(0) };
2551
- let echo = raw.get("echo").and_then(|e| e.as_str()).unwrap_or("").to_string();
2650
+ // PROTOCOL.md §3.5 echo forms, normalized to the port NAME: {"echo":"port","port":"X"} -> "X"
2651
+ // ({"echo":"items"} is already that shape). The generated node method just compares this
2652
+ // against its echoable port names.
2653
+ let echo = match raw.get("echo").and_then(|e| e.as_str()) {
2654
+ Some("port") => raw.get("port").and_then(|p| p.as_str()).unwrap_or("").to_string(),
2655
+ Some(other) => other.to_string(),
2656
+ None => String::new(),
2657
+ };
2552
2658
  if let Some(okv) = raw.get("ok") {
2553
2659
  let v = behavior_contracts::decode_value(okv).expect("decode ok");
2554
2660
  Some((false, String::new(), Some(v), echo))
@@ -2639,8 +2745,10 @@ ${arms}
2639
2745
  }
2640
2746
  /** emitInDecoder — a TEST-glue decoder `decode_InNR_<comp>(input) -> Result<InNR_<comp>, BehaviorError>`.
2641
2747
  * Reads each declared input port from the generic &[(String,Value)] into the concrete field (scalar →
2642
- * typed read; Value-typed → the raw Value clone). A missing key leaves Default (every covered input is
2643
- * required + supplied by the corpus). Lives ONLY in the observe companion. */
2748
+ * typed read; opt → Some/None; Value-typed → the raw Value clone). A key that is absent, or present as
2749
+ * `Value::Null`, leaves the field's Default for an OPTIONAL port that is `None` (absent), which is the
2750
+ * same value `refCore` observes for the `null` binding run_behavior is given. Lives ONLY in the observe
2751
+ * companion. */
2644
2752
  function emitInDecoder(comp, plan) {
2645
2753
  const name = inStructName(comp.name);
2646
2754
  const ports = Object.keys(comp.inputPorts ?? {});
@@ -2662,7 +2770,15 @@ function emitInDecoder(comp, plan) {
2662
2770
  const et = schema.elemType;
2663
2771
  const head = i === 0 ? " if" : " } else if";
2664
2772
  lines.push(`${head} k == ${rustStrLit(p)} {`);
2665
- if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2773
+ if (inputPortIsOptional(schema)) {
2774
+ // An OPTIONAL port decodes into its native Option<T>. materializeExpr's opt de-box IS the wire
2775
+ // rule: `Value::Null` → None (absent), any other value → Some(<inner>). A key that never appears
2776
+ // keeps the struct Default (None) — the same absent value. Checked FIRST: optionality wraps every
2777
+ // inner type (an optional array is Option<Vec<T>>, not Vec<T>).
2778
+ const optRef = inputPortTypeRef(schema, plan);
2779
+ lines.push(` in_.${field} = ${materializeExpr("v", optRef, plan)};`);
2780
+ }
2781
+ else if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2666
2782
  // #108: decode the boxed Value::Arr into the native Vec<ElemT> (test glue — materialize each elem).
2667
2783
  const elemRef = deriveTypeRef(et, plan);
2668
2784
  const elemTy = renderTypeRef(elemRef);
@@ -2700,6 +2816,16 @@ function emitInDecoder(comp, plan) {
2700
2816
  lines.push(`}`);
2701
2817
  return lines.join("\n");
2702
2818
  }
2819
+ /** echoablePorts (TEST glue) — the node's ports whose NATIVE type is exactly the node's outType, in
2820
+ * declaration order. Those are the ports a scripted `echo: "<port>"` can return as the row's `val`. A
2821
+ * port with a different type is not echoable (the row could not hold it), and a NAMED outType has no
2822
+ * `val` at all (its row carries the struct's fields flattened — see emitDecodeRow). */
2823
+ function echoablePorts(comp, node, ref, plan, typedNodes) {
2824
+ if (ref.kind === "named")
2825
+ return [];
2826
+ const want = renderTypeRef(ref);
2827
+ return Object.keys(node.ports).filter((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}'`, undefined, plan, typedNodes) === want);
2828
+ }
2703
2829
  /** emitDecodeRow — emit (once, deduped) a decoder `decode_row_<comp>_<node><suffix>(v: &Value) -> <rowT>`
2704
2830
  * that materializes the scripted ok Value into the concrete row struct. Named: reuse the Value->struct
2705
2831
  * marshaller + move fields; scalar/arr/opt: materialize into .val. TEST glue only. */