@enricai/barnacle 1.12.20 → 1.12.22

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;
@@ -1414,16 +1422,24 @@ function resolveManifestActionSequence(runRoot, captures) {
1414
1422
  * JWT refresh, reference-lookup) that a browser fires incidentally. Absent
1415
1423
  * patterns preserve the noise heuristic exactly.
1416
1424
  *
1425
+ * When the flow declares a `foldReturnSpec`, a GET whose URL matches its
1426
+ * `endpointPattern` is admitted despite the GET drop above — the same scoped
1427
+ * rule `buildFoldPlanFromSpec` later uses to resolve the fold plan, so a
1428
+ * spec-declared GET drill-down survives to reach it instead of being
1429
+ * dropped before the fold pipeline ever sees it. Every other GET is still
1430
+ * dropped.
1431
+ *
1417
1432
  * Exported for tests: this predicate decides what a generated plugin will POST
1418
1433
  * at a live site, and it is the only gate between a browser's incidental
1419
1434
  * chatter and the emitted hot path.
1420
1435
  */
1421
- function extractActionSequence(captures, submitPatterns = null) {
1436
+ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
1422
1437
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1438
+ const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
1423
1439
  return captures
1424
1440
  .map((capture, index) => ({ capture, index }))
1425
1441
  .filter(({ capture }) => {
1426
- if (capture.method === "GET")
1442
+ if (capture.method === "GET" && !matchesFoldReturn(capture))
1427
1443
  return false;
1428
1444
  if (capture.status < 200 || capture.status >= 300)
1429
1445
  return false;
@@ -1445,11 +1461,23 @@ function extractActionSequence(captures, submitPatterns = null) {
1445
1461
  * what let a chronologically-first fallback pick an unrelated query. Host is
1446
1462
  * NOT a filter criterion, matching {@link extractActionSequence}.
1447
1463
  *
1464
+ * When the flow declares a `foldReturnSpec`, a non-mutation capture whose
1465
+ * URL matches its `endpointPattern` is admitted despite the query drop
1466
+ * above, mirroring {@link extractActionSequence}'s GET admission. So is a
1467
+ * non-mutation capture whose response resolves the spec's own `resultsPath`
1468
+ * — the GraphQL-primary read op the drill-down folds onto, which
1469
+ * `endpointPattern` (naming the drill, not the primary) never matches on its
1470
+ * own; without this a declared spec would admit the drill-down but leave
1471
+ * `resolveFoldPlan` with no primary capture to resolve `resultsPath`
1472
+ * against. Every other non-mutation capture is still dropped.
1473
+ *
1448
1474
  * Exported for tests: this predicate decides what a generated GraphQL plugin
1449
1475
  * will send at a live site.
1450
1476
  */
1451
- function extractGraphQLActionSequence(captures, submitPatterns = null) {
1477
+ function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
1452
1478
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1479
+ const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
1480
+ const matchesFoldReturnResults = compileFoldReturnResultsMatcher(foldReturnSpec);
1453
1481
  return captures
1454
1482
  .map((capture, index) => ({ capture, index }))
1455
1483
  .filter(({ capture }) => {
@@ -1459,7 +1487,9 @@ function extractGraphQLActionSequence(captures, submitPatterns = null) {
1459
1487
  return false;
1460
1488
  if (!matchesSubmit(capture))
1461
1489
  return false;
1462
- return capture.query !== null && /^\s*mutation\b/.test(capture.query);
1490
+ if (capture.query !== null && /^\s*mutation\b/.test(capture.query))
1491
+ return true;
1492
+ return matchesFoldReturn(capture) || matchesFoldReturnResults(capture);
1463
1493
  });
1464
1494
  }
1465
1495
  /**
@@ -1530,8 +1560,10 @@ function jsonBodyLeafValues(requestPostData) {
1530
1560
  if (parsed === undefined)
1531
1561
  return null;
1532
1562
  const values = [];
1533
- for (const { value } of walkStringLeaves(parsed))
1534
- values.push(value);
1563
+ for (const { value } of walkAllPrimitiveLeaves(parsed)) {
1564
+ if (value !== null)
1565
+ values.push(String(value));
1566
+ }
1535
1567
  return values;
1536
1568
  }
1537
1569
  /**
@@ -2622,11 +2654,29 @@ function* walkSetCookiePairs(rawSetCookie) {
2622
2654
  * Exception: values in `PLACEHOLDER_STATE_VALUES` are skipped entirely so
2623
2655
  * the LATER non-placeholder occurrence at the same JSON path becomes the
2624
2656
  * canonical binding instead.
2657
+ *
2658
+ * `forceIncludeValues` (see {@link collectDependentDrillDownChainValues})
2659
+ * bypasses `MIN_STATE_VALUE_LENGTH` for the specific values it names — a
2660
+ * value already confirmed, by the fold-chain detector itself, to be threaded
2661
+ * from one dependent-drill-down chain hop's response into the next hop's
2662
+ * request is exactly as legitimate a produced state value as a long one; a
2663
+ * length floor exists to keep an UNRELATED short value (an enum code, a page
2664
+ * number) from being mistaken for reused state by blind substring/value
2665
+ * matching, and a value the chain detector already confirmed is threaded
2666
+ * carries no such ambiguity. Every other filter (MAX length, placeholder,
2667
+ * shielded UUID, GET-non-UUID) still applies.
2625
2668
  */
2626
2669
  /** Exported for unit testing — lets tests exercise the produces[] walk (body
2627
2670
  * AND header/cookie origins) directly against synthetic Capture sequences. */
2628
- function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
2671
+ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Set()) {
2629
2672
  const index = new Map();
2673
+ // Computed structurally off the SAME captures being indexed (no
2674
+ // foldReturnSpec available at this layer) — a spec-declared fold's own
2675
+ // chain values reach here via the caller-supplied `forceIncludeValues`
2676
+ // (see recon-generate's top-level `collectDependentDrillDownChainValues`
2677
+ // call), so this indexes a chain-produced value regardless of whether the
2678
+ // fold plan that confirmed it is structural or spec-declared.
2679
+ const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
2630
2680
  // First pass: identify the earliest origin among ACTION captures for each
2631
2681
  // value. Action-only earliest-origin tracking is what compileActionSteps'
2632
2682
  // produces[] check needs — it ignores non-action captures (telemetry GETs,
@@ -2645,7 +2695,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2645
2695
  const rawSetCookie = Object.entries(c.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
2646
2696
  if (rawSetCookie !== undefined) {
2647
2697
  for (const { name, value } of walkSetCookiePairs(rawSetCookie)) {
2648
- if (value.length < MIN_STATE_VALUE_LENGTH)
2698
+ // Same chain/force exemption as the body-value MIN_STATE_VALUE_LENGTH
2699
+ // floor below: a cookie-sourced value the fold-chain detector already
2700
+ // confirmed is threaded into a later hop's request is exactly as
2701
+ // legitimate as a long one, so it must not be dropped for being short.
2702
+ if (value.length < MIN_STATE_VALUE_LENGTH &&
2703
+ !chainForceIncludeValues.has(value) &&
2704
+ !forceIncludeValues.has(value))
2649
2705
  continue;
2650
2706
  if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
2651
2707
  continue;
@@ -2661,6 +2717,31 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2661
2717
  }
2662
2718
  }
2663
2719
  }
2720
+ // Non-cookie response headers are only indexed for values the fold-chain
2721
+ // detector already confirmed are threaded from this hop's response into a
2722
+ // later hop's request (`chainForceIncludeValues`) — unlike Set-Cookie,
2723
+ // which is always a plausible token mint, an arbitrary header (e.g.
2724
+ // `X-Conversation-Id`) is indexed as producible state only when chain
2725
+ // detection itself has already established that reuse, so this never
2726
+ // sweeps every header value as noise.
2727
+ for (const [headerName, headerValue] of Object.entries(c.responseHeaders)) {
2728
+ if (headerName.toLowerCase() === "set-cookie")
2729
+ continue;
2730
+ if (!chainForceIncludeValues.has(headerValue))
2731
+ continue;
2732
+ if (headerValue.length > MAX_COOKIE_STATE_VALUE_LENGTH)
2733
+ continue;
2734
+ if (PLACEHOLDER_STATE_VALUES.has(headerValue))
2735
+ continue;
2736
+ if (!index.has(headerValue)) {
2737
+ index.set(headerValue, {
2738
+ value: headerValue,
2739
+ originIndex: i,
2740
+ path: [],
2741
+ headerOrigin: { sourceHeader: headerName },
2742
+ });
2743
+ }
2744
+ }
2664
2745
  if (c.responseBody === undefined || c.responseBody === null)
2665
2746
  continue;
2666
2747
  // For GET captures, only index UUID-shaped strings. GET captures (today,
@@ -2671,8 +2752,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2671
2752
  // e.g. "candidate" as a state value gets substituted INSIDE an already-
2672
2753
  // emitted ${candidateId} interpolation, producing ${${entityTypeCode}Id}.
2673
2754
  const isGet = c.method === "GET";
2674
- for (const { value, path } of walkStringLeaves(c.responseBody)) {
2675
- if (value.length < MIN_STATE_VALUE_LENGTH)
2755
+ for (const { value: rawValue, path } of walkAllPrimitiveLeaves(c.responseBody)) {
2756
+ if (rawValue === null)
2757
+ continue;
2758
+ const value = String(rawValue);
2759
+ if (value.length < MIN_STATE_VALUE_LENGTH &&
2760
+ !chainForceIncludeValues.has(value) &&
2761
+ !forceIncludeValues.has(value))
2676
2762
  continue;
2677
2763
  if (value.length > MAX_STATE_VALUE_LENGTH)
2678
2764
  continue;
@@ -2685,7 +2771,16 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2685
2771
  // corrupt T2/T3's already-substituted Values.
2686
2772
  if (shieldedUuids.has(value))
2687
2773
  continue;
2688
- if (isGet && !UUID_REGEX.test(value))
2774
+ // Same chain/force exemption as the MIN_STATE_VALUE_LENGTH floor above:
2775
+ // a value the fold-chain detector already confirmed is threaded from
2776
+ // this GET hop's response into a later hop's request is exactly as
2777
+ // legitimate as a UUID anchor, so it must not be dropped just because
2778
+ // this hop happens to be a GET rather than every existing fixture's
2779
+ // POST.
2780
+ if (isGet &&
2781
+ !UUID_REGEX.test(value) &&
2782
+ !chainForceIncludeValues.has(value) &&
2783
+ !forceIncludeValues.has(value))
2689
2784
  continue;
2690
2785
  if (!index.has(value)) {
2691
2786
  index.set(value, { value, originIndex: i, path });
@@ -2891,8 +2986,48 @@ function compileActionSteps(actions, stateIndex) {
2891
2986
  });
2892
2987
  }
2893
2988
  }
2989
+ // Non-cookie response-header-origin produces — mirrors the Set-Cookie
2990
+ // block above but for a plain header (e.g. `X-Price-Token`) whose value
2991
+ // `indexStateValues` indexed with `headerOrigin.sourceHeader` set to the
2992
+ // real header name. Only emitted when the value is actually consumed as
2993
+ // a REQUEST HEADER downstream (`usedValueTargetHeader`) — `createHttpClient`'s
2994
+ // `bind` option (see http-client.ts) is the only mechanism that can
2995
+ // thread a header-origin value forward, since the emitted response
2996
+ // variable never exposes response headers to the rest of the generated
2997
+ // code the way it exposes the parsed body.
2998
+ for (const [headerName, headerValue] of Object.entries(capture.responseHeaders)) {
2999
+ if (headerName.toLowerCase() === "set-cookie")
3000
+ continue;
3001
+ if (!usedValues.has(headerValue))
3002
+ continue;
3003
+ const sv = stateIndex.get(headerValue);
3004
+ if (!sv || sv.originIndex !== index || !sv.headerOrigin)
3005
+ continue;
3006
+ const targetHeader = usedValueTargetHeader.get(headerValue);
3007
+ if (!targetHeader)
3008
+ continue;
3009
+ let name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header`;
3010
+ if (!/^[A-Za-z_$]/.test(name))
3011
+ name = `_${name}`;
3012
+ let suffix = 1;
3013
+ while (seenNames.has(name)) {
3014
+ suffix++;
3015
+ name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header${suffix}`;
3016
+ }
3017
+ seenNames.add(name);
3018
+ produces.push({
3019
+ kind: "header",
3020
+ name,
3021
+ sourceHeader: sv.headerOrigin.sourceHeader,
3022
+ cookieName: sv.headerOrigin.cookieName,
3023
+ targetHeader,
3024
+ });
3025
+ }
2894
3026
  if (capture.responseBody !== undefined && capture.responseBody !== null) {
2895
- for (const { value, path } of walkStringLeaves(capture.responseBody)) {
3027
+ for (const { value: rawValue, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
3028
+ if (rawValue === null)
3029
+ continue;
3030
+ const value = String(rawValue);
2896
3031
  if (!usedValues.has(value))
2897
3032
  continue;
2898
3033
  const sv = stateIndex.get(value);
@@ -2977,7 +3112,9 @@ function resolveResponsePathValue(responseBody, path) {
2977
3112
  return null;
2978
3113
  }
2979
3114
  }
2980
- return typeof cursor === "string" ? cursor : null;
3115
+ return typeof cursor === "string" || typeof cursor === "number" || typeof cursor === "boolean"
3116
+ ? String(cursor)
3117
+ : null;
2981
3118
  }
2982
3119
  /**
2983
3120
  * Replaces occurrences of state values in `template` with `${varName}`
@@ -3738,7 +3875,8 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3738
3875
  const matchesJoinField = target.joinFields.some((field) => {
3739
3876
  const value = readValueAtPath(firstItem, field.split("."));
3740
3877
  return ((typeof value === "string" && value.length > 0 && value === headerValue) ||
3741
- (typeof value === "number" && String(value) === headerValue));
3878
+ ((typeof value === "number" || typeof value === "boolean") &&
3879
+ String(value) === headerValue));
3742
3880
  });
3743
3881
  if (matchesJoinField)
3744
3882
  headerNames.add(headerName);
@@ -3750,7 +3888,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3750
3888
  const value = readValueAtPath(firstItem, field.split("."));
3751
3889
  if (typeof value === "string" && value.length > 0)
3752
3890
  joinValues.add(value);
3753
- else if (typeof value === "number")
3891
+ else if (typeof value === "number" || typeof value === "boolean")
3754
3892
  joinValues.add(String(value));
3755
3893
  }
3756
3894
  if (joinValues.size > 0)
@@ -4056,7 +4194,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4056
4194
  const value = readValueAtPath(firstItem, field.split("."));
4057
4195
  const stringValue = typeof value === "string" && value.length > 0
4058
4196
  ? value
4059
- : typeof value === "number"
4197
+ : typeof value === "number" || typeof value === "boolean"
4060
4198
  ? String(value)
4061
4199
  : null;
4062
4200
  return stringValue !== null
@@ -4461,17 +4599,42 @@ const ARRAY_WILDCARD_SEGMENT = "*";
4461
4599
  * A path segment for an array index the search descended through (to keep
4462
4600
  * looking for a nested candidate array) is the {@link ARRAY_WILDCARD_SEGMENT}
4463
4601
  * sentinel, never a literal index — see its docstring. */
4464
- function findAllObjectArrayFields(value, path = []) {
4602
+ function findAllObjectArrayFieldsUncached(value, path = []) {
4465
4603
  if (value === null || typeof value !== "object")
4466
4604
  return [];
4467
4605
  if (Array.isArray(value)) {
4468
4606
  const objectItems = value.filter(isObjectArrayItem);
4469
- const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFields(item, [...path, ARRAY_WILDCARD_SEGMENT]));
4607
+ const nestedCandidates = objectItems.flatMap((item) => findAllObjectArrayFieldsUncached(item, [...path, ARRAY_WILDCARD_SEGMENT]));
4470
4608
  return objectItems.length > 0
4471
4609
  ? [{ path, items: objectItems }, ...nestedCandidates]
4472
4610
  : nestedCandidates;
4473
4611
  }
4474
- return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFields(v, [...path, key]));
4612
+ return Object.entries(value).flatMap(([key, v]) => findAllObjectArrayFieldsUncached(v, [...path, key]));
4613
+ }
4614
+ /** Every distinct top-level `value` object {@link findAllObjectArrayFields}
4615
+ * is invoked on is scanned repeatedly — once per fold-chain candidate/join
4616
+ * disambiguation that re-derives the same response body — so a per-run,
4617
+ * identity-keyed cache lets a given body's whole-tree scan run at most once
4618
+ * regardless of how many callers re-derive it. Keyed on object identity
4619
+ * (never a serialized path/value pair) because captures/response bodies are
4620
+ * never mutated once produced (see this module's fold-plan investigation
4621
+ * notes), so identity alone is a safe, unconditionally correct cache key. */
4622
+ const objectArrayFieldsCache = new WeakMap();
4623
+ /** Memoized entry point for {@link findAllObjectArrayFieldsUncached} — see
4624
+ * {@link objectArrayFieldsCache}. Only the default top-level `path` is
4625
+ * cached (every real call site invokes with the default); a caller passing
4626
+ * an explicit `path` — recursion within the uncached walk itself — bypasses
4627
+ * the cache and hits the underlying scan directly. */
4628
+ function findAllObjectArrayFields(value, path = []) {
4629
+ if (path.length > 0 || value === null || typeof value !== "object") {
4630
+ return findAllObjectArrayFieldsUncached(value, path);
4631
+ }
4632
+ const cached = objectArrayFieldsCache.get(value);
4633
+ if (cached)
4634
+ return cached;
4635
+ const computed = findAllObjectArrayFieldsUncached(value, path);
4636
+ objectArrayFieldsCache.set(value, computed);
4637
+ return computed;
4475
4638
  }
4476
4639
  /** The first object-array field by DFS/key order — see
4477
4640
  * {@link findAllObjectArrayFields}. Every call site that must disambiguate
@@ -4495,9 +4658,24 @@ function findObjectArrayField(value, path = []) {
4495
4658
  * more genuine per-item data than a small real nested object-array; a
4496
4659
  * caller that just wants the first real array (the common case) is
4497
4660
  * unaffected since it still comes first. */
4661
+ const objectArrayFieldsOrWholeObjectCache = new WeakMap();
4662
+ /** Memoized the same way as {@link findAllObjectArrayFields} (see
4663
+ * {@link objectArrayFieldsCache}) — this is the candidate list
4664
+ * {@link selectDisambiguatedCandidate} and {@link buildFoldPlanFromSpec}
4665
+ * re-derive off the SAME responseBody on every chain hop/disambiguation, so
4666
+ * it is exactly as hot a redundant-recompute site as the underlying scan. */
4498
4667
  function findAllObjectArrayFieldsOrWholeObject(value, path = []) {
4668
+ if (path.length > 0 || value === null || typeof value !== "object") {
4669
+ const found = findAllObjectArrayFields(value, path);
4670
+ return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4671
+ }
4672
+ const cached = objectArrayFieldsOrWholeObjectCache.get(value);
4673
+ if (cached)
4674
+ return cached;
4499
4675
  const found = findAllObjectArrayFields(value, path);
4500
- return isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4676
+ const computed = isObjectArrayItem(value) ? [...found, { path, items: [value] }] : found;
4677
+ objectArrayFieldsOrWholeObjectCache.set(value, computed);
4678
+ return computed;
4501
4679
  }
4502
4680
  /** The first candidate from {@link findAllObjectArrayFieldsOrWholeObject} —
4503
4681
  * the flat-object-aware counterpart of {@link findObjectArrayField}. */
@@ -4539,14 +4717,14 @@ function collectRequestStringValues(capture) {
4539
4717
  })();
4540
4718
  if (parsedBody !== undefined) {
4541
4719
  for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
4542
- if (typeof value === "number")
4720
+ if (typeof value === "number" || typeof value === "boolean")
4543
4721
  values.add(String(value));
4544
4722
  }
4545
4723
  }
4546
4724
  return values;
4547
4725
  }
4548
4726
  /**
4549
- * Yields every string/numeric leaf reachable from `item` by walking nested
4727
+ * Yields every string/numeric/boolean leaf reachable from `item` by walking nested
4550
4728
  * plain objects only (not arrays — a join key is a scalar field of the item
4551
4729
  * or one of its nested objects, never an element drawn from a nested array),
4552
4730
  * paired with its dot-separated path from `item`'s root. A bare top-level
@@ -4565,8 +4743,8 @@ function* walkItemFieldPaths(item, path = []) {
4565
4743
  }
4566
4744
  }
4567
4745
  /**
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
4746
+ * Finds the ordered list of an array item's string/numeric/boolean field
4747
+ * paths whose values are threaded into `drillCapture`'s outbound request — the join key a
4570
4748
  * dependent drill-down call was built from. Each entry is a dot-separated
4571
4749
  * path (see {@link readValueAtPath} / {@link pathToAccessor}), so a bare
4572
4750
  * top-level field stays a single segment (e.g. `"sku"`) and a field nested
@@ -4576,29 +4754,107 @@ function* walkItemFieldPaths(item, path = []) {
4576
4754
  * order the primary response declares them, not sorted. Returns `[]` when no
4577
4755
  * field of the item threads into the request at all.
4578
4756
  */
4757
+ /**
4758
+ * Maps every string/numeric/boolean value seen in any action's request URL
4759
+ * or body (see {@link collectRequestStringValues} — deliberately headers-
4760
+ * excluded, unlike {@link buildRequestValueIndex}, matching the structural
4761
+ * heuristic's own header-blind scan) to the ascending list of action indices
4762
+ * whose request carries it. Built once per {@link detectDrillDownFoldPlan}
4763
+ * call and shared across every primary candidate's scan, so
4764
+ * {@link scanPrimaryCandidateGroups} can jump straight to the indices that
4765
+ * could possibly thread one of a primary array's own item values instead of
4766
+ * walking every later action — the O(actions)-per-primary forward scan that
4767
+ * makes the structural heuristic itself O(actions^2) on a large capture set
4768
+ * dominated by same-shaped primary candidates.
4769
+ */
4770
+ function buildRequestStringValueIndex(actions) {
4771
+ const index = new Map();
4772
+ for (let i = 0; i < actions.length; i++) {
4773
+ for (const value of collectRequestStringValues(actions[i].capture)) {
4774
+ const indices = index.get(value);
4775
+ if (indices)
4776
+ indices.push(i);
4777
+ else
4778
+ index.set(value, [i]);
4779
+ }
4780
+ }
4781
+ return index;
4782
+ }
4783
+ /** Every string/numeric/boolean field value present anywhere across `items`
4784
+ * — the set of values whose {@link buildRequestStringValueIndex} entries can
4785
+ * possibly thread out of this primary array, used to prune the candidate
4786
+ * drill indices {@link scanPrimaryCandidateGroups} walks instead of
4787
+ * considering every later action index. */
4788
+ function collectItemsFieldValues(items) {
4789
+ const values = new Set();
4790
+ for (const item of items) {
4791
+ for (const { value } of walkItemFieldPaths(item)) {
4792
+ if (typeof value === "string" && value.length > 0)
4793
+ values.add(value);
4794
+ else if (typeof value === "number" || typeof value === "boolean")
4795
+ values.add(String(value));
4796
+ }
4797
+ }
4798
+ return values;
4799
+ }
4800
+ /** Ascending, deduped action indices strictly greater than `afterIndex`
4801
+ * whose request carries at least one of `values` — see
4802
+ * {@link collectItemsFieldValues} / {@link buildRequestStringValueIndex}. */
4803
+ function collectCandidateIndicesAscending(requestStringValueIndex, values, afterIndex) {
4804
+ const candidates = new Set();
4805
+ for (const value of values) {
4806
+ for (const index of requestStringValueIndex.get(value) ?? []) {
4807
+ if (index > afterIndex)
4808
+ candidates.add(index);
4809
+ }
4810
+ }
4811
+ return [...candidates].sort((a, b) => a - b);
4812
+ }
4579
4813
  function findThreadedJoinFields(item, drillCapture) {
4580
4814
  const requestValues = collectRequestStringValues(drillCapture);
4581
4815
  if (requestValues.size === 0)
4582
4816
  return [];
4583
4817
  return [...walkItemFieldPaths(item)]
4584
4818
  .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
4585
- (typeof v === "number" && requestValues.has(String(v))))
4819
+ (typeof v === "number" && requestValues.has(String(v))) ||
4820
+ (typeof v === "boolean" && requestValues.has(String(v))))
4586
4821
  .map(({ path }) => path.join("."));
4587
4822
  }
