behavior-contracts 0.7.2 → 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 (68) hide show
  1. package/README.md +0 -1
  2. package/dist/authoring.d.ts +215 -3
  3. package/dist/authoring.d.ts.map +1 -1
  4. package/dist/authoring.js +338 -8
  5. package/dist/authoring.js.map +1 -1
  6. package/dist/behavior.d.ts +101 -3
  7. package/dist/behavior.d.ts.map +1 -1
  8. package/dist/behavior.js +126 -4
  9. package/dist/behavior.js.map +1 -1
  10. package/dist/bind.d.ts +64 -0
  11. package/dist/bind.d.ts.map +1 -0
  12. package/dist/bind.js +131 -0
  13. package/dist/bind.js.map +1 -0
  14. package/dist/builder.d.ts +30 -88
  15. package/dist/builder.d.ts.map +1 -1
  16. package/dist/builder.js +114 -59
  17. package/dist/builder.js.map +1 -1
  18. package/dist/expr-operator-types.d.ts +89 -0
  19. package/dist/expr-operator-types.d.ts.map +1 -0
  20. package/dist/expr-operator-types.js +134 -0
  21. package/dist/expr-operator-types.js.map +1 -0
  22. package/dist/generator/async-plan.d.ts.map +1 -1
  23. package/dist/generator/async-plan.js +4 -0
  24. package/dist/generator/async-plan.js.map +1 -1
  25. package/dist/generator/core.d.ts +15 -0
  26. package/dist/generator/core.d.ts.map +1 -1
  27. package/dist/generator/core.js +2 -0
  28. package/dist/generator/core.js.map +1 -1
  29. package/dist/generator/emit-php.d.ts.map +1 -1
  30. package/dist/generator/emit-php.js +1 -0
  31. package/dist/generator/emit-php.js.map +1 -1
  32. package/dist/generator/emit-python.d.ts.map +1 -1
  33. package/dist/generator/emit-python.js +1 -0
  34. package/dist/generator/emit-python.js.map +1 -1
  35. package/dist/generator/emit-straightline-typed-typescript.d.ts.map +1 -1
  36. package/dist/generator/emit-straightline-typed-typescript.js +1 -0
  37. package/dist/generator/emit-straightline-typed-typescript.js.map +1 -1
  38. package/dist/generator/emit-typed-native-go.d.ts.map +1 -1
  39. package/dist/generator/emit-typed-native-go.js +364 -14
  40. package/dist/generator/emit-typed-native-go.js.map +1 -1
  41. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  42. package/dist/generator/emit-typed-native-rust.js +347 -21
  43. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  44. package/dist/generator/emit-typescript.d.ts.map +1 -1
  45. package/dist/generator/emit-typescript.js +24 -8
  46. package/dist/generator/emit-typescript.js.map +1 -1
  47. package/dist/generator/index.d.ts +1 -1
  48. package/dist/generator/index.d.ts.map +1 -1
  49. package/dist/generator/index.js +7 -5
  50. package/dist/generator/index.js.map +1 -1
  51. package/dist/generator/straightline.d.ts.map +1 -1
  52. package/dist/generator/straightline.js +21 -1
  53. package/dist/generator/straightline.js.map +1 -1
  54. package/dist/guard.js +20 -1
  55. package/dist/guard.js.map +1 -1
  56. package/dist/index.d.ts +42 -9
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +28 -7
  59. package/dist/index.js.map +1 -1
  60. package/dist/provenance.d.ts +102 -0
  61. package/dist/provenance.d.ts.map +1 -0
  62. package/dist/provenance.js +128 -0
  63. package/dist/provenance.js.map +1 -0
  64. package/dist/type-gate.d.ts +63 -0
  65. package/dist/type-gate.d.ts.map +1 -0
  66. package/dist/type-gate.js +295 -0
  67. package/dist/type-gate.js.map +1 -0
  68. package/package.json +4 -3
@@ -119,13 +119,17 @@ function staticStringArrElems(node) {
119
119
  * Value-typed input) FAILS CLOSED — the covered read plane admits NO boxed escape (there is no
120
120
  * `dslcontracts.Value` fallback on the native module). `where` names the emission site for the error.
121
121
  */
