@enricai/barnacle 1.12.18 → 1.12.20

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 (45) hide show
  1. package/README.md +32 -1211
  2. package/dist/config.d.ts +8 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +3 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/lib/merge-folded-primary-bodies.d.ts +10 -0
  7. package/dist/lib/merge-folded-primary-bodies.d.ts.map +1 -0
  8. package/dist/lib/merge-folded-primary-bodies.js +29 -0
  9. package/dist/lib/merge-folded-primary-bodies.js.map +1 -0
  10. package/dist/plugins/loader.d.ts.map +1 -1
  11. package/dist/plugins/loader.js +4 -1
  12. package/dist/plugins/loader.js.map +1 -1
  13. package/dist/scraper/errors.d.ts +7 -7
  14. package/dist/scraper/errors.js +7 -7
  15. package/dist/scraper/fake-timer-advance.d.ts +35 -8
  16. package/dist/scraper/fake-timer-advance.d.ts.map +1 -1
  17. package/dist/scraper/fake-timer-advance.js +29 -9
  18. package/dist/scraper/fake-timer-advance.js.map +1 -1
  19. package/dist/scraper/flow-runner.d.ts.map +1 -1
  20. package/dist/scraper/flow-runner.js +11 -13
  21. package/dist/scraper/flow-runner.js.map +1 -1
  22. package/dist/scraper/http-client.d.ts +1 -1
  23. package/dist/scraper/http-client.js +1 -1
  24. package/dist/scraper/pool.d.ts +3 -3
  25. package/dist/scraper/pool.js +5 -5
  26. package/dist/scripts/recon-browser.d.ts +9 -1
  27. package/dist/scripts/recon-browser.d.ts.map +1 -1
  28. package/dist/scripts/recon-browser.js +35 -1
  29. package/dist/scripts/recon-browser.js.map +1 -1
  30. package/dist/scripts/recon-generate-multicall-fixture.d.ts +505 -0
  31. package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
  32. package/dist/scripts/recon-generate-multicall-fixture.js +1440 -1
  33. package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
  34. package/dist/scripts/recon-generate.d.ts +215 -6
  35. package/dist/scripts/recon-generate.d.ts.map +1 -1
  36. package/dist/scripts/recon-generate.js +1573 -49
  37. package/dist/scripts/recon-generate.js.map +1 -1
  38. package/dist/scripts/recon-heal.d.ts +3 -3
  39. package/dist/scripts/recon-heal.js +4 -4
  40. package/dist/scripts/recon-heal.js.map +1 -1
  41. package/dist/server.js +1 -1
  42. package/dist/server.js.map +1 -1
  43. package/dist/site-plugin.d.ts +4 -3
  44. package/dist/site-plugin.d.ts.map +1 -1
  45. package/package.json +2 -2
@@ -29,10 +29,12 @@ exports.harvestPersonaBindings = harvestPersonaBindings;
29
29
  exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
30
30
  exports.collectConditionalGraphQLFieldNames = collectConditionalGraphQLFieldNames;
31
31
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
32
+ exports.detectAggregateUnitBasisFindings = detectAggregateUnitBasisFindings;
32
33
  exports.selectPayloadAction = selectPayloadAction;
33
34
  exports.selectReturnAction = selectReturnAction;
34
35
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
35
36
  exports.extractEntryUrlParams = extractEntryUrlParams;
37
+ exports.gatherResponseBodySamples = gatherResponseBodySamples;
36
38
  exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
37
39
  exports.firstEndpointCapture = firstEndpointCapture;
38
40
  exports.firstEndpointPath = firstEndpointPath;
@@ -50,6 +52,9 @@ exports.compileActionSteps = compileActionSteps;
50
52
  exports.collectHeaderBindings = collectHeaderBindings;
51
53
  exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
52
54
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
55
+ exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
56
+ exports.parseFoldReturnSpec = parseFoldReturnSpec;
57
+ exports.resolveFoldPlan = resolveFoldPlan;
53
58
  exports.buildContractChecklist = buildContractChecklist;
54
59
  exports.emitContractTs = emitContractTs;
55
60
  exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
@@ -61,6 +66,7 @@ const node_path_1 = require("node:path");
61
66
  const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
62
67
  const errors_1 = require("../lib/errors");
63
68
  const logging_1 = require("../lib/logging");
69
+ const merge_folded_primary_bodies_1 = require("../lib/merge-folded-primary-bodies");
64
70
  const plugin_api_version_1 = require("../plugins/plugin-api-version");
65
71
  const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
66
72
  const capture_filters_1 = require("../recon/capture-filters");
@@ -508,7 +514,7 @@ function collectConditionalGraphQLFieldNames(query) {
508
514
  * what the endpoint actually returns rather than what one capture happened to
509
515
  * show.
510
516
  */
511
- function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
517
+ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}, path = []) {
512
518
  const maxDepth = opts.maxDepth ?? DEFAULT_MAX_INFER_DEPTH;
513
519
  if (depth > maxDepth)
514
520
  return "z.unknown()";
@@ -546,11 +552,11 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
546
552
  const items = nonNull.flat();
547
553
  if (items.length === 0)
548
554
  return wrap("z.array(z.unknown())");
549
- return wrap(`z.array(${inferZodSchemaFromSamples(items, depth + 1, indent, opts)})`);
555
+ return wrap(`z.array(${inferZodSchemaFromSamples(items, depth + 1, indent, opts, path)})`);
550
556
  }
551
557
  if (kind === "object") {
552
558
  const objects = nonNull;
553
- const keys = [...new Set(objects.flatMap((o) => Object.keys(o)))];
559
+ const keys = [...new Set(objects.flatMap((o) => Object.keys(o)))].filter((k) => !(opts.looseServerResponse && k === "__typename"));
554
560
  if (keys.length === 0)
555
561
  return wrap("z.record(z.string(), z.unknown())");
556
562
  const inner = `${indent} `;
@@ -558,18 +564,26 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
558
564
  // the generated file on first lint:fix.
559
565
  const fields = keys
560
566
  .map((k) => {
567
+ const fieldPath = [...path, k];
561
568
  const valuesForKey = objects.filter((o) => k in o).map((o) => o[k]);
562
- const expr = inferZodSchemaFromSamples(valuesForKey, depth + 1, inner, opts);
569
+ const expr = inferZodSchemaFromSamples(valuesForKey, depth + 1, inner, opts, fieldPath);
563
570
  // Seen on some samples but not others (the endpoint omits it sometimes)
564
571
  // OR the query marks it @include/@skip-conditional (the server can
565
572
  // legally omit it regardless of what this sample happened to show):
566
573
  // either signal alone is enough to require callers to guard the field.
567
574
  const isOptional = valuesForKey.length < objects.length || (opts.conditionalFieldNames?.has(k) ?? false);
568
575
  const optional = isOptional ? `${expr}.optional()` : expr;
569
- return `${inner}${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${optional}`;
576
+ const findings = opts.aggregateUnitBasisFindingsByPath?.get(fieldPath.join("."));
577
+ const described = findings?.length
578
+ ? `${optional}.describe(${JSON.stringify(`Derived: ${findings
579
+ .map((finding) => `equals the sum of "${finding.unitFieldName}" across every entry of "${finding.breakdownPath.join(".")}"`)
580
+ .join("; also ")}.`)})`
581
+ : optional;
582
+ return `${inner}${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${described}`;
570
583
  })
571
584
  .join(",\n");
572
- return wrap(`z.object({\n${fields},\n${indent}})`);
585
+ const objectExpr = `z.object({\n${fields},\n${indent}})${opts.looseServerResponse ? ".loose()" : ""}`;
586
+ return wrap(objectExpr);
573
587
  }
574
588
  return wrap("z.unknown()");
575
589
  }