4588
- /** Every string and numeric leaf value present anywhere in a response body
4823
+ /** Every string, numeric, and boolean leaf value present anywhere in a response —
4589
4824
  * the set a chained drill-down step's request must overlap with for that
4590
4825
  * step to count as depending on this response. Deliberately walks the WHOLE
4591
4826
  * body (not just object-array items, unlike {@link findThreadedJoinFields})
4592
4827
  * since a chained step can thread any response value, not only a per-item
4593
- * join field. */
4594
- function collectResponseLeafValues(responseBody) {
4828
+ * join field. Also walks every response HEADER value, mirroring
4829
+ * {@link collectRequestValuesIncludingHeaders} on the request side, since a
4830
+ * chain hop can just as easily mint its join token in a response header
4831
+ * (e.g. a `Location` or custom correlation header) as in the body.
4832
+ * Memoized like {@link objectArrayFieldsCache} — {@link computeFoldChain}'s
4833
+ * inner `dependsOnChain` check re-derives this for every chain member on
4834
+ * every outer loop iteration, making it the single hottest redundant-
4835
+ * recompute site in fold-chain resolution (see this module's fold-plan
4836
+ * investigation notes). */
4837
+ const responseLeafValuesCache = new WeakMap();
4838
+ function collectResponseLeafValues(capture) {
4839
+ const cached = responseLeafValuesCache.get(capture);
4840
+ if (cached)
4841
+ return cached;
4842
+ const computed = collectResponseLeafValuesUncached(capture);
4843
+ responseLeafValuesCache.set(capture, computed);
4844
+ return computed;
4845
+ }
4846
+ function collectResponseLeafValuesUncached(capture) {
4595
4847
  const values = new Set();
4596
- for (const { value } of walkAllPrimitiveLeaves(responseBody)) {
4848
+ for (const { value } of walkAllPrimitiveLeaves(capture.responseBody)) {
4597
4849
  if (typeof value === "string" && value.length > 0)
4598
4850
  values.add(value);
4599
4851
  if (typeof value === "number")
4600
4852
  values.add(String(value));
4853
+ if (typeof value === "boolean")
4854
+ values.add(String(value));
4601
4855
  }
4856
+ for (const v of Object.values(capture.responseHeaders))
4857
+ values.add(v);
4602
4858
  return values;
4603
4859
  }
4604
4860
  /**
@@ -4657,7 +4913,7 @@ function computeFoldChain(actions, drillStepIndex, drillArrayPath) {
4657
4913
  const requestValues = collectRequestValuesIncludingHeaders(candidate.capture);
4658
4914
  const dependsOnChain = chain.some((chainIndex) => {
4659
4915
  const chainStepCapture = actions[chainIndex].capture;
4660
- const responseValues = collectResponseLeafValues(chainStepCapture.responseBody);
4916
+ const responseValues = collectResponseLeafValues(chainStepCapture);
4661
4917
  const echoedValues = collectRequestValuesIncludingHeaders(chainStepCapture);
4662
4918
  return [...responseValues].some((v) => !echoedValues.has(v) && requestValues.has(v));
4663
4919
  });
@@ -4725,7 +4981,7 @@ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
4725
4981
  let n = 0;
4726
4982
  for (const v of Object.values(obj)) {
4727
4983
  if (v === null || (typeof v !== "object" && typeof v !== "function")) {
4728
- if (typeof v === "string" || typeof v === "number") {
4984
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
4729
4985
  if (requestValues.has(String(v)))
4730
4986
  continue;
4731
4987
  }
@@ -4747,7 +5003,20 @@ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
4747
5003
  * one array's target chain is never re-claimed as a fresh target thread of
4748
5004
  * a different, independent array on the same primary response.
4749
5005
  */
4750
- function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices) {
5006
+ let scanPrimaryCandidateGroupsCallCount = 0;
5007
+ /** Test-only instrumentation for asserting `scanPrimaryCandidateGroups`'s
5008
+ * O(actions.length) scan is reused across repeat queries of the same index
5009
+ * (via `detectDrillDownFoldPlan`'s per-index cache) rather than re-run.
5010
+ * Not read by any production path. */
5011
+ function getScanPrimaryCandidateGroupsCallCountForTest() {
5012
+ return scanPrimaryCandidateGroupsCallCount;
5013
+ }
5014
+ /** Test-only counterpart to {@link getScanPrimaryCandidateGroupsCallCountForTest}. */
5015
+ function resetScanPrimaryCandidateGroupsCallCountForTest() {
5016
+ scanPrimaryCandidateGroupsCallCount = 0;
5017
+ }
5018
+ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices, requestStringValueIndex) {
5019
+ scanPrimaryCandidateGroupsCallCount++;
4751
5020
  const primary = actions[primaryIndex];
4752
5021
  const primaryCandidates = findAllObjectArrayFields(primary.capture.responseBody);
4753
5022
  if (primaryCandidates.length === 0)
@@ -4761,7 +5030,12 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
4761
5030
  const consumedIndices = new Set();
4762
5031
  for (const primaryArray of primaryCandidates) {
4763
5032
  const targets = [];
4764
- for (let drillIndex = primaryIndex + 1; drillIndex < actions.length; drillIndex++) {
5033
+ // Pruned to the (typically tiny) set of later action indices whose
5034
+ // request could possibly thread one of this array's own item values —
5035
+ // see buildRequestStringValueIndex's docstring — instead of every
5036
+ // index from primaryIndex+1 to the end of actions.
5037
+ const candidateDrillIndices = collectCandidateIndicesAscending(requestStringValueIndex, collectItemsFieldValues(primaryArray.items), primaryIndex);
5038
+ for (const drillIndex of candidateDrillIndices) {
4765
5039
  const drill = actions[drillIndex];
4766
5040
  if (drill === primary)
4767
5041
  continue;
@@ -4851,11 +5125,40 @@ function detectDrillDownFoldPlan(actions) {
4851
5125
  // primary's response, not on this later primary's array, so it must
4852
5126
  // never be re-claimed as a fresh drill target for a subsequent primary.
4853
5127
  const globallyConsumedIndices = new Set();
5128
+ // scanPrimaryCandidateGroups's result for a given index only depends on
5129
+ // `actions` (fixed) and the current contents of `globallyConsumedIndices`,
5130
+ // so it is safe to reuse across repeat queries of the SAME index as long
5131
+ // as the consumed set hasn't grown since it was computed. The freshest-wins
5132
+ // loop below re-queries the same laterIndex once per sibling group on a
5133
+ // re-queried primary, and the outer loop often reaches that very index as
5134
+ // its own primaryIndex shortly after — both hit this cache instead of
5135
+ // repeating the O(actions.length) scan.
5136
+ let consumedVersion = 0;
5137
+ const scanCache = new Map();
5138
+ const scanCacheVersion = new Map();
5139
+ // Built once for the whole detectDrillDownFoldPlan call — see
5140
+ // buildRequestStringValueIndex's docstring.
5141
+ const requestStringValueIndex = buildRequestStringValueIndex(actions);
5142
+ const scanCached = (index) => {
5143
+ const cachedVersion = scanCacheVersion.get(index);
5144
+ if (cachedVersion === consumedVersion)
5145
+ return scanCache.get(index);
5146
+ const result = scanPrimaryCandidateGroups(actions, index, globallyConsumedIndices, requestStringValueIndex);
5147
+ scanCache.set(index, result);
5148
+ scanCacheVersion.set(index, consumedVersion);
5149
+ return result;
5150
+ };
5151
+ const addConsumed = (index) => {
5152
+ if (globallyConsumedIndices.has(index))
5153
+ return;
5154
+ globallyConsumedIndices.add(index);
5155
+ consumedVersion++;
5156
+ };
4854
5157
  for (let primaryIndex = 0; primaryIndex < actions.length; primaryIndex++) {
4855
5158
  if (globallyConsumedIndices.has(primaryIndex))
4856
5159
  continue;
4857
5160
  const primary = actions[primaryIndex];
4858
- const groups = scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndices);
5161
+ const groups = scanCached(primaryIndex);
4859
5162
  if (groups.length === 0)
4860
5163
  continue;
4861
5164
  const primaryEndpointKey = endpointKey(primary.capture.url);
@@ -4882,7 +5185,7 @@ function detectDrillDownFoldPlan(actions) {
4882
5185
  const laterAction = actions[laterIndex];
4883
5186
  if (endpointKey(laterAction.capture.url) !== primaryEndpointKey)
4884
5187
  continue;
4885
- const laterGroups = scanPrimaryCandidateGroups(actions, laterIndex, globallyConsumedIndices);
5188
+ const laterGroups = scanCached(laterIndex);
4886
5189
  const laterGroup = laterGroups.find((g) => JSON.stringify(g.primaryArrayPath) === JSON.stringify(freshestGroup.primaryArrayPath));
4887
5190
  if (laterGroup === undefined)
4888
5191
  continue;
@@ -4920,10 +5223,10 @@ function detectDrillDownFoldPlan(actions) {
4920
5223
  // itself is marked consumed once per group pushed (idempotent via
4921
5224
  // Set.add), since the step itself is only visited once regardless of
4922
5225
  // how many independent array groups it yields.
4923
- globallyConsumedIndices.add(freshestIndex);
5226
+ addConsumed(freshestIndex);
4924
5227
  for (const target of freshestGroup.targets) {
4925
5228
  for (const chainIndex of target.chain)
4926
- globallyConsumedIndices.add(chainIndex);
5229
+ addConsumed(chainIndex);
4927
5230
  }
4928
5231
  }
4929
5232
  }
@@ -5032,10 +5335,20 @@ function objectItemsAtPath(body, path) {
5032
5335
  * through a request HEADER), so matching a spec's `joinFields` against the
5033
5336
  * drill capture must search headers even though the structural heuristic
5034
5337
  * deliberately doesn't (see {@link collectRequestStringValues}'s docstring). */
5338
+ const requestValuesCache = new WeakMap();
5339
+ /** Memoized like {@link objectArrayFieldsCache} — the same capture's
5340
+ * request/header values are re-derived on every fold-chain candidate that
5341
+ * threads through it. Keyed on `Capture` identity rather than
5342
+ * `responseBody`, since this walks the capture's request side (URL, body,
5343
+ * headers), not its response. */
5035
5344
  function collectRequestValuesIncludingHeaders(capture) {
5345
+ const cached = requestValuesCache.get(capture);
5346
+ if (cached)
5347
+ return cached;
5036
5348
  const values = collectRequestStringValues(capture);
5037
5349
  for (const v of Object.values(capture.requestHeaders))
5038
5350
  values.add(v);
5351
+ requestValuesCache.set(capture, values);
5039
5352
  return values;
5040
5353
  }
5041
5354
  /** Finds which of `primaryItems` the drill call actually captured, by
@@ -5051,12 +5364,150 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
5051
5364
  const matchedIndex = primaryItems.findIndex((item) => joinFields.every((field) => {
5052
5365
  const value = readValueAtPath(item, field.split("."));
5053
5366
  return ((typeof value === "string" && value.length > 0 && requestValues.has(value)) ||
5054
- (typeof value === "number" && requestValues.has(String(value))));
5367
+ (typeof value === "number" && requestValues.has(String(value))) ||
5368
+ (typeof value === "boolean" && requestValues.has(String(value))));
5055
5369
  }));
5056
5370
  return matchedIndex === -1 ? null : matchedIndex;
5057
5371
  }
5058
- function buildFoldPlanFromSpec(actions, spec) {
5059
- const primaryArrayPath = spec.resultsPath.split(".");
5372
+ /**
5373
+ * Maps every string/numeric/boolean value seen in any action's request (URL,
5374
+ * body, or headers — see {@link collectRequestValuesIncludingHeaders}) to the
5375
+ * ascending list of action indices whose request carries it. Built once per
5376
+ * {@link buildFoldPlanFromSpec} call and shared across every primary/drill
5377
+ * candidate pair, so {@link resolveSpecMatchedPrimaryItemIndexAlongChain}'s
5378
+ * upstream search can jump straight to the (typically tiny) set of indices
5379
+ * that could possibly carry a given primary item's join value instead of
5380
+ * scanning every index between the primary and the drill — the O(actions)-
5381
+ * per-candidate backward walk the reported large-capture-set hang traced to.
5382
+ */
5383
+ function buildRequestValueIndex(actions) {
5384
+ const index = new Map();
5385
+ for (let i = 0; i < actions.length; i++) {
5386
+ for (const value of collectRequestValuesIncludingHeaders(actions[i].capture)) {
5387
+ const indices = index.get(value);
5388
+ if (indices)
5389
+ indices.push(i);
5390
+ else
5391
+ index.set(value, [i]);
5392
+ }
5393
+ }
5394
+ return index;
5395
+ }
5396
+ /** Every string/numeric/boolean {@link FoldReturnSpec.joinFields} value
5397
+ * present on any of `primaryItems`, stringified exactly as
5398
+ * {@link resolveSpecMatchedPrimaryItemIndex} compares them — the set of
5399
+ * values whose {@link buildRequestValueIndex} entries can possibly resolve
5400
+ * this primary's join, used to prune the candidate entry indices
5401
+ * {@link resolveSpecMatchedPrimaryItemIndexAlongChain} walks instead of
5402
+ * considering every action index in range. */
5403
+ function collectPrimaryJoinValues(primaryItems, joinFields) {
5404
+ const values = new Set();
5405
+ for (const item of primaryItems) {
5406
+ for (const field of joinFields) {
5407
+ const value = readValueAtPath(item, field.split("."));
5408
+ if (typeof value === "string" && value.length > 0)
5409
+ values.add(value);
5410
+ else if (typeof value === "number" || typeof value === "boolean")
5411
+ values.add(String(value));
5412
+ }
5413
+ }
5414
+ return values;
5415
+ }
5416
+ /** Descending, deduped action indices strictly greater than
5417
+ * `primaryStepIndex` whose request carries at least one of `joinValues` —
5418
+ * the pruned candidate set {@link resolveSpecMatchedPrimaryItemIndexAlongChain}
5419
+ * walks instead of every index in `(primaryStepIndex, actions.length)`. */
5420
+ function collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex) {
5421
+ const candidates = new Set();
5422
+ for (const value of joinValues) {
5423
+ for (const index of requestValueIndex.get(value) ?? []) {
5424
+ if (index > primaryStepIndex)
5425
+ candidates.add(index);
5426
+ }
5427
+ }
5428
+ return [...candidates].sort((a, b) => b - a);
5429
+ }
5430
+ /** Like {@link resolveSpecMatchedPrimaryItemIndex}, but also looks upstream of
5431
+ * `drillStepIndex` for the join key when `drillStepIndex`'s own request
5432
+ * doesn't carry it — a `foldReturn` spec's `endpointPattern` naturally names
5433
+ * the chain TERMINAL (the response actually holding the data an author wants
5434
+ * folded), not the opaque entry hop that carries the join key onward (e.g.
5435
+ * as a header-threaded token). Walks every earlier step back to
5436
+ * `primaryStepIndex`, in reverse so the closest (least ambiguous) candidate
5437
+ * wins first, and accepts one only once {@link computeFoldChain} confirms it
5438
+ * actually chains FORWARD to `drillStepIndex` — otherwise an unrelated
5439
+ * earlier step matching the join key by coincidence could hijack the fold.
5440
+ * Returns the resolved `entryIndex` alongside the matched item index so the
5441
+ * caller can build the fold's `chain` starting from the step that ACTUALLY
5442
+ * carries the join key, not from `drillStepIndex` — a chain built from
5443
+ * `drillStepIndex` alone would never include this upstream entry hop, so it
5444
+ * would never be re-executed (header-parameterized) per primary item at
5445
+ * runtime. */
5446
+ let resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
5447
+ /** Test-only instrumentation: how many times
5448
+ * {@link resolveSpecMatchedPrimaryItemIndexAlongChain} — the expensive
5449
+ * backward-walk + {@link computeFoldChain} resolution — has actually run
5450
+ * since the last {@link resetFoldPlanResolutionCallCountForTests} call, so a
5451
+ * test can assert `buildFoldPlanFromSpec` pays for it only once per primary
5452
+ * rather than once per matching drill occurrence. */
5453
+ function getFoldPlanResolutionCallCountForTests() {
5454
+ return resolveSpecMatchedPrimaryItemIndexAlongChainCallCount;
5455
+ }
5456
+ function resetFoldPlanResolutionCallCountForTests() {
5457
+ resolveSpecMatchedPrimaryItemIndexAlongChainCallCount = 0;
5458
+ }
5459
+ function resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, joinFields, drillStepIndex,
5460
+ // Keyed by entryIndex alone: valid for every call sharing the same
5461
+ // primaryStepIndex/primaryItems/joinFields, since resolveSpecMatchedPrimaryItemIndex's
5462
+ // result for a given entryIndex depends on nothing else. Without this,
5463
+ // buildFoldPlanFromSpec's per-primary candidate loop re-walks the SAME
5464
+ // overlapping entryIndex range from scratch for every matching drill
5465
+ // occurrence it tries — O(candidates * chain length) per primary instead
5466
+ // of O(chain length) total, the combinatorial blowup the reported hang
5467
+ // traced to at large capture-set sizes.
5468
+ matchedItemIndexCache,
5469
+ // Descending, deduped, computed once per primaryStepIndex by
5470
+ // {@link collectCandidateEntryIndicesDescending} — every index whose
5471
+ // request carries at least one of this primary's join values, i.e. a
5472
+ // strict superset of the indices `resolveSpecMatchedPrimaryItemIndex`
5473
+ // could ever match. Walking this instead of every index down to
5474
+ // `primaryStepIndex` is what collapses the backward search from
5475
+ // O(actions) to O(occurrences of the actual join value) per candidate.
5476
+ candidateEntryIndicesDescending) {
5477
+ resolveSpecMatchedPrimaryItemIndexAlongChainCallCount++;
5478
+ for (const entryIndex of candidateEntryIndicesDescending) {
5479
+ if (entryIndex > drillStepIndex)
5480
+ continue;
5481
+ const cached = matchedItemIndexCache.get(entryIndex);
5482
+ const matched = cached !== undefined
5483
+ ? cached
5484
+ : (() => {
5485
+ const resolved = resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, actions[entryIndex].capture);
5486
+ matchedItemIndexCache.set(entryIndex, resolved);
5487
+ return resolved;
5488
+ })();
5489
+ if (matched === null)
5490
+ continue;
5491
+ if (entryIndex === drillStepIndex)
5492
+ return { entryIndex, primaryMatchedItemIndex: matched };
5493
+ const { chain } = computeFoldChain(actions, entryIndex, []);
5494
+ if (chain.includes(drillStepIndex))
5495
+ return { entryIndex, primaryMatchedItemIndex: matched };
5496
+ }
5497
+ return null;
5498
+ }
5499
+ /**
5500
+ * Compiles a flow-declared {@link FoldReturnSpec.endpointPattern} into a
5501
+ * capture predicate, or a predicate that always returns `false` when `spec`
5502
+ * is `null` or its pattern isn't a valid regex — the same null-safe
5503
+ * try/catch shape {@link buildFoldPlanFromSpec} already applied inline,
5504
+ * shared here so the action-sequence extractors can admit a spec-matched
5505
+ * drill-down capture under the identical rule that later resolves its fold
5506
+ * plan, instead of dropping it before the fold pipeline ever sees it.
5507
+ */
5508
+ function compileFoldReturnEndpointMatcher(spec) {
5509
+ if (spec === null)
5510
+ return () => false;
5060
5511
  const endpointRx = (() => {
5061
5512
  try {
5062
5513
  return new RegExp(spec.endpointPattern);
@@ -5066,16 +5517,73 @@ function buildFoldPlanFromSpec(actions, spec) {
5066
5517
  }
5067
5518
  })();
5068
5519
  if (endpointRx === null)
5069
- return null;
5520
+ return () => false;
5521
+ return (capture) => endpointRx.test(capture.url);
5522
+ }
5523
+ /**
5524
+ * A capture predicate matching whichever capture actually holds
5525
+ * {@link FoldReturnSpec.resultsPath}'s object array — the flow's own PRIMARY
5526
+ * results source, as opposed to {@link compileFoldReturnEndpointMatcher}'s
5527
+ * `endpointPattern` match (the drill-down). REST's `extractActionSequence`
5528
+ * never needs this: a REST primary is a POST/non-GET and is admitted
5529
+ * unconditionally regardless of `foldReturnSpec`. GraphQL's primary is
5530
+ * always a `query`, and `extractGraphQLActionSequence` drops every
5531
+ * non-mutation capture by default, so without this predicate a declared
5532
+ * `foldReturnSpec` would admit only the drill-down capture and never the
5533
+ * read op whose response the drill-down folds onto — leaving
5534
+ * `buildFoldPlanFromSpec` with no `primaryStepIndex` to resolve against.
5535
+ */
5536
+ function compileFoldReturnResultsMatcher(spec) {
5537
+ if (spec === null)
5538
+ return () => false;
5539
+ const resultsPath = spec.resultsPath.split(".");
5540
+ return (capture) => objectItemsAtPath(capture.responseBody, resultsPath) !== null;
5541
+ }
5542
+ function buildFoldPlanFromSpec(actions, spec) {
5543
+ const primaryArrayPath = spec.resultsPath.split(".");
5544
+ const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
5545
+ // Built once and shared across every primaryStepIndex — see
5546
+ // buildRequestValueIndex's docstring. Lets each primary immediately tell
5547
+ // whether ANY action anywhere carries one of its own join values before
5548
+ // paying for anything else, instead of scanning its own drill candidates
5549
+ // one by one only to discover none of them can ever match.
5550
+ const requestValueIndex = buildRequestValueIndex(actions);
5070
5551
  let freshestPlan = null;
5071
5552
  for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
5072
5553
  const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
5073
5554
  if (!primaryItems)
5074
5555
  continue;
5556
+ const joinValues = collectPrimaryJoinValues(primaryItems, spec.joinFields);
5557
+ const candidateEntryIndicesDescending = collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex);
5558
+ // No action anywhere carries any of this primary's join values, so no
5559
+ // drillStepIndex candidate could ever resolve — skip straight to the
5560
+ // next primary instead of scanning this one's drill candidates (each of
5561
+ // which would only rediscover the same dead end).
5562
+ if (candidateEntryIndicesDescending.length === 0)
5563
+ continue;
5564
+ // Cheap regex-only pass first: collects every endpointPattern-matching
5565
+ // drillStepIndex without paying for the expensive backward-walk +
5566
+ // computeFoldChain resolution below. Scanned from the freshest (highest)
5567
+ // index down so the expensive resolution — tried only on the entries
5568
+ // this loop actually visits — runs on the candidate that would win
5569
+ // ties in the original last-write-wins scan first, falling through to
5570
+ // the next-freshest only when a candidate fails to resolve, instead of
5571
+ // resolving every earlier occurrence just to have it overwritten.
5572
+ const matchingDrillStepIndices = [];
5075
5573
  for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
5574
+ if (matchesFoldReturnEndpoint(actions[drillStepIndex].capture)) {
5575
+ matchingDrillStepIndices.push(drillStepIndex);
5576
+ }
5577
+ }
5578
+ // Shared across every matching-drill candidate tried below for THIS
5579
+ // primaryStepIndex — see resolveSpecMatchedPrimaryItemIndexAlongChain's
5580
+ // docstring on why a fresh cache per primary (not per drill candidate)
5581
+ // is what collapses the backward-walk from O(candidates * chain length)
5582
+ // to O(chain length).
5583
+ const matchedItemIndexCache = new Map();
5584
+ for (let i = matchingDrillStepIndices.length - 1; i >= 0; i--) {
5585
+ const drillStepIndex = matchingDrillStepIndices[i];
5076
5586
  const drill = actions[drillStepIndex];
5077
- if (!endpointRx.test(drill.capture.url))
5078
- continue;
5079
5587
  // Widened to a flat (non-array) object response the same way the
5080
5588
  // structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
5081
5589
  // an explicit foldReturn declaration must be able to express a
@@ -5090,17 +5598,31 @@ function buildFoldPlanFromSpec(actions, spec) {
5090
5598
  // now does (see detectDrillDownFoldPlan). Validated after the chain
5091
5599
  // resolves, below, since `[]` is a valid empty baseline for
5092
5600
  // computeFoldChain but not a valid final drillArrayPath on its own.
5601
+ const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, drillStepIndex, matchedItemIndexCache, candidateEntryIndicesDescending);
5602
+ if (matchResult === null)
5603
+ continue;
5604
+ const { entryIndex, primaryMatchedItemIndex } = matchResult;
5605
+ // Rooted at `entryIndex` — the step whose request ACTUALLY carries the
5606
+ // join key — not `drillStepIndex`, so an upstream entry hop the join
5607
+ // key was only resolvable through (e.g. a header-threaded token) is
5608
+ // itself part of `chain` and gets re-executed (join-parameterized) per
5609
+ // primary item at runtime, same as every structurally-detected chain.
5610
+ // `drillResultsPath`, when given, still targets `drillStepIndex`'s own
5611
+ // response specifically (the endpoint the spec names); it is otherwise
5612
+ // left for computeFoldChain's own forward richness walk to resolve,
5613
+ // exactly as it does for every step beyond the chain's entry.
5093
5614
  const drillArrayPath = (() => {
5615
+ if (entryIndex !== drillStepIndex) {
5616
+ return (findObjectArrayFieldOrWholeObject(actions[entryIndex].capture.responseBody)?.path ??
5617
+ null);
5618
+ }
5094
5619
  if (spec.drillResultsPath === undefined) {
5095
5620
  return findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null;
5096
5621
  }
5097
5622
  const path = spec.drillResultsPath.split(".");
5098
5623
  return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
5099
5624
  })();
5100
- const primaryMatchedItemIndex = resolveSpecMatchedPrimaryItemIndex(primaryItems, spec.joinFields, drill.capture);
5101
- if (primaryMatchedItemIndex === null)
5102
- continue;
5103
- const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, drillStepIndex, drillArrayPath ?? []);
5625
+ const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, entryIndex, drillArrayPath ?? []);
5104
5626
  // The chain's resolved terminal must actually hold foldable data —
5105
5627
  // an intermediate drill step with neither its own candidate NOR a
5106
5628
  // later chained step that resolves one has nothing to merge, so it
@@ -5114,7 +5636,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5114
5636
  targets: [
5115
5637
  {
5116
5638
  joinFields: spec.joinFields,
5117
- drillStepIndex,
5639
+ drillStepIndex: entryIndex,
5118
5640
  drillArrayPath: drillArrayPath ?? [],
5119
5641
  primaryMatchedItemIndex,
5120
5642
  chain,
@@ -5123,6 +5645,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5123
5645
  },
5124
5646
  ],
5125
5647
  };