122
- function portFieldGoType(node, inputPorts, where = "port", asBinding, plan) {
122
+ function portFieldGoType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
123
123
  if (staticStringArrElems(node) !== null)
124
124
  return "[]string";
125
125
  // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → []ElemT.
126
126
  const arrElem = portArrayElemRef(node, inputPorts, plan);
127
127
  if (arrElem !== null)
128
128
  return `[]${renderTypeRef(arrElem)}`;
129
+ // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) → []ElemT.
130
+ const priorArr = priorNodeArrElemInfo(node, typedNodes, plan);
131
+ if (priorArr !== null)
132
+ return `[]${renderTypeRef(priorArr.elemRef)}`;
129
133
  const num = numLiteralGoExpr(node);
130
134
  if (num !== null)
131
135
  return Number.isInteger(node) ? "int64" : "float64";
@@ -322,6 +326,39 @@ function coveredMapNode(n, bodyIds, comp) {
322
326
  return false;
323
327
  return true;
324
328
  }
329
+ /**
330
+ * coveredFanoutNode (v3) — a FANOUT node (first-class `@refs` id-list fan-out) is covered when:
331
+ * - over is a static ref to a PRIOR BODY NODE's typed arr field OR an INPUT array port with elemType,
332
+ * - every element port is static (literal / ref into the $as binding / concat / static arr),
333
+ * - default 'fail' policy, relationKind connection (guard-checked by the guard/builder).
334
+ * The fanout de-boxes to: one deduped batched dispatch → first-seen dedupe (by the elem's dedupeKey
335
+ * field) + dangling drop + implicitSource strip + connection wrap {items, cursor:nil} — NO boxed Value.
336
+ */
337
+ function coveredFanoutNode(n, bodyIds, comp) {
338
+ if (n === null || typeof n !== "object" || !("fanout" in n))
339
+ return false;
340
+ const f = n.fanout;
341
+ if (f.policy !== undefined && f.policy !== "fail")
342
+ return false;
343
+ if (f.relationKind !== "connection")
344
+ return false;
345
+ const overE = classifyExpr(f.over);
346
+ if (overE.kind !== "ref")
347
+ return false;
348
+ const head = overE.path[0];
349
+ if (!bodyIds.has(head)) {
350
+ if (overE.path.length !== 1)
351
+ return false;
352
+ const schema = comp.inputPorts?.[head];
353
+ if (schema === undefined || schema.elemType === undefined)
354
+ return false;
355
+ }
356
+ const ports = f.ports;
357
+ for (const p of Object.keys(ports))
358
+ if (!portIsStatic(ports[p]))
359
+ return false;
360
+ return true;
361
+ }
325
362
  /** A covered read SHAPE (typing-independent) — sequential componentRef reads + covered map...into,
326
363
  * OR a real-concurrency staged plan (bc#87) whose parallel-stage members are plain componentRef point
327
364
  * reads. A real-concurrency plan whose parallel members include a map / bindField-relation child is
@@ -366,6 +403,13 @@ function coverageRejectReason(comp) {
366
403
  return `node '${n.id}' cond has no outType (required to de-box the branch join)`;
367
404
  continue;
368
405
  }
406
+ if ("fanout" in n) {
407
+ // v3: a first-class fanout node (deduped connection list-in fan-out) IS covered when its over is a
408
+ // covered ref (prior-node arr / input array with elemType) and its element ports are static.
409
+ if (!coveredFanoutNode(n, bodyIds, comp))
410
+ return `node '${n.id}' is a non-covered fanout shape (over an input array without elemType, or a dynamic element port)`;
411
+ continue;
412
+ }
369
413
  if ("map" in n) {
370
414
  // #86 pt2 batched map...into / #93 shape 2/3 / #108 guarded map (no into) / map-over-input (with
371
415
  // elemType) ARE covered. A non-covered map shape (guard+into heterogeneous / over-input without
@@ -412,13 +456,13 @@ function assertTypedCoverage(comp) {
412
456
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go 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 go-straightline-native (boxed result).`);
413
457
  }
414
458
  // ── native ports struct + PortReader ──────────────────────────────────────────────────
415
- function emitPortsStruct(comp, node, asBinding, plan) {
459
+ function emitPortsStruct(comp, node, asBinding, plan, typedNodes) {
416
460
  const structName = portsStructName(comp.name, node.id);
417
461
  const portNames = Object.keys(node.ports);
418
462
  if (portNames.length === 0) {
419
463
  return `// ${structName} — native ports for node '${node.id}' (${node.component}); no ports.\ntype ${structName} struct{}`;
420
464
  }
421
- const fieldTypes = portNames.map((p) => portFieldGoType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan));
465
+ const fieldTypes = portNames.map((p) => portFieldGoType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan, typedNodes));
422
466
  const fieldNames = portNames.map(goPortFieldName);
423
467
  const fields = portNames
424
468
  .map((p, i) => `\t${fieldNames[i]} ${fieldTypes[i]} // ${JSON.stringify(p)}`)
@@ -486,6 +530,15 @@ function emitRawRowStructs(comp, typedNodes, plan) {
486
530
  if ("cond" in n)
487
531
  continue;
488
532
  const ref = typedNodes.get(n.id);
533
+ if ("fanout" in n) {
534
+ // a covered fanout node: RawElem_ is the connection items element (the per-body row the batched
535
+ // handler yields, aligned to the deduped id list); the batch RawRow_ carries the aligned []RawElem_.
536
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
537
+ parts.push(emitOneRowStruct(rawElemStructName(comp.name, n.id), elemRowRef, plan));
538
+ parts.push(`// ${rawRowStructName(comp.name, n.id)} — the BATCH row for fanout '${n.id}': the aligned per-body rows.\n` +
539
+ `type ${rawRowStructName(comp.name, n.id)} struct {\n\tIsError bool\n\tErr string\n\tRows []${rawElemStructName(comp.name, n.id)}\n}`);
540
+ continue;
541
+ }
489
542
  if ("map" in n) {
490
543
  // a covered map node: RawElem_<comp>_<node> is the HANDLER'S per-element return shape:
491
544
  // - into: the `into` field's type (the fetched item merged onto every over element);
@@ -583,6 +636,14 @@ function emitHandlerInterface(comp, typedNodes, plan) {
583
636
  continue; // #108: a cond node has no handler method (pure Expression).
584
637
  const method = nodeMethodName(comp.name, n.id);
585
638
  const bt = boundGoType(n, comp, typedNodes, plan);
639
+ if ("fanout" in n) {
640
+ // fanout (v3): one batched dispatch of the deduped id list. Ports = the batch struct; returns the
641
+ // aligned batch row (per-body rows) + resolved. The runner applies dedupe/drop/wrap after.
642
+ const portsT = `${portsStructName(comp.name, n.id)}_batch`;
643
+ const retT = rawRowStructName(comp.name, n.id);
644
+ lines.push(`\t${method}(ports ${portsT}, bound ${bt}) (${retT}, bool)`);
645
+ continue;
646
+ }
586
647
  if ("map" in n) {
587
648
  const m = n.map;
588
649
  const batched = m.batched === true;
@@ -626,6 +687,59 @@ type ${batchStruct} struct {
626
687
  Items []${structName}
627
688
  }`;
628
689
  }
690
+ /** emitFanoutPortsStructs (v3) — the per-body element ports struct + the batch struct for a covered
691
+ * fanout node (identical shape to a batched map's ports). The element ports read the `$as` id binding. */
692
+ function emitFanoutPortsStructs(comp, node, typedNodes, plan) {
693
+ const f = node.fanout;
694
+ const structName = portsStructName(comp.name, node.id);
695
+ const { elemRef } = fanoutOverElemInfo(comp, node, typedNodes, plan);
696
+ const asBinding = { name: f.as, ref: elemRef, plan };
697
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: f.component, ports: f.ports }, asBinding, plan);
698
+ const batchStruct = `${structName}_batch`;
699
+ return `${elemStruct}
700
+
701
+ // ${batchStruct} — NATIVE batched ports for fanout '${node.id}': Items is a native slice of the concrete
702
+ // per-id ports structs (${structName}). The consumer reads Items[i].<Field> DIRECTLY (bc#90 / runtime-free).
703
+ type ${batchStruct} struct {
704
+ Items []${structName}
705
+ }`;
706
+ }
707
+ /**
708
+ * fanoutItemsElemRef (v3) — the connection items ELEMENT TypeRef for a covered fanout node. The fanout
709
+ * outType is a connection struct `{items:[]Elem, cursor:*string}`; this returns Elem (the per-body row
710
+ * the batched handler yields, aligned to the deduped id list, before dedupe/drop). Fails closed if the
711
+ * outType is not that connection shape (named struct with an `items` arr field).
712
+ */
713
+ function fanoutItemsElemRef(node, connRef, plan) {
714
+ if (connRef.kind !== "named") {
715
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go fanout (v3): node '${node.id}' outType must be a connection struct {items,cursor} (got ${JSON.stringify(connRef.kind)}).`);
716
+ }
717
+ const decl = findDecl(plan, connRef.name);
718
+ const itemsField = decl.fields.find((fd) => fd.name === "items");
719
+ if (!itemsField || itemsField.type.kind !== "arr") {
720
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go fanout (v3): node '${node.id}' connection struct '${connRef.name}' has no 'items' array field.`);
721
+ }
722
+ return itemsField.type.elem;
723
+ }
724
+ /** fanoutOverElemInfo (v3) — the over id-list element TypeRef + the native Go over-slice expression.
725
+ * Mirrors mapOverElemInfo but reads the FanoutNode.fanout spec (prior-node arr OR input array w/ elemType). */
726
+ function fanoutOverElemInfo(comp, node, typedNodes, plan) {
727
+ const f = node.fanout;
728
+ const e = classifyExpr(f.over);
729
+ if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0])) {
730
+ const baseRef = typedNodes.get(e.path[0]);
731
+ const { expr, ref } = goTypedInternals.typedFieldAccess(typedLocal(e.path[0]), baseRef, e.path.slice(1), plan);
732
+ if (ref.kind === "arr")
733
+ return { elemRef: ref.elem, overExpr: expr };
734
+ }
735
+ if (e.kind === "ref" && e.path.length === 1) {
736
+ const schema = comp.inputPorts?.[e.path[0]];
737
+ const et = schema?.elemType;
738
+ if (et !== undefined)
739
+ return { elemRef: deriveTypeRef(et, plan), overExpr: `in.${goFieldName(e.path[0])}` };
740
+ }
741
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go fanout (v3): fanout '${node.id}' 'over' must be a ref to a prior node's typed arr field OR an input array port with a declared elemType (got ${JSON.stringify(f.over)}).`);
742
+ }
629
743
  /** mapElemRowRef — the handler's per-element RETURN shape for a covered map node: the `into` field's
630
744
  * type (into) or the element outType directly (no-into). This is the type of RawElem_<comp>_<node>. */