@@ -579,6 +593,198 @@ function inferZodSchemaFromSamples(samples, depth = 0, indent = "", opts = {}) {
579
593
  function inferZodSchema(value, depth = 0, indent = "", opts = {}) {
580
594
  return inferZodSchemaFromSamples([value], depth, indent, opts);
581
595
  }
596
+ /** Bound on how far {@link detectAggregateUnitBasisFindings} descends into a
597
+ * response tree, matching {@link DEFAULT_MAX_INFER_DEPTH} so detection never
598
+ * outruns the schema inference it feeds. */
599
+ const MAX_AGGREGATE_UNIT_BASIS_DEPTH = DEFAULT_MAX_INFER_DEPTH;
600
+ /** Relative tolerance for aggregate-vs-sum comparison, wide enough to absorb
601
+ * float rounding in tax/currency math but tight enough that two merely
602
+ * similar-magnitude fields won't pass by coincidence. */
603
+ const AGGREGATE_UNIT_BASIS_RELATIVE_EPSILON = 1e-6;
604
+ const AGGREGATE_UNIT_BASIS_ABSOLUTE_EPSILON = 1e-6;
605
+ function isPlainObject(value) {
606
+ return typeof value === "object" && value !== null && !Array.isArray(value);
607
+ }
608
+ /** A "map of objects" is a dynamically-keyed collection (guest index, room id,
609
+ * line-item id, ...) where every value is itself a record — the per-unit
610
+ * breakdown shape, as opposed to a plain nested object or a scalar map. */
611
+ function isMapOfObjects(value) {
612
+ if (!isPlainObject(value))
613
+ return false;
614
+ const values = Object.values(value);
615
+ if (values.length === 0)
616
+ return false;
617
+ return values.every(isPlainObject);
618
+ }
619
+ /**
620
+ * Collects, for one merged object node, every numeric field that could be an
621
+ * aggregate: fields directly on the node, and fields one level deeper inside
622
+ * a plain (non-map) nested object — the shape the report evidences
623
+ * (`price.summary.total`, where `summary` is a single object, not a
624
+ * dynamically-keyed map).
625
+ */
626
+ function collectAggregateCandidates(instances, path, keys) {
627
+ const candidates = [];
628
+ for (const key of keys) {
629
+ if (instances.some((instance) => typeof instance[key] === "number")) {
630
+ candidates.push({
631
+ fieldName: key,
632
+ path: [...path, key],
633
+ getValue: (instance) => typeof instance[key] === "number" ? instance[key] : undefined,
634
+ });
635
+ }
636
+ const nestedObjects = instances.map((instance) => instance[key]).filter(isPlainObject);
637
+ if (nestedObjects.length === 0 || nestedObjects.some(isMapOfObjects))
638
+ continue;
639
+ const innerKeys = new Set(nestedObjects.flatMap((o) => Object.keys(o)));
640
+ for (const innerKey of innerKeys) {
641
+ if (!nestedObjects.some((o) => typeof o[innerKey] === "number"))
642
+ continue;
643
+ candidates.push({
644
+ fieldName: innerKey,
645
+ path: [...path, key, innerKey],
646
+ getValue: (instance) => {
647
+ const nested = instance[key];
648
+ return isPlainObject(nested) && typeof nested[innerKey] === "number"
649
+ ? nested[innerKey]
650
+ : undefined;
651
+ },
652
+ });
653
+ }
654
+ }
655
+ return candidates;
656
+ }
657
+ function collectBreakdownCandidates(instances, keys) {
658
+ const candidates = [];
659
+ for (const key of keys) {
660
+ if (!instances.some((instance) => isMapOfObjects(instance[key])))
661
+ continue;
662
+ candidates.push({
663
+ key,
664
+ getEntries: (instance) => {
665
+ const value = instance[key];
666
+ return isMapOfObjects(value) ? Object.values(value) : undefined;
667
+ },
668
+ });
669
+ }
670
+ return candidates;
671
+ }
672
+ /**
673
+ * Checks one aggregate/breakdown candidate pair against every instance at
674
+ * this node and records a finding only when the sum relation holds in ALL
675
+ * evidence. A single sample that violates the relation, or a breakdown entry
676
+ * missing the shared field, disqualifies the pair entirely -- proving the
677
+ * relation from a subset of samples is exactly the false-positive pattern
678
+ * this detector exists to avoid (a field that merely happens to share
679
+ * magnitude with a sum on some responses).
680
+ */
681
+ function evaluateAggregateUnitBasisPair(instances, breakdownPath, aggregate, breakdown, findings) {
682
+ let confirmedSamples = 0;
683
+ let maxEntries = 0;
684
+ let sawEvidence = false;
685
+ for (const instance of instances) {
686
+ const aggregateValue = aggregate.getValue(instance);
687
+ const entries = breakdown.getEntries(instance);
688
+ if (aggregateValue === undefined || entries === undefined || entries.length === 0)
689
+ continue;
690
+ sawEvidence = true;
691
+ maxEntries = Math.max(maxEntries, entries.length);
692
+ const unitValues = entries.map((entry) => entry[aggregate.fieldName]);
693
+ if (!unitValues.every((v) => typeof v === "number"))
694
+ return;
695
+ const sum = unitValues.reduce((total, v) => total + v, 0);
696
+ const tolerance = Math.max(Math.abs(aggregateValue) * AGGREGATE_UNIT_BASIS_RELATIVE_EPSILON, AGGREGATE_UNIT_BASIS_ABSOLUTE_EPSILON);
697
+ if (Math.abs(sum - aggregateValue) > tolerance)
698
+ return;
699
+ confirmedSamples++;
700
+ }
701
+ if (!sawEvidence || confirmedSamples === 0)
702
+ return;
703
+ // A single-entry breakdown makes aggregate == unit trivially true and
704
+ // proves nothing about summation -- at least one sample must carry a real
705
+ // multi-entry breakdown for the relation to be evidence of aggregation.
706
+ if (maxEntries < 2)
707
+ return;
708
+ findings.push({
709
+ aggregatePath: aggregate.path,
710
+ breakdownPath,
711
+ unitFieldName: aggregate.fieldName,
712
+ sampleCount: confirmedSamples,
713
+ maxBreakdownEntries: maxEntries,
714
+ });
715
+ }
716
+ function detectAggregateUnitBasisAtNode(instances, path, findings) {
717
+ const keys = new Set(instances.flatMap((instance) => Object.keys(instance)));
718
+ const aggregateCandidates = collectAggregateCandidates(instances, path, keys);
719
+ const breakdownCandidates = collectBreakdownCandidates(instances, keys);
720
+ for (const aggregate of aggregateCandidates) {
721
+ // The immediate child key the aggregate was found under -- either the
722
+ // aggregate's own key (direct case) or the plain-object container it was
723
+ // found nested inside (one-level-deeper case).
724
+ const aggregateContainerKey = aggregate.path[path.length];
725
+ for (const breakdown of breakdownCandidates) {
726
+ if (aggregateContainerKey === breakdown.key)
727
+ continue;
728
+ evaluateAggregateUnitBasisPair(instances, [...path, breakdown.key], aggregate, breakdown, findings);
729
+ }
730
+ }
731
+ }
732
+ function walkAggregateUnitBasis(values, path, depth, findings) {
733
+ if (depth > MAX_AGGREGATE_UNIT_BASIS_DEPTH)
734
+ return;
735
+ const instances = values.filter(isPlainObject);
736
+ if (instances.length > 0) {
737
+ detectAggregateUnitBasisAtNode(instances, path, findings);
738
+ const keys = new Set(instances.flatMap((instance) => Object.keys(instance)));
739
+ for (const key of keys) {
740
+ const childValues = instances
741
+ .map((instance) => instance[key])
742
+ .filter((v) => v !== undefined && v !== null);
743
+ if (childValues.length === 0)
744
+ continue;
745
+ walkAggregateUnitBasis(childValues, [...path, key], depth + 1, findings);
746
+ }
747
+ }
748
+ const arrayItems = values.filter((v) => Array.isArray(v)).flat();
749
+ if (arrayItems.length > 0) {
750
+ walkAggregateUnitBasis(arrayItems, path, depth + 1, findings);
751
+ }
752
+ }
753
+ /**
754
+ * Finds every object path where a numeric field's value observably equals
755
+ * the sum of a same-named numeric field carried by every entry of a sibling
756
+ * map-of-objects field -- the party-total/per-guest-breakdown shape
757
+ * generalized to any aggregate/per-unit pair, regardless of field or site
758
+ * naming, so recon-generate can annotate the price basis instead of leaving
759
+ * every plugin author and consumer to re-derive it independently and
760
+ * diverge. See docs/architecture.md, "Why the generator annotates
761
+ * aggregate/per-unit basis instead of deriving it".
762
+ *
763
+ * Pure and read-only: walks the merged sample set the same way
764
+ * {@link inferZodSchemaFromSamples} does, but only ever compares numbers
765
+ * already present in the samples -- it emits no schema and mutates nothing.
766
+ */
767
+ function detectAggregateUnitBasisFindings(samples) {
768
+ const findings = [];
769
+ walkAggregateUnitBasis(samples.filter((s) => s !== undefined && s !== null), [], 0, findings);
770
+ return findings;
771
+ }
772
+ /** Groups {@link detectAggregateUnitBasisFindings} output by aggregate path so
773
+ * {@link inferZodSchemaFromSamples} can look findings up per-field in O(1). */
774
+ function groupAggregateUnitBasisFindingsByPath(samples) {
775
+ const byPath = new Map();
776
+ for (const finding of detectAggregateUnitBasisFindings(samples)) {
777
+ const key = finding.aggregatePath.join(".");
778
+ const existing = byPath.get(key);
779
+ if (existing) {
780
+ existing.push(finding);
781
+ }
782
+ else {
783
+ byPath.set(key, [finding]);
784
+ }
785
+ }
786
+ return byPath;
787
+ }
582
788
  function deriveMinTime(rateLimits) {
583
789
  const first = rateLimits.find((f) => f.safeRps !== null);
584
790
  return first?.safeRps ? Math.floor(1000 / first.safeRps) : 200;
@@ -681,11 +887,50 @@ function selectReturnAction(steps) {
681
887
  * submission flow's `executeHttp` and its inferred type/schema have to agree
682
888
  * on which call they describe, or the emitted type disagrees with the value
683
889
  * actually returned. Falls back to the replay body for single-endpoint sites.
890
+ *
891
+ * A resolved drill-down fold plan (see {@link resolveFoldPlan}) bypasses
892
+ * `selectReturnAction` entirely, exactly as `emitMultiStepExecuteHttp` does
893
+ * when it emits the per-item loop-and-merge — so the shape this infers has to
894
+ * be the FOLDED primary body, not the plain one, or the schema would omit
895
+ * every field the fold adds. Both resolve through `resolveFoldPlan` with the
896
+ * same `foldReturnSpec` so they can never disagree on whether a fold applies.
897
+ * When multiple plans resolve, each plan's own folded body is merged via
898
+ * `mergeFoldedPrimaryBodies` into one combined object — the same merge
899
+ * `emitMultiStepExecuteHttp` performs for `return { data }` — so the inferred
900
+ * shape and the runtime return value always describe the same call, and two
901
+ * plans that share a top-level array key (e.g. the same paginated primary
902
+ * endpoint drilled into twice) both contribute their items instead of one
903
+ * clobbering the other. A plan whose folded body isn't a plain object can't
904
+ * be merged meaningfully, so in that case this falls back to the LAST plan's
905
+ * folded body alone, exactly as before this merge was introduced.
684
906
  */
685
- function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody) {
907
+ function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody, foldReturnSpec = null) {
686
908
  if (!isSubmissionFlow)
687
909
  return replayResponseBody;
688
- return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
910
+ const foldPlans = resolveFoldPlan(actionSteps, foldReturnSpec);
911
+ const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
912
+ if (foldPlans.length <= 1) {
913
+ if (lastFoldPlan)
914
+ return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
915
+ return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
916
+ }
917
+ // Plans sharing a primaryStepIndex (independent arrays on one primary
918
+ // response) MUST fold onto the SAME accumulating body, not independent
919
+ // copies of the original — folding each in isolation and then merging via
920
+ // mergeFoldedPrimaryBodies would array-concat one plan's folded items with
921
+ // the other's still-unfolded ones, duplicating every item.
922
+ const plansByPrimaryStep = new Map();
923
+ for (const plan of foldPlans) {
924
+ plansByPrimaryStep.set(plan.primaryStepIndex, [
925
+ ...(plansByPrimaryStep.get(plan.primaryStepIndex) ?? []),
926
+ plan,
927
+ ]);
928
+ }
929
+ const foldedBodies = [...plansByPrimaryStep.values()].map((plans) => plans.reduce((body, plan) => foldResponseBodyForShapeInference(actionSteps, plan, body), actionSteps[plans[0].primaryStepIndex].capture.responseBody));
930
+ if (foldedBodies.every(isPlainObject)) {
931
+ return (0, merge_folded_primary_bodies_1.mergeFoldedPrimaryBodies)(...foldedBodies);
932
+ }
933
+ return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
689
934
  }
690
935
  /**
691
936
  * A capture's own URL is not guaranteed parseable (see the try/catch in
@@ -854,7 +1099,7 @@ function stripLeadingGraphQLComments(query) {
854
1099
  }
855
1100
  /**
856
1101
  * Parses `$name:` declarations out of a GraphQL operation signature, e.g.
857
- * `query cruiseSearch_Cruises($filters: String, $qualifiers: String)` yields
1102
+ * `query catalogSearch_Catalog($filters: String, $qualifiers: String)` yields
858
1103
  * `["filters", "qualifiers"]`.
859
1104
  */
860
1105
  function declaredOperationVariableNames(query) {
@@ -891,6 +1136,35 @@ function operationGroupKey(capture) {
891
1136
  const name = capture.operationName ?? parsedOperationName(capture.query ?? "") ?? "anonymous";
892
1137
  return `${endpointPath}::${name}`;
893
1138
  }
1139
+ /**
1140
+ * Every 2xx capture in the run sharing `winningCapture`'s operation identity
1141
+ * (its `operationGroupKey` for a GraphQL winner, `endpointKey` for a
1142
+ * plain-REST one), deduplicated by response body. A single recon run can
1143
+ * capture the same read operation multiple times (pagination, re-filtering),
1144
+ * and each occurrence is evidence for `inferZodSchemaFromSamples` about
1145
+ * which fields the operation actually returns every time versus only
1146
+ * sometimes -- evidence the winning capture alone can't provide.
1147
+ */
1148
+ function gatherResponseBodySamples(winningCapture, isGraphQLWinner, captures) {
1149
+ if (!winningCapture)
1150
+ return [null];
1151
+ const identityOf = (c) => isGraphQLWinner ? operationGroupKey(c) : endpointKey(c.url);
1152
+ const winningIdentity = identityOf(winningCapture);
1153
+ const seen = new Set();
1154
+ const samples = [];
1155
+ for (const c of captures) {
1156
+ if (c.status < 200 || c.status >= 300)
1157
+ continue;
1158
+ if (identityOf(c) !== winningIdentity)
1159
+ continue;
1160
+ const serialized = JSON.stringify(c.responseBody);
1161
+ if (seen.has(serialized))
1162
+ continue;
1163
+ seen.add(serialized);
1164
+ samples.push(c.responseBody);
1165
+ }
1166
+ return samples.length > 0 ? samples : [winningCapture.responseBody];
1167
+ }
894
1168
  /**
895
1169
  * A declared variable is "populated" only by a non-null, non-empty-string
896
1170
  * value — an operation can declare a filter input that every capture leaves
@@ -2191,9 +2465,25 @@ function locateFormEnvelopePath(parsedBody) {
2191
2465
  * whole blob is caller-supplied; the generator can't reach inside a value it
2192
2466
  * has delegated wholesale.
2193
2467
  *
2468
+ * A candidate whose leaves include a value already threaded from elsewhere in
2469
+ * the fold — either a PRIOR step's response (e.g. a per-item join token
2470
+ * wrapped in a bulk-lookup array like `{"tokens":["<prior-response-value>"]}`)
2471
+ * OR the fold's own PRIMARY ITEM field feeding the immediate drill step's
2472
+ * request (e.g. `{"orderIds":["<primary-item-value>"]}`) — is never swallowed
2473
+ * here, no matter how array/object-shaped it looks: both are threaded
2474
+ * dependent-drill-down coordinates, not caller-supplied history data. The
2475
+ * prior-step-response case must stay reachable for `interpolateStateValues`
2476
+ * (Pass 1, which runs AFTER this pass); the primary-item case must stay
2477
+ * reachable as literal text for the fold-loop's own `parameterize` pass
2478
+ * (which runs even later, once rendering enters the per-item loop) to find
2479
+ * and swap for `${item.<field>}`. Freezing either into an opaque
2480
+ * `${JSON.stringify(payload.tokens)}` blob here would silently drop the
2481
+ * value the request depends on, breaking the fold at exactly the case an
2482
+ * ARRAY/OBJECT-wrapped join field represents.
2483
+ *
2194
2484
  * Site-agnostic: operates only on the recon body's own shape.
2195
2485
  */
2196
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
2486
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateValues = new Set()) {
2197
2487
  if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
2198
2488
  return template;
2199
2489
  }
@@ -2217,6 +2507,11 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
2217
2507
  Object.keys(value).length > 0;
2218
2508
  if (!isNonEmptyArray && !isNestedObject)
2219
2509
  continue;
2510
+ if (priorStepStateValues.size > 0) {
2511
+ const carriesThreadedValue = [...walkAllPrimitiveLeaves(value)].some(({ value: leaf }) => leaf !== null && priorStepStateValues.has(String(leaf)));
2512
+ if (carriesThreadedValue)
2513
+ continue;
2514
+ }
2220
2515
  const keyMarker = `"${key}":`;
2221
2516
  const markerIdx = result.indexOf(keyMarker);
2222
2517
  if (markerIdx === -1)
@@ -2454,6 +2749,44 @@ function pathToAssertionType(path) {
2454
2749
  const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
2455
2750
  return `{ ${key}: ${pathToAssertionType(path.slice(1))} }`;
2456
2751
  }
2752
+ /**
2753
+ * Same nesting as {@link pathToAssertionType} but the leaf types as
2754
+ * `Record<string, unknown>[]` instead of `string` — used to cast a step's
2755
+ * response down to the object-array field a {@link FoldPlan} located
2756
+ * (the primary results array, or the drill-down's per-item match array). An
2757
+ * {@link ARRAY_WILDCARD_SEGMENT} segment types as an array of whatever the
2758
+ * rest of the path resolves to, matching the `.flatMap` accessor
2759
+ * {@link pathToFoldAccessorExpr} emits for the same segment.
2760
+ */
2761
+ function foldArrayAssertionType(path) {
2762
+ if (path.length === 0)
2763
+ return "Record<string, unknown>[]";
2764
+ const segment = path[0];
2765
+ if (segment === ARRAY_WILDCARD_SEGMENT) {
2766
+ return `(${foldArrayAssertionType(path.slice(1))})[]`;
2767
+ }
2768
+ const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
2769
+ return `{ ${key}: ${foldArrayAssertionType(path.slice(1))} }`;
2770
+ }
2771
+ /**
2772
+ * Builds a JS access expression reading `path` off of `expr`, generalizing
2773
+ * across every {@link ARRAY_WILDCARD_SEGMENT} in `path` via `.flatMap` so the
2774
+ * emitted accessor visits every element of that outer array instead of
2775
+ * freezing the single index that happened to contain the matched item during
2776
+ * detection (see {@link ARRAY_WILDCARD_SEGMENT}'s docstring). A path with no
2777
+ * wildcard segment degrades to the plain {@link pathToAccessor} chain.
2778
+ */
2779
+ function pathToFoldAccessorExpr(expr, path, depth = 0) {
2780
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
2781
+ if (wildcardIndex === -1) {
2782
+ return `${expr}${pathToAccessor(path, { assertNonNull: false })}`;
2783
+ }
2784
+ const before = path.slice(0, wildcardIndex);
2785
+ const after = path.slice(wildcardIndex + 1);
2786
+ const groupVar = `g${depth}`;
2787
+ const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
2788
+ return `${outerExpr}.flatMap((${groupVar}) => ${pathToFoldAccessorExpr(groupVar, after, depth + 1)})`;
2789
+ }
2457
2790
  /** Suggests a JS-camelCase variable name for a state value path. Falls back
2458
2791
  * up the path if the tail is numeric or not a valid JS identifier. */
2459
2792
  function pathToVarName(path) {
@@ -3193,7 +3526,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
3193
3526
  }
3194
3527
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
3195
3528
  * without going through the full emitContractTs pipeline. */
3196
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map()) {
3529
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null) {
3197
3530
  // Walk the first action's request body to map each leaf string value to its
3198
3531
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
3199
3532
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -3366,6 +3699,65 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3366
3699
  const boundValues = new Set(payloadAccessorByValue.keys());
3367
3700
  // Captured literals that survived every pass — surfaced as a review TODO.
3368
3701
  const unboundLiteralKeys = new Set();
3702
+ // A fold target's join value can be threaded through a request HEADER
3703
+ // rather than the URL/body (see collectRequestValuesIncludingHeaders) —
3704
+ // the structural heuristic can't see those, but a flow-declared foldReturn
3705
+ // spec resolves the target anyway, and the drill step's per-item re-issue
3706
+ // below must still carry that header or every iteration replays the SAME
3707
+ // captured header value instead of re-keying it. Resolved once, up front
3708
+ // (fold plans depend only on `actions`/`foldReturnSpec`, not on Pass 1's
3709
+ // render), so Pass 1's header collection below knows which non-auth header
3710
+ // names are actually load-bearing for a resolved fold.
3711
+ const earlyFoldPlans = resolveFoldPlan(actions, foldReturnSpec);
3712
+ const joinCarryingHeaderNamesByStep = new Map();
3713
+ // A fold target's join value is the PRIMARY ITEM's own field, threaded into
3714
+ // the drill step's request text-literally by the fold-loop's own
3715
+ // `parameterize` pass (later, once rendering enters the per-item loop) — it
3716
+ // is never a prior STEP's produced state var, so `deriveStateVarByValue`
3717
+ // (built from `produces[]`) never sees it. Mechanism B
3718
+ // (`applyStructuredValuePayloadSubstitutions`) runs BEFORE that loop-aware
3719
+ // pass and only knows to spare a candidate carrying a prior step's state
3720
+ // value; without this, it freezes an array/object-wrapped join field (e.g.
3721
+ // `{"orderIds":["<item.orderId>"]}`) into an opaque
3722
+ // `${JSON.stringify(payload.orderIds)}` blob, destroying the literal text
3723
+ // `parameterize` needs to find and swap for `${item.orderId}` — every
3724
+ // iteration then replays one caller-supplied value instead of the item's
3725
+ // own. Collected once here (fold plans depend only on `actions`/
3726
+ // `foldReturnSpec`, not on Pass 1's render) so Pass 1 knows which values to
3727
+ // spare per step, mirroring the header-name collection above.
3728
+ const joinFieldValuesByStep = new Map();
3729
+ for (const plan of earlyFoldPlans) {
3730
+ const primaryItems = objectItemsAtPath(actions[plan.primaryStepIndex].capture.responseBody, plan.primaryArrayPath);
3731
+ for (const target of plan.targets) {
3732
+ const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
3733
+ if (!firstItem)
3734
+ continue;
3735
+ for (const stepIndex of [target.drillStepIndex, ...target.chain]) {
3736
+ const headerNames = joinCarryingHeaderNamesByStep.get(stepIndex) ?? new Set();
3737
+ for (const [headerName, headerValue] of Object.entries(actions[stepIndex].capture.requestHeaders)) {
3738
+ const matchesJoinField = target.joinFields.some((field) => {
3739
+ const value = readValueAtPath(firstItem, field.split("."));
3740
+ return ((typeof value === "string" && value.length > 0 && value === headerValue) ||
3741
+ (typeof value === "number" && String(value) === headerValue));
3742
+ });
3743
+ if (matchesJoinField)
3744
+ headerNames.add(headerName);
3745
+ }
3746
+ if (headerNames.size > 0)
3747
+ joinCarryingHeaderNamesByStep.set(stepIndex, headerNames);
3748
+ const joinValues = joinFieldValuesByStep.get(stepIndex) ?? new Set();
3749
+ for (const field of target.joinFields) {
3750
+ const value = readValueAtPath(firstItem, field.split("."));
3751
+ if (typeof value === "string" && value.length > 0)
3752
+ joinValues.add(value);
3753
+ else if (typeof value === "number")
3754
+ joinValues.add(String(value));
3755
+ }
3756
+ if (joinValues.size > 0)
3757
+ joinFieldValuesByStep.set(stepIndex, joinValues);
3758
+ }
3759
+ }
3760
+ }
3369
3761
  // Pass 1: render every step's emitted strings; collect referenced var names.
3370
3762
  const rendered = [];
3371
3763
  for (let i = 0; i < actions.length; i++) {
@@ -3401,9 +3793,16 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3401
3793
  // (experienceData/educationData history, opaque eventData) BEFORE value
3402
3794
  // substitution reaches inside them: swallowing the entire array/object first
3403
3795
  // keeps interpolateStateValues from binding a code buried in the history
3404
- // sample (e.g. a work entry's state code) to an unrelated field.
3796
+ // sample (e.g. a work entry's state code) to an unrelated field. Excludes
3797
+ // any candidate that itself carries a PRIOR step's produced value (see
3798
+ // applyStructuredValuePayloadSubstitutions' docstring) — an array-wrapped
3799
+ // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
3800
+ // must stay reachable for state threading, not get frozen as caller data.
3405
3801
  const rawBodyWithStructuredSubs = parsedBody !== null
3406
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys)
3802
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
3803
+ ...deriveStateVarByValue(prior).keys(),
3804
+ ...(joinFieldValuesByStep.get(i) ?? []),
3805
+ ]))
3407
3806
  : rawBodyWithFormSubs;
