@enricai/barnacle 1.12.21 → 1.12.23

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.
@@ -52,8 +52,16 @@ exports.compileActionSteps = compileActionSteps;
52
52
  exports.collectHeaderBindings = collectHeaderBindings;
53
53
  exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
54
54
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
55
+ exports.findAllObjectArrayFields = findAllObjectArrayFields;
56
+ exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
57
+ exports.collectResponseLeafValues = collectResponseLeafValues;
58
+ exports.getScanPrimaryCandidateGroupsCallCountForTest = getScanPrimaryCandidateGroupsCallCountForTest;
59
+ exports.resetScanPrimaryCandidateGroupsCallCountForTest = resetScanPrimaryCandidateGroupsCallCountForTest;
55
60
  exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
56
61
  exports.parseFoldReturnSpec = parseFoldReturnSpec;
62
+ exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHeaders;
63
+ exports.getFoldPlanResolutionCallCountForTests = getFoldPlanResolutionCallCountForTests;
64
+ exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCountForTests;
57
65
  exports.resolveFoldPlan = resolveFoldPlan;
58
66
  exports.buildContractChecklist = buildContractChecklist;
59
67
  exports.emitContractTs = emitContractTs;
@@ -903,15 +911,22 @@ function selectReturnAction(steps) {
903
911
  * clobbering the other. A plan whose folded body isn't a plain object can't
904
912
  * be merged meaningfully, so in that case this falls back to the LAST plan's
905
913
  * folded body alone, exactly as before this merge was introduced.
914
+ *
915
+ * The `!isSubmissionFlow` short-circuit only applies once no fold plan
916
+ * resolves: a single-primary read flow (isSubmissionFlow false) with a
917
+ * declared `foldReturn` still needs its primary body folded here, or the
918
+ * inferred response shape would omit every field the single-primary
919
+ * getGql/httpClient emission's own fold-merge loop (see `emitContractTs`)
920
+ * adds at runtime.
906
921
  */
907
922
  function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody, foldReturnSpec = null) {
908
- if (!isSubmissionFlow)
909
- return replayResponseBody;
910
923
  const foldPlans = resolveFoldPlan(actionSteps, foldReturnSpec);
911
924
  const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
912
925
  if (foldPlans.length <= 1) {
913
926
  if (lastFoldPlan)
914
927
  return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
928
+ if (!isSubmissionFlow)
929
+ return replayResponseBody;
915
930
  return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
916
931
  }
917
932
  // Plans sharing a primaryStepIndex (independent arrays on one primary
@@ -3653,6 +3668,55 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
3653
3668
  }
3654
3669
  return out;
3655
3670
  }
