@enricai/barnacle 1.12.20 → 1.12.21

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.
@@ -1414,16 +1414,24 @@ function resolveManifestActionSequence(runRoot, captures) {
1414
1414
  * JWT refresh, reference-lookup) that a browser fires incidentally. Absent
1415
1415
  * patterns preserve the noise heuristic exactly.
1416
1416
  *
1417
+ * When the flow declares a `foldReturnSpec`, a GET whose URL matches its
1418
+ * `endpointPattern` is admitted despite the GET drop above — the same scoped
1419
+ * rule `buildFoldPlanFromSpec` later uses to resolve the fold plan, so a
1420
+ * spec-declared GET drill-down survives to reach it instead of being
1421
+ * dropped before the fold pipeline ever sees it. Every other GET is still
1422
+ * dropped.
1423
+ *
1417
1424
  * Exported for tests: this predicate decides what a generated plugin will POST
1418
1425
  * at a live site, and it is the only gate between a browser's incidental
1419
1426
  * chatter and the emitted hot path.
1420
1427
  */
1421
- function extractActionSequence(captures, submitPatterns = null) {
1428
+ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
1422
1429
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1430
+ const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
1423
1431
  return captures
1424
1432
  .map((capture, index) => ({ capture, index }))
1425
1433
  .filter(({ capture }) => {
1426
- if (capture.method === "GET")
1434
+ if (capture.method === "GET" && !matchesFoldReturn(capture))
1427
1435
  return false;
1428
1436
  if (capture.status < 200 || capture.status >= 300)
1429
1437
  return false;
@@ -1445,11 +1453,23 @@ function extractActionSequence(captures, submitPatterns = null) {
1445
1453
  * what let a chronologically-first fallback pick an unrelated query. Host is
1446
1454
  * NOT a filter criterion, matching {@link extractActionSequence}.
1447
1455
  *
1456
+ * When the flow declares a `foldReturnSpec`, a non-mutation capture whose
1457
+ * URL matches its `endpointPattern` is admitted despite the query drop
1458
+ * above, mirroring {@link extractActionSequence}'s GET admission. So is a
1459
+ * non-mutation capture whose response resolves the spec's own `resultsPath`
1460
+ * — the GraphQL-primary read op the drill-down folds onto, which
1461
+ * `endpointPattern` (naming the drill, not the primary) never matches on its
1462
+ * own; without this a declared spec would admit the drill-down but leave
1463
+ * `resolveFoldPlan` with no primary capture to resolve `resultsPath`
1464
+ * against. Every other non-mutation capture is still dropped.
1465
+ *
1448
1466
  * Exported for tests: this predicate decides what a generated GraphQL plugin
1449
1467
  * will send at a live site.
1450
1468
  */
1451
- function extractGraphQLActionSequence(captures, submitPatterns = null) {
1469
+ function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null) {
1452
1470
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1471
+ const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
1472
+ const matchesFoldReturnResults = compileFoldReturnResultsMatcher(foldReturnSpec);
1453
1473
  return captures
1454
1474
  .map((capture, index) => ({ capture, index }))
1455
1475
  .filter(({ capture }) => {
@@ -1459,7 +1479,9 @@ function extractGraphQLActionSequence(captures, submitPatterns = null) {
1459
1479
  return false;
1460
1480
  if (!matchesSubmit(capture))
1461
1481
  return false;
1462
- return capture.query !== null && /^\s*mutation\b/.test(capture.query);
1482
+ if (capture.query !== null && /^\s*mutation\b/.test(capture.query))
1483
+ return true;
1484
+ return matchesFoldReturn(capture) || matchesFoldReturnResults(capture);
1463
1485
  });
1464
1486
  }
1465
1487
  /**
@@ -1530,8 +1552,10 @@ function jsonBodyLeafValues(requestPostData) {
1530
1552
  if (parsed === undefined)
1531
1553
  return null;
1532
1554
  const values = [];
1533
- for (const { value } of walkStringLeaves(parsed))
1534
- values.push(value);
1555
+ for (const { value } of walkAllPrimitiveLeaves(parsed)) {
1556
+ if (value !== null)
1557
+ values.push(String(value));
1558
+ }
1535
1559
  return values;
1536
1560
  }
1537
1561
  /**
@@ -2622,11 +2646,29 @@ function* walkSetCookiePairs(rawSetCookie) {
2622
2646
  * Exception: values in `PLACEHOLDER_STATE_VALUES` are skipped entirely so
2623
2647
  * the LATER non-placeholder occurrence at the same JSON path becomes the
2624
2648
  * canonical binding instead.
2649
+ *
2650
+ * `forceIncludeValues` (see {@link collectDependentDrillDownChainValues})
2651
+ * bypasses `MIN_STATE_VALUE_LENGTH` for the specific values it names — a
2652
+ * value already confirmed, by the fold-chain detector itself, to be threaded
2653
+ * from one dependent-drill-down chain hop's response into the next hop's
2654
+ * request is exactly as legitimate a produced state value as a long one; a
2655
+ * length floor exists to keep an UNRELATED short value (an enum code, a page
2656
+ * number) from being mistaken for reused state by blind substring/value
2657
+ * matching, and a value the chain detector already confirmed is threaded
2658
+ * carries no such ambiguity. Every other filter (MAX length, placeholder,
2659
+ * shielded UUID, GET-non-UUID) still applies.
2625
2660
  */
2626
2661
  /** Exported for unit testing — lets tests exercise the produces[] walk (body
2627
2662
  * AND header/cookie origins) directly against synthetic Capture sequences. */
2628
- function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
2663
+ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Set()) {
2629
2664
  const index = new Map();
2665
+ // Computed structurally off the SAME captures being indexed (no
2666
+ // foldReturnSpec available at this layer) — a spec-declared fold's own
2667
+ // chain values reach here via the caller-supplied `forceIncludeValues`
2668
+ // (see recon-generate's top-level `collectDependentDrillDownChainValues`
2669
+ // call), so this indexes a chain-produced value regardless of whether the
2670
+ // fold plan that confirmed it is structural or spec-declared.
2671
+ const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
2630
2672
  // First pass: identify the earliest origin among ACTION captures for each
2631
2673
  // value. Action-only earliest-origin tracking is what compileActionSteps'
2632
2674
  // produces[] check needs — it ignores non-action captures (telemetry GETs,
@@ -2645,7 +2687,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2645
2687
  const rawSetCookie = Object.entries(c.responseHeaders).find(([k]) => k.toLowerCase() === "set-cookie")?.[1];
2646
2688
  if (rawSetCookie !== undefined) {
2647
2689
  for (const { name, value } of walkSetCookiePairs(rawSetCookie)) {
2648
- if (value.length < MIN_STATE_VALUE_LENGTH)
2690
+ // Same chain/force exemption as the body-value MIN_STATE_VALUE_LENGTH
2691
+ // floor below: a cookie-sourced value the fold-chain detector already
2692
+ // confirmed is threaded into a later hop's request is exactly as
2693
+ // legitimate as a long one, so it must not be dropped for being short.
2694
+ if (value.length < MIN_STATE_VALUE_LENGTH &&
2695
+ !chainForceIncludeValues.has(value) &&
2696
+ !forceIncludeValues.has(value))
2649
2697
  continue;
2650
2698
  if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
2651
2699
  continue;
@@ -2661,6 +2709,31 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2661
2709
  }
2662
2710
  }
2663
2711
  }
2712
+ // Non-cookie response headers are only indexed for values the fold-chain
2713
+ // detector already confirmed are threaded from this hop's response into a
2714
+ // later hop's request (`chainForceIncludeValues`) — unlike Set-Cookie,
2715
+ // which is always a plausible token mint, an arbitrary header (e.g.
2716
+ // `X-Conversation-Id`) is indexed as producible state only when chain
2717
+ // detection itself has already established that reuse, so this never
2718
+ // sweeps every header value as noise.
2719
+ for (const [headerName, headerValue] of Object.entries(c.responseHeaders)) {
2720
+ if (headerName.toLowerCase() === "set-cookie")
2721
+ continue;
2722
+ if (!chainForceIncludeValues.has(headerValue))
2723
+ continue;
2724
+ if (headerValue.length > MAX_COOKIE_STATE_VALUE_LENGTH)
2725
+ continue;
2726
+ if (PLACEHOLDER_STATE_VALUES.has(headerValue))
2727
+ continue;
2728
+ if (!index.has(headerValue)) {
2729
+ index.set(headerValue, {
2730
+ value: headerValue,
2731
+ originIndex: i,
2732
+ path: [],
2733
+ headerOrigin: { sourceHeader: headerName },
2734
+ });
2735
+ }
2736
+ }
2664
2737
  if (c.responseBody === undefined || c.responseBody === null)
2665
2738
  continue;
2666
2739
  // For GET captures, only index UUID-shaped strings. GET captures (today,
@@ -2671,8 +2744,13 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2671
2744
  // e.g. "candidate" as a state value gets substituted INSIDE an already-
2672
2745
  // emitted ${candidateId} interpolation, producing ${${entityTypeCode}Id}.
2673
2746
  const isGet = c.method === "GET";
2674
- for (const { value, path } of walkStringLeaves(c.responseBody)) {
2675
- if (value.length < MIN_STATE_VALUE_LENGTH)
2747
+ for (const { value: rawValue, path } of walkAllPrimitiveLeaves(c.responseBody)) {
2748
+ if (rawValue === null)
2749
+ continue;
2750
+ const value = String(rawValue);
2751
+ if (value.length < MIN_STATE_VALUE_LENGTH &&
2752
+ !chainForceIncludeValues.has(value) &&
2753
+ !forceIncludeValues.has(value))
2676
2754
  continue;
2677
2755
  if (value.length > MAX_STATE_VALUE_LENGTH)
2678
2756
  continue;
@@ -2685,7 +2763,16 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2685
2763
  // corrupt T2/T3's already-substituted Values.
2686
2764
  if (shieldedUuids.has(value))
2687
2765
  continue;
2688
- if (isGet && !UUID_REGEX.test(value))
2766
+ // Same chain/force exemption as the MIN_STATE_VALUE_LENGTH floor above:
2767
+ // a value the fold-chain detector already confirmed is threaded from
2768
+ // this GET hop's response into a later hop's request is exactly as
2769
+ // legitimate as a UUID anchor, so it must not be dropped just because
2770
+ // this hop happens to be a GET rather than every existing fixture's
2771
+ // POST.
2772
+ if (isGet &&
2773
+ !UUID_REGEX.test(value) &&
2774
+ !chainForceIncludeValues.has(value) &&
2775
+ !forceIncludeValues.has(value))
2689
2776
  continue;
2690
2777
  if (!index.has(value)) {
2691
2778
  index.set(value, { value, originIndex: i, path });
@@ -2891,8 +2978,48 @@ function compileActionSteps(actions, stateIndex) {
2891
2978
  });
2892
2979
  }
2893
2980
  }
2981
+ // Non-cookie response-header-origin produces — mirrors the Set-Cookie
2982
+ // block above but for a plain header (e.g. `X-Price-Token`) whose value
2983
+ // `indexStateValues` indexed with `headerOrigin.sourceHeader` set to the
2984
+ // real header name. Only emitted when the value is actually consumed as
2985
+ // a REQUEST HEADER downstream (`usedValueTargetHeader`) — `createHttpClient`'s
2986
+ // `bind` option (see http-client.ts) is the only mechanism that can
2987
+ // thread a header-origin value forward, since the emitted response
2988
+ // variable never exposes response headers to the rest of the generated
2989
+ // code the way it exposes the parsed body.
2990
+ for (const [headerName, headerValue] of Object.entries(capture.responseHeaders)) {
2991
+ if (headerName.toLowerCase() === "set-cookie")
2992
+ continue;
2993
+ if (!usedValues.has(headerValue))
2994
+ continue;
2995
+ const sv = stateIndex.get(headerValue);
2996
+ if (!sv || sv.originIndex !== index || !sv.headerOrigin)
2997
+ continue;
2998
+ const targetHeader = usedValueTargetHeader.get(headerValue);
2999
+ if (!targetHeader)
3000
+ continue;
3001
+ let name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header`;
3002
+ if (!/^[A-Za-z_$]/.test(name))
3003
+ name = `_${name}`;
3004
+ let suffix = 1;
3005
+ while (seenNames.has(name)) {
3006
+ suffix++;
3007
+ name = `${headerName.replace(/[^A-Za-z0-9]/g, "")}Header${suffix}`;
3008
+ }
3009
+ seenNames.add(name);
3010
+ produces.push({
3011
+ kind: "header",
3012
+ name,
3013
+ sourceHeader: sv.headerOrigin.sourceHeader,
3014
+ cookieName: sv.headerOrigin.cookieName,
3015
+ targetHeader,
3016
+ });
3017
+ }
2894
3018
  if (capture.responseBody !== undefined && capture.responseBody !== null) {
2895
- for (const { value, path } of walkStringLeaves(capture.responseBody)) {
3019
+ for (const { value: rawValue, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
3020
+ if (rawValue === null)
3021
+ continue;
3022
+ const value = String(rawValue);
2896
3023
  if (!usedValues.has(value))
2897
3024
  continue;
2898
3025
  const sv = stateIndex.get(value);
@@ -2977,7 +3104,9 @@ function resolveResponsePathValue(responseBody, path) {
2977
3104
  return null;
2978
3105
  }
2979
3106
  }
2980
- return typeof cursor === "string" ? cursor : null;
3107
+ return typeof cursor === "string" || typeof cursor === "number" || typeof cursor === "boolean"
3108
+ ? String(cursor)
3109
+ : null;
2981
3110
  }
2982
3111
  /**
2983
3112
  * Replaces occurrences of state values in `template` with `${varName}`
@@ -3738,7 +3867,8 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3738
3867
  const matchesJoinField = target.joinFields.some((field) => {
3739
3868
  const value = readValueAtPath(firstItem, field.split("."));
3740
3869
  return ((typeof value === "string" && value.length > 0 && value === headerValue) ||
3741
- (typeof value === "number" && String(value) === headerValue));
3870
+ ((typeof value === "number" || typeof value === "boolean") &&
3871
+ String(value) === headerValue));
3742
3872
  });
3743
3873
  if (matchesJoinField)
3744
3874
  headerNames.add(headerName);
@@ -3750,7 +3880,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
3750
3880
  const value = readValueAtPath(firstItem, field.split("."));
3751
3881
  if (typeof value === "string" && value.length > 0)
3752
3882
  joinValues.add(value);
3753
- else if (typeof value === "number")
3883
+ else if (typeof value === "number" || typeof value === "boolean")
3754
3884
  joinValues.add(String(value));
3755
3885
  }
3756
3886
  if (joinValues.size > 0)
@@ -4056,7 +4186,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4056
4186
  const value = readValueAtPath(firstItem, field.split("."));
4057
4187
  const stringValue = typeof value === "string" && value.length > 0
4058
4188
  ? value
4059
- : typeof value === "number"
4189
+ : typeof value === "number" || typeof value === "boolean"
4060
4190
  ? String(value)
4061
4191
  : null;
4062
4192
  return stringValue !== null
@@ -4539,14 +4669,14 @@ function collectRequestStringValues(capture) {
4539
4669
  })();
4540
4670
  if (parsedBody !== undefined) {
4541
4671
  for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
4542
- if (typeof value === "number")
4672
+ if (typeof value === "number" || typeof value === "boolean")
4543
4673
  values.add(String(value));
4544
4674
  }
4545
4675
  }
4546
4676
  return values;
4547
4677
  }
4548
4678
  /**
4549
- * Yields every string/numeric leaf reachable from `item` by walking nested
4679
+ * Yields every string/numeric/boolean leaf reachable from `item` by walking nested
4550
4680
  * plain objects only (not arrays — a join key is a scalar field of the item
4551
4681
  * or one of its nested objects, never an element drawn from a nested array),
4552
4682
  * paired with its dot-separated path from `item`'s root. A bare top-level
@@ -4565,8 +4695,8 @@ function* walkItemFieldPaths(item, path = []) {
4565
4695
  }
4566
4696
  }
4567
4697
  /**
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
4698
+ * Finds the ordered list of an array item's string/numeric/boolean field
4699
+ * paths whose values are threaded into `drillCapture`'s outbound request — the join key a
4570
4700
  * dependent drill-down call was built from. Each entry is a dot-separated
4571
4701
  * path (see {@link readValueAtPath} / {@link pathToAccessor}), so a bare
4572
4702
  * top-level field stays a single segment (e.g. `"sku"`) and a field nested
@@ -4582,23 +4712,31 @@ function findThreadedJoinFields(item, drillCapture) {
4582
4712
  return [];
4583
4713
  return [...walkItemFieldPaths(item)]
4584
4714
  .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
4585
- (typeof v === "number" && requestValues.has(String(v))))
4715
+ (typeof v === "number" && requestValues.has(String(v))) ||
4716
+ (typeof v === "boolean" && requestValues.has(String(v))))
4586
4717
  .map(({ path }) => path.join("."));
4587
4718
  }
4588
- /** Every string and numeric leaf value present anywhere in a response body
4719
+ /** Every string, numeric, and boolean leaf value present anywhere in a response —
4589
4720
  * the set a chained drill-down step's request must overlap with for that
4590
4721
  * step to count as depending on this response. Deliberately walks the WHOLE
4591
4722
  * body (not just object-array items, unlike {@link findThreadedJoinFields})
4592
4723
  * since a chained step can thread any response value, not only a per-item
4593
- * join field. */
4594
- function collectResponseLeafValues(responseBody) {
4724
+ * join field. Also walks every response HEADER value, mirroring
4725
+ * {@link collectRequestValuesIncludingHeaders} on the request side, since a
4726
+ * 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. */
4728
+ function collectResponseLeafValues(capture) {
4595
4729
  const values = new Set();
4596
- for (const { value } of walkAllPrimitiveLeaves(responseBody)) {
4730
+ for (const { value } of walkAllPrimitiveLeaves(capture.responseBody)) {
4597
4731
  if (typeof value === "string" && value.length > 0)
4598
4732
  values.add(value);
4599
4733
  if (typeof value === "number")
4600
4734
  values.add(String(value));
4735
+ if (typeof value === "boolean")
4736
+ values.add(String(value));
4601
4737
  }
4738
+ for (const v of Object.values(capture.responseHeaders))
4739
+ values.add(v);
4602
4740
  return values;
4603
4741
  }
4604
4742
  /**
@@ -4657,7 +4795,7 @@ function computeFoldChain(actions, drillStepIndex, drillArrayPath) {
4657
4795
  const requestValues = collectRequestValuesIncludingHeaders(candidate.capture);
4658
4796
  const dependsOnChain = chain.some((chainIndex) => {
4659
4797
  const chainStepCapture = actions[chainIndex].capture;
4660
- const responseValues = collectResponseLeafValues(chainStepCapture.responseBody);
4798
+ const responseValues = collectResponseLeafValues(chainStepCapture);
4661
4799
  const echoedValues = collectRequestValuesIncludingHeaders(chainStepCapture);
4662
4800
  return [...responseValues].some((v) => !echoedValues.has(v) && requestValues.has(v));
4663
4801
  });
@@ -4725,7 +4863,7 @@ function directPrimitiveChildCountExcludingEchoed(obj, requestValues) {
4725
4863
  let n = 0;
4726
4864
  for (const v of Object.values(obj)) {
4727
4865
  if (v === null || (typeof v !== "object" && typeof v !== "function")) {
4728
- if (typeof v === "string" || typeof v === "number") {
4866
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
4729
4867
  if (requestValues.has(String(v)))
4730
4868
  continue;
4731
4869
  }
@@ -5051,12 +5189,52 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
5051
5189
  const matchedIndex = primaryItems.findIndex((item) => joinFields.every((field) => {
5052
5190
  const value = readValueAtPath(item, field.split("."));
5053
5191
  return ((typeof value === "string" && value.length > 0 && requestValues.has(value)) ||
5054
- (typeof value === "number" && requestValues.has(String(value))));
5192
+ (typeof value === "number" && requestValues.has(String(value))) ||
5193
+ (typeof value === "boolean" && requestValues.has(String(value))));
5055
5194
  }));
5056
5195
  return matchedIndex === -1 ? null : matchedIndex;
5057
5196
  }
5058
- function buildFoldPlanFromSpec(actions, spec) {
5059
- const primaryArrayPath = spec.resultsPath.split(".");
5197
+ /** Like {@link resolveSpecMatchedPrimaryItemIndex}, but also looks upstream of
5198
+ * `drillStepIndex` for the join key when `drillStepIndex`'s own request
5199
+ * doesn't carry it — a `foldReturn` spec's `endpointPattern` naturally names
5200
+ * the chain TERMINAL (the response actually holding the data an author wants
5201
+ * folded), not the opaque entry hop that carries the join key onward (e.g.
5202
+ * as a header-threaded token). Walks every earlier step back to
5203
+ * `primaryStepIndex`, in reverse so the closest (least ambiguous) candidate
5204
+ * wins first, and accepts one only once {@link computeFoldChain} confirms it
5205
+ * actually chains FORWARD to `drillStepIndex` — otherwise an unrelated
5206
+ * earlier step matching the join key by coincidence could hijack the fold.
5207
+ * Returns the resolved `entryIndex` alongside the matched item index so the
5208
+ * caller can build the fold's `chain` starting from the step that ACTUALLY
5209
+ * carries the join key, not from `drillStepIndex` — a chain built from
5210
+ * `drillStepIndex` alone would never include this upstream entry hop, so it
5211
+ * would never be re-executed (header-parameterized) per primary item at
5212
+ * 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);
5216
+ if (matched === null)
5217
+ continue;
5218
+ if (entryIndex === drillStepIndex)
5219
+ return { entryIndex, primaryMatchedItemIndex: matched };
5220
+ const { chain } = computeFoldChain(actions, entryIndex, []);
5221
+ if (chain.includes(drillStepIndex))
5222
+ return { entryIndex, primaryMatchedItemIndex: matched };
5223
+ }
5224
+ return null;
5225
+ }
5226
+ /**
5227
+ * Compiles a flow-declared {@link FoldReturnSpec.endpointPattern} into a
5228
+ * capture predicate, or a predicate that always returns `false` when `spec`
5229
+ * is `null` or its pattern isn't a valid regex — the same null-safe
5230
+ * try/catch shape {@link buildFoldPlanFromSpec} already applied inline,
5231
+ * shared here so the action-sequence extractors can admit a spec-matched
5232
+ * drill-down capture under the identical rule that later resolves its fold
5233
+ * plan, instead of dropping it before the fold pipeline ever sees it.
5234
+ */
5235
+ function compileFoldReturnEndpointMatcher(spec) {
5236
+ if (spec === null)
5237
+ return () => false;
5060
5238
  const endpointRx = (() => {
5061
5239
  try {
5062
5240
  return new RegExp(spec.endpointPattern);
@@ -5066,7 +5244,31 @@ function buildFoldPlanFromSpec(actions, spec) {
5066
5244
  }
5067
5245
  })();
5068
5246
  if (endpointRx === null)
5069
- return null;
5247
+ return () => false;
5248
+ return (capture) => endpointRx.test(capture.url);
5249
+ }
5250
+ /**
5251
+ * A capture predicate matching whichever capture actually holds
5252
+ * {@link FoldReturnSpec.resultsPath}'s object array — the flow's own PRIMARY
5253
+ * results source, as opposed to {@link compileFoldReturnEndpointMatcher}'s
5254
+ * `endpointPattern` match (the drill-down). REST's `extractActionSequence`
5255
+ * never needs this: a REST primary is a POST/non-GET and is admitted
5256
+ * unconditionally regardless of `foldReturnSpec`. GraphQL's primary is
5257
+ * always a `query`, and `extractGraphQLActionSequence` drops every
5258
+ * non-mutation capture by default, so without this predicate a declared
5259
+ * `foldReturnSpec` would admit only the drill-down capture and never the
5260
+ * read op whose response the drill-down folds onto — leaving
5261
+ * `buildFoldPlanFromSpec` with no `primaryStepIndex` to resolve against.
5262
+ */
5263
+ function compileFoldReturnResultsMatcher(spec) {
5264
+ if (spec === null)
5265
+ return () => false;
5266
+ const resultsPath = spec.resultsPath.split(".");
5267
+ return (capture) => objectItemsAtPath(capture.responseBody, resultsPath) !== null;
5268
+ }
5269
+ function buildFoldPlanFromSpec(actions, spec) {
5270
+ const primaryArrayPath = spec.resultsPath.split(".");
5271
+ const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
5070
5272
  let freshestPlan = null;
5071
5273
  for (let primaryStepIndex = 0; primaryStepIndex < actions.length; primaryStepIndex++) {
5072
5274
  const primaryItems = objectItemsAtPath(actions[primaryStepIndex].capture.responseBody, primaryArrayPath);
@@ -5074,7 +5276,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5074
5276
  continue;
5075
5277
  for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
5076
5278
  const drill = actions[drillStepIndex];
5077
- if (!endpointRx.test(drill.capture.url))
5279
+ if (!matchesFoldReturnEndpoint(drill.capture))
5078
5280
  continue;
5079
5281
  // Widened to a flat (non-array) object response the same way the
5080
5282
  // structural heuristic is (see findAllObjectArrayFieldsOrWholeObject):
@@ -5090,17 +5292,31 @@ function buildFoldPlanFromSpec(actions, spec) {
5090
5292
  // now does (see detectDrillDownFoldPlan). Validated after the chain
5091
5293
  // resolves, below, since `[]` is a valid empty baseline for
5092
5294
  // computeFoldChain but not a valid final drillArrayPath on its own.
5295
+ const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, primaryStepIndex, drillStepIndex);
5296
+ if (matchResult === null)
5297
+ continue;
5298
+ const { entryIndex, primaryMatchedItemIndex } = matchResult;
5299
+ // Rooted at `entryIndex` — the step whose request ACTUALLY carries the
5300
+ // join key — not `drillStepIndex`, so an upstream entry hop the join
5301
+ // key was only resolvable through (e.g. a header-threaded token) is
5302
+ // itself part of `chain` and gets re-executed (join-parameterized) per
5303
+ // primary item at runtime, same as every structurally-detected chain.
5304
+ // `drillResultsPath`, when given, still targets `drillStepIndex`'s own
5305
+ // response specifically (the endpoint the spec names); it is otherwise
5306
+ // left for computeFoldChain's own forward richness walk to resolve,
5307
+ // exactly as it does for every step beyond the chain's entry.
5093
5308
  const drillArrayPath = (() => {
5309
+ if (entryIndex !== drillStepIndex) {
5310
+ return (findObjectArrayFieldOrWholeObject(actions[entryIndex].capture.responseBody)?.path ??
5311
+ null);
5312
+ }
5094
5313
  if (spec.drillResultsPath === undefined) {
5095
5314
  return findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null;
5096
5315
  }
5097
5316
  const path = spec.drillResultsPath.split(".");
5098
5317
  return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
5099
5318
  })();
5100
- const primaryMatchedItemIndex = resolveSpecMatchedPrimaryItemIndex(primaryItems, spec.joinFields, drill.capture);
5101
- if (primaryMatchedItemIndex === null)
5102
- continue;
5103
- const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, drillStepIndex, drillArrayPath ?? []);
5319
+ const { chain, chainArrayPath, chainTerminalIndex } = computeFoldChain(actions, entryIndex, drillArrayPath ?? []);
5104
5320
  // The chain's resolved terminal must actually hold foldable data —
5105
5321
  // an intermediate drill step with neither its own candidate NOR a
5106
5322
  // later chained step that resolves one has nothing to merge, so it
@@ -5114,7 +5330,7 @@ function buildFoldPlanFromSpec(actions, spec) {
5114
5330
  targets: [
5115
5331
  {
5116
5332
  joinFields: spec.joinFields,
5117
- drillStepIndex,
5333
+ drillStepIndex: entryIndex,
5118
5334
  drillArrayPath: drillArrayPath ?? [],
5119
5335
  primaryMatchedItemIndex,
5120
5336
  chain,
@@ -5207,6 +5423,62 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
5207
5423
  * plan that survives multipart disqualification, letting every downstream
5208
5424
  * emitter/shape-inference caller fold each of them.
5209
5425
  */
5426
+ /**
5427
+ * Every value ACTUALLY threaded from one dependent-drill-down chain hop's
5428
+ * response into a LATER chain hop's own request/headers — the same overlap
5429
+ * `computeFoldChain`'s `dependsOnChain` check already computes to decide a
5430
+ * step belongs in the chain at all — across every fold target
5431
+ * `detectDrillDownFoldPlan` / `buildFoldPlanFromSpec` resolve. These are the
5432
+ * values `indexStateValues` must index as producible state regardless of
5433
+ * `MIN_STATE_VALUE_LENGTH`, so `compileActionSteps` can thread a
5434
+ * chain-produced join value into the next hop's request even when that
5435
+ * value is a short one (e.g. a bare numeric status token).
5436
+ *
5437
+ * Deliberately NOT "every leaf value on a chain hop's response" — a chain
5438
+ * terminal's response commonly ECHOES the primary item's own join field
5439
+ * back (e.g. `sku` on the folded record), and that echoed value is a
5440
+ * PRIMARY-ITEM field the fold loop's own per-item render already threads
5441
+ * literally (see {@link findThreadedJoinFields}'s docstring); sweeping it
5442
+ * into state-threading's single-earliest-origin index would collapse every
5443
+ * item's distinct value onto whichever item's capture was indexed first.
5444
+ * Restricting to values that themselves reappear in a STRICTLY LATER chain
5445
+ * hop's own request — real cross-call threading, not an echo sitting still
5446
+ * in one response — keeps this exact to the shape state-threading is
5447
+ * actually needed for.
5448
+ *
5449
+ * Runs directly off raw actions (not `resolveFoldPlan`, which needs
5450
+ * `isMultipart` — unavailable before `compileActionSteps` has run) since
5451
+ * fold-plan DETECTION depends only on each action's `capture`.
5452
+ */
5453
+ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
5454
+ const structuralPlans = detectDrillDownFoldPlan(actions);
5455
+ const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
5456
+ const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
5457
+ const values = new Set();
5458
+ for (const plan of plans) {
5459
+ for (const target of plan.targets) {
5460
+ for (let j = 0; j < target.chain.length; j++) {
5461
+ const priorIndex = target.chain[j];
5462
+ const priorCapture = actions[priorIndex]?.capture;
5463
+ if (!priorCapture)
5464
+ continue;
5465
+ const responseValues = collectResponseLeafValues(priorCapture);
5466
+ const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
5467
+ for (let k = j + 1; k < target.chain.length; k++) {
5468
+ const laterCapture = actions[target.chain[k]]?.capture;
5469
+ if (!laterCapture)
5470
+ continue;
5471
+ const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
5472
+ for (const v of responseValues) {
5473
+ if (!echoedValues.has(v) && laterRequestValues.has(v))
5474
+ values.add(v);
5475
+ }
5476
+ }
5477
+ }
5478
+ }
5479
+ }
5480
+ return values;
5481
+ }
5210
5482
  function resolveFoldPlan(actions, foldReturnSpec = null) {
5211
5483
  const structuralPlans = detectDrillDownFoldPlan(actions);
5212
5484
  const plans = structuralPlans.length > 0
@@ -6573,7 +6845,9 @@ async function main() {
6573
6845
  // Hoisted so both the primary-operation gate below and rawActionCaptures
6574
6846
  // (further down) read the same computed sequence instead of calling the
6575
6847
  // extractor twice.
6576
- const graphqlActionSequence = gql ? extractGraphQLActionSequence(captures, submitPatterns) : [];
6848
+ const graphqlActionSequence = gql
6849
+ ? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
6850
+ : [];
6577
6851
  const primaryGraphQLOperation = gql && graphqlActionSequence.length === 0
6578
6852
  ? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
6579
6853
  : null;
@@ -6624,7 +6898,7 @@ async function main() {
6624
6898
  // heuristic extraction finds.
6625
6899
  const patternedHeuristicActionCaptures = gql
6626
6900
  ? graphqlActionSequence
6627
- : collapseRedundantPatches(extractActionSequence(captures, submitPatterns));
6901
+ : collapseRedundantPatches(extractActionSequence(captures, submitPatterns, foldReturnSpec));
6628
6902
  // The same undercount hazard applies one layer below the manifest: a
6629
6903
  // flow-declared submitEndpointPattern that matches only one section's URL
6630
6904
  // (the natural way to describe "the button that finishes the wizard")
@@ -6637,8 +6911,8 @@ async function main() {
6637
6911
  const unfilteredHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
6638
6912
  ? patternedHeuristicActionCaptures
6639
6913
  : gql
6640
- ? extractGraphQLActionSequence(captures, null)
6641
- : collapseRedundantPatches(extractActionSequence(captures, null));
6914
+ ? extractGraphQLActionSequence(captures, null, foldReturnSpec)
6915
+ : collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
6642
6916
  const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
6643
6917
  if (patternUndercounts) {
6644
6918
  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 +6943,8 @@ async function main() {
6669
6943
  const rawUnfilteredActionCaptures = submitEndpointPattern === null
6670
6944
  ? null
6671
6945
  : gql
6672
- ? extractGraphQLActionSequence(captures, null)
6673
- : collapseRedundantPatches(extractActionSequence(captures, null));
6946
+ ? extractGraphQLActionSequence(captures, null, foldReturnSpec)
6947
+ : collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec));
6674
6948
  // Form-schema detection runs BEFORE state-indexing so the field-id/option-id
6675
6949
  // UUIDs can be shielded from indexing — those UUIDs are stable schema
6676
6950
  // anchors that T2/T3 substitution depends on remaining literal in body
@@ -6718,8 +6992,16 @@ async function main() {
6718
6992
  ];
6719
6993
  })();
6720
6994
  const actionCaptureIndices = new Set(actionCaptures.map((a) => a.index));
6995
+ // Resolved off raw actionCaptures — fold-plan DETECTION depends only on
6996
+ // each action's capture, so this runs before compileActionSteps/
6997
+ // indexStateValues even exist — so a short numeric join value threaded
6998
+ // through a dependent-drill-down chain hop still gets indexed as
6999
+ // producible state (see collectDependentDrillDownChainValues).
7000
+ const dependentDrillDownChainValues = actionCaptures.length > 1
7001
+ ? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
7002
+ : new Set();
6721
7003
  const stateIndex = actionCaptures.length > 1
6722
- ? indexStateValues(captures, shieldedUuids, actionCaptureIndices)
7004
+ ? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
6723
7005
  : new Map();
6724
7006
  const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
6725
7007
  const isSubmissionFlow = actionSteps.length > 1;