3408
3807
  // Whole-value caller coordinates bind here — after structured subs, BEFORE
3409
3808
  // state threading — so a composite coordinate (a jobLocation or jobSeqNo
@@ -3459,10 +3858,11 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3459
3858
  unboundLiteralKeys.add(key);
3460
3859
  }
3461
3860
  }
3861
+ const joinCarryingHeaderNames = joinCarryingHeaderNamesByStep.get(i);
3462
3862
  const perCallHeaders = {};
3463
3863
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
3464
3864
  const lower = k.toLowerCase();
3465
- if (lower === "api-token" || lower === "authorization") {
3865
+ if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
3466
3866
  perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
3467
3867
  }
3468
3868
  }
@@ -3496,7 +3896,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3496
3896
  // validates any individual call. Without this override, HttpRequestInit.schema
3497
3897
  // would default to the client's z.unknown() and narrowing the caller-facing
3498
3898
  // contract would enforce that narrowed shape on every call in the chain.
3499
- const schemaExpr = inferZodSchema(cap.responseBody);
3899
+ const schemaExpr = inferZodSchema(cap.responseBody, 0, "", {
3900
+ looseServerResponse: true,
3901
+ aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([cap.responseBody]),
3902
+ });
3500
3903
  rendered.push({ url, method: cap.method, headersExpr, bodyArg, schemaExpr });
3501
3904
  }
3502
3905
  // Identifier scan against the rendered text — captures `${foo}`, `${foo.bar}`,
@@ -3520,11 +3923,17 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3520
3923
  }
3521
3924
  }
3522
3925
  }
3523
- // The relevance-selected step's var is also referenced by the closing
3524
- // `return { data }` — see selectReturnAction.
3525
- const returnAction = selectReturnAction(actions);
3926
+ // Every resolved drill-down fold plan bypasses selectReturnAction entirely:
3927
+ // each plan's primary step's array is folded in place (see the loop-and-merge
3928
+ // emitted below, one such loop per plan), so a primary step's var — not
3929
+ // whichever call selectReturnAction would otherwise pick — is what
3930
+ // `return { data }` must reference when any plan resolves.
3931
+ const foldPlans = earlyFoldPlans;
3932
+ const returnAction = foldPlans.length > 0 ? null : selectReturnAction(actions);
3526
3933
  if (returnAction)
3527
3934
  referencedNames.add(returnAction.varName);
3935
+ for (const plan of foldPlans)
3936
+ referencedNames.add(actions[plan.primaryStepIndex].varName);
3528
3937
  // Pass 2: emit. Skip response bindings that aren't referenced; skip
3529
3938
  // produces[] entries whose name isn't referenced. A step's response var
3530
3939
  // is still needed when at least one of its produces[] entries IS
@@ -3544,10 +3953,195 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3544
3953
  lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
3545
3954
  }
3546
3955
  const declaredNames = new Set();