3671
+ /**
3672
+ * Emits the lines that match a fold target's drill-down chain response
3673
+ * against the loop item and merge the match onto it — the last leg of a
3674
+ * fold, shared by {@link emitMultiStepExecuteHttp}'s per-item loop and
3675
+ * `emitContractTs`'s single-primary getGql/httpClient fold-merge loop, so
3676
+ * both emitters describe the exact same match/merge semantics rather than
3677
+ * two copies that could drift apart.
3678
+ */
3679
+ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor) {
3680
+ // An empty chainArrayPath means the terminal step's response IS the
3681
+ // implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
3682
+ // / objectItemsAtPath's flat-object branch): the response is a flat
3683
+ // object at runtime, not an array. There is exactly one candidate, so
3684
+ // no join-field match is needed (or even possible against an array
3685
+ // API) — emit a direct object reference instead of the array
3686
+ // `.find()` machinery the multi-item branch below needs.
3687
+ if (target.chainArrayPath.length === 0) {
3688
+ return [
3689
+ ` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`,
3690
+ ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3691
+ ];
3692
+ }
3693
+ const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
3694
+ return [
3695
+ ` const foldMatches${suffix} = ${foldMatchesExpr};`,
3696
+ ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
3697
+ .map((f) => {
3698
+ const segments = f.split(".");
3699
+ // The drill-down response is a DIFFERENT payload than the
3700
+ // primary item, so it has no obligation to mirror the
3701
+ // primary item's own nesting for the join key (e.g. a
3702
+ // primary item's `identifiers.sku` is typically echoed
3703
+ // back flat, as `sku`, on the drill response). Try the
3704
+ // full nested path first (optional-chained, since an
3705
+ // intermediate segment may not exist on a flat response),
3706
+ // then fall back to the bare last segment.
3707
+ const lastSegment = segments[segments.length - 1];
3708
+ const optionalBracketAccessor = segments
3709
+ .map((segment) => `?.[${JSON.stringify(segment)}]`)
3710
+ .join("");
3711
+ const matchAccessor = segments.length > 1
3712
+ ? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
3713
+ : `m[${JSON.stringify(lastSegment)}]`;
3714
+ return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
3715
+ })
3716
+ .join(" && ")}) ?? foldMatches${suffix}[0];`,
3717
+ ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3718
+ ];
3719
+ }
3656
3720
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
3657
3721
  * without going through the full emitContractTs pipeline. */
3658
3722
  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) {
@@ -4228,40 +4292,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4228
4292
  }
4229
4293
  }
4230
4294
  const terminalStep = actions[target.chainTerminalIndex];
4231
- // An empty chainArrayPath means the terminal step's response IS the
4232
- // implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
4233
- // / objectItemsAtPath's flat-object branch): the response is a flat
4234
- // object at runtime, not an array. There is exactly one candidate, so
4235
- // no join-field match is needed (or even possible against an array
4236
- // API) — emit a direct object reference instead of the array
4237
- // `.find()` machinery the multi-item branch below needs.
4238
- if (target.chainArrayPath.length === 0) {
4239
- lines.push(` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4240
- }
4241
- else {
4242
- const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
4243
- lines.push(` const foldMatches${suffix} = ${foldMatchesExpr};`, ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
4244
- .map((f) => {
4245
- const segments = f.split(".");
4246
- // The drill-down response is a DIFFERENT payload than the
4247
- // primary item, so it has no obligation to mirror the
4248
- // primary item's own nesting for the join key (e.g. a
4249
- // primary item's `identifiers.sku` is typically echoed
4250
- // back flat, as `sku`, on the drill response). Try the
4251
- // full nested path first (optional-chained, since an
4252
- // intermediate segment may not exist on a flat response),
4253
- // then fall back to the bare last segment.
4254
- const lastSegment = segments[segments.length - 1];
4255
- const optionalBracketAccessor = segments
4256
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
4257
- .join("");
4258
- const matchAccessor = segments.length > 1
4259
- ? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
4260
- : `m[${JSON.stringify(lastSegment)}]`;
4261
- return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
4262
- })
4263
- .join(" && ")}) ?? foldMatches${suffix}[0];`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4264
- }
4295
+ lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
4265
4296
  }
4266
4297
  lines.push(` }`, "");
4267
4298
  continue;
@@ -4591,17 +4622,42 @@ const ARRAY_WILDCARD_SEGMENT = "*";
4591
4622
  * A path segment for an array index the search descended through (to keep
4592
4623
  * looking for a nested candidate array) is the {@link ARRAY_WILDCARD_SEGMENT}
4593
4624
  * sentinel, never a literal index — see its docstring. */
4594
- function findAllObjectArrayFields(value, path = []) {
4625
+ function findAllObjectArrayFieldsUncached(value, path = []) {
4595
4626
  if (value === null || typeof value !== "object")
4596
4627
  return [];
4597
4628
  if (Array.isArray(value)) {
4598
4629
  const objectItems = value.filter(isObjectArrayItem);
4599
- const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFields(item, [...path, ARRAY_WILDCARD_SEGMENT]));
4630
+ const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFieldsUncached(item, [...path, ARRAY_WILDCARD_SEGMENT]));
4600
4631
  return objectItems.length > 0
4601
4632
  ? [{ path, items: objectItems }, ...nestedCandidates]
4602
4633
  : nestedCandidates;
4603
4634
  }
4604
- return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFields(v, [...path, key]));
4635
+ return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFieldsUncached(v, [...path, key]));
4636
+ }
4637
+ /** Every distinct top-level `value` object {@link findAllObjectArrayFields}
4638
+ * is invoked on is scanned repeatedly — once per fold-chain candidate/join
4639
+ * disambiguation that re-derives the same response body — so a per-run,
4640
+ * identity-keyed cache lets a given body's whole-tree scan run at most once
4641
+ * regardless of how many callers re-derive it. Keyed on object identity
4642
+ * (never a serialized path/value pair) because captures/response bodies are
4643
+ * never mutated once produced (see this module's fold-plan investigation
4644
+ * notes), so identity alone is a safe, unconditionally correct cache key. */
4645
+ const objectArrayFieldsCache = new WeakMap();
4646
+ /** Memoized entry point for {@link findAllObjectArrayFieldsUncached} — see
4647
+ * {@link objectArrayFieldsCache}. Only the default top-level `path` is
4648
+ * cached (every real call site invokes with the default); a caller passing
4649
+ * an explicit `path` — recursion within the uncached walk itself — bypasses
4650
+ * the cache and hits the underlying scan directly. */
4651
+ function findAllObjectArrayFields(value, path = []) {
4652
+ if (path.length > 0 || value === null || typeof value !== "object") {
4653
+ return findAllObjectArrayFieldsUncached(value, path);
4654
+ }
4655
+ const cached = objectArrayFieldsCache.get(value);
4656
+ if (cached)
4657
+ return cached;
4658
+ const computed = findAllObjectArrayFieldsUncached(value, path);
4659
+ objectArrayFieldsCache.set(value, computed);
4660
+ return computed;
4605
4661
  }
4606
4662
  /** The first object-array field by DFS/key order — see
4607
4663
  * {@link findAllObjectArrayFields}. Every call site that must disambiguate
@@ -4625,9 +4681,24 @@ function findObjectArrayField(value, path = []) {
4625
4681
  * more genuine per-item data than a small real nested object-array; a
4626
4682
  * caller that just wants the first real array (the common case) is
4627
4683
  * unaffected since it still comes first. */
4684
+ const objectArrayFieldsOrWholeObjectCache = new WeakMap();
4685
+ /** Memoized the same way as {@link findAllObjectArrayFields} (see
4686
+ * {@link objectArrayFieldsCache}) — this is the candidate list
4687
+ * {@link selectDisambiguatedCandidate} and {@link buildFoldPlanFromSpec}
4688
+ * re-derive off the SAME responseBody on every chain hop/disambiguation, so
4689
+ * it is exactly as hot a redundant-recompute site as the underlying scan. */
4628
4690
  function findAllObjectArrayFieldsOrWholeObject(value, path = []) {
4691
+ if (path.length > 0 || value === null || typeof value !== "object") {
4692
+ const found = findAllObjectArrayFields(value, path);
4693
+ return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4694
+ }
4695
+ const cached = objectArrayFieldsOrWholeObjectCache.get(value);
4696
+ if (cached)
4697
+ return cached;
4629
4698
  const found = findAllObjectArrayFields(value, path);
4630
- return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4699
+ const computed = isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4700
+ objectArrayFieldsOrWholeObjectCache.set(value, computed);
4701
+ return computed;
4631
4702
  }
4632
4703
  /** The first candidate from {@link findAllObjectArrayFieldsOrWholeObject} —
4633
4704
  * the flat-object-aware counterpart of {@link findObjectArrayField}. */
@@ -4706,6 +4777,62 @@ function* walkItemFieldPaths(item, path = []) {
4706
4777
  * order the primary response declares them, not sorted. Returns `[]` when no
4707
4778
  * field of the item threads into the request at all.
4708
4779
  */
4780
+ /**
4781
+ * Maps every string/numeric/boolean value seen in any action's request URL
4782
+ * or body (see {@link collectRequestStringValues} — deliberately headers-
4783
+ * excluded, unlike {@link buildRequestValueIndex}, matching the structural
4784
+ * heuristic's own header-blind scan) to the ascending list of action indices
4785
+ * whose request carries it. Built once per {@link detectDrillDownFoldPlan}
4786
+ * call and shared across every primary candidate's scan, so
4787
+ * {@link scanPrimaryCandidateGroups} can jump straight to the indices that
4788
+ * could possibly thread one of a primary array's own item values instead of
4789
+ * walking every later action — the O(actions)-per-primary forward scan that
4790
+ * makes the structural heuristic itself O(actions^2) on a large capture set
4791
+ * dominated by same-shaped primary candidates.
4792
+ */
4793
+ function buildRequestStringValueIndex(actions) {
4794
+ const index = new Map();
4795
+ for (let i = 0; i < actions.length; i++) {
4796
+ for (const value of collectRequestStringValues(actions[i].capture)) {
4797
+ const indices = index.get(value);
4798
+ if (indices)
4799
+ indices.push(i);
4800
+ else
4801
+ index.set(value, [i]);
4802
+ }
4803
+ }
4804
+ return index;
4805
+ }
4806
+ /** Every string/numeric/boolean field value present anywhere across `items`
4807
+ * — the set of values whose {@link buildRequestStringValueIndex} entries can
4808
+ * possibly thread out of this primary array, used to prune the candidate
4809
+ * drill indices {@link scanPrimaryCandidateGroups} walks instead of
4810
+ * considering every later action index. */
4811
+ function collectItemsFieldValues(items) {
4812
+ const values = new Set();
4813
+ for (const item of items) {
4814
+ for (const { value } of walkItemFieldPaths(item)) {
4815
+ if (typeof value === "string" && value.length > 0)
4816
+ values.add(value);
4817
+ else if (typeof value === "number" || typeof value === "boolean")
4818
+ values.add(String(value));
4819
+ }
4820
+ }
4821
+ return values;
4822
+ }
4823
+ /** Ascending, deduped action indices strictly greater than `afterIndex`
4824
+ * whose request carries at least one of `values` — see
4825
+ * {@link collectItemsFieldValues} / {@link buildRequestStringValueIndex}. */
4826
+ function collectCandidateIndicesAscending(requestStringValueIndex, values, afterIndex) {
4827
+ const candidates = new Set();
4828
+ for (const value of values) {
4829
+ for (const index of requestStringValueIndex.get(value) ?? []) {
4830
+ if (index > afterIndex)
4831
+ candidates.add(index);
4832
+ }
4833
+ }
4834
+ return [...candidates].sort((a, b) => a - b);
4835
+ }
4709
4836
  function findThreadedJoinFields(item, drillCapture) {
4710
4837
  const requestValues = collectRequestStringValues(drillCapture);
4711
4838
  if (requestValues.size === 0)
@@ -4724,8 +4851,22 @@ function findThreadedJoinFields(item, drillCapture) {
4724
4851
  * join field. Also walks every response HEADER value, mirroring
4725
4852
  * {@link collectRequestValuesIncludingHeaders} on the request side, since a
4726
4853
  * chain hop can just as easily mint its join token in a response header
4727
- * (e.g. a `Location` or custom correlation header) as in the body. */
4854
+ * (e.g. a `Location` or custom correlation header) as in the body.
4855
+ * Memoized like {@link objectArrayFieldsCache} — {@link computeFoldChain}'s
4856
+ * inner `dependsOnChain` check re-derives this for every chain member on
4857
+ * every outer loop iteration, making it the single hottest redundant-
4858
+ * recompute site in fold-chain resolution (see this module's fold-plan
4859
+ * investigation notes). */
4860
+ const responseLeafValuesCache = new WeakMap();
4728
4861
  function collectResponseLeafValues(capture) {
4862
+ const cached = responseLeafValuesCache.get(capture);
4863
+ if (cached)
4864
+ return cached;
4865
+ const computed = collectResponseLeafValuesUncached(capture);
4866
+ responseLeafValuesCache.set(capture, computed);
4867
+ return computed;
4868
+ }
4869
+ function collectResponseLeafValuesUncached(capture) {
4729
4870
  const values = new Set();
4730
4871
  for (const { value } of walkAllPrimitiveLeaves(capture.responseBody)) {
4731
4872
  if (typeof value === "string" && value.length > 0)
@@ -4885,7 +5026,20 @@ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
4885
5026
  * one array's target chain is never re-claimed as a fresh target thread of
4886
5027
  * a different, independent array on the same primary response.
4887
5028
  */
4888
- function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices) {
5029
+ let scanPrimaryCandidateGroupsCallCount = 0;
5030
+ /** Test-only instrumentation for asserting `scanPrimaryCandidateGroups`'s
5031
+ * O(actions.length) scan is reused across repeat queries of the same index
5032
+ * (via `detectDrillDownFoldPlan`'s per-index cache) rather than re-run.
5033
+ * Not read by any production path. */
5034
+ function getScanPrimaryCandidateGroupsCallCountForTest() {
5035
+ return scanPrimaryCandidateGroupsCallCount;
5036
+ }
5037
+ /** Test-only counterpart to {@link getScanPrimaryCandidateGroupsCallCountForTest}. */
5038
+ function resetScanPrimaryCandidateGroupsCallCountForTest() {
5039
+ scanPrimaryCandidateGroupsCallCount = 0;
5040
+ }
5041
+ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices, requestStringValueIndex) {
5042
+ scanPrimaryCandidateGroupsCallCount++;
4889
5043
  const primary = actions[primaryIndex];
4890
5044
  const primaryCandidates = findAllObjectArrayFields(primary.capture.responseBody);
4891
5045
  if (primaryCandidates.length === 0)
@@ -4899,7 +5053,12 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
4899
5053
  const consumedIndices = new Set();
4900
5054
  for (const primaryArray of primaryCandidates) {
4901
5055
  const targets = [];
4902
- for (let drillIndex = primaryIndex + 1; drillIndex < actions.length; drillIndex++) {
5056
+ // Pruned to the (typically tiny) set of later action indices whose
5057
+ // request could possibly thread one of this array's own item values —
5058
+ // see buildRequestStringValueIndex's docstring — instead of every
5059
+ // index from primaryIndex+1 to the end of actions.
5060
+ const candidateDrillIndices = collectCandidateIndicesAscending(requestStringValueIndex, collectItemsFieldValues(primaryArray.items), primaryIndex);
5061
+ for (const drillIndex of candidateDrillIndices) {
4903
5062
  const drill = actions[drillIndex];
4904
5063
  if (drill === primary)
4905
5064
  continue;
@@ -4989,11 +5148,40 @@ function detectDrillDownFoldPlan(actions) {
4989
5148
  // primary's response, not on this later primary's array, so it must
4990
5149
  // never be re-claimed as a fresh drill target for a subsequent primary.
4991
5150
  const globallyConsumedIndices = new Set();
5151
+ // scanPrimaryCandidateGroups's result for a given index only depends on
5152
+ // `actions` (fixed) and the current contents of `globallyConsumedIndices`,
5153
+ // so it is safe to reuse across repeat queries of the SAME index as long
5154
+ // as the consumed set hasn't grown since it was computed. The freshest-wins
5155
+ // loop below re-queries the same laterIndex once per sibling group on a
5156
+ // re-queried primary, and the outer loop often reaches that very index as
5157
+ // its own primaryIndex shortly after — both hit this cache instead of
5158
+ // repeating the O(actions.length) scan.
5159
+ let consumedVersion = 0;
5160
+ const scanCache = new Map();
5161
+ const scanCacheVersion = new Map();
5162
+ // Built once for the whole detectDrillDownFoldPlan call — see
5163
+ // buildRequestStringValueIndex's docstring.
5164
+ const requestStringValueIndex = buildRequestStringValueIndex(actions);
5165
+ const scanCached = (index) => {
5166
+ const cachedVersion = scanCacheVersion.get(index);
5167
+ if (cachedVersion === consumedVersion)
5168
+ return scanCache.get(index);
5169
+ const result = scanPrimaryCandidateGroups(actions, index, globallyConsumedIndices, requestStringValueIndex);
5170
+ scanCache.set(index, result);
5171
+ scanCacheVersion.set(index, consumedVersion);
5172
+ return result;
5173
+ };
5174
+ const addConsumed = (index) => {
5175
+ if (globallyConsumedIndices.has(index))
5176
+ return;
5177
+ globallyConsumedIndices.add(index);
5178
+ consumedVersion++;
5179
+ };
4992
5180
  for (let primaryIndex = 0; primaryIndex < actions.length; primaryIndex++) {
4993
5181
  if (globallyConsumedIndices.has(primaryIndex))
4994
5182
  continue;
4995
5183
  const primary = actions[primaryIndex];
4996
- const groups = scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices);
5184
+ const groups = scanCached(primaryIndex);
4997
5185
  if (groups.length === 0)
4998
5186
  continue;
4999
5187
  const primaryEndpointKey = endpointKey(primary.capture.url);
@@ -5020,7 +5208,7 @@ function detectDrillDownFoldPlan(actions) {
5020
5208
  const laterAction = actions[laterIndex];
5021
5209
  if (endpointKey(laterAction.capture.url) !== primaryEndpointKey)
5022
5210
  continue;
5023
- const laterGroups = scanPrimaryCandidateGroups(actions, laterIndex, globallyConsumedIndices);
5211
+ const laterGroups = scanCached(laterIndex);
5024
5212
  const laterGroup = laterGroups.find((g) => JSON.stringify(g.primaryArrayPath) === JSON.stringify(freshestGroup.primaryArrayPath));
5025
5213
  if (laterGroup === undefined)
5026
5214
  continue;
@@ -5058,10 +5246,10 @@ function detectDrillDownFoldPlan(actions) {
5058
5246
  // itself is marked consumed once per group pushed (idempotent via
5059
5247
  // Set.add), since the step itself is only visited once regardless of
5060
5248
  // how many independent array groups it yields.
5061
- globallyConsumedIndices.add(freshestIndex);
5249
+ addConsumed(freshestIndex);
5062
5250
  for (const target of freshestGroup.targets) {
5063
5251
  for (const chainIndex of target.chain)
5064
- globallyConsumedIndices.add(chainIndex);
5252
+ addConsumed(chainIndex);
5065
5253
  }
5066
5254
  }
5067
5255
  }
@@ -5170,10 +5358,20 @@ function objectItemsAtPath(body, path) {
5170
5358
  * through a request HEADER), so matching a spec's `joinFields` against the
5171
5359
  * drill capture must search headers even though the structural heuristic
5172
5360
  * deliberately doesn't (see {@link collectRequestStringValues}'s docstring). */
5361
+ const requestValuesCache = new WeakMap();
5362
+ /** Memoized like {@link objectArrayFieldsCache} — the same capture's
5363
+ * request/header values are re-derived on every fold-chain candidate that
5364
+ * threads through it. Keyed on `Capture` identity rather than
5365
+ * `responseBody`, since this walks the capture's request side (URL, body,
5366
+ * headers), not its response. */
5173
5367
  function collectRequestValuesIncludingHeaders(capture) {
5368
+ const cached = requestValuesCache.get(capture);
5369
+ if (cached)
5370
+ return cached;
5174
5371
  const values = collectRequestStringValues(capture);
5175
5372
  for (const v of Object.values(capture.requestHeaders))
5176
5373
  values.add(v);
5374
+ requestValuesCache.set(capture, values);
5177
5375
  return values;
5178
5376
  }
5179
5377
  /** Finds which of `primaryItems` the drill call actually captured, by
@@ -5194,6 +5392,64 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
5194
5392
  }));
5195
5393
  return matchedIndex === -1 ? null : matchedIndex;
5196
5394
  }
5395
+ /**
5396
+ * Maps every string/numeric/boolean value seen in any action's request (URL,
5397
+ * body, or headers — see {@link collectRequestValuesIncludingHeaders}) to the
5398
+ * ascending list of action indices whose request carries it. Built once per
5399
+ * {@link buildFoldPlanFromSpec} call and shared across every primary/drill
5400
+ * candidate pair, so {@link resolveSpecMatchedPrimaryItemIndexAlongChain}'s
5401
+ * upstream search can jump straight to the (typically tiny) set of indices
5402
+ * that could possibly carry a given primary item's join value instead of
5403
+ * scanning every index between the primary and the drill — the O(actions)-
5404
+ * per-candidate backward walk the reported large-capture-set hang traced to.
5405
+ */
5406
+ function buildRequestValueIndex(actions) {
5407
+ const index = new Map();
5408
+ for (let i = 0; i < actions.length; i++) {
5409
+ for (const value of collectRequestValuesIncludingHeaders(actions[i].capture)) {
5410
+ const indices = index.get(value);
5411
+ if (indices)
5412
+ indices.push(i);
5413
+ else
5414
+ index.set(value, [i]);
5415
+ }
5416
+ }
5417
+ return index;
5418
+ }
5419
+ /** Every string/numeric/boolean {@link FoldReturnSpec.joinFields} value
5420
+ * present on any of `primaryItems`, stringified exactly as
5421
+ * {@link resolveSpecMatchedPrimaryItemIndex} compares them — the set of
5422
+ * values whose {@link buildRequestValueIndex} entries can possibly resolve
5423
+ * this primary's join, used to prune the candidate entry indices
5424
+ * {@link resolveSpecMatchedPrimaryItemIndexAlongChain} walks instead of
5425
+ * considering every action index in range. */
5426
+ function collectPrimaryJoinValues(primaryItems, joinFields) {
5427
+ const values = new Set();
5428
+ for (const item of primaryItems) {
5429
+ for (const field of joinFields) {
5430
+ const value = readValueAtPath(item, field.split("."));
5431
+ if (typeof value === "string" && value.length > 0)
5432
+ values.add(value);
5433
+ else if (typeof value === "number" || typeof value === "boolean")
5434
+ values.add(String(value));
5435
+ }
5436
+ }
5437
+ return values;
5438
+ }
5439
+ /** Descending, deduped action indices strictly greater than
5440
+ * `primaryStepIndex` whose request carries at least one of `joinValues` —
5441
+ * the pruned candidate set {@link resolveSpecMatchedPrimaryItemIndexAlongChain}
5442
+ * walks instead of every index in `(primaryStepIndex, actions.length)`. */
5443
+ function collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex) {
5444
+ const candidates = new Set();
5445
+ for (const value of joinValues) {
5446
+ for (const index of requestValueIndex.get(value) ?? []) {
5447
+ if (index > primaryStepIndex)
5448
+ candidates.add(index);
5449
+ }
5450
+ }
5451
+ return [...candidates].sort((a, b) => b - a);
5452
+ }
5197
5453
  /** Like {@link resolveSpecMatchedPrimaryItemIndex}, but also looks upstream of
5198
5454
  * `drillStepIndex` for the join key when `drillStepIndex`'s own request
5199
5455
  * doesn't carry it — a `foldReturn` spec's `endpointPattern` naturally names
@@ -5210,9 +5466,49 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
5210
5466
  * `drillStepIndex` alone would never include this upstream entry hop, so it
5211
5467
  * would never be re-executed (header-parameterized) per primary item at
5212
5468
  * runtime. */
5213
- function resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, joinFields, primaryStepIndex, drillStepIndex) {
5214
- for (let entryIndex = drillStepIndex; entryIndex > primaryStepIndex; entryIndex--) {
5215
- const matched = resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, actions[entryIndex].capture);
5469
+ let resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
5470
+ /** Test-only instrumentation: how many times
5471
+ * {@link resolveSpecMatchedPrimaryItemIndexAlongChain} the expensive
5472
+ * backward-walk + {@link computeFoldChain} resolution — has actually run
5473
+ * since the last {@link resetFoldPlanResolutionCallCountForTests} call, so a
5474
+ * test can assert `buildFoldPlanFromSpec` pays for it only once per primary
5475
+ * rather than once per matching drill occurrence. */
5476
+ function getFoldPlanResolutionCallCountForTests() {
5477
+ return resolveSpecMatchedPrimaryItemIndexAlongChainCallCount;
5478
+ }
5479
+ function resetFoldPlanResolutionCallCountForTests() {
5480
+ resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
5481
+ }
5482
+ function resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, joinFields, drillStepIndex,
5483
+ // Keyed by entryIndex alone: valid for every call sharing the same
5484
+ // primaryStepIndex/primaryItems/joinFields, since resolveSpecMatchedPrimaryItemIndex's
5485
+ // result for a given entryIndex depends on nothing else. Without this,
5486
+ // buildFoldPlanFromSpec's per-primary candidate loop re-walks the SAME
5487
+ // overlapping entryIndex range from scratch for every matching drill
5488
+ // occurrence it tries — O(candidates * chain length) per primary instead
5489
+ // of O(chain length) total, the combinatorial blowup the reported hang
5490
+ // traced to at large capture-set sizes.
5491
+ matchedItemIndexCache,
5492
+ // Descending, deduped, computed once per primaryStepIndex by
5493
+ // {@link collectCandidateEntryIndicesDescending} — every index whose
5494
+ // request carries at least one of this primary's join values, i.e. a
5495
+ // strict superset of the indices `resolveSpecMatchedPrimaryItemIndex`
5496
+ // could ever match. Walking this instead of every index down to
5497
+ // `primaryStepIndex` is what collapses the backward search from
5498
+ // O(actions) to O(occurrences of the actual join value) per candidate.
5499
+ candidateEntryIndicesDescending) {
5500
+ resolveSpecMatchedPrimaryItemIndexAlongChainCallCount++;
5501
+ for (const entryIndex of candidateEntryIndicesDescending) {
5502
+ if (entryIndex > drillStepIndex)
5503
+ continue;
5504
+ const cached = matchedItemIndexCache.get(entryIndex);
5505
+ const matched = cached !== undefined
5506
+ ? cached
5507
+ : (() => {
5508
+ const resolved = resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, actions[entryIndex].capture);
5509
+ matchedItemIndexCache.set(entryIndex, resolved);
5510
+ return resolved;
5511
+ })();
5216
5512
  if (matched === null)
5217
5513
  continue;
5218
5514
  if (entryIndex === drillStepIndex)
@@ -5269,15 +5565,48 @@ function compileFoldReturnResultsMatcher(spec) {
5269
5565
  function buildFoldPlanFromSpec(actions, spec) {
5270
5566
  const primaryArrayPath = spec.resultsPath.split(".");
5271
5567
  const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
5568
+ // Built once and shared across every primaryStepIndex — see
5569
+ // buildRequestValueIndex's docstring. Lets each primary immediately tell
5570
+ // whether ANY action anywhere carries one of its own join values before
5571
+ // paying for anything else, instead of scanning its own drill candidates
5572
+ // one by one only to discover none of them can ever match.
5573
+ const requestValueIndex = buildRequestValueIndex(actions);
5272
5574
  let freshestPlan = null;
5273
5575
  for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
5274
5576
  const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
5275
5577
  if (!primaryItems)
5276
5578
  continue;
5579
+ const joinValues = collectPrimaryJoinValues(primaryItems, spec.joinFields);
5580
+ const candidateEntryIndicesDescending = collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex);
5581
+ // No action anywhere carries any of this primary's join values, so no
5582
+ // drillStepIndex candidate could ever resolve — skip straight to the
5583
+ // next primary instead of scanning this one's drill candidates (each of
5584
+ // which would only rediscover the same dead end).
5585
+ if (candidateEntryIndicesDescending.length === 0)
5586
+ continue;
5587
+ // Cheap regex-only pass first: collects every endpointPattern-matching
5588
+ // drillStepIndex without paying for the expensive backward-walk +
5589
+ // computeFoldChain resolution below. Scanned from the freshest (highest)
5590
+ // index down so the expensive resolution — tried only on the entries
5591
+ // this loop actually visits — runs on the candidate that would win
5592
+ // ties in the original last-write-wins scan first, falling through to
5593
+ // the next-freshest only when a candidate fails to resolve, instead of
5594
+ // resolving every earlier occurrence just to have it overwritten.
5595
+ const matchingDrillStepIndices = [];
5277
5596
  for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
5597
+ if (matchesFoldReturnEndpoint(actions[drillStepIndex].capture)) {
5598
+ matchingDrillStepIndices.push(drillStepIndex);
5599
+ }
5600
+ }
5601
+ // Shared across every matching-drill candidate tried below for THIS
5602
+ // primaryStepIndex — see resolveSpecMatchedPrimaryItemIndexAlongChain's
5603
+ // docstring on why a fresh cache per primary (not per drill candidate)
5604
+ // is what collapses the backward-walk from O(candidates * chain length)
5605
+ // to O(chain length).
5606
+ const matchedItemIndexCache = new Map();
5607
+ for (let i = matchingDrillStepIndices.length - 1; i >= 0; i--) {
5608
+ const drillStepIndex = matchingDrillStepIndices[i];
5278
5609
  const drill = actions[drillStepIndex];
5279
- if (!matchesFoldReturnEndpoint(drill.capture))
5280
- continue;
5281
5610
  // Widened to a flat (non-array) object response the same way the
5282
5611
  // structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
5283
5612
  // an explicit foldReturn declaration must be able to express a
@@ -5292,7 +5621,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5292
5621
  // now does (see detectDrillDownFoldPlan). Validated after the chain
5293
5622
  // resolves, below, since `[]` is a valid empty baseline for
5294
5623
  // computeFoldChain but not a valid final drillArrayPath on its own.
5295
- const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, primaryStepIndex, drillStepIndex);
5624
+ const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, drillStepIndex, matchedItemIndexCache, candidateEntryIndicesDescending);
5296
5625
  if (matchResult === null)
5297
5626
  continue;
5298
5627
  const { entryIndex, primaryMatchedItemIndex } = matchResult;
@@ -5339,6 +5668,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5339
5668
  },
5340
5669
  ],
5341
5670
  };
5671
+ break;
5342
5672
  }
5343
5673
  }
5344
5674
  return freshestPlan;
@@ -5694,7 +6024,7 @@ function buildContractChecklist(opts) {
5694
6024
  ].filter((line) => line !== "");
5695
6025
  }
5696
6026
  function emitContractTs(opts) {
5697
- 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;
6027
+ 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, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
5698
6028
  // This is the CLIENT-level schema — createHttpClient's default, and the
5699
6029
  // plugin's caller-facing contract (what executeHttp's return value promises
5700
6030
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -5762,6 +6092,22 @@ function emitContractTs(opts) {
5762
6092
  const paginationSignal = !multiStepBody && gql && gqlOperationName
5763
6093
  ? detectPaginationSignal(responseBody, gqlVariables)
5764
6094
  : null;
6095
+ // A resolved drill-down fold plan on the single-primary getGql/httpClient
6096
+ // hot path (`multiStepBody` unset — see emitMultiStepExecuteHttp for the
6097
+ // multi-step equivalent) folds onto `data` in place, exactly as the
6098
+ // multi-step loop folds onto its own primary var: without this, a
6099
+ // single-primary read flow with a declared `foldReturn` would silently
6100
+ // drop the fold feature the flow author declared (see
6101
+ // recon-generate-foldreturn-regresses-primary-op-and-payload-to-ats-submission-shape.md).
6102
+ // Not attempted alongside `paginationSignal` — a paginated primary's items
6103
+ // span multiple page fetches this branch never issues, so a fold plan
6104
+ // resolving against only the FIRST page's captured sample would be
6105
+ // incomplete; that combination is out of scope here.
6106
+ const singlePrimaryFoldPlans = multiStepBody || paginationSignal ? [] : resolveFoldPlan(actionSteps, foldReturnSpec);
6107
+ // A GraphQL primary with a resolved drill-down fold has no other REST
6108
+ // client to issue the drill request(s) with — getGql only ever speaks
6109
+ // GraphQL to the primary endpoint.
6110
+ const needsFoldHttpClient = gql && singlePrimaryFoldPlans.length > 0;
5765
6111
  // Every field source below (the base extend's own keys, form-schema
5766
6112
  // discovery, browser-flow splicing, option/raw-option enums, additional
5767
6113
  // body keys, and structured keys) is merged into a SINGLE `.extend({...})`
@@ -6005,7 +6351,7 @@ function emitContractTs(opts) {
6005
6351
  const clientImport = omitExecuteHttp
6006
6352
  ? ""
6007
6353
  : gql
6008
- ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";`
6354
+ ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
6009
6355
  : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