5648
+ break;
5126
5649
  }
5127
5650
  }
5128
5651
  return freshestPlan;
@@ -5207,6 +5730,62 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
5207
5730
  * plan that survives multipart disqualification, letting every downstream
5208
5731
  * emitter/shape-inference caller fold each of them.
5209
5732
  */
5733
+ /**
5734
+ * Every value ACTUALLY threaded from one dependent-drill-down chain hop's
5735
+ * response into a LATER chain hop's own request/headers — the same overlap
5736
+ * `computeFoldChain`'s `dependsOnChain` check already computes to decide a
5737
+ * step belongs in the chain at all — across every fold target
5738
+ * `detectDrillDownFoldPlan` / `buildFoldPlanFromSpec` resolve. These are the
5739
+ * values `indexStateValues` must index as producible state regardless of
5740
+ * `MIN_STATE_VALUE_LENGTH`, so `compileActionSteps` can thread a
5741
+ * chain-produced join value into the next hop's request even when that
5742
+ * value is a short one (e.g. a bare numeric status token).
5743
+ *
5744
+ * Deliberately NOT "every leaf value on a chain hop's response" — a chain
5745
+ * terminal's response commonly ECHOES the primary item's own join field
5746
+ * back (e.g. `sku` on the folded record), and that echoed value is a
5747
+ * PRIMARY-ITEM field the fold loop's own per-item render already threads
5748
+ * literally (see {@link findThreadedJoinFields}'s docstring); sweeping it
5749
+ * into state-threading's single-earliest-origin index would collapse every
5750
+ * item's distinct value onto whichever item's capture was indexed first.
5751
+ * Restricting to values that themselves reappear in a STRICTLY LATER chain
5752
+ * hop's own request — real cross-call threading, not an echo sitting still
5753
+ * in one response — keeps this exact to the shape state-threading is
5754
+ * actually needed for.
5755
+ *
5756
+ * Runs directly off raw actions (not `resolveFoldPlan`, which needs
5757
+ * `isMultipart` — unavailable before `compileActionSteps` has run) since
5758
+ * fold-plan DETECTION depends only on each action's `capture`.
5759
+ */
5760
+ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
5761
+ const structuralPlans = detectDrillDownFoldPlan(actions);
5762
+ const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
5763
+ const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
5764
+ const values = new Set();
5765
+ for (const plan of plans) {
5766
+ for (const target of plan.targets) {
5767
+ for (let j = 0; j < target.chain.length; j++) {
5768
+ const priorIndex = target.chain[j];
5769
+ const priorCapture = actions[priorIndex]?.capture;
5770
+ if (!priorCapture)
5771
+ continue;
5772
+ const responseValues = collectResponseLeafValues(priorCapture);
5773
+ const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
5774
+ for (let k = j + 1; k < target.chain.length; k++) {
5775
+ const laterCapture = actions[target.chain[k]]?.capture;
5776
+ if (!laterCapture)
5777
+ continue;
5778
+ const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
5779
+ for (const v of responseValues) {
5780
+ if (!echoedValues.has(v) && laterRequestValues.has(v))
5781
+ values.add(v);
5782
+ }
5783
+ }
5784
+ }
5785
+ }
5786
+ }
5787
+ return values;
5788
+ }
5210
5789
  function resolveFoldPlan(actions, foldReturnSpec = null) {
5211
5790
  const structuralPlans = detectDrillDownFoldPlan(actions);
5212
5791
  const plans = structuralPlans.length > 0
@@ -6573,7 +7152,9 @@ async function main() {
6573
7152
  // Hoisted so both the primary-operation gate below and rawActionCaptures
6574
7153
  // (further down) read the same computed sequence instead of calling the
6575
7154
  // extractor twice.
6576
- const graphqlActionSequence = gql ? extractGraphQLActionSequence(captures, submitPatterns) : [];
7155
+ const graphqlActionSequence = gql
7156
+ ? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
7157
+ : [];
6577
7158
  const primaryGraphQLOperation = gql && graphqlActionSequence.length === 0
6578
7159
  ? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
6579
7160
  : null;
@@ -6624,7 +7205,7 @@ async function main() {
6624
7205
  // heuristic extraction finds.
6625
7206
  const patternedHeuristicActionCaptures = gql
6626
7207
  ? graphqlActionSequence
6627
- : collapseRedundantPatches(extractActionSequence(captures, submitPatterns));
7208
+ : collapseRedundantPatches(extractActionSequence(captures, submitPatterns, foldReturnSpec));
6628
7209
  // The same undercount hazard applies one layer below the manifest: a
6629
7210
  // flow-declared submitEndpointPattern that matches only one section's URL
6630
7211
  // (the natural way to describe "the button that finishes the wizard")
@@ -6637,8 +7218,8 @@ async function main() {
6637
7218
  const unfilteredHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
6638
7219
  ? patternedHeuristicActionCaptures
6639
7220
  : gql
6640
- ? extractGraphQLActionSequence(captures, null)
6641
- : collapseRedundantPatches(extractActionSequence(captures, null));
7221
+ ? extractGraphQLActionSequence(captures, null, foldReturnSpec)
7222
+ : collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
6642
7223
  const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
6643
7224
  if (patternUndercounts) {
6644
7225
  logger.info(`submission selection: ignoring submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) as an undercount of the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s))`);
@@ -6669,8 +7250,8 @@ async function main() {
6669
7250
  const rawUnfilteredActionCaptures = submitEndpointPattern === null
6670
7251
  ? null
6671
7252
  : gql
6672
- ? extractGraphQLActionSequence(captures, null)
6673
- : collapseRedundantPatches(extractActionSequence(captures, null));
7253
+ ? extractGraphQLActionSequence(captures, null, foldReturnSpec)
7254
+ : collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
6674
7255
  // Form-schema detection runs BEFORE state-indexing so the field-id/option-id
6675
7256
  // UUIDs can be shielded from indexing — those UUIDs are stable schema
6676
7257
  // anchors that T2/T3 substitution depends on remaining literal in body
@@ -6718,8 +7299,16 @@ async function main() {
6718
7299
  ];
6719
7300
  })();
6720
7301
  const actionCaptureIndices = new Set(actionCaptures.map((a) => a.index));
7302
+ // Resolved off raw actionCaptures — fold-plan DETECTION depends only on
7303
+ // each action's capture, so this runs before compileActionSteps/
7304
+ // indexStateValues even exist — so a short numeric join value threaded
7305
+ // through a dependent-drill-down chain hop still gets indexed as
7306
+ // producible state (see collectDependentDrillDownChainValues).
7307
+ const dependentDrillDownChainValues = actionCaptures.length > 1
7308
+ ? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
7309
+ : new Set();
6721
7310
  const stateIndex = actionCaptures.length > 1
6722
- ? indexStateValues(captures, shieldedUuids, actionCaptureIndices)
7311
+ ? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
6723
7312
  : new Map();
6724
7313
  const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
6725
7314
  const isSubmissionFlow = actionSteps.length > 1;