3956
+ // Every non-terminal chain step past the drill step is folded into the SAME
3957
+ // per-item loop the drill step itself emits (see below) — it must not also
3958
+ // get the normal single-call treatment this pass gives every other step, or
3959
+ // its request would be issued a second time, unconditionally, outside the
3960
+ // loop.
3961
+ // Every step in a fold target's chain (the drill step and every further
3962
+ // step transitively dependent on it — see FoldTarget.chain) is emitted
3963
+ // together as a single per-item loop below, not by this pass's normal
3964
+ // per-step produce/response declarations: their responses and produces are
3965
+ // block-scoped to that loop and never escape to the rest of the function,
3966
+ // so none of them may run through the outer `declaredNames`/produceLines
3967
+ // bookkeeping below (that bookkeeping assumes function-scope declarations).
3968
+ const foldChainIndices = new Set(foldPlans.flatMap((plan) => plan.targets.flatMap((target) => target.chain)));
3547
3969
  for (let i = 0; i < actions.length; i++) {
3548
3970
  const step = actions[i];
3549
3971
  const cap = step.capture;
3550
3972
  const r = rendered[i];
3973
+ // Every independent fold target within a given plan — regardless of which
3974
+ // drill step starts it — is folded into ONE shared per-item loop over
3975
+ // that plan's primary array, emitted once, at the plan's FIRST target's
3976
+ // drillStepIndex: each target's chain calls and Object.assign both run
3977
+ // inside that same `for (const item of foldItems)` body, so a primary
3978
+ // item ends up with fields folded in from every independent dependent
3979
+ // drill-down of that plan, not just the first. When more than one
3980
+ // independent plan resolves (distinct primary arrays), each plan gets its
3981
+ // OWN loop block, anchored at that plan's own first target's
3982
+ // drillStepIndex, so each primary array is folded with only its own
3983
+ // drill-downs' matched fields.
3984
+ // Each chain step re-issues the SAME url/headers/bodyArg/schemaExpr this
3985
+ // pass already rendered for it, only with the captured join value(s)
3986
+ // swapped for the loop item's own field accessor (the drill step) or its
3987
+ // own produced response values (later chain steps, which already render
3988
+ // with `${producedName}` templates — see deriveStateVarByValue).
3989
+ const matchingPlanIndex = foldPlans.findIndex((plan) => plan.targets.length > 0 && plan.targets[0].drillStepIndex === i);
3990
+ if (matchingPlanIndex !== -1) {
3991
+ const foldPlan = foldPlans[matchingPlanIndex];
3992
+ const primaryStep = actions[foldPlan.primaryStepIndex];
3993
+ // Read at the plan's OWN path rather than re-running the DFS: a
3994
+ // flow-declared `resultsPath` (see FoldReturnSpec) can name a different
3995
+ // array than findObjectArrayField's first match.
3996
+ const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
3997
+ const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
3998
+ // Plan-level suffix mirrors the target-level suffix below: multiple
3999
+ // loop blocks now sharing the same function scope can't declare
4000
+ // unsuffixed `foldItems`/`item` locals without colliding. The
4001
+ // overwhelmingly common single-plan case keeps the original
4002
+ // unsuffixed names.
4003
+ const planSuffix = foldPlans.length > 1 ? String(matchingPlanIndex) : "";
4004
+ const foldItemsVar = `foldItems${planSuffix}`;
4005
+ const foldItemsExpr = pathToFoldAccessorExpr(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
4006
+ const itemVar = `item${planSuffix}`;
4007
+ lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
4008
+ for (const [targetIndex, target] of foldPlan.targets.entries()) {
4009
+ // `firstItem` decides which captured literal `parameterize` rewrites
4010
+ // — it must be the item at `primaryMatchedItemIndex`, the one THIS
4011
+ // target's drill request was actually built from, not always index
4012
+ // 0, and can differ per target even though every target now shares
4013
+ // the same runtime loop item.
4014
+ const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
4015
+ if (!firstItem) {
4016
+ throw new Error(`emitMultiStepExecuteHttp: fold plan primary step ${primaryStep.varName} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
4017
+ }
4018
+ // Each target's chain variables and merge result get their own
4019
+ // suffixed local names so multiple independent targets sharing the
4020
+ // same loop body can each declare their own locals without
4021
+ // colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
4022
+ // single-target case keeps the original unsuffixed names.
4023
+ const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
4024
+ const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4025
+ // A join field can reach the render either as the raw captured literal
4026
+ // (URL query params) or as an already-generic `${payload.<field>}`
4027
+ // reference (top-level JSON body keys — see
4028
+ // applyPayloadKeyValueSubstitutions, which payload-ifies every scalar
4029
+ // body key regardless of length, running BEFORE this fold branch ever
4030
+ // sees the value). Both must resolve to the loop item's own field, not
4031
+ // a caller-supplied payload value shared across every iteration.
4032
+ // Word-boundary anchored: a plain `.split(value).join(...)` would also
4033
+ // rewrite unrelated substrings that happen to contain the join value
4034
+ // (e.g. a "p1" product id colliding with a "/v1/" path segment or a
4035
+ // "p10" sibling id), corrupting parts of the request the join field
4036
+ // never touched.
4037
+ const replaceWholeValue = (haystack, value, replacement) => haystack.replace(new RegExp(`\\b${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), replacement);
4038
+ const parameterize = (text) => target.joinFields.reduce((acc, field) => {
4039
+ const replacement = `\${${joinAccessor(field)}}`;
4040
+ // applyPayloadKeyValueSubstitutions only ever names a payload
4041
+ // accessor after the DRILL REQUEST's own top-level JSON key
4042
+ // (`${payload.sku}`), never after `field`'s dot path into the
4043
+ // PRIMARY ITEM — those are unrelated structures that only
4044
+ // happen to share a leaf name for a top-level join field. A
4045
+ // nested join field (e.g. `identifiers.sku`) must therefore
4046
+ // also match on its bare last segment, or the accessor swap
4047
+ // silently no-ops and leaves an undefined `payload.sku`
4048
+ // reference behind once the literal value itself has already
4049
+ // been replaced by the payload-key-value pass.
4050
+ const lastSegment = field.split(".").pop();
4051
+ const withAccessorSwapped = acc
4052
+ .split(`\${payload.${field}}`)
4053
+ .join(replacement)
4054
+ .split(`\${payload.${lastSegment}}`)
4055
+ .join(replacement);
4056
+ const value = readValueAtPath(firstItem, field.split("."));
4057
+ const stringValue = typeof value === "string" && value.length > 0
4058
+ ? value
4059
+ : typeof value === "number"
4060
+ ? String(value)
4061
+ : null;
4062
+ return stringValue !== null
4063
+ ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4064
+ : withAccessorSwapped;
4065
+ }, text);
4066
+ // Every chain step's response and produces are block-scoped to this
4067
+ // `for` — they never escape to the rest of the function. That is
4068
+ // exactly the constraint the previous (now-removed) throw enforced by
4069
+ // refusing to run at all: instead of failing, each chain step's
4070
+ // produces are re-declared here as loop-scoped locals, so a later
4071
+ // chain step's own request (already rendered with `${producedName}`
4072
+ // templates by the pass above, same as it would be for any two
4073
+ // sequential non-fold steps) resolves them from this narrower scope.
4074
+ const chainDeclared = new Set();
4075
+ for (const chainIndex of target.chain) {
4076
+ const chainStep = actions[chainIndex];
4077
+ const chainRendered = rendered[chainIndex];
4078
+ lines.push(` const ${chainStep.varName} = (await httpClient(\`${parameterize(chainRendered.url)}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
4079
+ const joined = [
4080
+ parameterize(chainRendered.headersExpr),
4081
+ parameterize(chainRendered.bodyArg),
4082
+ ]
4083
+ .filter((s) => s !== "")
4084
+ .join(" ");
4085
+ if (joined !== "")
4086
+ lines.push(` ${joined}`);
4087
+ lines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
4088
+ for (const p of chainStep.produces) {
4089
+ if (p.kind === "header")
4090
+ continue;
4091
+ if (chainDeclared.has(p.name))
4092
+ continue;
4093
+ if (!referencedNames.has(p.name))
4094
+ continue;
4095
+ chainDeclared.add(p.name);
4096
+ const assertion = pathToAssertionType(p.path);
4097
+ lines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
4098
+ }
4099
+ }
4100
+ const terminalStep = actions[target.chainTerminalIndex];
4101
+ // An empty chainArrayPath means the terminal step's response IS the
4102
+ // implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
4103
+ // / objectItemsAtPath's flat-object branch): the response is a flat
4104
+ // object at runtime, not an array. There is exactly one candidate, so
4105
+ // no join-field match is needed (or even possible against an array
4106
+ // API) — emit a direct object reference instead of the array
4107
+ // `.find()` machinery the multi-item branch below needs.
4108
+ if (target.chainArrayPath.length === 0) {
4109
+ lines.push(` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4110
+ }
4111
+ else {
4112
+ const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
4113
+ lines.push(` const foldMatches${suffix} = ${foldMatchesExpr};`, ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
4114
+ .map((f) => {
4115
+ const segments = f.split(".");
4116
+ // The drill-down response is a DIFFERENT payload than the
4117
+ // primary item, so it has no obligation to mirror the
4118
+ // primary item's own nesting for the join key (e.g. a
4119
+ // primary item's `identifiers.sku` is typically echoed
4120
+ // back flat, as `sku`, on the drill response). Try the
4121
+ // full nested path first (optional-chained, since an
4122
+ // intermediate segment may not exist on a flat response),
4123
+ // then fall back to the bare last segment.
4124
+ const lastSegment = segments[segments.length - 1];
4125
+ const optionalBracketAccessor = segments
4126
+ .map((segment) => `?.[${JSON.stringify(segment)}]`)
4127
+ .join("");
4128
+ const matchAccessor = segments.length > 1
4129
+ ? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
4130
+ : `m[${JSON.stringify(lastSegment)}]`;
4131
+ return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
4132
+ })
4133
+ .join(" && ")}) ?? foldMatches${suffix}[0];`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4134
+ }
4135
+ }
4136
+ lines.push(` }`, "");
4137
+ continue;
4138
+ }
4139
+ // Every other chain step (already fully emitted, inline, by the fold
4140
+ // block above) must not also get the normal single-call treatment this
4141
+ // pass gives every step, or its request would be issued a second time,
4142
+ // unconditionally, outside the loop.
4143
+ if (foldChainIndices.has(i))
4144
+ continue;
3551
4145
  // Build the produce-extraction lines FIRST so the binding decision reflects
3552
4146
  // what is actually emitted, not a pre-scan predicate. A produce whose name
3553
4147
  // was already declared by an earlier step is de-dup-skipped here — and must
@@ -3659,8 +4253,38 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3659
4253
  lines.push(line);
3660
4254
  lines.push("");
3661
4255
  }
3662
- const returnVar = returnAction ? returnAction.varName : "undefined";
3663
- lines.push(` return { data: ${returnVar} };`);
4256
+ // When multiple plans resolve, every plan's own primary var is deep-merged
4257
+ // together into one combined result via mergeFoldedPrimaryBodies —
4258
+ // mirroring selectEffectiveResponseBody's merge — so the runtime return
4259
+ // value and the inferred shape always describe the same call. A plain
4260
+ // object-spread would silently drop one plan's array whenever two plans'
4261
+ // primary bodies share a top-level array key (e.g. the same paginated
4262
+ // primary endpoint drilled into twice). A primary whose OWN top-level body
4263
+ // isn't a plain object can't be merged meaningfully, so in that case this
4264
+ // falls back to the LAST plan's primary var alone, same as before this
4265
+ // merge was introduced.
4266
+ //
4267
+ // Deduped by var name (not one entry per plan): two plans anchored on the
4268
+ // SAME primary step (e.g. a structural plan and a spec-only plan each
4269
+ // resolving a different array on one shared response — see
4270
+ // mergeSpecPlanOntoSamePrimary) both mutate that ONE response object's own
4271
+ // arrays in place per their own loop above. Passing that same var into
4272
+ // mergeFoldedPrimaryBodies once per plan would concatenate every one of its
4273
+ // arrays with itself, duplicating every already-folded item.
4274
+ const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
4275
+ const uniquePrimaryVarNames = [
4276
+ ...new Set(foldPlans.map((plan) => actions[plan.primaryStepIndex].varName)),
4277
+ ];
4278
+ const everyPrimaryIsPlainObject = foldPlans.every((plan) => isPlainObject(actions[plan.primaryStepIndex].capture.responseBody));
4279
+ if (uniquePrimaryVarNames.length > 1 && everyPrimaryIsPlainObject) {
4280
+ lines.push(` return { data: mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")}) };`);
4281
+ }
4282
+ else {
4283
+ const returnVar = lastFoldPlan
4284
+ ? actions[lastFoldPlan.primaryStepIndex].varName
4285
+ : (returnAction?.varName ?? "undefined");
4286
+ lines.push(` return { data: ${returnVar} };`);
4287
+ }
3664
4288
  return lines.join("\n");
3665
4289
  }
3666
4290
  function summariseResponseShape(value) {
@@ -3812,22 +4436,843 @@ function findNumericFieldByName(value, pattern, path = []) {
3812
4436
  }
3813
4437
  return null;
3814
4438
  }
3815
- /** Depth-first search for the first array whose elements are (non-array)
4439
+ /** Filter predicate isolating an array's (non-array) object elements — the
4440
+ * shape both {@link findAllObjectArrayFields} and {@link objectItemsAtPath}
4441
+ * treat as a "results array" candidate. */
4442
+ function isObjectArrayItem(v) {
4443
+ return v !== null && typeof v === "object" && !Array.isArray(v);
4444
+ }
4445
+ /** Sentinel path segment marking "every element of the array reached so
4446
+ * far", emitted by {@link findAllObjectArrayFields} in place of a literal
4447
+ * numeric index whenever it descends through an array to keep searching —
4448
+ * the array itself is a container of candidate groups, not a single fixed
4449
+ * one. Freezing the literal index of whichever group happened to contain
4450
+ * the matched item (the bug this sentinel fixes) meant a multi-element
4451
+ * outer array — e.g. a paginated/grouped response wrapping several
4452
+ * sub-collections — only ever resolved/iterated the ONE group seen during
4453
+ * detection. {@link objectItemsAtPath} and the fold-emission accessor
4454
+ * builders below both flatten across every element at this position instead
4455
+ * of indexing into one. */
4456
+ const ARRAY_WILDCARD_SEGMENT = "*";
4457
+ /** Depth-first search for every array whose elements are (non-array)
3816
4458
  * objects — the same "per-item response array" shape schema inference
3817
- * already resolves to when it emits `z.array(z.object({...}))`. */
3818
- function findObjectArrayField(value, path = []) {
4459
+ * already resolves to when it emits `z.array(z.object({...}))`. Ordered by
4460
+ * DFS/key order, so `[0]` is {@link findObjectArrayField}'s first match.
4461
+ * A path segment for an array index the search descended through (to keep
4462
+ * looking for a nested candidate array) is the {@link ARRAY_WILDCARD_SEGMENT}
4463
+ * sentinel, never a literal index — see its docstring. */
4464
+ function findAllObjectArrayFields(value, path = []) {
3819
4465
  if (value === null || typeof value !== "object")
3820
- return null;
4466
+ return [];
3821
4467
  if (Array.isArray(value)) {
3822
- const objectItems = value.filter((v) => v !== null && typeof v === "object" && !Array.isArray(v));
3823
- return objectItems.length > 0 ? { path, items: objectItems } : null;
4468
+ const objectItems = value.filter(isObjectArrayItem);
4469
+ const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFields(item, [...path, ARRAY_WILDCARD_SEGMENT]));
4470
+ return objectItems.length > 0
4471
+ ? [{ path, items: objectItems }, ...nestedCandidates]
4472
+ : nestedCandidates;
3824
4473
  }
3825
- for (const [key, v] of Object.entries(value)) {
3826
- const found = findObjectArrayField(v, [...path, key]);
3827
- if (found)
3828
- return found;
4474
+ return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFields(v, [...path, key]));
4475
+ }
4476
+ /** The first object-array field by DFS/key order — see
4477
+ * {@link findAllObjectArrayFields}. Every call site that must disambiguate
4478
+ * between several candidate arrays (e.g. a decoy array positioned earlier in
4479
+ * key order than the real one) uses {@link findAllObjectArrayFields}
4480
+ * directly instead of this first-match shortcut. */
4481
+ function findObjectArrayField(value, path = []) {
4482
+ return findAllObjectArrayFields(value, path)[0] ?? null;
4483
+ }
4484
+ /** {@link findAllObjectArrayFields}, widened to ALSO offer the flat
4485
+ * (non-array) whole object itself as a candidate — a detail-by-id
4486
+ * drill/chain response (e.g. `GET /widgets/{id}` returning the widget
4487
+ * object directly, not `{ widget: {...} }`) is exactly as foldable as a
4488
+ * one-element array would be. The flat entry's path is `[]` (the whole
4489
+ * body); {@link objectItemsAtPath} recognizes a flat object at a resolved
4490
+ * path the same way, so a {@link FoldTarget} built from this fallback
4491
+ * resolves correctly end to end. The flat candidate is appended AFTER every
4492
+ * real object-array candidate (never in place of one) so a caller that
4493
+ * needs to compare candidates by richness — see {@link
4494
+ * chainTerminalItemRichness} — can prefer the flat shape when it carries
4495
+ * more genuine per-item data than a small real nested object-array; a
4496
+ * caller that just wants the first real array (the common case) is
4497
+ * unaffected since it still comes first. */
4498
+ function findAllObjectArrayFieldsOrWholeObject(value, path = []) {
4499
+ const found = findAllObjectArrayFields(value, path);
4500
+ return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4501
+ }
4502
+ /** The first candidate from {@link findAllObjectArrayFieldsOrWholeObject} —
4503
+ * the flat-object-aware counterpart of {@link findObjectArrayField}. */
4504
+ function findObjectArrayFieldOrWholeObject(value, path = []) {
4505
+ return findAllObjectArrayFieldsOrWholeObject(value, path)[0] ?? null;
4506
+ }
4507
+ /** Every string and numeric value present in a capture's outbound request —
4508
+ * its URL path segments and query parameters (always strings) and its JSON
4509
+ * body's string and numeric leaves (numeric leaves stringified) — the set a
4510
+ * drill-down request's threaded join value must appear in. Path segments are
4511
+ * included because REST-style APIs commonly thread a primary item's id as a
4512
+ * path segment (e.g. `/orders/{id}`) rather than a query param or body
4513
+ * field. Numeric leaves are included because a join key is just as often a
4514
+ * numeric id (threaded as a query param string or a JSON body number
4515
+ * literal) as a string one. */
4516
+ function collectRequestStringValues(capture) {
4517
+ const values = new Set();
4518
+ try {
4519
+ const url = new URL(capture.url);
4520
+ for (const v of url.searchParams.values())
4521
+ values.add(v);
4522
+ for (const segment of url.pathname.split("/").filter(Boolean))
4523
+ values.add(segment);
3829
4524
  }
3830
- return null;
4525
+ catch {
4526
+ // Relative or malformed URL — no query params or path segments to contribute.
4527
+ }
4528
+ for (const v of jsonBodyLeafValues(capture.requestPostData) ?? [])
4529
+ values.add(v);
4530
+ const parsedBody = (() => {
4531
+ try {
4532
+ return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
4533
+ ? JSON.parse(capture.requestPostData)
4534
+ : undefined;
4535
+ }
4536
+ catch {
4537
+ return undefined;
4538
+ }
4539
+ })();
4540
+ if (parsedBody !== undefined) {
4541
+ for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
4542
+ if (typeof value === "number")
4543
+ values.add(String(value));
4544
+ }
4545
+ }
4546
+ return values;
4547
+ }
4548
+ /**
4549
+ * Yields every string/numeric leaf reachable from `item` by walking nested
4550
+ * plain objects only (not arrays — a join key is a scalar field of the item
4551
+ * or one of its nested objects, never an element drawn from a nested array),
4552
+ * paired with its dot-separated path from `item`'s root. A bare top-level
4553
+ * field yields a single-segment path (e.g. `["sku"]`), matching every
4554
+ * existing joinFields entry's shape unchanged; a field nested inside an
4555
+ * object (e.g. `{ identifiers: { sku } }`) yields `["identifiers", "sku"]`.
4556
+ */
4557
+ function* walkItemFieldPaths(item, path = []) {
4558
+ for (const [k, v] of Object.entries(item)) {
4559
+ const childPath = [...path, k];
4560
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
4561
+ yield* walkItemFieldPaths(v, childPath);
4562
+ continue;
4563
+ }
4564
+ yield { path: childPath, value: v };
4565
+ }
4566
+ }
4567
+ /**
4568
+ * Finds the ordered list of an array item's string/numeric field paths whose
4569
+ * values are threaded into `drillCapture`'s outbound request — the join key a
4570
+ * dependent drill-down call was built from. Each entry is a dot-separated
4571
+ * path (see {@link readValueAtPath} / {@link pathToAccessor}), so a bare
4572
+ * top-level field stays a single segment (e.g. `"sku"`) and a field nested
4573
+ * inside an object becomes e.g. `"identifiers.sku"`. Field order follows the
4574
+ * item's own key order (nested objects walked depth-first as encountered),
4575
+ * so a composite join (e.g. `accountId` + `region`) comes out in the same
4576
+ * order the primary response declares them, not sorted. Returns `[]` when no
4577
+ * field of the item threads into the request at all.
4578
+ */
4579
+ function findThreadedJoinFields(item, drillCapture) {
4580
+ const requestValues = collectRequestStringValues(drillCapture);
4581
+ if (requestValues.size === 0)
4582
+ return [];
4583
+ return [...walkItemFieldPaths(item)]
4584
+ .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
4585
+ (typeof v === "number" && requestValues.has(String(v))))
4586
+ .map(({ path }) => path.join("."));
4587
+ }
4588
+ /** Every string and numeric leaf value present anywhere in a response body —
4589
+ * the set a chained drill-down step's request must overlap with for that
4590
+ * step to count as depending on this response. Deliberately walks the WHOLE
4591
+ * body (not just object-array items, unlike {@link findThreadedJoinFields})
4592
+ * since a chained step can thread any response value, not only a per-item
4593
+ * join field. */
4594
+ function collectResponseLeafValues(responseBody) {
4595
+ const values = new Set();
4596
+ for (const { value } of walkAllPrimitiveLeaves(responseBody)) {
4597
+ if (typeof value === "string" && value.length > 0)
4598
+ values.add(value);
4599
+ if (typeof value === "number")
4600
+ values.add(String(value));
4601
+ }
4602
+ return values;
4603
+ }
4604
+ /**
4605
+ * Walks forward from `drillStepIndex`, following the transitive per-item
4606
+ * dependency chain: each subsequent step whose request threads a value out
4607
+ * of ANY step already in the chain is itself added to the chain, since its
4608
+ * own request — and therefore its response — only makes sense once per
4609
+ * matched primary item. Reuses {@link collectRequestValuesIncludingHeaders}
4610
+ * (URL, body, AND headers) so a chained step threading its join value
4611
+ * through a header, exactly like {@link buildFoldPlanFromSpec}'s own
4612
+ * threading, is still picked up. Returns the ordered step indices (starting
4613
+ * at `drillStepIndex`) plus the object-array path AND owning step index found
4614
+ * on the LAST chain step whose response actually has an object-array field,
4615
+ * OR — failing that — the last chain step whose response is a flat object
4616
+ * carrying MORE of its own per-item data than the chain's best terminal
4617
+ * found so far (see {@link chainTerminalItemRichness}). Either way this is
4618
+ * NOT necessarily `chain`'s last entry, since a step can be chained purely
4619
+ * because it threads a value onward (e.g. a status-check response with no
4620
+ * array of its own and no richer data than what came before) without itself
4621
+ * holding the foldable data. Falls back to `drillArrayPath`/`drillStepIndex`
4622
+ * (the immediate drill step's own array or flat object) when nothing
4623
+ * threads further, so `chain` degrades to `[drillStepIndex]` for the common
4624
+ * single-step case.
4625
+ */
4626
+ /** Disambiguates among every object-array (or flat-object) candidate on
4627
+ * `responseBody`, preferring the one whose items thread `capture`'s own
4628
+ * request/header values (see {@link findThreadedJoinFields}), falling back
4629
+ * to the richest candidate by {@link chainTerminalItemRichness} when none
4630
+ * thread. Both the immediate drill step ({@link scanPrimaryCandidateGroups})
4631
+ * and every later chained step ({@link computeFoldChain}) must disambiguate
4632
+ * identically — a decoy candidate positioned earlier in key order than the
4633
+ * real per-item array is exactly as invalid a pick on a chain hop as it is
4634
+ * on the immediate drill call — so this is the single implementation both
4635
+ * reuse instead of each doing its own first-DFS-match shortcut. */
4636
+ function selectDisambiguatedCandidate(responseBody, capture) {
4637
+ const candidates = findAllObjectArrayFieldsOrWholeObject(responseBody);
4638
+ if (candidates.length === 0)
4639
+ return null;
4640
+ const requestValues = collectRequestValuesIncludingHeaders(capture);
4641
+ const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields(item, capture).length > 0));
4642
+ if (threaded)
4643
+ return threaded;
4644
+ return candidates.reduce((richest, candidate) => {
4645
+ const candidateRichness = chainTerminalItemRichness(responseBody, candidate.path, requestValues);
4646
+ const richestSoFar = chainTerminalItemRichness(responseBody, richest.path, requestValues);
4647
+ return candidateRichness > richestSoFar ? candidate : richest;
4648
+ });
4649
+ }
4650
+ function computeFoldChain(actions, drillStepIndex, drillArrayPath) {
4651
+ const chain = [drillStepIndex];
4652
+ let chainArrayPath = drillArrayPath;
4653
+ let chainTerminalIndex = drillStepIndex;
4654
+ let chainTerminalRichness = chainTerminalItemRichness(actions[drillStepIndex].capture.responseBody, drillArrayPath, collectRequestValuesIncludingHeaders(actions[drillStepIndex].capture));
4655
+ for (let i = drillStepIndex + 1; i < actions.length; i++) {
4656
+ const candidate = actions[i];
4657
+ const requestValues = collectRequestValuesIncludingHeaders(candidate.capture);
4658
+ const dependsOnChain = chain.some((chainIndex) => {
4659
+ const chainStepCapture = actions[chainIndex].capture;
4660
+ const responseValues = collectResponseLeafValues(chainStepCapture.responseBody);
4661
+ const echoedValues = collectRequestValuesIncludingHeaders(chainStepCapture);
4662
+ return [...responseValues].some((v) => !echoedValues.has(v) && requestValues.has(v));
4663
+ });
4664
+ if (!dependsOnChain)
4665
+ continue;
4666
+ chain.push(i);
4667
+ // Disambiguated identically to the immediate drill step (see
4668
+ // selectDisambiguatedCandidate): a decoy object-array field positioned
4669
+ // earlier in key order than the real per-item array must not win just
4670
+ // for being found first on THIS chain step's own response, any more
4671
+ // than it would on the immediate drill call. The winning candidate
4672
+ // still only displaces the terminal when it's STRICTLY richer than the
4673
+ // chain's best terminal so far, OR ties it with a genuine object-ARRAY
4674
+ // candidate (isGenuineArrayCandidate: candidateArray.path is non-empty).
4675
+ // A flat single-object candidate never wins a tie — otherwise a step
4676
+ // chained purely for threading a value onward (e.g. a `{ held: true }`
4677
+ // confirmation, never richer than the real per-item shape it merely
4678
+ // threads from) would always qualify as an implicit one-item collection
4679
+ // and collapse this into "always advance the terminal to the newest
4680
+ // chain member" regardless of whether that member actually holds
4681
+ // foldable data. But a later hop that DOES resolve to a real per-item
4682
+ // array, tied only because a same-shaped flat confirmation hop sits
4683
+ // earlier in the chain, is the genuine terminal and must still win —
4684
+ // see buildMulticallSingleShotSearchDrillDownRichnessTiedConfirmationHopChainedDependentActionSteps.
4685
+ const candidateArray = selectDisambiguatedCandidate(candidate.capture.responseBody, candidate.capture);
4686
+ if (!candidateArray)
4687
+ continue;
4688
+ const candidateRichness = chainTerminalItemRichness(candidate.capture.responseBody, candidateArray.path, requestValues);
4689
+ const isGenuineArrayCandidate = candidateArray.path.length > 0;
4690
+ const advancesOnTie = candidateRichness === chainTerminalRichness &&
4691
+ candidateRichness > 0 &&
4692
+ isGenuineArrayCandidate;
4693
+ if (candidateRichness > chainTerminalRichness || advancesOnTie) {
4694
+ chainArrayPath = candidateArray.path;
4695
+ chainTerminalIndex = i;
4696
+ chainTerminalRichness = candidateRichness;
4697
+ }
4698
+ }
4699
+ return { chain, chainArrayPath, chainTerminalIndex };
4700
+ }
4701
+ /** The per-item primitive-field richness of a chain terminal candidate at
4702
+ * `path` — {@link directPrimitiveChildCountExcludingEchoed} of the first item
4703
+ * {@link objectItemsAtPath} resolves there (an object-array item when `path`
4704
+ * names a real array, or the whole flat object when `path` is `[]`), or 0
4705
+ * when `path` resolves to nothing. `requestValues` excludes fields the
4706
+ * candidate's own response merely echoes back from its request (join keys,
4707
+ * threaded ids), so an echo can never be mistaken for genuine per-item data.
4708
+ * The single metric every {@link computeFoldChain} comparison — baseline,
4709
+ * array-branch, and flat-branch alike — uses, so a later step only ever
4710
+ * displaces the terminal by actually contributing more of its own data. */
4711
+ function chainTerminalItemRichness(responseBody, path, requestValues) {
4712
+ const items = objectItemsAtPath(responseBody, path);
4713
+ return items && items.length > 0
4714
+ ? directPrimitiveChildCountExcludingEchoed(items[0], requestValues)
4715
+ : 0;
4716
+ }
4717
+ /** Like {@link directPrimitiveChildCount}, but skips a field whose value was
4718
+ * itself already threaded INTO this response's own request (present in
4719
+ * `requestValues`) — a confirmation step routinely echoes the id/token it
4720
+ * was called with alongside a status flag, and that echo must not count as
4721
+ * genuine per-item data or a side-effect-only response (e.g. `{ token:
4722
+ * "t1", held: true }` echoing a threaded `token`) would out-rank the real
4723
+ * terminal on field count alone. */
4724
+ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
4725
+ let n = 0;
4726
+ for (const v of Object.values(obj)) {
4727
+ if (v === null || (typeof v !== "object" && typeof v !== "function")) {
4728
+ if (typeof v === "string" || typeof v === "number") {
4729
+ if (requestValues.has(String(v)))
4730
+ continue;
4731
+ }
4732
+ n++;
4733
+ }
4734
+ }
4735
+ return n;
4736
+ }
4737
+ /**
4738
+ * Scans every candidate object-array field of `actions[primaryIndex]`'s
4739
+ * response independently, each yielding its own {@link PrimaryScanGroup} of
4740
+ * targets when at least one later step threads a join field out of it — a
4741
+ * single primary response holding two genuinely unrelated per-item
4742
+ * collections (e.g. a search response with both a `results[]` and a
4743
+ * `facets[]` that are each independently drilled by their own later call)
4744
+ * must fold BOTH, not just whichever array the first qualifying drill-down
4745
+ * happens to thread from. `consumedIndices` is shared across every
4746
+ * candidate's scan (not reset per candidate) so a step already folded into
4747
+ * one array's target chain is never re-claimed as a fresh target thread of
4748
+ * a different, independent array on the same primary response.
4749
+ */
4750
+ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices) {
4751
+ const primary = actions[primaryIndex];
4752
+ const primaryCandidates = findAllObjectArrayFields(primary.capture.responseBody);
4753
+ if (primaryCandidates.length === 0)
4754
+ return [];
4755
+ const groups = [];
4756
+ // A step already consumed as a later member of an earlier target's
4757
+ // chain — from THIS primary array or an independent one on the same
4758
+ // primary response — threads its request from that target's own
4759
+ // response, not straight off the primary array, so it must not also be
4760
+ // picked up as a second, independent target of any candidate array.
4761
+ const consumedIndices = new Set();
4762
+ for (const primaryArray of primaryCandidates) {
4763
+ const targets = [];
4764
+ for (let drillIndex = primaryIndex + 1; drillIndex < actions.length; drillIndex++) {
4765
+ const drill = actions[drillIndex];
4766
+ if (drill === primary)
4767
+ continue;
4768
+ if (consumedIndices.has(drillIndex))
4769
+ continue;
4770
+ if (globallyConsumedIndices.has(drillIndex))
4771
+ continue;
4772
+ // Every item in THIS candidate array is searched, not just items[0]
4773
+ // — a flow that only ever drilled into a later item (never the
4774
+ // first) must still resolve.
4775
+ const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields(item, drill.capture).length > 0);
4776
+ if (primaryMatchedItemIndex === -1)
4777
+ continue;
4778
+ const joinFields = findThreadedJoinFields(primaryArray.items[primaryMatchedItemIndex], drill.capture);
4779
+ // Widened to a flat (non-array) object response when the drill step has
4780
+ // no object-array field of its own — see
4781
+ // findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
4782
+ // `GET /widgets/{id}` returning the widget directly) is just as valid a
4783
+ // fold target as a one-element array would be; only skipping it, rather
4784
+ // than treating it as an implicit one-item collection, was the actual
4785
+ // root cause of dependent drill-downs never folding onto primary
4786
+ // results. Disambiguated via selectDisambiguatedCandidate — a per-item
4787
+ // drill array commonly echoes the join value(s) it was looked up by
4788
+ // (e.g. a `sku` search parameter mirrored back on each result), which
4789
+ // distinguishes it from a decoy array (e.g. an `errors[]` collection)
4790
+ // that never does. Not every real drill array echoes the join value,
4791
+ // though (e.g. a `productId`-keyed lookup returning `units[]` with no
4792
+ // `productId` field of its own), so when no candidate threads, the
4793
+ // richest candidate by per-item primitive-field count wins — the same
4794
+ // selection computeFoldChain applies to every later chained step, so a
4795
+ // decoy is never disambiguated differently on the immediate drill call
4796
+ // than it is one hop further down the chain.
4797
+ // An immediate hop with no object-array/flat-object candidate at all
4798
+ // (a bare token/id array, a scalar, an empty body) must not abandon
4799
+ // the whole candidate here — a LATER hop in the chain may still
4800
+ // thread forward to the real per-item data (e.g. an id array whose
4801
+ // values are looked up individually on a following step). Feed
4802
+ // computeFoldChain the empty-path baseline FoldTarget already
4803
+ // documents as its fallback contract, and only bail once the
4804
+ // resolved chain terminal itself has no real items to fold onto.
4805
+ const drillArray = selectDisambiguatedCandidate(drill.capture.responseBody, drill.capture);
4806
+ const drillArrayPath = drillArray?.path ?? [];
4807
+ const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, drillIndex, drillArrayPath);
4808
+ const chainTerminalItems = objectItemsAtPath(actions[chainTerminalIndex].capture.responseBody, chainArrayPath);
4809
+ if (!chainTerminalItems || chainTerminalItems.length === 0)
4810
+ continue;
4811
+ // `primaryArray.path` can carry an ARRAY_WILDCARD_SEGMENT (a matched
4812
+ // item nested inside a multi-element outer array — e.g. a
4813
+ // paginated/grouped response wrapping several sub-collections), in
4814
+ // which case `primaryMatchedItemIndex` above is only the LOCAL index
4815
+ // within the one group `primaryArray.items` happens to be. Re-resolve
4816
+ // it against the FLATTENED items every group at that path contributes,
4817
+ // by object identity (findAllObjectArrayFields and objectItemsAtPath
4818
+ // both read the same references, never cloning), so downstream
4819
+ // consumers reading through `objectItemsAtPath` — the emitter's
4820
+ // `firstItem` lookup and the shape-inference fold — land on the exact
4821
+ // same item regardless of which group it came from.
4822
+ const flattenedPrimaryItems = objectItemsAtPath(primary.capture.responseBody, primaryArray.path) ?? [];
4823
+ const globalMatchedItemIndex = flattenedPrimaryItems.indexOf(primaryArray.items[primaryMatchedItemIndex]);
4824
+ targets.push({
4825
+ joinFields,
4826
+ drillStepIndex: drillIndex,
4827
+ drillArrayPath,
4828
+ primaryMatchedItemIndex: globalMatchedItemIndex === -1 ? primaryMatchedItemIndex : globalMatchedItemIndex,
4829
+ chain,
4830
+ chainArrayPath,
4831
+ chainTerminalIndex,
4832
+ });
4833
+ // Everything past drillIndex already folded into this target's own
4834
+ // chain is per-item dependent on THIS drill-down, not independently
4835
+ // threaded off the primary array, so it must be skipped rather than
4836
+ // re-considered as a new target — by this candidate array's own
4837
+ // further scanning or any other candidate array's scan.
4838
+ for (const chainIndex of chain)
4839
+ consumedIndices.add(chainIndex);
4840
+ }
4841
+ if (targets.length > 0)
4842
+ groups.push({ primaryArrayPath: primaryArray.path, targets });
4843
+ }
4844
+ return groups;
4845
+ }
4846
+ function detectDrillDownFoldPlan(actions) {
4847
+ const plans = [];
4848
+ // Spans every primary candidate, not just the current one's own drill
4849
+ // scan: a step already folded into an earlier plan's chain — as either
4850
+ // its drill step or a later chained step — depended on that earlier
4851
+ // primary's response, not on this later primary's array, so it must
4852
+ // never be re-claimed as a fresh drill target for a subsequent primary.
4853
+ const globallyConsumedIndices = new Set();
4854
+ for (let primaryIndex = 0; primaryIndex < actions.length; primaryIndex++) {
4855
+ if (globallyConsumedIndices.has(primaryIndex))
4856
+ continue;
4857
+ const primary = actions[primaryIndex];
4858
+ const groups = scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices);
4859
+ if (groups.length === 0)
4860
+ continue;
4861
+ const primaryEndpointKey = endpointKey(primary.capture.url);
4862
+ // Each independent array group on this primary is resolved (and
4863
+ // freshest-wins-checked) on its own — a re-queried primary can have one
4864
+ // array whose freshest occurrence is a later re-query while a second,
4865
+ // unrelated array on the SAME step is only ever threaded from the
4866
+ // first occurrence, so the two groups must not be forced to share one
4867
+ // anchor index.
4868
+ for (const group of groups) {
4869
+ // A re-queried primary (same endpoint hit more than once, per
4870
+ // findRequeriedActions) can have MULTIPLE occurrences that each
4871
+ // independently thread a join key into the SAME later drill-down —
4872
+ // e.g. two "available-products" calls that both happen to contain the
4873
+ // item the drill-down looks up. selectReturnAction/selectPayloadAction
4874
+ // already establish freshest-wins for this exact re-queried-primary
4875
+ // case, so the plan must anchor on the LAST such occurrence, not the
4876
+ // first one the forward scan happens to reach.
4877
+ let freshestIndex = primaryIndex;
4878
+ let freshestGroup = group;
4879
+ for (let laterIndex = primaryIndex + 1; laterIndex < actions.length; laterIndex++) {
4880
+ if (globallyConsumedIndices.has(laterIndex))
4881
+ continue;
4882
+ const laterAction = actions[laterIndex];
4883
+ if (endpointKey(laterAction.capture.url) !== primaryEndpointKey)
4884
+ continue;
4885
+ const laterGroups = scanPrimaryCandidateGroups(actions, laterIndex, globallyConsumedIndices);
4886
+ const laterGroup = laterGroups.find((g) => JSON.stringify(g.primaryArrayPath) === JSON.stringify(freshestGroup.primaryArrayPath));
4887
+ if (laterGroup === undefined)
4888
+ continue;
4889
+ const threadsSameDrill = laterGroup.targets.some((laterTarget) => freshestGroup.targets.some((currentTarget) => currentTarget.drillStepIndex === laterTarget.drillStepIndex));
4890
+ if (!threadsSameDrill)
4891
+ continue;
4892
+ // A primary occurrence's targets all read from THAT occurrence's own
4893
+ // response array, so switching the anchor to a later occurrence can
4894
+ // only be done wholesale, not merged field-by-field. Doing so is only
4895
+ // safe when the later occurrence re-threads EVERY drill-down the
4896
+ // current anchor already covers — otherwise a drill-down target
4897
+ // unique to the earlier occurrence (one it threads independently of
4898
+ // the re-queried join key) would be silently dropped instead of
4899
+ // folded at all.
4900
+ const laterCoversEveryCurrentTarget = freshestGroup.targets.every((currentTarget) => laterGroup.targets.some((laterTarget) => laterTarget.drillStepIndex === currentTarget.drillStepIndex));
4901
+ if (!laterCoversEveryCurrentTarget)
4902
+ continue;
4903
+ freshestIndex = laterIndex;
4904
+ freshestGroup = laterGroup;
4905
+ }
4906
+ // Defer to the later occurrence: it will be picked up on its own turn
4907
+ // through the outer loop, once it is reached as `primaryIndex`.
4908
+ if (freshestIndex !== primaryIndex)
4909
+ continue;
4910
+ plans.push({
4911
+ primaryStepIndex: freshestIndex,
4912
+ primaryArrayPath: freshestGroup.primaryArrayPath,
4913
+ targets: freshestGroup.targets,
4914
+ });
4915
+ // A step already folded into this plan's chains — the drill step(s)
4916
+ // and everything threaded onward from them — was already merged
4917
+ // into this primary's own array; it must not be re-picked up as a
4918
+ // fresh PRIMARY (its response was already consumed here) nor as a
4919
+ // drill target for a later, independent primary. The primary index
4920
+ // itself is marked consumed once per group pushed (idempotent via
4921
+ // Set.add), since the step itself is only visited once regardless of
4922
+ // how many independent array groups it yields.
4923
+ globallyConsumedIndices.add(freshestIndex);
4924
+ for (const target of freshestGroup.targets) {
4925
+ for (const chainIndex of target.chain)
4926
+ globallyConsumedIndices.add(chainIndex);
4927
+ }
4928
+ }
4929
+ }
4930
+ return plans;
4931
+ }
4932
+ /**
4933
+ * Parses an object-form recon-flow.json's optional `foldReturn` declaration
4934
+ * into a typed {@link FoldReturnSpec}, or `null` when the flow is array-form,
4935
+ * doesn't declare `foldReturn`, or declares it with a non-string field —
4936
+ * mirroring the same null-safe pattern the flow loader already applies to
4937
+ * `submitEndpointPattern`/`submitBodyPattern`.
4938
+ */
4939
+ function parseFoldReturnSpec(flowFileContents) {
4940
+ try {
4941
+ const raw = JSON.parse(flowFileContents);
4942
+ if (Array.isArray(raw))
4943
+ return null;
4944
+ if (raw === null ||
4945
+ typeof raw !== "object" ||
4946
+ !("steps" in raw) ||
4947
+ !Array.isArray(raw.steps)) {
4948
+ return null;
4949
+ }
4950
+ const foldReturn = raw.foldReturn;
4951
+ if (foldReturn === undefined || foldReturn === null || typeof foldReturn !== "object") {
4952
+ return null;
4953
+ }
4954
+ const { endpointPattern, resultsPath, drillResultsPath, joinFields } = foldReturn;
4955
+ if (typeof endpointPattern !== "string" ||
4956
+ typeof resultsPath !== "string" ||
4957
+ (drillResultsPath !== undefined &&
4958
+ (typeof drillResultsPath !== "string" || drillResultsPath.length === 0)) ||
4959
+ !Array.isArray(joinFields) ||
4960
+ joinFields.length === 0 ||
4961
+ !joinFields.every((f) => typeof f === "string" && f.length > 0)) {
4962
+ return null;
4963
+ }
4964
+ return {
4965
+ endpointPattern,
4966
+ resultsPath,
4967
+ ...(drillResultsPath !== undefined ? { drillResultsPath } : {}),
4968
+ joinFields,
4969
+ };
4970
+ }
4971
+ catch {
4972
+ return null;
4973
+ }
4974
+ }
4975
+ /** Reads the value at an exact JSON path out of a response body, or
4976
+ * `undefined` when any segment doesn't resolve. The exactness is the point:
4977
+ * {@link findObjectArrayField} is a DFS FIRST-match, so it would silently
4978
+ * override a flow-declared `resultsPath` that names a later array. */
4979
+ function readValueAtPath(body, path) {
4980
+ return path.reduce((node, segment) => {
4981
+ if (node === null || typeof node !== "object")
4982
+ return undefined;
4983
+ return node[segment];
4984
+ }, body);
4985
+ }
4986
+ /** The object items of the array at `path` — the same subset
4987
+ * {@link findObjectArrayField} exposes as `items`, but anchored to a
4988
+ * caller-supplied path instead of discovered by DFS. `null` when `path`
4989
+ * doesn't resolve to an array holding at least one non-array object, UNLESS
4990
+ * `path` resolves to a flat (non-array) object itself, in which case that
4991
+ * object is treated as an implicit one-item collection — see {@link
4992
+ * findAllObjectArrayFieldsOrWholeObject}, which resolves a {@link
4993
+ * FoldTarget}'s `chainArrayPath` to exactly this whole-body shape for a
4994
+ * detail-by-id drill/chain response. An {@link ARRAY_WILDCARD_SEGMENT}
4995
+ * segment in `path` flattens across every element of the array reached at
4996
+ * that point — in DFS/outer-array order — instead of indexing into one,
4997
+ * mirroring the `.flatMap` accessor {@link pathToFoldAccessorExpr} emits for
4998
+ * the same segment, so plan resolution and codegen always agree on which
4999
+ * items a fold covers. */
5000
+ function objectItemsAtPath(body, path) {
5001
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
5002
+ if (wildcardIndex === -1) {
5003
+ const value = readValueAtPath(body, path);
5004
+ if (Array.isArray(value)) {
5005
+ const items = value.filter(isObjectArrayItem);
5006
+ return items.length > 0 ? items : null;
5007
+ }
5008
+ return isObjectArrayItem(value) ? [value] : null;
5009
+ }
5010
+ const outer = readValueAtPath(body, path.slice(0, wildcardIndex));
5011
+ if (!Array.isArray(outer))
5012
+ return null;
5013
+ const after = path.slice(wildcardIndex + 1);
5014
+ const items = outer.flatMap((element) => objectItemsAtPath(element, after) ?? []);
5015
+ return items.length > 0 ? items : null;
5016
+ }
5017
+ /**
5018
+ * Builds a {@link FoldPlan} from a flow-declared {@link FoldReturnSpec}, so a
5019
+ * site author can express a fold the structural heuristic misses.
5020
+ *
5021
+ * Returns `null` — the same null-safe contract as
5022
+ * {@link detectDrillDownFoldPlan} — when `endpointPattern` is not a valid
5023
+ * regex, when `resultsPath` resolves to no object array on any action, when
5024
+ * no strictly-later action's URL matches `endpointPattern`, or when neither
5025
+ * the matched drill-down's own response nor any later chained hop off of it
5026
+ * holds an object array or flat object (the emitter folds `foldMatches[0]`
5027
+ * out of that collection, so a plan without one has nothing to merge).
5028
+ */
5029
+ /** Same value set as {@link collectRequestStringValues}, plus every request
5030
+ * header value — a flow-declared `foldReturn` exists specifically to cover
5031
+ * joins the structural heuristic can't see (most notably a value threaded
5032
+ * through a request HEADER), so matching a spec's `joinFields` against the
5033
+ * drill capture must search headers even though the structural heuristic
5034
+ * deliberately doesn't (see {@link collectRequestStringValues}'s docstring). */
5035
+ function collectRequestValuesIncludingHeaders(capture) {
5036
+ const values = collectRequestStringValues(capture);
5037
+ for (const v of Object.values(capture.requestHeaders))
5038
+ values.add(v);
5039
+ return values;
5040
+ }
5041
+ /** Finds which of `primaryItems` the drill call actually captured, by
5042
+ * checking every field named in `joinFields` against the drill request's
5043
+ * full value set (URL, body, and headers) — the item matches only when ALL
5044
+ * join fields resolve, since a composite join (e.g. `accountId` + `region`)
5045
+ * is only a real match when every field lines up together. Returns `null`
5046
+ * (never a guessed index) when no item fully matches. */
5047
+ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCapture) {
5048
+ const requestValues = collectRequestValuesIncludingHeaders(drillCapture);
5049
+ if (requestValues.size === 0)
5050
+ return null;
5051
+ const matchedIndex = primaryItems.findIndex((item) => joinFields.every((field) => {
5052
+ const value = readValueAtPath(item, field.split("."));
5053
+ return ((typeof value === "string" && value.length > 0 && requestValues.has(value)) ||
5054
+ (typeof value === "number" && requestValues.has(String(value))));
5055
+ }));
5056
+ return matchedIndex === -1 ? null : matchedIndex;
5057
+ }
5058
+ function buildFoldPlanFromSpec(actions, spec) {
5059
+ const primaryArrayPath = spec.resultsPath.split(".");
5060
+ const endpointRx = (() => {
5061
+ try {
5062
+ return new RegExp(spec.endpointPattern);
5063
+ }
5064
+ catch {
5065
+ return null;
5066
+ }
5067
+ })();
5068
+ if (endpointRx === null)
5069
+ return null;
5070
+ let freshestPlan = null;
5071
+ for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
5072
+ const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
5073
+ if (!primaryItems)
5074
+ continue;
5075
+ for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
5076
+ const drill = actions[drillStepIndex];
5077
+ if (!endpointRx.test(drill.capture.url))
5078
+ continue;
5079
+ // Widened to a flat (non-array) object response the same way the
5080
+ // structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
5081
+ // an explicit foldReturn declaration must be able to express a
5082
+ // detail-by-id drill response exactly like the case the heuristic
5083
+ // detects on its own, not just an array field.
5084
+ // Falls through with an empty `[]` path baseline — rather than
5085
+ // bailing here — when the matched drill step's own response holds no
5086
+ // object-array/flat-object candidate, so an intermediate hop that
5087
+ // merely threads a value onward (holding no foldable data itself)
5088
+ // still lets computeFoldChain walk to a later chained step that DOES
5089
+ // hold the real per-item data, exactly as the structural heuristic
5090
+ // now does (see detectDrillDownFoldPlan). Validated after the chain
5091
+ // resolves, below, since `[]` is a valid empty baseline for
5092
+ // computeFoldChain but not a valid final drillArrayPath on its own.
5093
+ const drillArrayPath = (() => {
5094
+ if (spec.drillResultsPath === undefined) {
5095
+ return findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null;
5096
+ }
5097
+ const path = spec.drillResultsPath.split(".");
5098
+ return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
5099
+ })();
5100
+ const primaryMatchedItemIndex = resolveSpecMatchedPrimaryItemIndex(primaryItems, spec.joinFields, drill.capture);
5101
+ if (primaryMatchedItemIndex === null)
5102
+ continue;
5103
+ const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, drillStepIndex, drillArrayPath ?? []);
5104
+ // The chain's resolved terminal must actually hold foldable data —
5105
+ // an intermediate drill step with neither its own candidate NOR a
5106
+ // later chained step that resolves one has nothing to merge, so it
5107
+ // is skipped exactly as the pre-chain null check used to skip it.
5108
+ if (!objectItemsAtPath(actions[chainTerminalIndex].capture.responseBody, chainArrayPath)) {
5109
+ continue;
5110
+ }
5111
+ freshestPlan = {
5112
+ primaryStepIndex,
5113
+ primaryArrayPath,
5114
+ targets: [
5115
+ {
5116
+ joinFields: spec.joinFields,
5117
+ drillStepIndex,
5118
+ drillArrayPath: drillArrayPath ?? [],
5119
+ primaryMatchedItemIndex,
5120
+ chain,
5121
+ chainArrayPath,
5122
+ chainTerminalIndex,
5123
+ },
5124
+ ],
5125
+ };
5126
+ }
5127
+ }
5128
+ return freshestPlan;
5129
+ }
5130
+ /**
5131
+ * Unions a flow-declared `foldReturn` spec's drill-down target into the
5132
+ * structurally-detected plan for the SAME primary array (matched by
5133
+ * `primaryStepIndex` and `primaryArrayPath`), so a spec declaring an
5134
+ * independent target the heuristic missed is not silently discarded just
5135
+ * because the heuristic already resolved something for that primary. A spec
5136
+ * whose own primary/drill pair is entirely independent of every structural
5137
+ * plan — its `primaryStepIndex` and its target chains touch no index any
5138
+ * structural plan already consumes — is appended as a brand-new plan
5139
+ * instead. Only a spec whose primary step is itself already consumed by an
5140
+ * unrelated structural plan's own chain (not its own primary) is left alone,
5141
+ * to avoid folding onto a step that plan already depends on. A spec
5142
+ * re-declaring a `drillStepIndex` the heuristic already found (for the SAME
5143
+ * primary) is skipped, not duplicated.
5144
+ */
5145
+ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec) {
5146
+ if (foldReturnSpec === null)
5147
+ return [...structuralPlans];
5148
+ const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec);
5149
+ if (specPlan === null)
5150
+ return [...structuralPlans];
5151
+ const samePrimaryPlan = structuralPlans.find((plan) => plan.primaryStepIndex === specPlan.primaryStepIndex &&
5152
+ JSON.stringify(plan.primaryArrayPath) === JSON.stringify(specPlan.primaryArrayPath));
5153
+ if (samePrimaryPlan !== undefined) {
5154
+ return structuralPlans.map((plan) => {
5155
+ if (plan !== samePrimaryPlan)
5156
+ return plan;
5157
+ const existingDrillStepIndexes = new Set(plan.targets.map((target) => target.drillStepIndex));
5158
+ const newTargets = specPlan.targets.filter((target) => !existingDrillStepIndexes.has(target.drillStepIndex));
5159
+ return newTargets.length === 0
5160
+ ? plan
5161
+ : { ...plan, targets: [...plan.targets, ...newTargets] };
5162
+ });
5163
+ }
5164
+ // Keyed by the (primaryStepIndex, primaryArrayPath) pair, not
5165
+ // primaryStepIndex alone — a structural plan only ever consumed ITS OWN
5166
+ // array on that step, not the whole step. A spec whose resultsPath names
5167
+ // a second, structurally-undetected array on that exact same primary step
5168
+ // has already been proven independent by the samePrimaryPlan lookup above
5169
+ // (its primaryArrayPath differs from every structural plan's), so keying
5170
+ // solely on the step index would wrongly treat it as already consumed and
5171
+ // silently drop it.
5172
+ const consumedIndices = new Set();
5173
+ const consumedPrimarySteps = new Set();
5174
+ for (const plan of structuralPlans) {
5175
+ consumedPrimarySteps.add(`${plan.primaryStepIndex}:${JSON.stringify(plan.primaryArrayPath)}`);
5176
+ for (const target of plan.targets) {
5177
+ for (const chainIndex of target.chain)
5178
+ consumedIndices.add(chainIndex);
5179
+ }
5180
+ }
5181
+ const specConsumesOnlyItsOwnIndices = !consumedIndices.has(specPlan.primaryStepIndex) &&
5182
+ !consumedPrimarySteps.has(`${specPlan.primaryStepIndex}:${JSON.stringify(specPlan.primaryArrayPath)}`) &&
5183
+ specPlan.targets.every((target) => target.chain.every((chainIndex) => !consumedIndices.has(chainIndex)));
5184
+ return specConsumesOnlyItsOwnIndices ? [...structuralPlans, specPlan] : [...structuralPlans];
5185
+ }
5186
+ /**
5187
+ * The single fold-plan entry point: {@link detectDrillDownFoldPlan}'s
5188
+ * structural heuristic first, falling back to a flow-declared `foldReturn`
5189
+ * spec when the heuristic finds nothing. `emitMultiStepExecuteHttp` and
5190
+ * `selectEffectiveResponseBody` MUST both resolve through this rather than
5191
+ * calling the detector directly, or the emitted `executeHttp` and its
5192
+ * inferred schema would describe different calls (see
5193
+ * {@link selectEffectiveResponseBody}'s own docstring).
5194
+ *
5195
+ * A multipart step anywhere in a target's fold chain disqualifies only that
5196
+ * target: the fold loop re-issues EVERY chain step's request per item by
5197
+ * re-keying its rendered JSON request template (not just the immediate drill
5198
+ * step's), and a raw `FormData` upload has no such template to re-key — so
5199
+ * that target falls back to ordinary single-call emission instead of
5200
+ * emitting a broken loop, while any other target on the same primary that
5201
+ * has no multipart step in its chain still folds normally. A plan is dropped
5202
+ * from the returned array only when EVERY target for its primary is
5203
+ * multipart-disqualified, leaving nothing left to fold.
5204
+ *
5205
+ * {@link detectDrillDownFoldPlan} can find more than one independent
5206
+ * primary/drill-down pair in a single action sequence, so this returns every
5207
+ * plan that survives multipart disqualification, letting every downstream
5208
+ * emitter/shape-inference caller fold each of them.
5209
+ */
5210
+ function resolveFoldPlan(actions, foldReturnSpec = null) {
5211
+ const structuralPlans = detectDrillDownFoldPlan(actions);
5212
+ const plans = structuralPlans.length > 0
5213
+ ? mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
5214
+ : (() => {
5215
+ if (foldReturnSpec === null)
5216
+ return [];
5217
+ const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec);
5218
+ return specPlan === null ? [] : [specPlan];
5219
+ })();
5220
+ return plans.flatMap((plan) => {
5221
+ const targets = plan.targets.filter((target) => !target.chain.some((chainIndex) => actions[chainIndex].isMultipart));
5222
+ return targets.length === 0 ? [] : [{ ...plan, targets }];
5223
+ });
5224
+ }
5225
+ /** Rebuilds `value` with every occurrence of `target` (compared by object
5226
+ * identity) replaced by `replacement`, spreading every ancestor
5227
+ * array/object level so sibling fields and sibling array elements survive
5228
+ * unchanged. Identity, not a path, is what locates the splice point: a
5229
+ * {@link FoldPlan.primaryArrayPath} carrying an {@link ARRAY_WILDCARD_SEGMENT}
5230
+ * names a whole family of per-group arrays, not one splice-able location, so
5231
+ * only the matched item's own object reference (never cloned by
5232
+ * {@link findAllObjectArrayFields}/{@link objectItemsAtPath}, both of which
5233
+ * only filter) pins down where the fold actually lands, regardless of which
5234
+ * group it came from. */
5235
+ function replaceByReference(value, target, replacement) {
5236
+ if (value === target)
5237
+ return replacement;
5238
+ if (Array.isArray(value))
5239
+ return value.map((v) => replaceByReference(v, target, replacement));
5240
+ if (value !== null && typeof value === "object") {
5241
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
5242
+ k,
5243
+ replaceByReference(v, target, replacement),
5244
+ ]));
5245
+ }
5246
+ return value;
5247
+ }
5248
+ /**
5249
+ * The value-level counterpart of the per-item loop-and-merge
5250
+ * `emitMultiStepExecuteHttp` emits for a detected {@link FoldPlan}: merges
5251
+ * the captured drill-down item whose `joinFields` match the single captured
5252
+ * primary sample's matched array item — at `primaryMatchedItemIndex`, the
5253
+ * same item `detectDrillDownFoldPlan` built the join key from, not
5254
+ * necessarily index 0 — falling back to the drill array's first item only
5255
+ * when no drill item matches, so schema inference walks the SAME shape the
5256
+ * folded `executeHttp` actually returns at runtime. Reads both arrays at
5257
+ * the plan's OWN paths rather than re-running `findObjectArrayField`, so a
5258
+ * flow-declared `resultsPath` stays authoritative here exactly as it is in
5259
+ * the emitter. Falls back to the unmerged primary body if either capture no
5260
+ * longer resolves an object array — same drift guard as the emitter's own
5261
+ * `throw` at the analogous point, minus the throw, since shape inference
5262
+ * degrading gracefully is preferable to failing a generate run over it.
5263
+ */
5264
+ function foldResponseBodyForShapeInference(actionSteps, foldPlan, initialBody = actionSteps[foldPlan.primaryStepIndex].capture.responseBody) {
5265
+ return foldPlan.targets.reduce((body, target) => {
5266
+ const drillBody = actionSteps[target.chainTerminalIndex].capture.responseBody;
5267
+ const primaryItems = objectItemsAtPath(body, foldPlan.primaryArrayPath);
5268
+ const drillItems = objectItemsAtPath(drillBody, target.chainArrayPath);
5269
+ const matchedItem = primaryItems?.[target.primaryMatchedItemIndex];
5270
+ const drillMatch = drillItems?.find((d) => target.joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
5271
+ String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems?.[0];
5272
+ if (!primaryItems || !matchedItem || !drillMatch)
5273
+ return body;
5274
+ return replaceByReference(body, matchedItem, { ...matchedItem, ...drillMatch });
5275
+ }, initialBody);
3831
5276
  }
3832
5277
  /**
3833
5278
  * Detects whether a read-only GraphQL primary operation exposes a bounded
@@ -3914,7 +5359,7 @@ function buildPaginatedGqlExecuteHttpBody(opts) {
3914
5359
  return ` const baseVariables = ${gqlVariablesExpr};
3915
5360
  const PAGE_SIZE = ${pageSize};
3916
5361
  // Bounded so a paging bug (a total that never converges) can't loop forever.
3917
- const MAX_PAGES = 50;
5362
+ const MAX_PAGES = payload.maxPages ?? 50;
3918
5363
  const itemsById = new Map<string, unknown>();
3919
5364
  let skip = 0;
3920
5365
  const page = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${queryConstName}, ${variablesForCall});
@@ -3977,7 +5422,7 @@ function buildContractChecklist(opts) {
3977
5422
  ].filter((line) => line !== "");
3978
5423
  }
3979
5424
  function emitContractTs(opts) {
3980
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
5425
+ const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
3981
5426
  // This is the CLIENT-level schema — createHttpClient's default, and the
3982
5427
  // plugin's caller-facing contract (what executeHttp's return value promises
3983
5428
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -4002,11 +5447,16 @@ function emitContractTs(opts) {
4002
5447
  // successful return IS a real signal — z.unknown() would be dishonest
4003
5448
  // in the other direction, hiding a field the flow can actually promise.
4004
5449
  const conditionalFieldNames = gql && gqlQuery ? collectConditionalGraphQLFieldNames(gqlQuery) : undefined;
5450
+ const aggregateUnitBasisFindingsByPath = groupAggregateUnitBasisFindingsByPath(responseBodySamples);
4005
5451
  const responseSchemaExpr = omitExecuteHttp && isSubmissionFlow
4006
5452
  ? `z.object({ verified: z.boolean() })`
4007
5453
  : omitExecuteHttp
4008
5454
  ? `z.unknown()`
4009
- : inferZodSchema(responseBody, 0, "", { conditionalFieldNames });
5455
+ : inferZodSchemaFromSamples(responseBodySamples, 0, "", {
5456
+ conditionalFieldNames,
5457
+ looseServerResponse: true,
5458
+ aggregateUnitBasisFindingsByPath,
5459
+ });
4010
5460
  // Multi-step flows that include a multipart upload need the binary asset
4011
5461
  // on the payload. ApplicantContactSchema (via ApplicantResumeSchema) already
4012
5462
  // declares Resume/ResumeContentType/ResumeFilename, so submission flows
@@ -4034,6 +5484,12 @@ function emitContractTs(opts) {
4034
5484
  const basePayloadSchemaExpr = inputBody
4035
5485
  ? `ApplicantContactSchema`
4036
5486
  : `z.object({\n query: z.string().min(1),\n})`;
5487
+ // Only the single-endpoint GraphQL read path (a real primary operation, no
5488
+ // multi-step flow) is a candidate for a paging signal — multiStepBody
5489
+ // already owns its own per-call semantics.
5490
+ const paginationSignal = !multiStepBody && gql && gqlOperationName
5491
+ ? detectPaginationSignal(responseBody, gqlVariables)
5492
+ : null;
4037
5493
  // Every field source below (the base extend's own keys, form-schema
4038
5494
  // discovery, browser-flow splicing, option/raw-option enums, additional
4039
5495
  // body keys, and structured keys) is merged into a SINGLE `.extend({...})`
@@ -4046,6 +5502,13 @@ function emitContractTs(opts) {
4046
5502
  const addExtendField = (name, line) => {
4047
5503
  extendFields.set(name, line);
4048
5504
  };
5505
+ // A detected bounded-paging signal means buildPaginatedGqlExecuteHttpBody
5506
+ // will emit a loop bounded by MAX_PAGES — expose that bound as a caller-
5507
+ // overridable payload field, mirroring how PAGE_SIZE is already sourced
5508
+ // from the detected signal.
5509
+ if (paginationSignal) {
5510
+ addExtendField("maxPages", " maxPages: z.number().int().positive().optional(),");
5511
+ }
4049
5512
  // The base extend's own keys — submission flows only.
4050
5513
  if (inputBody) {
4051
5514
  addExtendField("Email", " Email: z.email(),");
@@ -4206,6 +5669,25 @@ function emitContractTs(opts) {
4206
5669
  // that collides with the base extend's own Email/ClickUrl/Answers) collapses
4207
5670
  // to its last-declared line, rather than becoming a second, dupe-prone
4208
5671
  // `.extend()` call chained onto the schema.
5672
+ // A field whose name matches (case-insensitively, the same convention
5673
+ // renderGqlVariablesExpr uses) a declared GraphQL variable that no capture
5674
+ // ever populated has no wiring target in executeHttp — it would replay the
5675
+ // captured frozen value regardless of what the caller sends. Downgrading it
5676
+ // to `.optional()` here, at the single merge point every source funnels
5677
+ // through, keeps the schema honest without special-casing any one source.
5678
+ // Email/ClickUrl/Answers are the public contract every submission-flow
5679
+ // plugin must declare unconditionally (see the basePayloadSchemaExpr
5680
+ // comment above) — a GraphQL mutation that happens to declare an
5681
+ // unpopulated variable with a matching name (e.g. `$email`) must not
5682
+ // downgrade that required base field.
5683
+ const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
5684
+ for (const [fieldName, line] of extendFields) {
5685
+ if (inputBody && baseContractFieldNames.has(fieldName))
5686
+ continue;
5687
+ if (unpopulatedDeclaredVariables.some((name) => name.toLowerCase() === fieldName.toLowerCase())) {
5688
+ extendFields.set(fieldName, line.replace(/,\s*$/, ".optional(),"));
5689
+ }
5690
+ }
4209
5691
  const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
4210
5692
  const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
4211
5693
  // basePayloadSchemaExpr's own Answers field always wraps in
@@ -4232,6 +5714,12 @@ function emitContractTs(opts) {
4232
5714
  const caseInsensitiveHeadersImport = hasMultipartStep && !omitExecuteHttp
4233
5715
  ? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
4234
5716
  : "";
5717
+ // emitMultiStepExecuteHttp emits a call to this helper whenever more than
5718
+ // one fold plan resolves — the generated plugin package can't reach into
5719
+ // recon-generate.ts's own module scope, so it must import it separately.
5720
+ const mergeFoldedPrimaryBodiesImport = multiStepBody?.includes("mergeFoldedPrimaryBodies(") === true
5721
+ ? `import { mergeFoldedPrimaryBodies } from "${ENGINE_PKG}/lib/merge-folded-primary-bodies";\n`
5722
+ : "";
4235
5723
  // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
4236
5724
  // the generated file on first lint:fix.
4237
5725
  const headersLiteral = Object.entries(baseHeaders)
@@ -4248,7 +5736,7 @@ function emitContractTs(opts) {
4248
5736
  ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";`
4249
5737
  : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
4250
5738
  const queryConst = !omitExecuteHttp && gql && gqlQuery
4251
- ? `\n// Lifted verbatim from recon capture — trim UI-only fields before shipping.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
5739
+ ? `\n// Lifted verbatim from recon capture. The adjacent response schema is drift-tolerant by construction (dropped __typename, .loose() objects), so this query text is not hand-trimmed.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
4252
5740
  : "";
4253
5741
  const gqlCacheBlock = omitExecuteHttp
4254
5742
  ? ""
@@ -4281,12 +5769,6 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
4281
5769
  const gqlVariablesExpr = gqlOperationName
4282
5770
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
4283
5771
  : "{ q: payload.query }";
4284
- // Only the single-endpoint GraphQL read path (a real primary operation, no
4285
- // multi-step flow) is a candidate for a paging signal — multiStepBody
4286
- // already owns its own per-call semantics.
4287
- const paginationSignal = !multiStepBody && gql && gqlOperationName
4288
- ? detectPaginationSignal(responseBody, gqlVariables)
4289
- : null;
4290
5772
  const executeHttpBody = multiStepBody
4291
5773
  ? multiStepBody
4292
5774
  : paginationSignal
@@ -4380,7 +5862,7 @@ ${executeHttpBody}
4380
5862
 
4381
5863
  ${bottleneckImport}import { z } from "zod/v4";
4382
5864
 
4383
- ${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${multipartBoolImport}${clientImport}
5865
+ ${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
4384
5866
  import type { BrowserSession } from "${ENGINE_PKG}/scraper/session";
4385
5867
  import type { SitePlugin, SitePluginContext, SitePluginResult } from "${ENGINE_PKG}/site-plugin";
4386
5868
  import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
@@ -4398,7 +5880,7 @@ ${internalRequestReferenceBlock}${queryConst}${gqlCacheBlock}${fixtureComments}
4398
5880
  ${pluginDocComment}
4399
5881
  export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
4400
5882
  meta: {
4401
- siteId: ${JSON.stringify(siteId)},
5883
+ siteId: ${JSON.stringify(siteId)},${displayName !== undefined ? `\n displayName: ${JSON.stringify(displayName)},` : ""}
4402
5884
  bodySchema: ${pascal}PayloadSchema,
4403
5885
  responseSchema: ${pascal}ResponseSchema,
4404
5886
  defaultBaseUrl: ${JSON.stringify(baseUrl)},
@@ -4968,15 +6450,29 @@ async function main() {
4968
6450
  return [];
4969
6451
  }
4970
6452
  })();
4971
- const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern } = (() => {
6453
+ const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern, displayName, foldReturnSpec, } = (() => {
6454
+ const flowFileContents = (() => {
6455
+ try {
6456
+ return (0, node_fs_1.readFileSync)(flowFile, "utf8");
6457
+ }
6458
+ catch {
6459
+ return null;
6460
+ }
6461
+ })();
6462
+ // Parsed off the raw bytes, independently of the steps shape below, so a
6463
+ // valid `foldReturn` still resolves when the rest of the flow file is
6464
+ // degenerate — the two declarations fail independently.
6465
+ const foldReturnSpec = flowFileContents === null ? null : parseFoldReturnSpec(flowFileContents);
4972
6466
  try {
4973
- const raw = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
6467
+ const raw = flowFileContents === null ? null : JSON.parse(flowFileContents);
4974
6468
  if (Array.isArray(raw))
4975
6469
  return {
4976
6470
  flowSteps: raw,
4977
6471
  frameSelector: undefined,
4978
6472
  submitEndpointPattern: null,
4979
6473
  submitBodyPattern: null,
6474
+ displayName: undefined,
6475
+ foldReturnSpec,
4980
6476
  };
4981
6477
  if (raw !== null &&
4982
6478
  typeof raw === "object" &&
@@ -4988,6 +6484,8 @@ async function main() {
4988
6484
  frameSelector: obj.frameSelector,
4989
6485
  submitEndpointPattern: obj.submitEndpointPattern ?? null,
4990
6486
  submitBodyPattern: obj.submitBodyPattern ?? null,
6487
+ displayName: obj.displayName,
6488
+ foldReturnSpec,
4991
6489
  };
4992
6490
  }
4993
6491
  return {
@@ -4995,6 +6493,8 @@ async function main() {
4995
6493
  frameSelector: undefined,
4996
6494
  submitEndpointPattern: null,
4997
6495
  submitBodyPattern: null,
6496
+ displayName: undefined,
6497
+ foldReturnSpec,
4998
6498
  };
4999
6499
  }
5000
6500
  catch {
@@ -5003,6 +6503,8 @@ async function main() {
5003
6503
  frameSelector: undefined,
5004
6504
  submitEndpointPattern: null,
5005
6505
  submitBodyPattern: null,
6506
+ displayName: undefined,
6507
+ foldReturnSpec,
5006
6508
  };
5007
6509
  }
5008
6510
  })();
@@ -5097,9 +6599,17 @@ async function main() {
5097
6599
  // replay array order -- a replay's body reflects whichever endpoint fired
5098
6600
  // first, not necessarily the primary operation, and only exists once
5099
6601
  // recon:http has run.
5100
- const responseBody = (primaryGraphQLOperation?.capture ??
6602
+ const winningCapture = primaryGraphQLOperation?.capture ??
5101
6603
  fallbackGraphQLCapture ??
5102
- firstEndpointCapture(captures, ownBackendHostnames, fallbackDomain))?.responseBody ?? null;
6604
+ firstEndpointCapture(captures, ownBackendHostnames, fallbackDomain);
6605
+ const responseBody = winningCapture?.responseBody ?? null;
6606
+ // Every 2xx capture sharing the winning capture's operation identity, not
6607
+ // just the one that happened to win selection -- a paginated/re-filtered
6608
+ // re-fire of the same operation can omit a field the winning capture
6609
+ // happened to have (or vice versa), and inferZodSchemaFromSamples needs
6610
+ // that presence evidence across occurrences to mark the field .optional()
6611
+ // instead of required from a single observation.
6612
+ const responseBodySamples = gatherResponseBodySamples(winningCapture, primaryGraphQLOperation !== null || fallbackGraphQLCapture !== null, captures);
5103
6613
  // Detect a multi-step submission flow (transactional sites like apply forms,
5104
6614
  // checkout, etc.). When the action sequence has 2+ POSTs, switch the
5105
6615
  // contract template to emit a state-threaded executeHttp.
@@ -5321,18 +6831,25 @@ async function main() {
5321
6831
  const multiStepBody = browserFlowOnly
5322
6832
  ? undefined
5323
6833
  : isSubmissionFlow
5324
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
6834
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
5325
6835
  : undefined;
5326
6836
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
5327
6837
  const headerBindings = collectHeaderBindings(actionSteps);
5328
6838
  // Shape inference targets the SAME call executeHttp returns — see
5329
6839
  // selectEffectiveResponseBody — so the two surfaces can't describe different calls.
5330
- const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody);
6840
+ const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody, foldReturnSpec);
6841
+ // A declared foldReturn that resolves to no plan is a silent no-op otherwise
6842
+ // — the flow author gets the discarding selectReturnAction path with nothing
6843
+ // in the output saying their declaration never applied.
6844
+ if (foldReturnSpec !== null && resolveFoldPlan(actionSteps, foldReturnSpec).length === 0) {
6845
+ logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
6846
+ }
5331
6847
  logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
5332
6848
  if (emit === "config") {
5333
6849
  (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
5334
6850
  (0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
5335
6851
  siteId,
6852
+ displayName,
5336
6853
  baseUrl,
5337
6854
  flowSteps,
5338
6855
  vocabulary,
@@ -5366,6 +6883,7 @@ async function main() {
5366
6883
  });
5367
6884
  const contractOpts = {
5368
6885
  siteId,
6886
+ displayName,
5369
6887
  pascal,
5370
6888
  baseUrl,
5371
6889
  // G1+G2: only the static headers (no baseUrl/tenant-subdomain references)
@@ -5375,6 +6893,11 @@ async function main() {
5375
6893
  safeRps,
5376
6894
  hasRateLimitProbeData,
5377
6895
  responseBody: effectiveResponseBody,
6896
+ // selectEffectiveResponseBody already resolves a submission flow's own
6897
+ // single terminal capture -- the multi-sample evidence gathered above
6898
+ // describes the (possibly different) read-flow winning capture and
6899
+ // doesn't apply once a submission flow has picked its own return call.
6900
+ responseBodySamples: isSubmissionFlow ? [effectiveResponseBody] : responseBodySamples,
5378
6901
  gql,
5379
6902
  gqlQuery,
5380
6903
  endpointPath,
@@ -5398,6 +6921,7 @@ async function main() {
5398
6921
  discoveredStructuredKeys,
5399
6922
  payloadFieldNames: browserFlow.payloadFieldNames,
5400
6923
  headerBindings,
6924
+ unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
5401
6925
  };
5402
6926
  const contractCode = emitContractTs(contractOpts);
5403
6927
  // Fails loudly rather than shipping a flow that requires a URL field it