6010
6356
  const queryConst = !omitExecuteHttp && gql && gqlQuery
6011
6357
  ? `\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`
@@ -6031,7 +6377,15 @@ function getGql(baseUrl: string): GqlFn {
6031
6377
  }
6032
6378
  return client;
6033
6379
  }
6380
+ ${
6381
+ // A GraphQL primary with a resolved drill-down fold has no other REST
6382
+ // client to issue the drill request(s) with — getGql only ever speaks
6383
+ // GraphQL to the primary endpoint.
6384
+ needsFoldHttpClient
6385
+ ? `
6386
+ const httpClient = createHttpClient({ schema: z.unknown(), bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
6034
6387
  `
6388
+ : ""}`
6035
6389
  : `
6036
6390
  const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
6037
6391
  `;
@@ -6041,6 +6395,78 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6041
6395
  const gqlVariablesExpr = gqlOperationName
6042
6396
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
6043
6397
  : "{ q: payload.query }";
6398
+ /** Builds the `for (const item of foldItems) { ... }` block(s) that fold
6399
+ * every resolved plan's drill-down data onto `dataVarName`'s primary array
6400
+ * — the single-primary counterpart of emitMultiStepExecuteHttp's own
6401
+ * per-item loop, sharing its match/merge tail via
6402
+ * {@link emitFoldMatchAndMergeLines} so the two can't describe different
6403
+ * merge semantics. Chain hops beyond the drill step itself are rendered
6404
+ * directly off each hop's own captured request (no state-threading
6405
+ * pipeline) since a single-primary read flow carries no submitted payload
6406
+ * for those calls to reference. */
6407
+ const buildFoldMergeLines = (dataVarName) => {
6408
+ const lines = [];
6409
+ for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
6410
+ const primaryStep = actionSteps[foldPlan.primaryStepIndex];
6411
+ if (!primaryStep)
6412
+ continue;
6413
+ const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6414
+ const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
6415
+ const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
6416
+ const foldItemsVar = `foldItems${planSuffix}`;
6417
+ const foldItemsExpr = pathToFoldAccessorExpr(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
6418
+ const itemVar = `item${planSuffix}`;
6419
+ lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
6420
+ for (const [targetIndex, target] of foldPlan.targets.entries()) {
6421
+ const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
6422
+ if (!firstItem) {
6423
+ throw new Error(`emitContractTs: fold plan primary step ${foldPlan.primaryStepIndex} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
6424
+ }
6425
+ const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
6426
+ const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
6427
+ // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
6428
+ // `parameterize` performs — a plain split/join would also rewrite
6429
+ // unrelated substrings that happen to contain the join value.
6430
+ const parameterizeUrl = (rawUrl) => {
6431
+ const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
6432
+ ? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
6433
+ : rawUrl;
6434
+ return target.joinFields.reduce((acc, field) => {
6435
+ const value = readValueAtPath(firstItem, field.split("."));
6436
+ const stringValue = typeof value === "string" && value.length > 0
6437
+ ? value
6438
+ : typeof value === "number" || typeof value === "boolean"
6439
+ ? String(value)
6440
+ : null;
6441
+ if (stringValue === null)
6442
+ return acc;
6443
+ return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${joinAccessor(field)}}`);
6444
+ }, withBase);
6445
+ };
6446
+ for (const chainIndex of target.chain) {
6447
+ const chainStep = actionSteps[chainIndex];
6448
+ if (!chainStep)
6449
+ continue;
6450
+ const url = parameterizeUrl(chainStep.capture.url);
6451
+ const schemaExpr = inferZodSchema(chainStep.capture.responseBody, 0, "", {
6452
+ looseServerResponse: true,
6453
+ aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([
6454
+ chainStep.capture.responseBody,
6455
+ ]),
6456
+ });
6457
+ lines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
6458
+ }
6459
+ const terminalStep = actionSteps[target.chainTerminalIndex];
6460
+ if (!terminalStep)
6461
+ continue;
6462
+ lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
6463
+ }
6464
+ lines.push(` }`, "");
6465
+ }
6466
+ return lines;
6467
+ };
6468
+ const dataFoldMergeLines = buildFoldMergeLines("data");
6469
+ const dataFoldMergeBlock = dataFoldMergeLines.length > 0 ? `${dataFoldMergeLines.join("\n")}\n` : "";
6044
6470
  const executeHttpBody = multiStepBody
6045
6471
  ? multiStepBody
6046
6472
  : paginationSignal
@@ -6053,12 +6479,12 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6053
6479
  })
6054
6480
  : gql
6055
6481
  ? ` const data = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${pascal.toUpperCase()}_QUERY, ${gqlVariablesExpr});