631
745
  function mapElemRowRef(node, arrRef, plan) {
@@ -714,6 +828,43 @@ function mapOverElemInfo(comp, node, typedNodes, plan) {
714
828
  function mapOverElemType(comp, node, typedNodes, plan) {
715
829
  return renderTypeRef(mapOverElemInfo(comp, node, typedNodes, plan).elemRef);
716
830
  }
831
+ /**
832
+ * priorNodeArrElemInfo (#129) — resolve a leaf port that is a `ref` into a PRIOR BODY NODE whose
833
+ * annotated `outType` is (or reaches, through a field path) a native array, to its element TypeRef +
834
+ * the native Go slice expression that yields it. This GENERALIZES the `map…over` prior-node-arr
835
+ * dataflow (mapOverElemInfo's first branch) to ANY leaf input port: a fold/dedup leaf whose `items`
836
+ * port is `{ref:["<priorNode>"]}` (or `.items` of a prior connection node) now lowers to `[]ElemT`
837
+ * fed straight from the prior node's typed local — ZERO boxed Value on the read hot path.
838
+ *
839
+ * The element type comes STRICTLY from the prior node's EXPLICIT `outType` annotation walked through
840
+ * typedFieldAccess (BC does NOT infer — consumer-interface C3 / [[graphddb-typed-native-static-link]]):
841
+ * if the head is not a prior body node, or its outType (through the field path) is not an `arr`, this
842
+ * returns null and the caller keeps its existing fail-closed behaviour (never a silent box). A
843
+ * single-segment INPUT-array port (elemType) is a DIFFERENT covered shape (portArrayElemRef) — handled
844
+ * ahead of this. Requires the typedNodes map (prior-node outType TypeRefs) and a TypePlan.
845
+ */
846
+ function priorNodeArrElemInfo(node, typedNodes, plan) {
847
+ if (typedNodes === undefined || plan === undefined)
848
+ return null;
849
+ if (staticArrElems(node) !== null)
850
+ return null; // a static array literal — not a prior-node ref.
851
+ const e = classifyExpr(node);
852
+ if (e.kind !== "ref" || e.opt || e.path.length < 1)
853
+ return null;
854
+ if (!typedNodes.has(e.path[0]))
855
+ return null; // head is not a prior body node → not this shape.
856
+ const baseRef = typedNodes.get(e.path[0]);
857
+ let acc;
858
+ try {
859
+ acc = goTypedInternals.typedFieldAccess(typedLocal(e.path[0]), baseRef, e.path.slice(1), plan);
860
+ }
861
+ catch {
862
+ return null; // the field path does not resolve on the prior node's outType → fail-closed at caller.
863
+ }
864
+ if (acc.ref.kind !== "arr")
865
+ return null; // the prior-node result (through the path) is not an array.
866
+ return { elemRef: acc.ref.elem, overExpr: acc.expr };
867
+ }
717
868
  function classifyPort(node) {
718
869
  if (staticArrElems(node) !== null)
719
870
  return "static";
@@ -945,7 +1096,7 @@ function emitNativeScalar(node, want, resolveRef) {
945
1096
  * plane — a boxed escape would re-introduce the bc-runtime coupling this module removes). Returns the
946
1097
  * struct field initializer (`Field: <expr>`). `resolveRef` resolves a ref head for the scalar path.
947
1098
  */
948
- function emitPortInit(pn, expr, fieldGoType, resolveRef, inputPorts, plan) {
1099
+ function emitPortInit(pn, expr, fieldGoType, resolveRef, inputPorts, plan, typedNodes) {
949
1100
  const fieldName = goPortFieldName(pn);
950
1101
  // #110: a componentRef port bound to a runtime array input port (with elemType) → the native []ElemT,
951
1102
  // read straight from the concrete input struct (`in.<Field>`) — ZERO boxed Value on the read hot path.
@@ -957,6 +1108,13 @@ function emitPortInit(pn, expr, fieldGoType, resolveRef, inputPorts, plan) {
957
1108
  return `${fieldName}: in.${goFieldName(e.path[0])}`;
958
1109
  }
959
1110
  }
1111
+ // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) →
1112
+ // the native []ElemT read straight off the prior node's typed local (`t_<node>.Field…`) — ZERO boxed
1113
+ // Value on the read hot path. The over-source case (map…over) resolves via mapOverElemInfo; this is
1114
+ // the plain leaf array input port (a fold/dedup leaf whose `items` = a prior node's array result).
1115
+ const priorArr = priorNodeArrElemInfo(expr, typedNodes, plan);
1116
+ if (priorArr !== null)
1117
+ return `${fieldName}: ${priorArr.overExpr}`;
960
1118
  // change #2: a static string array (projection) → a native `[]string{...}` literal.
961
1119
  const strArr = staticStringArrElems(expr);
962
1120
  if (strArr !== null) {
@@ -1066,8 +1224,8 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1066
1224
  const resolveRef = resolveRefAt(atPos);
1067
1225
  for (const pn of Object.keys(node.ports)) {
1068
1226
  const expr = node.ports[pn];
1069
- const fieldGoType = portFieldGoType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1070
- inits.push(emitPortInit(pn, expr, fieldGoType, resolveRef, comp.inputPorts, plan));
1227
+ const fieldGoType = portFieldGoType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1228
+ inits.push(emitPortInit(pn, expr, fieldGoType, resolveRef, comp.inputPorts, plan, typedNodes));
1071
1229
  }
1072
1230
  lines.push(`${ind}ps_${s} = ${structName}{${inits.join(", ")}}`);
1073
1231
  lines.push(`${ind}run_${s} = true`);
@@ -1220,6 +1378,12 @@ function emitNativeRawRunner(comp, plan, flags) {
1220
1378
  k += stage.length - 1;
1221
1379
  continue;
1222
1380
  }
1381
+ // v3: a covered fanout node — deduped batched dispatch + first-seen dedupe + dangling drop +
1382
+ // connection wrap (the SAME definition as run_behavior's fanoutDedupDrop).
1383
+ if ("fanout" in comp.body[i]) {
1384
+ lines.push(emitFanoutArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, zeroOut));
1385
+ continue;
1386
+ }
1223
1387
  // #86 pt2: a covered batched map...into node — struct-native element collection + into enrichment.
1224
1388
  if ("map" in comp.body[i]) {
1225
1389
  lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, zeroOut, flags));
@@ -1276,8 +1440,8 @@ function emitNativeRawRunner(comp, plan, flags) {
1276
1440
  const resolveRef = resolveRefAt(k);
1277
1441
  for (const pn of portNames) {
1278
1442
  const expr = node.ports[pn];
1279
- const fieldGoType = portFieldGoType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1280
- inits.push(emitPortInit(pn, expr, fieldGoType, resolveRef, comp.inputPorts, plan));
1443
+ const fieldGoType = portFieldGoType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1444
+ inits.push(emitPortInit(pn, expr, fieldGoType, resolveRef, comp.inputPorts, plan, typedNodes));
1281
1445
  }
1282
1446
  lines.push(`${indent}ports_${s} := ${structName}{${inits.join(", ")}}`);
1283
1447
  const oc = `row_${s}`;
@@ -1536,6 +1700,132 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, zeroOut, f
1536
1700
  function elemLocal(s) {
1537
1701
  return `oel_${s}`;
1538
1702
  }
1703
+ /**
1704
+ * emitFanoutArm (v3) — the struct-native exec of a covered FANOUT node. This is the genuinely new
1705
+ * native list-in fan-out handler: build the per-id native ports (all over ids), dispatch the batched
1706
+ * handler ONCE (aligned per-body rows), then apply THE SAME dedupe/drop as run_behavior's
1707
+ * fanoutDedupDrop — first-seen dedupe by the elem's dedupeKey field, drop dangling (a body whose
1708
+ * dedupeKey field is the zero value ≡ the interpreter's null/absent-key body), implicitSource strip
1709
+ * (STRUCTURAL — the connection items elem type already omits the implicitSource field), and wrap into
1710
+ * the connection struct {items, cursor:nil}. NO boxed Value on any plane. Byte-equal to run_behavior.
1711
+ */
1712
+ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, zeroOut) {
1713
+ const f = node.fanout;
1714
+ const s = sanitize(node.id);
1715
+ const structName = portsStructName(comp.name, node.id);
1716
+ const batchStruct = `${structName}_batch`;
1717
+ const connRef = typedNodes.get(node.id);
1718
+ const elemRef = fanoutItemsElemRef(node, connRef, plan);
1719
+ const elemGo = renderTypeRef(elemRef);
1720
+ const { elemRef: overElemRef, overExpr } = fanoutOverElemInfo(comp, node, typedNodes, plan);
1721
+ const asName = f.as;
1722
+ const priorHere = (head) => priorNodeAt(head, atPos);
1723
+ const portNames = Object.keys(f.ports);
1724
+ // the dedupeKey Go field on the connection items elem (must be a string scalar for the seen-set key).
1725
+ if (elemRef.kind !== "named")
1726
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go fanout (v3): node '${node.id}' connection items element must be a named struct (got ${JSON.stringify(elemRef.kind)}).`);
1727
+ const elemDecl = findDecl(plan, elemRef.name);
1728
+ const dkField = elemDecl.fields.find((fd) => fd.name === f.dedupeKey);
1729
+ if (!dkField || dkField.type.kind !== "scalar" || dkField.type.scalar !== "string")
1730
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `go fanout (v3): node '${node.id}' dedupeKey '${f.dedupeKey}' must be a string field of the connection items element type '${elemRef.name}'.`);
1731
+ const dkGo = goFieldName(f.dedupeKey);
1732
+ // per-id native ports build (same as a batched map's per-element ports). $as → the over id value.
1733
+ const buildPorts = (il, elemVar) => {
1734
+ const inits = [];
1735
+ const resolveRef = (head, restPath, opt) => {
1736
+ if (opt)
1737
+ return null;
1738
+ let baseExpr;
1739
+ let baseRef;
1740
+ if (head === asName) {
1741
+ baseExpr = elemVar;
1742
+ baseRef = overElemRef;
1743
+ }
1744
+ else if (priorHere(head)) {
1745
+ baseExpr = typedLocal(head);
1746
+ baseRef = typedNodes.get(head);
1747
+ }
1748
+ else {
1749
+ if (restPath.length !== 0)
1750
+ return null;
1751
+ const scalar = inputScalarKind(comp.inputPorts?.[head]);
1752
+ if (scalar === undefined)
1753
+ return null;
1754
+ return { expr: `in.${goFieldName(head)}`, scalar };
1755
+ }
1756
+ let acc;
1757
+ let expr;
1758
+ try {
1759
+ const r = goTypedInternals.typedFieldAccess(baseExpr, baseRef, restPath, plan);
1760
+ expr = r.expr;
1761
+ acc = r.ref;
1762
+ }
1763
+ catch {
1764
+ return null;
1765
+ }
1766
+ if (acc.kind !== "scalar" || acc.scalar === "null")
1767
+ return null;
1768
+ return { expr, scalar: goScalar(acc.scalar) };
1769
+ };
1770
+ const asBinding = { name: asName, ref: overElemRef, plan };
1771
+ for (const pn of portNames) {
1772
+ const fieldGoType = portFieldGoType(f.ports[pn], comp.inputPorts, `fanout port '${pn}' of node '${node.id}'`, asBinding, plan);
1773
+ inits.push(emitPortInit(pn, f.ports[pn], fieldGoType, resolveRef, comp.inputPorts, plan));
1774
+ }
1775
+ return [`${il}ep_${s} := ${structName}{${inits.join(", ")}}`];
1776
+ };
1777
+ const lines = [];
1778
+ const closers = [];
1779
+ lines.push(`\t// ── fanout '${node.id}' (${f.component}, dedupeKey:${JSON.stringify(f.dedupeKey)}, drop:${JSON.stringify(f.drop)}${f.implicitSource ? ", implicitSource:" + JSON.stringify(f.implicitSource) : ""}${f.parent ? ", parent:" + f.parent : ""}) ──`);
1780
+ let indent = "\t";
1781
+ if (f.parent !== undefined && priorNodeAt(f.parent, atPos)) {
1782
+ lines.push(`${indent}if produced_${sanitize(f.parent)} {`);
1783
+ closers.unshift(`${indent}}`);
1784
+ indent += "\t";
1785
+ }
1786
+ // over id-list (typed field access — no serialize) → per-id ports (all ids; dedupe is on the result).
1787
+ lines.push(`${indent}over_${s} := ${overExpr}`);
1788
+ lines.push(`${indent}var items_${s} []${elemGo}`);
1789
+ lines.push(`${indent}pi_${s} := make([]${structName}, 0, len(over_${s}))`);
1790
+ lines.push(`${indent}for _, ${elemLocal(s)} := range over_${s} {`);
1791
+ for (const l of buildPorts(indent + "\t", elemLocal(s)))
1792
+ lines.push(l);
1793
+ lines.push(`${indent}\tpi_${s} = append(pi_${s}, ep_${s})`);
1794
+ lines.push(`${indent}}`);
1795
+ // empty id-list => empty connection (handler not called), matching run_behavior.
1796
+ lines.push(`${indent}if len(pi_${s}) > 0 {`);
1797
+ lines.push(`${indent}\tbp_${s} := ${batchStruct}{Items: pi_${s}}`);
1798
+ lines.push(`${indent}\tfo_${s}, fo_${s}Resolved := handlers.${nodeMethodName(comp.name, node.id)}(bp_${s}, nil)`);
1799
+ lines.push(`${indent}\tif !fo_${s}Resolved {`, `${indent}\t\treturn ${zeroOut}, ${goErr("UNKNOWN_COMPONENT", `"component '${f.component}' has no handler (fail-closed)"`)}`, `${indent}\t}`);
1800
+ lines.push(`${indent}\tif fo_${s}.IsError {`, `${indent}\t\treturn ${zeroOut}, ${goErr("OP_FAILED", `"operation '${node.id}' failed under 'fail' policy: "+fo_${s}.Err`)}`, `${indent}\t}`);
1801
+ lines.push(`${indent}\tif len(fo_${s}.Rows) != len(pi_${s}) {`, `${indent}\t\treturn ${zeroOut}, ${goErr("FANOUT_BATCH_RESULT_MISMATCH", `fmt.Sprintf("fanout '${node.id}': batched handler must return a list aligned to the deduped id list (want %d)", len(pi_${s}))`)}`, `${indent}\t}`);
1802
+ // ── THE ONE dedupe/drop (mirrors fanoutDedupDrop verbatim): first-seen dedupe by dedupeKey field;
1803
+ // drop dangling (zero dedupeKey ≡ interpreter's null/absent-key body); implicitSource strip is
1804
+ // structural (the elem type already omits it). cursor is always nil (connection wrap).
1805
+ lines.push(`${indent}\tseen_${s} := make(map[string]struct{}, len(fo_${s}.Rows))`);
1806
+ lines.push(`${indent}\titems_${s} = make([]${elemGo}, 0, len(fo_${s}.Rows))`);
1807
+ lines.push(`${indent}\tfor fi_${s} := range fo_${s}.Rows {`);
1808
+ lines.push(`${indent}\t\tbody_${s} := fo_${s}.Rows[fi_${s}]`);
1809
+ lines.push(`${indent}\t\tvar el_${s} ${elemGo}`);
1810
+ lines.push(emitRowCopy(`el_${s}`, `body_${s}`, elemRef, plan, indent.length + 2));
1811
+ lines.push(`${indent}\t\tkey_${s} := el_${s}.${dkGo}`);
1812
+ if (f.drop === "dangling") {
1813
+ // dangling: a body whose dedupeKey is the zero value (empty string) is dropped.
1814
+ lines.push(`${indent}\t\tif key_${s} == "" {`, `${indent}\t\t\tcontinue`, `${indent}\t\t}`);
1815
+ }
1816
+ lines.push(`${indent}\t\tif _, dup_${s} := seen_${s}[key_${s}]; dup_${s} {`, `${indent}\t\t\tcontinue`, `${indent}\t\t}`);
1817
+ lines.push(`${indent}\t\tseen_${s}[key_${s}] = struct{}{}`);
1818
+ lines.push(`${indent}\t\titems_${s} = append(items_${s}, el_${s})`);
1819
+ lines.push(`${indent}\t}`);
1820
+ lines.push(`${indent}}`);
1821
+ // wrap into the connection struct {Items, Cursor:nil}. A nil items slice serializes to [] (the
1822
+ // connection serializer normalizes it) — matching {items:[],cursor:null} for an empty fan-out.
1823
+ lines.push(`${indent}${typedLocal(node.id)} = ${connRef.name}{${goFieldName("items")}: items_${s}}`);
1824
+ lines.push(`${indent}produced_${s} = true`);
1825
+ for (const c of closers)
1826
+ lines.push(c);
1827
+ return lines.join("\n");
1828
+ }
1539
1829
  /** #108: resolve an INPUT port head for a native-expr ref (cond `if` / map `when` / branches). A scalar
1540
1830
  * port → `in.<Field>` of the scalar type; an array port WITH elemType → `in.<Field>` of `[]ElemT` (so
1541
1831
  * `len(in.<Field>)` works); anything else → null (fail-closed). */
@@ -1974,7 +2264,11 @@ function emit(ctx) {
1974
2264
  for (const n of c.body)
1975
2265
  typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1976
2266
  for (const n of c.body) {
1977
- if ("map" in n) {
2267
+ if ("fanout" in n) {
2268
+ // v3: a fanout node emits a per-id element ports struct + a batch struct (like a batched map).
2269
+ structs.push(emitFanoutPortsStructs(c, n, typedNodes, plan));
2270
+ }
2271
+ else if ("map" in n) {
1978
2272
  // #86 pt2 / #108: a map node emits an element ports struct + a batch struct. The element ports
1979
2273
  // may read the `$as` binding — resolved via the over-element type (prior node / input array).
1980
2274
  structs.push(emitMapPortsStructs(c, n, typedNodes, plan));
@@ -1983,7 +2277,8 @@ function emit(ctx) {
1983
2277
  // #108: a cond node has NO ports struct (it is a pure Expression — no handler dispatch).
1984
2278
  }
1985
2279
  else {
1986
- structs.push(emitPortsStruct(c, n, undefined, plan));
2280
+ // #129: pass typedNodes so a leaf array port fed by a prior node's arr outType types as []ElemT.
2281
+ structs.push(emitPortsStruct(c, n, undefined, plan, typedNodes));
1987
2282
  }
1988
2283
  }
1989
2284
  // CONCRETE per-node row structs + the per-component handler interface (the fully-concrete seam).
@@ -2002,7 +2297,7 @@ function emit(ctx) {
2002
2297
  const needSync = native.some((c) => concurrencyPlan(c) !== null);
2003
2298
  // bc#90: the ONLY std-lib `fmt.Sprintf` site on the covered plane is the batched-map result-count
2004
2299
  // mismatch error — so `fmt` is imported iff a covered component has a batched map node.
2005
- const needFmt = native.some((c) => c.body.some((n) => "map" in n && n.map.batched === true));
2300
+ const needFmt = native.some((c) => c.body.some((n) => ("map" in n && n.map.batched === true) || "fanout" in n));
2006
2301
  // The FULLY-covered module (no non-native components) gets a MINIMAL, RUNTIME-FREE header — NO
2007
2302
  // bc-runtime import at all (bc#90).
2008
2303
  // FAIL CLOSED (bc#86/#89): the --typed-native (native codegen) surface is native-only. If ANY
@@ -2114,6 +2409,39 @@ ${native.map((c) => `\t${handlerIfaceName(c.name)}`).join("\n")}
2114
2409
  if ("cond" in n)
2115
2410
  continue; // #108: cond has no handler method (pure Expression) — no scripted impl.
2116
2411
  const ref = typedNodes.get(n.id);
2412
+ if ("fanout" in n) {
2413
+ // v3: scripted fanout impl — drain one batched outcome and decode the aligned []Value into the
2414
+ // per-body elem rows (a null array element materializes to the zero elem row = dangling).
2415
+ const f = n.fanout;
2416
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
2417
+ const elemT = rawElemStructName(c.name, n.id);
2418
+ const batchT = rawRowStructName(c.name, n.id);
2419
+ const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2420
+ const portsT = `${portsStructName(c.name, n.id)}_batch`;
2421
+ const foBoundT = boundGoType(n, c, typedNodes, plan);
2422
+ methodImpls.push(`func (s *ScriptedNativeRaw) ${nodeMethodName(c.name, n.id)}(_ ${portsT}, _ ${foBoundT}) (${batchT}, bool) {
2423
+ src, ok := s.next(${JSON.stringify(f.component)})
2424
+ if !ok {
2425
+ return ${batchT}{}, false
2426
+ }
2427
+ if src.isErr {
2428
+ return ${batchT}{IsError: true, Err: src.err}, true
2429
+ }
2430
+ arr, _ := src.ok.([]${PKG}.Value)
2431
+ rows := make([]${elemT}, 0, len(arr))
2432
+ for _, ev := range arr {
2433
+ // v3: a null aligned body is a DANGLING id — decode it to the zero elem row (empty dedupeKey
2434
+ // field), so the runner's dangling-drop excludes it (byte-equal to fanoutDedupDrop).
2435
+ if ev == nil {
2436
+ rows = append(rows, ${elemT}{})
2437
+ continue
2438
+ }
2439
+ rows = append(rows, ${elemDecode}(ev))
2440
+ }
2441
+ return ${batchT}{Rows: rows}, true
2442
+ }`);
2443
+ continue;
2444
+ }
2117
2445
  if ("map" in n) {
2118
2446
  const m = n.map;
2119
2447
  const batched = m.batched === true;
@@ -2161,7 +2489,18 @@ ${native.map((c) => `\t${handlerIfaceName(c.name)}`).join("\n")}
2161
2489
  const rowT = rawRowStructName(c.name, node.id);
2162
2490
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
2163
2491
  const nodeBoundT = boundGoType(node, c, typedNodes, plan);
2164
- methodImpls.push(`func (s *ScriptedNativeRaw) ${nodeMethodName(c.name, node.id)}(_ ${portsStructName(c.name, node.id)}, _ ${nodeBoundT}) (${rowT}, bool) {
2492
+ // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
2493
+ // reference scriptedHandlers' `echo === "items"` — the scripted handler ECHOES the NATIVE `items`
2494
+ // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
2495
+ // observed result is exactly the native array that flowed into its ports struct, so a dropped or
2496
+ // boxed native array would diverge from run_behavior. `ports` is named only when the echo path
2497
+ // uses it (else the param stays `_` so a non-echo node compiles clean / unused-param-free).
2498
+ const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
2499
+ const portsParam = canEcho ? "ports" : "_";
2500
+ const echoBranch = canEcho
2501
+ ? `\tif src.echo == "items" {\n\t\treturn ${rowT}{Val: ports.${goPortFieldName("items")}}, true\n\t}\n`
2502
+ : "";
2503
+ methodImpls.push(`func (s *ScriptedNativeRaw) ${nodeMethodName(c.name, node.id)}(${portsParam} ${portsStructName(c.name, node.id)}, _ ${nodeBoundT}) (${rowT}, bool) {
2165
2504
  src, ok := s.next(${JSON.stringify(node.component)})
2166
2505
  if !ok {
2167
2506
  return ${rowT}{}, false
@@ -2169,14 +2508,18 @@ ${native.map((c) => `\t${handlerIfaceName(c.name)}`).join("\n")}
2169
2508
  if src.isErr {
2170
2509
  return ${rowT}{IsError: true, Err: src.err}, true
2171
2510
  }
2172
- return ${decode}(src.ok), true
2511
+ ${echoBranch} return ${decode}(src.ok), true
2173
2512
  }`);
2174
2513
  }
2175
2514
  }
2176
2515
  const adapter = `// scriptSrc — a scripted per-component outcome (an ok Value or an error), drained in order.
2516
+ // #129: \`echo\` carries the reference scriptedHandlers' \`echo\` directive (e.g. "items") so a scripted
2517
+ // node can ECHO a named input PORT back as its result — used to make the node→leaf array dataflow test
2518
+ // non-vacuous (the leaf handler returns its \`items\` port, so a dropped/boxed native array diverges).
2177
2519
  type scriptSrc struct {
2178
2520
  isErr bool
2179
2521
  err string
2522
+ echo string
2180
2523
  ok ${PKG}.Value
2181
2524
  }
2182
2525
 
@@ -2207,12 +2550,18 @@ func NewScriptedNativeRaw(spec *${PKG}.JObj) (*ScriptedNativeRaw, error) {
2207
2550
  }
2208
2551
  for _, e := range entries {
2209
2552
  eo, _ := e.(*${PKG}.JObj)
2553
+ // #129: an \`echo\` directive names an input port to echo back (mirrors the reference
2554
+ // scriptedHandlers' \`echo === "items"\`). It is drained together with the ok payload.
2555
+ echo := ""
2556
+ if echoNode, has := eo.Get("echo"); has {
2557
+ echo, _ = echoNode.(string)
2558
+ }
2210
2559
  if okNode, has := eo.Get("ok"); has {
2211
2560
  val, derr := ${PKG}.DecodeValue(okNode)
2212
2561
  if derr != nil {
2213
2562
  return nil, derr
2214
2563
  }
2215
- queues[comp] = append(queues[comp], scriptSrc{ok: val})
2564
+ queues[comp] = append(queues[comp], scriptSrc{ok: val, echo: echo})
2216
2565
  continue
2217
2566
  }
2218
2567
  ev, _ := eo.Get("error")
@@ -2324,6 +2673,7 @@ function emitDecodeRow(compName, nodeId, rowT, ref, plan, emitted, out, suffix)
2324
2673
  */
2325
2674
  export const goTypedNativeEmitter = {
2326
2675
  language: "go-typed-native",
2676
+ classification: "codegen", // native: 型付きフラットコード生成・IR/dict 非在(#128/A6)
2327
2677
  fileExtension: ".go",
2328
2678
  defaultRuntimeImport: "github.com/foo-ogawa/behavior-contracts/go/coderuntime",
2329
2679
  filenameHint: "behaviors.typed.native.generated.go",