6056
- return { data };`
6482
+ ${dataFoldMergeBlock} return { data };`
6057
6483
  : ` const data = await httpClient(\`\${context.baseUrl}${endpointPath}\`, {
6058
6484
  method: "POST",
6059
6485
  body: JSON.stringify({ query: payload.query }),
6060
6486
  });
6061
- return { data };`;
6487
+ ${dataFoldMergeBlock} return { data };`;
6062
6488
  const fixtureComments = auxFiles.length > 0
6063
6489
  ? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
6064
6490
  auxFiles
@@ -6848,7 +7274,14 @@ async function main() {
6848
7274
  const graphqlActionSequence = gql
6849
7275
  ? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
6850
7276
  : [];
6851
- const primaryGraphQLOperation = gql && graphqlActionSequence.length === 0
7277
+ // A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
7278
+ // doc comment) can put 2+ entries in graphqlActionSequence with none of them
7279
+ // an actual `mutation` — a GraphQL-primary query plus its drill-down, not a
7280
+ // transactional multi-step submission. Only a real mutation makes this a
7281
+ // submission flow; an admitted read/drill capture must not, on its own, null
7282
+ // out primaryGraphQLOperation below or flip isSubmissionFlow further down.
7283
+ const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
7284
+ const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
6852
7285
  ? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
6853
7286
  : null;
6854
7287
  if (primaryGraphQLOperation && primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
@@ -7004,7 +7437,7 @@ async function main() {
7004
7437
  ? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
7005
7438
  : new Map();
7006
7439
  const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
7007
- const isSubmissionFlow = actionSteps.length > 1;
7440
+ const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
7008
7441
  // Loud failure for a submitEndpointPattern that under-matches the raw traffic badly
7009
7442
  // enough to collapse the flow to the single-endpoint fallback: heuristicActionCaptures
7010
7443
  // is the pattern-filtered sequence (used both directly and as rawActionCaptures' floor
@@ -7195,6 +7628,8 @@ async function main() {
7195
7628
  isSubmissionFlow,
7196
7629
  inputBody,
7197
7630
  hasMultipartStep,
7631
+ actionSteps,
7632
+ foldReturnSpec,
7198
7633
  discoveredFormFields,
7199
7634
  fieldOptionsMap,
7200
7635
  discoveredOptionFields,