@enricai/barnacle 1.12.49 → 1.12.50

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.
@@ -43,6 +43,7 @@ exports.resolveManifestActionSequence = resolveManifestActionSequence;
43
43
  exports.extractActionSequence = extractActionSequence;
44
44
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
45
45
  exports.dedupRedundantSameOperationCaptures = dedupRedundantSameOperationCaptures;
46
+ exports.isRedundantSameEndpointGroup = isRedundantSameEndpointGroup;
46
47
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
47
48
  exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
48
49
  exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
@@ -1504,6 +1505,13 @@ function resolveManifestActionSequence(runRoot, captures) {
1504
1505
  * a capture whose host fails {@link isAllowedFixtureHost} is dropped too —
1505
1506
  * `isNoiseUrl` alone lets a third-party telemetry/beacon POST masquerade as
1506
1507
  * a submission step, since it can look identical in shape to a real one.
1508
+ * A capture that recurs elsewhere with a byte-identical method/URL/body is
1509
+ * dropped too ({@link isZeroVarianceRepeatCapture}) — a same-host beacon
1510
+ * whose extension and host both look legitimate (e.g. a `.html` sensor
1511
+ * endpoint) still gives itself away by never varying across calls, which
1512
+ * `isNoiseUrl`'s substring/extension checks can't see and which the
1513
+ * structural-isolation pass below can even be fooled by (N identical copies
1514
+ * of the same path "vouch" for each other's tokens).
1507
1515
  *
1508
1516
  * When the flow declares submit patterns, only POSTs matching them survive —
1509
1517
  * this isolates the submission from same-origin page chrome (bootstrap, chatbot,
@@ -1552,6 +1560,8 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1552
1560
  return false;
1553
1561
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
1554
1562
  return false;
1563
+ if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
1564
+ return false;
1555
1565
  if (!matchesSubmit(capture))
1556
1566
  return false;
1557
1567
  if (hasHostProvenance &&
@@ -1573,11 +1583,28 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1573
1583
  // but a 1-2 capture pool has no "everything else" to be isolated from, so
1574
1584
  // skip it there rather than risk flagging a single-endpoint site's own
1575
1585
  // hyphenated path.
1586
+ //
1587
+ // A candidate's own same-pathname repeats only count as evidence against
1588
+ // itself (not for it) when its path carries a densely name-spaced,
1589
+ // marketing/tracking-shaped signal — more than one compound segment's
1590
+ // worth of tokens (e.g. `/site-banner/promotions-widget`, 4 tokens across
1591
+ // two compound segments) — OR when the repeats themselves carry no
1592
+ // business-relevant response state ({@link hasNoBusinessRelevantResponseState}):
1593
+ // a real own-backend endpoint (a polled toggles feed, a paged listing) is
1594
+ // often named with at most one compound segment, so path shape alone can't
1595
+ // tell it apart from a same-shaped, same-host, zero-business-value poll
1596
+ // (an availability/feature-flag ping that answers every call with nothing
1597
+ // a caller could not already know) — both are "one compound segment,
1598
+ // repeats identically." Response content is what actually distinguishes
1599
+ // them, so a candidate whose own repeats carry no business-relevant state
1600
+ // loses the self-vouching exemption regardless of its token count, while a
1601
+ // genuinely data-bearing single-compound-segment endpoint keeps it.
1576
1602
  const structurallyGated = hasHostProvenance && hostGated.length > 2
1577
1603
  ? hostGated.filter(({ capture }, i) => {
1578
1604
  const path = safeUrlPathname(capture.url);
1605
+ const denselyNameSpaced = (0, capture_filters_1.pathStructuralTokens)(path).size > 2 || (0, capture_filters_1.hasNoBusinessRelevantResponseState)(capture);
1579
1606
  const otherPaths = hostGated
1580
- .filter((_, j) => j !== i)
1607
+ .filter((h, j) => denselyNameSpaced ? safeUrlPathname(h.capture.url) !== path : j !== i)
1581
1608
  .map((h) => safeUrlPathname(h.capture.url));
1582
1609
  return !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths);
1583
1610
  })
@@ -1638,6 +1665,24 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1638
1665
  * Exported for tests: this predicate decides what a generated GraphQL plugin
1639
1666
  * will send at a live site.
1640
1667
  */
1668
+ /** REST HTTP methods that write/mutate server state rather than merely
1669
+ * reading it — a capture using one of these is never a re-readable
1670
+ * poll/listing, regardless of how flat its response body looks. */
1671
+ const MUTATING_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1672
+ /** A mutation capture: either GraphQL (identified by its parsed operation
1673
+ * query string starting with `mutation`) or REST (identified by a
1674
+ * non-idempotent HTTP method). Its response is a single mutated object
1675
+ * rather than a re-readable list/flag, so it must never be folded in with
1676
+ * genuinely idempotent reads by {@link isRedundantSameEndpointGroup} — a
1677
+ * flat-response REST POST (e.g. a wizard section save) is exactly as
1678
+ * non-poll-able as a GraphQL mutation, but `capture.query` is always null
1679
+ * for REST, so the GraphQL-only check alone would misclassify it as a
1680
+ * collapsible poll. */
1681
+ function isMutationCapture(capture) {
1682
+ if (capture.query !== null)
1683
+ return /^\s*mutation\b/.test(capture.query);
1684
+ return MUTATING_HTTP_METHODS.has(capture.method.toUpperCase());
1685
+ }
1641
1686
  function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null) {
1642
1687
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1643
1688
  const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
@@ -1648,7 +1693,7 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
1648
1693
  // only applies once the caller has actually resolved a notion of "own
1649
1694
  // backend" to check against.
1650
1695
  const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
1651
- const isMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
1696
+ const isMutation = isMutationCapture;
1652
1697
  const admitted = captures
1653
1698
  .map((capture, index) => ({ capture, index }))
1654
1699
  .filter(({ capture }) => {
@@ -1656,6 +1701,8 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
1656
1701
  return false;
1657
1702
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
1658
1703
  return false;
1704
+ if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
1705
+ return false;
1659
1706
  if (!matchesSubmit(capture))
1660
1707
  return false;
1661
1708
  if (hasHostProvenance &&
@@ -1688,7 +1735,7 @@ function responseShapeKey(capture) {
1688
1735
  const arrayField = findObjectArrayField(capture.responseBody);
1689
1736
  if (!arrayField)
1690
1737
  return null;
1691
- return `${endpointKey(capture.url)}${arrayField.path.join(".")}`;
1738
+ return `${endpointKey(capture.url)} ${arrayField.path.join(".")}`;
1692
1739
  }
1693
1740
  /**
1694
1741
  * Drops redundant re-issues of the primary GraphQL read operation from a
@@ -1750,9 +1797,28 @@ function collapseRedundantPatches(actions) {
1750
1797
  * counter, unpaired with an explicit page-size key) since that is the common
1751
1798
  * REST shape, unlike GraphQL's paired variables convention. */
1752
1799
  const PAGINATION_FIELD_NAME_PATTERN = /^(page|pagenum|pagenumber|pageindex|pageno|offset|skip|start|cursor)$/i;
1800
+ /** Request-field key names that name known client-generated scaffolding
1801
+ * (a monotonic sequence counter, a correlation/trace id, an idempotency
1802
+ * nonce) rather than genuine payload data. Gates {@link
1803
+ * isFieldValueThreadedElsewhere} on a FLAT (non-array) response's
1804
+ * BODY-carried varying field -- unlike an array-shaped listing/facet
1805
+ * re-query, a mutation's request-body field could just as easily be real
1806
+ * user-entered payload (an address line, a card's last4) that happens
1807
+ * never to be echoed back downstream, so that field additionally requires
1808
+ * its key name to look like scaffolding before trusting the "never echoed"
1809
+ * proof. A varying field that lives ONLY in the URL query string (never in
1810
+ * the body) skips this name requirement instead -- see {@link
1811
+ * isQueryStringOnlyKey}. */
1812
+ const SCAFFOLDING_FIELD_NAME_PATTERN = /^(req|request|correlation|trace|session|idempotency)?[-_]?(seq|id|key|nonce)$/i;
1753
1813
  /** Every query-string and (when JSON-object-shaped) request-body field on a
1754
1814
  * capture, merged into one comparable map -- REST pagination/facet state can
1755
- * live in either depending on the endpoint's own convention. */
1815
+ * live in either depending on the endpoint's own convention. Body fields are
1816
+ * flattened to their full leaf path (`paging.page`, not just `paging`) via
1817
+ * {@link walkAllPrimitiveLeaves} so a nested pagination/facet/scaffolding
1818
+ * object (`{"paging":{"page":1}}`) surfaces as its own comparable leaf
1819
+ * instead of collapsing into one opaque, always-varying JSON-stringified
1820
+ * key -- see {@link fieldKeyLeafName} for how callers recover the bare leaf
1821
+ * name a nested path's own key-name pattern match must test against. */
1756
1822
  function captureRequestFields(capture) {
1757
1823
  const fields = {};
1758
1824
  try {
@@ -1767,7 +1833,9 @@ function captureRequestFields(capture) {
1767
1833
  try {
1768
1834
  const parsed = JSON.parse(capture.requestPostData);
1769
1835
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1770
- Object.assign(fields, parsed);
1836
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
1837
+ fields[path.join(".")] = value;
1838
+ }
1771
1839
  }
1772
1840
  }
1773
1841
  catch {
@@ -1776,28 +1844,223 @@ function captureRequestFields(capture) {
1776
1844
  }
1777
1845
  return fields;
1778
1846
  }
1847
+ /** Recovers the bare field/leaf name (`page`) from a {@link
1848
+ * captureRequestFields} key that may be a dotted nested path (`paging.page`)
1849
+ * -- a flat top-level key is its own leaf name, so this is a no-op for the
1850
+ * pre-existing flat case. Used wherever a key is tested against a NAME
1851
+ * pattern ({@link PAGINATION_FIELD_NAME_PATTERN}, {@link
1852
+ * SCAFFOLDING_FIELD_NAME_PATTERN}) or looked up in {@link
1853
+ * requestAndResponseValuesByKey}'s index, both of which key by bare leaf
1854
+ * name, not by the nested path that disambiguates it during varying-key
1855
+ * detection. */
1856
+ function fieldKeyLeafName(key) {
1857
+ const segments = key.split(".");
1858
+ return segments[segments.length - 1] ?? key;
1859
+ }
1860
+ /** True when `key` is carried ONLY by the URL query string across every
1861
+ * capture in `group` -- never by a JSON request body. Used to widen the
1862
+ * flat-response scaffolding gate past its closed name allowlist for the
1863
+ * common case of a REST poll's tracking param, without extending that same
1864
+ * trust to a mutation's body-carried payload field (see the comment at its
1865
+ * call site in {@link isRedundantSameEndpointGroup}). */
1866
+ function isQueryStringOnlyKey(key, group) {
1867
+ return group.every((a) => {
1868
+ let inQuery = false;
1869
+ try {
1870
+ inQuery = new URL(a.capture.url).searchParams.has(key);
1871
+ }
1872
+ catch {
1873
+ return false;
1874
+ }
1875
+ if (!inQuery)
1876
+ return false;
1877
+ if (!a.capture.requestPostData)
1878
+ return true;
1879
+ try {
1880
+ const parsed = JSON.parse(a.capture.requestPostData);
1881
+ return (parsed === null ||
1882
+ typeof parsed !== "object" ||
1883
+ Array.isArray(parsed) ||
1884
+ !(key in parsed));
1885
+ }
1886
+ catch {
1887
+ return true;
1888
+ }
1889
+ });
1890
+ }
1891
+ /** Per-capture memoization cache for {@link requestAndResponseValuesByKey} --
1892
+ * without it, {@link isFieldValueThreadedElsewhere} re-parses the same
1893
+ * capture's JSON body and re-walks the same response-body leaves once per
1894
+ * (group, varying-key, group-member) combination it's compared against,
1895
+ * which is O(groups * keys * members * allActions) recomputations of
1896
+ * identical work instead of O(allActions). */
1897
+ const requestAndResponseValuesCache = new WeakMap();
1898
+ function requestAndResponseValuesByKey(capture) {
1899
+ const cached = requestAndResponseValuesCache.get(capture);
1900
+ if (cached)
1901
+ return cached;
1902
+ const byKey = new Map();
1903
+ const pathSegments = new Set();
1904
+ const add = (key, value) => {
1905
+ const values = byKey.get(key) ?? new Set();
1906
+ values.add(value);
1907
+ byKey.set(key, values);
1908
+ };
1909
+ try {
1910
+ const url = new URL(capture.url);
1911
+ for (const segment of url.pathname.split("/").filter(Boolean))
1912
+ pathSegments.add(segment);
1913
+ for (const [key, value] of url.searchParams)
1914
+ add(key, value);
1915
+ }
1916
+ catch {
1917
+ // Relative/invalid URLs carry no path/query signal to contribute.
1918
+ }
1919
+ if (capture.requestPostData) {
1920
+ try {
1921
+ const parsed = JSON.parse(capture.requestPostData);
1922
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
1923
+ if (value !== null && path.length > 0)
1924
+ add(path[path.length - 1], String(value));
1925
+ }
1926
+ }
1927
+ catch {
1928
+ // A non-JSON body carries no leaf values to contribute.
1929
+ }
1930
+ }
1931
+ for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
1932
+ if (value !== null && path.length > 0)
1933
+ add(path[path.length - 1], String(value));
1934
+ }
1935
+ const result = { byKey, pathSegments };
1936
+ requestAndResponseValuesCache.set(capture, result);
1937
+ return result;
1938
+ }
1939
+ const fieldValueIndexCache = new WeakMap();
1940
+ function fieldValueIndex(allActions) {
1941
+ const cached = fieldValueIndexCache.get(allActions);
1942
+ if (cached)
1943
+ return cached;
1944
+ const byKeyValue = new Map();
1945
+ const byPathSegment = new Map();
1946
+ for (const { capture } of allActions) {
1947
+ const { byKey, pathSegments } = requestAndResponseValuesByKey(capture);
1948
+ for (const [key, values] of byKey) {
1949
+ const valueMap = byKeyValue.get(key) ?? new Map();
1950
+ byKeyValue.set(key, valueMap);
1951
+ for (const value of values) {
1952
+ const captures = valueMap.get(value) ?? new Set();
1953
+ captures.add(capture);
1954
+ valueMap.set(value, captures);
1955
+ }
1956
+ }
1957
+ for (const segment of pathSegments) {
1958
+ const captures = byPathSegment.get(segment) ?? new Set();
1959
+ captures.add(capture);
1960
+ byPathSegment.set(segment, captures);
1961
+ }
1962
+ }
1963
+ const index = { byKeyValue, byPathSegment };
1964
+ fieldValueIndexCache.set(allActions, index);
1965
+ return index;
1966
+ }
1967
+ /** True when `value` -- one member's own value for `fieldKey`, the sole
1968
+ * varying request field of a same-endpoint group -- shows up under that
1969
+ * SAME field/leaf name in some capture OUTSIDE the group itself (any OTHER,
1970
+ * DIFFERENT-endpoint capture's query/body/response), proving some later
1971
+ * step reads or threads it. False means the value is either scaffolding the
1972
+ * client generated and nothing downstream ever consumes, OR a cursor the
1973
+ * group's OWN members hand to each other -- e.g. page 1's response minting
1974
+ * the exact cursor value page 2's request carries -- which is chained
1975
+ * pagination state, not distinct data a different step depends on, so an
1976
+ * echo confined to sibling occurrences of this SAME same-endpoint group
1977
+ * must not block collapsing it, OR a short scalar (a low-cardinality page
1978
+ * counter) that merely string-equals some unrelated field elsewhere by
1979
+ * coincidence -- requiring the match to occur under the SAME field name is
1980
+ * what tells genuine cross-step threading (an id echoed back under its own
1981
+ * name) apart from that coincidence, since an unrelated field publishing
1982
+ * the same short digit string under a DIFFERENT name proves nothing. This
1983
+ * is the structural signal {@link isRedundantSameEndpointGroup} uses to
1984
+ * widen collapsing past the literal {@link CACHE_BUSTER_QUERY_KEYS}/{@link
1985
+ * PAGINATION_FIELD_NAME_PATTERN} allowlists without hand-enumerating more
1986
+ * key-name shapes. A non-primitive or empty value can't be structurally
1987
+ * proven dead, so it's treated as load-bearing by default. */
1988
+ function isFieldValueThreadedElsewhere(fieldKey, value, groupCaptures, allActions) {
1989
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
1990
+ return true;
1991
+ }
1992
+ const stringValue = String(value);
1993
+ if (stringValue.length === 0)
1994
+ return true;
1995
+ const { byKeyValue, byPathSegment } = fieldValueIndex(allActions);
1996
+ const isOutsideGroup = (captures) => captures !== undefined && [...captures].some((capture) => !groupCaptures.has(capture));
1997
+ return (isOutsideGroup(byKeyValue.get(fieldKey)?.get(stringValue)) ||
1998
+ isOutsideGroup(byPathSegment.get(stringValue)));
1999
+ }
1779
2000
  /**
1780
2001
  * A same-endpoint capture group qualifies for collapsing when every capture
1781
- * resolves to the same {@link responseShapeKey} (ruling out a mutation POST,
1782
- * whose response is a single mutated object rather than an array, and any
1783
- * group whose members diverge in response shape) AND its request fields vary
1784
- * in at most one field once known non-semantic noise keys ({@link
1785
- * CACHE_BUSTER_QUERY_KEYS}) are excluded from consideration, and that
1786
- * remaining field is pagination-shaped -- a paged listing/facet re-query --
1787
- * or vary in no field at all -- a polled toggles/feature-flag endpoint
1788
- * re-fired with an identical request. A group
1789
- * whose members vary in a non-pagination field (e.g. a per-item drill's
1790
- * item-id body field) is left untouched: that variance carries the distinct
1791
- * per-item state the existing fold-chain mechanism (`target.chain` in
2002
+ * resolves to the same {@link responseShapeKey}, OR (when the response has no
2003
+ * array field anywhere, e.g. a flat poll/flag-style object) every capture is
2004
+ * either a non-mutation, or a mutation whose response body is byte-identical
2005
+ * across every occurrence in the group, whose response independently
2006
+ * resolves via {@link findObjectArrayFieldOrWholeObject}'s whole-object
2007
+ * fallback -- this rules out a mutation POST with a genuinely varying
2008
+ * response (e.g. a wizard section save), whose flat response is a single
2009
+ * mutated object rather than a re-readable poll result, while still
2010
+ * admitting a flat zero-variance re-poll fired via a mutating method (e.g. a
2011
+ * feature-flag/heartbeat check fired via POST) -- AND EVERY request field
2012
+ * that varies once known non-semantic
2013
+ * noise keys ({@link CACHE_BUSTER_QUERY_KEYS}) are excluded from
2014
+ * consideration is EITHER pagination-shaped -- a paged listing/facet re-query
2015
+ * -- OR (when `allActions`, the full capture sequence, is supplied)
2016
+ * independently proven via {@link isFieldValueThreadedElsewhere} to never be
2017
+ * read by any capture OUTSIDE this same-endpoint group -- a cache-buster/
2018
+ * nonce/request-id shape the literal allowlists don't happen to name, OR a
2019
+ * cursor the group's own members hand to each other (page 1's response
2020
+ * minting the exact value page 2's request carries) -- or the group varies
2021
+ * in no field at all -- a polled toggles/feature-flag endpoint re-fired with
2022
+ * an identical request. A group can have any number of varying keys; each
2023
+ * one must clear its own pagination-or-dead check independently, so a page
2024
+ * cursor alongside an unrelated dead cache-buster key still collapses.
2025
+ * Without `allActions` (unit tests exercising this predicate in isolation,
2026
+ * with no flow context to check against) a non-pagination varying key can't
2027
+ * be structurally proven dead, so the group is left untouched -- the same
2028
+ * conservative outcome as before this widening. A group with any varying
2029
+ * field proven read by a DIFFERENT step outside the group (e.g. a per-item
2030
+ * drill's item-id, later echoed into that item's detail request) is
2031
+ * likewise left untouched: that variance carries the distinct per-item
2032
+ * state the existing fold-chain mechanism (`target.chain` in
1792
2033
  * `emitMultiStepExecuteHttp`) already hoists correctly once resolved, and
1793
2034
  * collapsing it here would erase the very state that hoisting depends on.
1794
2035
  */
1795
- function isRedundantSameEndpointGroup(group) {
2036
+ function isRedundantSameEndpointGroup(group, allActions) {
1796
2037
  const shapeKey = responseShapeKey(group[0].capture);
1797
- if (shapeKey === null)
1798
- return false;
1799
- if (!group.every((a) => responseShapeKey(a.capture) === shapeKey))
1800
- return false;
2038
+ if (shapeKey !== null) {
2039
+ if (!group.every((a) => responseShapeKey(a.capture) === shapeKey))
2040
+ return false;
2041
+ }
2042
+ else {
2043
+ // A flat (non-array) response never resolves a `responseShapeKey`, but a
2044
+ // zero-variance re-poll of a flag/toggle endpoint still needs a shape to
2045
+ // key on -- fall back to the whole-object candidate every group member
2046
+ // must independently resolve to. A GraphQL mutation (detected via the
2047
+ // parsed operation query) is always excluded, since its response is by
2048
+ // definition the result of a state change. A REST capture excluded only
2049
+ // because of its HTTP method (POST/PUT/PATCH/DELETE) is admitted anyway
2050
+ // when every occurrence's response body is byte-identical -- that is
2051
+ // proof the call carries no distinct mutated state at all (a
2052
+ // feature-flag/heartbeat check fired via POST), the same zero-variance
2053
+ // signal {@link isZeroVarianceRepeatCapture} already uses for noise
2054
+ // exclusion, generalized here for the collapse decision.
2055
+ const isGraphQLMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
2056
+ const responsesByteIdentical = group.every((a) => JSON.stringify(a.capture.responseBody) === JSON.stringify(group[0].capture.responseBody));
2057
+ const isFlatObject = (capture) => !isGraphQLMutation(capture) &&
2058
+ (!isMutationCapture(capture) || responsesByteIdentical) &&
2059
+ responseShapeKey(capture) === null &&
2060
+ findObjectArrayFieldOrWholeObject(capture.responseBody) !== null;
2061
+ if (!group.every((a) => isFlatObject(a.capture)))
2062
+ return false;
2063
+ }
1801
2064
  const fieldSets = group.map((a) => captureRequestFields(a.capture));
1802
2065
  const allKeys = new Set();
1803
2066
  for (const fields of fieldSets) {
@@ -1812,7 +2075,30 @@ function isRedundantSameEndpointGroup(group) {
1812
2075
  });
1813
2076
  if (varyingKeys.length === 0)
1814
2077
  return true;
1815
- return varyingKeys.length === 1 && PAGINATION_FIELD_NAME_PATTERN.test(varyingKeys[0]);
2078
+ const groupCaptures = new Set(group.map((a) => a.capture));
2079
+ return varyingKeys.every((key) => {
2080
+ const leafName = fieldKeyLeafName(key);
2081
+ if (PAGINATION_FIELD_NAME_PATTERN.test(leafName))
2082
+ return true;
2083
+ // A flat response's varying field name must still look like scaffolding
2084
+ // UNLESS it lives only in the URL query string (never the JSON body) --
2085
+ // a query-string param is the conventional home for ephemeral
2086
+ // client-generated metadata (cache-busters, correlation ids, poll
2087
+ // ticks) regardless of what the site happens to call it, whereas a
2088
+ // JSON body field is where a mutation's genuine submitted payload (an
2089
+ // address line, a card's last4) lives, and that ambiguity is exactly
2090
+ // why the name-pattern requirement stays for body fields: an unnamed
2091
+ // body field being "never echoed elsewhere" is no proof it's dead, only
2092
+ // that nothing downstream happened to read it back.
2093
+ if (shapeKey === null &&
2094
+ !SCAFFOLDING_FIELD_NAME_PATTERN.test(leafName) &&
2095
+ !isQueryStringOnlyKey(key, group)) {
2096
+ return false;
2097
+ }
2098
+ if (!allActions)
2099
+ return false;
2100
+ return fieldSets.every((fields) => !isFieldValueThreadedElsewhere(leafName, fields[key], groupCaptures, allActions));
2101
+ });
1816
2102
  }
1817
2103
  /**
1818
2104
  * REST counterpart of {@link dedupRedundantSameOperationCaptures}: collapses
@@ -1827,6 +2113,16 @@ function isRedundantSameEndpointGroup(group) {
1827
2113
  * whichever page's response survives — page 1 is what a browsing/drill flow
1828
2114
  * actually saw and drilled into first, so it is the occurrence downstream
1829
2115
  * join values are captured against, not the endpoint's final paged state.
2116
+ *
2117
+ * A real per-item drill can join against ANY page's item, though, not just
2118
+ * page 1's — so before the rest of the group is dropped, every OTHER
2119
+ * occurrence's own array-field items (at the same {@link responseShapeKey}
2120
+ * path proven identical across the group) are concatenated onto the kept
2121
+ * representative's response body. Without this, {@link
2122
+ * detectDrillDownFoldPlan}'s structural scan only ever sees page 1's items
2123
+ * (every later page having just been deleted), so a drill keyed off a
2124
+ * later page's item can never resolve a join match and falls through to a
2125
+ * hardcoded per-capture `httpClient` call instead of folding into the loop.
1830
2126
  */
1831
2127
  function collapseRedundantSameEndpointCaptures(actions) {
1832
2128
  const positionsByGroup = new Map();
@@ -1836,17 +2132,82 @@ function collapseRedundantSameEndpointCaptures(actions) {
1836
2132
  positions.push(i);
1837
2133
  positionsByGroup.set(key, positions);
1838
2134
  });
2135
+ const mergedRepresentativeByPosition = new Map();
1839
2136
  const drop = new Set();
1840
2137
  for (const positions of positionsByGroup.values()) {
1841
2138
  if (positions.length < 2)
1842
2139
  continue;
1843
2140
  const group = positions.map((i) => actions[i]);
1844
- if (!isRedundantSameEndpointGroup(group))
2141
+ if (!isRedundantSameEndpointGroup(group, actions))
1845
2142
  continue;
2143
+ const merged = mergeCollapsedGroupItemsIntoRepresentative(group);
2144
+ if (merged !== null)
2145
+ mergedRepresentativeByPosition.set(positions[0], merged);
1846
2146
  for (const position of positions.slice(1))
1847
2147
  drop.add(position);
1848
2148
  }
1849
- return actions.filter((_, i) => !drop.has(i));
2149
+ return actions
2150
+ .map((a, i) => mergedRepresentativeByPosition.get(i) ?? a)
2151
+ .filter((_, i) => !drop.has(i));
2152
+ }
2153
+ /**
2154
+ * Builds a REPLACEMENT for the kept representative's (`group[0]`) own
2155
+ * {@link ActionCapture} whose response body concatenates every OTHER group
2156
+ * member's array-field items at their shared {@link responseShapeKey} path
2157
+ * onto the representative's own items — see {@link
2158
+ * collapseRedundantSameEndpointCaptures}'s docstring for why. Returns `null`
2159
+ * (no replacement needed) when the group's shape key is `null` (the
2160
+ * flat/zero-variance-poll branch of {@link isRedundantSameEndpointGroup}, with
2161
+ * no array-field path to merge items at) or when no other member actually
2162
+ * contributes an item at that path.
2163
+ *
2164
+ * A NEW response body/capture/action is built rather than mutating the
2165
+ * representative's own objects in place, deliberately: {@link
2166
+ * findAllObjectArrayFields}'s `objectArrayFieldsCache` is keyed on response-body
2167
+ * object IDENTITY under the explicit invariant that a response body is never
2168
+ * mutated after it's produced — mutating `representative.capture.responseBody`
2169
+ * in place would poison that cache with whatever shape happened to be computed
2170
+ * (and cached) from it before this runs, silently discarding the merge for
2171
+ * every caller downstream that hits the stale cache entry instead of the
2172
+ * mutated array.
2173
+ */
2174
+ function mergeCollapsedGroupItemsIntoRepresentative(group) {
2175
+ const representative = group[0];
2176
+ const arrayField = findObjectArrayField(representative.capture.responseBody);
2177
+ if (!arrayField)
2178
+ return null;
2179
+ const mergedItems = arrayField.items.slice();
2180
+ let contributed = false;
2181
+ for (const other of group.slice(1)) {
2182
+ const otherArrayField = findObjectArrayField(other.capture.responseBody);
2183
+ if (!otherArrayField || otherArrayField.path.join(".") !== arrayField.path.join("."))
2184
+ continue;
2185
+ mergedItems.push(...otherArrayField.items);
2186
+ contributed = true;
2187
+ }
2188
+ if (!contributed)
2189
+ return null;
2190
+ const mergedBody = setValueAtPath(representative.capture.responseBody, arrayField.path, mergedItems);
2191
+ return { ...representative, capture: { ...representative.capture, responseBody: mergedBody } };
2192
+ }
2193
+ /**
2194
+ * Returns a shallow-cloned-along-the-path copy of `body` with the value at
2195
+ * `path` replaced by `newValue` — the immutable counterpart to mutating a
2196
+ * response body in place, used by {@link
2197
+ * mergeCollapsedGroupItemsIntoRepresentative} so the object-identity-keyed
2198
+ * {@link objectArrayFieldsCache} never sees the same object with two different
2199
+ * shapes. Only plain-object segments are supported (every real caller's
2200
+ * `arrayField.path` is a DFS-discovered chain of object keys, never an array
2201
+ * index), so an unresolvable segment returns `body` unchanged.
2202
+ */
2203
+ function setValueAtPath(body, path, newValue) {
2204
+ if (path.length === 0)
2205
+ return newValue;
2206
+ if (body === null || typeof body !== "object" || Array.isArray(body))
2207
+ return body;
2208
+ const [head, ...rest] = path;
2209
+ const record = body;
2210
+ return { ...record, [head]: setValueAtPath(record[head], rest, newValue) };
1850
2211
  }
1851
2212
  /**
1852
2213
  * Recursively walks a JSON value and yields every string leaf, paired with its
@@ -3002,7 +3363,7 @@ function* walkSetCookiePairs(rawSetCookie) {
3002
3363
  */
3003
3364
  /** Exported for unit testing — lets tests exercise the produces[] walk (body
3004
3365
  * AND header/cookie origins) directly against synthetic Capture sequences. */
3005
- function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Set()) {
3366
+ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Map()) {
3006
3367
  const index = new Map();
3007
3368
  // Computed structurally off the SAME captures being indexed (no
3008
3369
  // foldReturnSpec available at this layer) — a spec-declared fold's own
@@ -3011,6 +3372,18 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3011
3372
  // call), so this indexes a chain-produced value regardless of whether the
3012
3373
  // fold plan that confirmed it is structural or spec-declared.
3013
3374
  const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
3375
+ // The set of captures a short/force-included value is actually eligible to
3376
+ // be spliced into — the union of whatever `chainForceIncludeValues` and the
3377
+ // caller-supplied `forceIncludeValues` proved for that value. `undefined`
3378
+ // when the value isn't exemption-derived, meaning the eligibility
3379
+ // restriction doesn't apply (see `StateValue.eligibleConsumers`).
3380
+ const eligibleConsumersFor = (value) => {
3381
+ const chainConsumers = chainForceIncludeValues.get(value);
3382
+ const forceConsumers = forceIncludeValues.get(value);
3383
+ if (!chainConsumers && !forceConsumers)
3384
+ return undefined;
3385
+ return new Set([...(chainConsumers ?? []), ...(forceConsumers ?? [])]);
3386
+ };
3014
3387
  // First pass: identify the earliest origin among ACTION captures for each
3015
3388
  // value. Action-only earliest-origin tracking is what compileActionSteps'
3016
3389
  // produces[] check needs — it ignores non-action captures (telemetry GETs,
@@ -3033,9 +3406,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3033
3406
  // floor below: a cookie-sourced value the fold-chain detector already
3034
3407
  // confirmed is threaded into a later hop's request is exactly as
3035
3408
  // legitimate as a long one, so it must not be dropped for being short.
3036
- if (value.length < MIN_STATE_VALUE_LENGTH &&
3037
- !chainForceIncludeValues.has(value) &&
3038
- !forceIncludeValues.has(value))
3409
+ const isShort = value.length < MIN_STATE_VALUE_LENGTH;
3410
+ if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
3039
3411
  continue;
3040
3412
  if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
3041
3413
  continue;
@@ -3047,6 +3419,7 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3047
3419
  originIndex: i,
3048
3420
  path: [],
3049
3421
  headerOrigin: { sourceHeader: "set-cookie", cookieName: name },
3422
+ eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
3050
3423
  });
3051
3424
  }
3052
3425
  }
@@ -3073,6 +3446,9 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3073
3446
  originIndex: i,
3074
3447
  path: [],
3075
3448
  headerOrigin: { sourceHeader: headerName },
3449
+ eligibleConsumers: headerValue.length < MIN_STATE_VALUE_LENGTH
3450
+ ? eligibleConsumersFor(headerValue)
3451
+ : undefined,
3076
3452
  });
3077
3453
  }
3078
3454
  }
@@ -3090,9 +3466,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3090
3466
  if (rawValue === null)
3091
3467
  continue;
3092
3468
  const value = String(rawValue);
3093
- if (value.length < MIN_STATE_VALUE_LENGTH &&
3094
- !chainForceIncludeValues.has(value) &&
3095
- !forceIncludeValues.has(value))
3469
+ const isShort = value.length < MIN_STATE_VALUE_LENGTH;
3470
+ if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
3096
3471
  continue;
3097
3472
  if (value.length > MAX_STATE_VALUE_LENGTH)
3098
3473
  continue;
@@ -3117,7 +3492,12 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3117
3492
  !forceIncludeValues.has(value))
3118
3493
  continue;
3119
3494
  if (!index.has(value)) {
3120
- index.set(value, { value, originIndex: i, path });
3495
+ index.set(value, {
3496
+ value,
3497
+ originIndex: i,
3498
+ path,
3499
+ eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
3500
+ });
3121
3501
  }
3122
3502
  }
3123
3503
  }
@@ -3320,6 +3700,13 @@ function compileActionSteps(actions, stateIndex) {
3320
3700
  for (const { capture } of actions) {
3321
3701
  const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
3322
3702
  for (const sv of stateIndex.values()) {
3703
+ // A short value indexed only via the chain/force-include exemption
3704
+ // (see `StateValue.eligibleConsumers`) is a real dependency ONLY for
3705
+ // the specific capture(s) the chain detector proved it threads into —
3706
+ // everywhere else, a coincidental substring match (a digit inside an
3707
+ // unrelated opaque path segment) is not reuse and must not splice.
3708
+ if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
3709
+ continue;
3323
3710
  if (capture.url.includes(sv.value)) {
3324
3711
  usedValues.add(sv.value);
3325
3712
  continue;
@@ -3346,6 +3733,8 @@ function compileActionSteps(actions, stateIndex) {
3346
3733
  }
3347
3734
  for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
3348
3735
  for (const sv of stateIndex.values()) {
3736
+ if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
3737
+ continue;
3349
3738
  if (!headerValue.includes(sv.value))
3350
3739
  continue;
3351
3740
  usedValues.add(sv.value);
@@ -3447,7 +3836,7 @@ function compileActionSteps(actions, stateIndex) {
3447
3836
  name = `${pathToVarName(path)}${suffix}`;
3448
3837
  }
3449
3838
  seenNames.add(name);
3450
- produces.push({ kind: "body", name, path });
3839
+ produces.push({ kind: "body", name, path, eligibleConsumers: sv.eligibleConsumers });
3451
3840
  }
3452
3841
  }
3453
3842
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -3522,37 +3911,138 @@ function resolveResponsePathValue(responseBody, path) {
3522
3911
  ? String(cursor)
3523
3912
  : null;
3524
3913
  }
3914
+ /** Builds the shared word-boundary-guarded, longest-value-first alternation
3915
+ * pattern used by both {@link interpolateStateValues} and
3916
+ * {@link substituteThreadedValues}: a value can never win a match at a position
3917
+ * a longer value also matches, and a value flanked by an alphanumeric — or by a
3918
+ * `-`/`.` itself flanked by an alphanumeric — never matches inside an unrelated
3919
+ * opaque token (e.g. splicing a "12" into a hyphen-joined "SKU-12-9F3Z"
3920
+ * segment) while a standalone occurrence (e.g. "/items/42/") still matches. */
3921
+ function buildValueAlternationPattern(sortedValues) {
3922
+ return new RegExp(`(?<![A-Za-z0-9][-.])\\b(?:${sortedValues.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b(?![-.][A-Za-z0-9])`, "g");
3923
+ }
3924
+ /**
3925
+ * Finds every `${...}` span in `text` by brace-depth counting rather than a
3926
+ * non-nesting regex, so an ALREADY-nested placeholder (e.g. one produced by an
3927
+ * earlier, buggier pass, or in principle any `${a${b}c}` shape) is reported as
3928
+ * ONE span covering the outer `${` through its true matching `}` — never as
3929
+ * just the innermost `${b}` — because a regex excluding `{`/`}` from its body
3930
+ * (`/\$\{[^{}]*\}/`) cannot see past the first inner brace and would otherwise
3931
+ * leave the outer span's `a`/`c` text unprotected for a later pass to splice
3932
+ * into, compounding the corruption instead of guarding against it.
3933
+ */
3934
+ function findBalancedPlaceholderSpans(text) {
3935
+ const spans = [];
3936
+ let searchFrom = 0;
3937
+ while (searchFrom < text.length) {
3938
+ const start = text.indexOf("${", searchFrom);
3939
+ if (start === -1)
3940
+ break;
3941
+ let depth = 1;
3942
+ let cursor = start + 2;
3943
+ while (cursor < text.length && depth > 0) {
3944
+ if (text[cursor] === "{")
3945
+ depth++;
3946
+ else if (text[cursor] === "}")
3947
+ depth--;
3948
+ cursor++;
3949
+ }
3950
+ spans.push([start, cursor]);
3951
+ searchFrom = cursor;
3952
+ }
3953
+ return spans;
3954
+ }
3955
+ /**
3956
+ * Runs `pattern` over `text`, replacing each match via `bindingByValue`,
3957
+ * EXCEPT a match that overlaps a `${...}` placeholder already present in
3958
+ * `text`. A single call's own matches never overlap each other (`replace`/
3959
+ * `matchAll` scan left-to-right without revisiting consumed text), so within
3960
+ * one call this only matters when `text` is the OUTPUT of an earlier call on
3961
+ * this same mechanism — e.g. a fold's per-item pass re-running over Pass 1's
3962
+ * already-interpolated URL/header/body text with a different (per-item)
3963
+ * binding table. Without this guard, that second pass's value-equality match
3964
+ * has no way to know a span it's about to touch is actually the FIRST pass's
3965
+ * `${varName}` placeholder for an entirely different producer/consumer
3966
+ * relationship — it just sees literal characters that happen to equal one of
3967
+ * its own bound values (e.g. the digits inside `${warehouseSlot47}`,
3968
+ * coincidentally also this fold item's own field value) and splices its
3969
+ * replacement in anyway, producing an invalidly-nested `${a${b}c}` literal
3970
+ * that resolves to neither value at runtime. Skipping any match that overlaps
3971
+ * an existing placeholder keeps every substitution scoped to the pass that
3972
+ * actually owns that span, which is the producer/consumer relationship this
3973
+ * mechanism is supposed to encode — and guarantees the output can never open
3974
+ * a `${` before a prior `${...}` closes.
3975
+ */
3976
+ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue) {
3977
+ const protectedSpans = findBalancedPlaceholderSpans(text);
3978
+ let result = "";
3979
+ let cursor = 0;
3980
+ for (const match of text.matchAll(pattern)) {
3981
+ const start = match.index;
3982
+ const end = start + match[0].length;
3983
+ if (protectedSpans.some(([spanStart, spanEnd]) => start < spanEnd && end > spanStart))
3984
+ continue;
3985
+ result += text.slice(cursor, start) + (bindingByValue.get(match[0]) ?? match[0]);
3986
+ cursor = end;
3987
+ }
3988
+ return result + text.slice(cursor);
3989
+ }
3525
3990
  /**
3526
3991
  * Replaces occurrences of state values in `template` with `${varName}`
3527
3992
  * interpolations. Returns a JS template-literal string fragment (no backticks).
3528
3993
  *
3529
3994
  * Algorithm: walk the producing steps' response bodies in order, harvest each
3530
- * produced value's concrete string, and map it to the produces[].name. Then
3531
- * scan the template for those strings and replace with ${varName}. Length-
3532
- * descending order avoids prefix conflicts (e.g. an 8-char prefix of a
3533
- * 36-char UUID).
3534
- */
3535
- function interpolateStateValues(template, priorSteps, payloadAccessorByValue = new Map()) {
3536
- const varNameByValue = deriveStateVarByValue(priorSteps);
3537
- let result = template;
3538
- // Pass 1: substitute state values (length-descending to avoid prefix
3539
- // conflicts). `\$` is a literal dollar sign (NOT an interpolation);
3540
- // `${varName}` interpolates the binding name at code-generation time so
3541
- // the resulting string contains a template-literal placeholder like
3542
- // `${candidateId}`.
3543
- const sortedState = [...varNameByValue.entries()].sort((a, b) => b[0].length - a[0].length);
3544
- for (const [value, varName] of sortedState) {
3545
- result = result.split(value).join(`\${${varName}}`);
3546
- }
3547
- // Pass 2: substitute payload values that survived the state pass. Same
3548
- // length-descending order. The payload pass only fires on remaining
3549
- // literal occurrences, so state substitutions win on collisions
3550
- // (e.g., when an Auth.UserName response value contains the user's email).
3551
- const sortedPayload = [...payloadAccessorByValue.entries()].sort((a, b) => b[0].length - a[0].length);
3552
- for (const [value, accessor] of sortedPayload) {
3553
- result = result.split(value).join(`\${${accessor}}`);
3995
+ * produced value's concrete string, and map it to the produces[].name, then
3996
+ * merge in the payload accessors (state wins on collision — e.g. when an
3997
+ * Auth.UserName response value equals the user's submitted email). A single
3998
+ * word-boundary-anchored regex alternation (longest value first, so an 8-char
3999
+ * prefix never shadows the 36-char UUID it's a prefix of) is matched over the
4000
+ * ORIGINAL template text exactly once — see {@link buildValueAlternationPattern}
4001
+ * for the anchoring guarantee and {@link replaceGuardedAgainstExistingPlaceholders}
4002
+ * for why a match overlapping an already-emitted `${...}` is skipped rather
4003
+ * than spliced into.
4004
+ */
4005
+ function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map()) {
4006
+ const varNameByValue = deriveStateVarByValue(priorSteps, targetCapture);
4007
+ const bindingByValue = new Map();
4008
+ for (const [value, accessor] of payloadAccessorByValue) {
4009
+ bindingByValue.set(value, `\${${accessor}}`);
4010
+ }
4011
+ for (const [value, varName] of varNameByValue) {
4012
+ bindingByValue.set(value, `\${${varName}}`);
4013
+ }
4014
+ if (bindingByValue.size === 0)
4015
+ return template;
4016
+ const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
4017
+ const pattern = buildValueAlternationPattern(sortedValues);
4018
+ return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue);
4019
+ }
4020
+ /**
4021
+ * Rewrites every occurrence of a set of literal values to their accessor
4022
+ * expressions in ONE pass over `text`, matching {@link interpolateStateValues}'s
4023
+ * guarded shape — see {@link buildValueAlternationPattern} for the anchoring
4024
+ * guarantee. A per-value sequential `.replace()` loop would re-scan the
4025
+ * PROGRESSIVELY MUTATED result on every iteration, letting one field's inserted
4026
+ * `${...}` replacement text land inside a position a later field's regex still
4027
+ * matches — producing a nested `${...${...}}` placeholder. Doing it once over
4028
+ * the original text closes that class of bug within this call; when `text` is
4029
+ * itself the already-interpolated output of an EARLIER call on this mechanism
4030
+ * (a fold's per-item pass over Pass 1's rendered URL/header/body), {@link
4031
+ * replaceGuardedAgainstExistingPlaceholders} closes the same class of bug
4032
+ * across calls by refusing to match inside a placeholder that call already
4033
+ * emitted.
4034
+ */
4035
+ function substituteThreadedValues(text, bindings) {
4036
+ if (bindings.length === 0)
4037
+ return text;
4038
+ const bindingByValue = new Map();
4039
+ for (const { value, replacement } of bindings) {
4040
+ if (!bindingByValue.has(value))
4041
+ bindingByValue.set(value, replacement);
3554
4042
  }
3555
- return result;
4043
+ const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
4044
+ const pattern = buildValueAlternationPattern(sortedValues);
4045
+ return replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue);
3556
4046
  }
3557
4047
  /**
3558
4048
  * Finds the request-body coordinates a PRODUCING step must source from the
@@ -3729,13 +4219,22 @@ const MAX_URL_PARAM_DECODE_DEPTH = 3;
3729
4219
  * Header/cookie-origin produces are skipped: they have no body path and their
3730
4220
  * value never appears as a literal in a URL/body template (http-client's `bind`
3731
4221
  * forwards it directly as a request header), so there is nothing to interpolate.
3732
- */
3733
- function deriveStateVarByValue(priorSteps) {
4222
+ *
4223
+ * `targetCapture` is the capture the returned bindings are about to be spliced
4224
+ * INTO. A produce whose value is chain/force-include-exempt (see
4225
+ * `BodyProduce.eligibleConsumers`) is a real dependency only for the specific
4226
+ * capture(s) the chain detector proved it threads into — everywhere else, a
4227
+ * coincidental substring match must not bind, or `interpolateStateValues`
4228
+ * splices it into an unrelated capture's URL/body/headers.
4229
+ */
4230
+ function deriveStateVarByValue(priorSteps, targetCapture) {
3734
4231
  const varNameByValue = new Map();
3735
4232
  for (const step of priorSteps) {
3736
4233
  for (const p of step.produces) {
3737
4234
  if (p.kind === "header")
3738
4235
  continue;
4236
+ if (p.eligibleConsumers && !p.eligibleConsumers.has(targetCapture))
4237
+ continue;
3739
4238
  const value = resolveResponsePathValue(step.capture.responseBody, p.path);
3740
4239
  if (value !== null)
3741
4240
  varNameByValue.set(value, p.name);
@@ -4368,7 +4867,17 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4368
4867
  const step = actions[i];
4369
4868
  const cap = step.capture;
4370
4869
  const prior = actions.slice(0, i);
4371
- const url = interpolateStateValues(cap.url, prior, payloadAccessorByValue);
4870
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
4871
+ // must never be treated as an interpolation target at all: its URL is
4872
+ // provably fixed across every occurrence, so any state-value splice into
4873
+ // it can only be a coincidental match, never a real dependency — the same
4874
+ // failure shape already fixed for GET responses (see `indexStateValues`'s
4875
+ // isGet UUID-only floor) and for the fold/drill per-item pass. Rendering
4876
+ // its exact literal URL makes this hold even when a value's own
4877
+ // length/chain-eligibility scoping doesn't happen to catch the coincidence.
4878
+ const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, actions.map((a) => a.capture))
4879
+ ? cap.url
4880
+ : interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue);
4372
4881
  // Form-schema substitution runs first on the raw recon body so its
4373
4882
  // field-id-anchored matches see the original JSON. State-threading and
4374
4883
  // payload key-value passes then run on top. Option-id substitution runs
@@ -4404,7 +4913,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4404
4913
  // must stay reachable for state threading, not get frozen as caller data.
4405
4914
  const rawBodyWithStructuredSubs = parsedBody !== null
4406
4915
  ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
4407
- ...deriveStateVarByValue(prior).keys(),
4916
+ ...deriveStateVarByValue(prior, cap).keys(),
4408
4917
  ...(joinFieldValuesByStep.get(i) ?? []),
4409
4918
  ]))
4410
4919
  : rawBodyWithFormSubs;
@@ -4431,14 +4940,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4431
4940
  if (binding.producerIndex === i)
4432
4941
  urlParamBindings.set(value, binding.accessor);
4433
4942
  }
4434
- for (const [value, varName] of deriveStateVarByValue(prior)) {
4943
+ for (const [value, varName] of deriveStateVarByValue(prior, cap)) {
4435
4944
  urlParamBindings.set(value, varName);
4436
4945
  }
4437
4946
  const rawBodyWithUrlParams = parsedBody !== null
4438
4947
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
4439
4948
  : rawBodyWithProducerBoundary;
4440
4949
  const bodyAfterStateAndKv = rawBodyWithUrlParams
4441
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
4950
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
4442
4951
  : "";
4443
4952
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
4444
4953
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -4467,7 +4976,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4467
4976
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
4468
4977
  const lower = k.toLowerCase();
4469
4978
  if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
4470
- perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
4979
+ perCallHeaders[k] = interpolateStateValues(v, prior, cap, payloadAccessorByValue);
4471
4980
  }
4472
4981
  }
4473
4982
  // G1: emit baseUrl-derived headers (Origin, Referer) per-call from
@@ -4569,7 +5078,15 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4569
5078
  // block-scoped to that loop and never escape to the rest of the function,
4570
5079
  // so none of them may run through the outer `declaredNames`/produceLines
4571
5080
  // bookkeeping below (that bookkeeping assumes function-scope declarations).
4572
- const foldChainIndices = new Set(foldPlans.flatMap((plan) => plan.targets.flatMap((target) => target.chain)));
5081
+ // Absorbed indices (see FoldPlan.absorbedIndices) are repeat raw captures
5082
+ // of a target's own endpoint, threaded from a DIFFERENT primary item — the
5083
+ // single representative target already re-issues that endpoint once per
5084
+ // fold-loop iteration, so these must be dropped from normal per-step
5085
+ // emission too, exactly like the chain indices they're folded in with.
5086
+ const foldChainIndices = new Set(foldPlans.flatMap((plan) => [
5087
+ ...plan.targets.flatMap((target) => target.chain),
5088
+ ...plan.absorbedIndices,
5089
+ ]));
4573
5090
  for (let i = 0; i < actions.length; i++) {
4574
5091
  const step = actions[i];
4575
5092
  const cap = step.capture;
@@ -4663,12 +5180,32 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4663
5180
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
4664
5181
  const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4665
5182
  const joinAccessor = (field) => scopedAccessor(itemVar, field);
4666
- // Word-boundary anchored: a plain `.split(value).join(...)` would also
4667
- // rewrite unrelated substrings that happen to contain the join value
4668
- // (e.g. a "p1" product id colliding with a "/v1/" path segment or a
4669
- // "p10" sibling id), corrupting parts of the request the join field
4670
- // never touched.
4671
- const replaceWholeValue = (haystack, value, replacement) => haystack.replace(new RegExp(`\\b${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), replacement);
5183
+ // Computed once per fold target instead of once per `parameterize`
5184
+ // call: `actions` never changes across the url/headers/body calls a
5185
+ // single chain step makes (or across chain steps), so re-deriving
5186
+ // this array inside the closure was O(actions.length) work repeated
5187
+ // 3x per chain hop for no reason.
5188
+ const allCaptures = actions.map((a) => a.capture);
5189
+ // `isZeroVarianceRepeatCapture`'s verdict is a pure function of
5190
+ // `chainCapture` (and the now-hoisted `allCaptures`, which is fixed
5191
+ // for the whole target) — memoized here so the 3 `parameterize`
5192
+ // calls a single chain step makes (url, headers, body) each share
5193
+ // the one verdict computed for that step's `chainCapture` instead of
5194
+ // re-scanning `allCaptures` from scratch every time.
5195
+ const isProvenInvariantMemo = new Map();
5196
+ const isProvenInvariantFor = (chainCapture) => {
5197
+ const cached = isProvenInvariantMemo.get(chainCapture);
5198
+ if (cached !== undefined)
5199
+ return cached;
5200
+ const computed = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
5201
+ isProvenInvariantMemo.set(chainCapture, computed);
5202
+ return computed;
5203
+ };
5204
+ // Whole-value substitution runs via substituteThreadedValues: a single
5205
+ // guarded regex-alternation pass over the original text, not a
5206
+ // per-field sequential `.replace()` loop — see that function's doc for
5207
+ // why the sequential shape corrupts opaque path segments and can nest
5208
+ // `${...}` placeholders.
4672
5209
  const parameterize = (text, chainCapture) => {
4673
5210
  // A join field can reach the render either as the raw captured
4674
5211
  // literal (URL query params) or as an already-generic
@@ -4685,7 +5222,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4685
5222
  // invisible and gets frozen as a literal.
4686
5223
  const rawThreadedFields = dedupeThreadedFields([
4687
5224
  ...target.joinFields.map((field) => ({ varName: itemVar, field })),
4688
- ...findThreadedJoinFields(threadingScopes, chainCapture, actions.map((a) => a.capture)),
5225
+ ...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
4689
5226
  ]);
4690
5227
  // A proven ancestor-scoped drill (see isAncestorScoped above) still
4691
5228
  // rebinds fields findThreadedJoinFields left on itemVar purely
@@ -4713,7 +5250,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4713
5250
  : tf,
4714
5251
  }))
4715
5252
  : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
4716
- const result = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
5253
+ // Accessor swap first: rewrites an already-templated `${payload.X}`
5254
+ // reference (from applyPayloadKeyValueSubstitutions) to this field's
5255
+ // real accessor. Each target (`${payload.X}`) is a unique, fully
5256
+ // delimited string that the swap's own output (`${accessorField...}`,
5257
+ // never re-shaped into `${payload.X}` form) can't re-match, so a
5258
+ // sequential pass here carries none of the reentrancy risk the
5259
+ // literal-value pass below has.
5260
+ const swapped = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
4717
5261
  const replacement = `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`;
4718
5262
  // applyPayloadKeyValueSubstitutions only ever names a payload
4719
5263
  // accessor after the DRILL REQUEST's own top-level JSON key
@@ -4726,11 +5270,18 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4726
5270
  // reference behind once the literal value itself has already
4727
5271
  // been replaced by the payload-key-value pass.
4728
5272
  const lastSegment = valueField.field.split(".").pop();
4729
- const withAccessorSwapped = acc
5273
+ return acc
4730
5274
  .split(`\${payload.${valueField.field}}`)
4731
5275
  .join(replacement)
4732
5276
  .split(`\${payload.${lastSegment}}`)
4733
5277
  .join(replacement);
5278
+ }, text);
5279
+ // Literal-value substitution: ONE guarded regex-alternation pass over
5280
+ // `swapped` for every threaded field's value, longest first — see
5281
+ // substituteThreadedValues's doc for why a per-field sequential pass
5282
+ // here (the bug this replaces) can nest `${...}` placeholders.
5283
+ const valueBindings = threadedFieldPairs
5284
+ .map(({ valueField, accessorField }) => {
4734
5285
  const scopeObj = valueField.varName === itemVar
4735
5286
  ? firstItem
4736
5287
  : ancestorObjByVar.get(valueField.varName);
@@ -4740,12 +5291,36 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4740
5291
  : typeof value === "number" || typeof value === "boolean"
4741
5292
  ? String(value)
4742
5293
  : null;
4743
- return stringValue !== null
4744
- ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4745
- : withAccessorSwapped;
4746
- }, text);
5294
+ return stringValue === null
5295
+ ? null
5296
+ : {
5297
+ value: stringValue,
5298
+ replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
5299
+ };
5300
+ })
5301
+ .filter((b) => b !== null);
5302
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
5303
+ // must never have a COINCIDENTAL threaded value spliced into it —
5304
+ // but the invariance verdict is per-capture, not per-field: a
5305
+ // capture can be fixed on one key (a repeated `qty`) while still
5306
+ // genuinely varying on another (`itemId`), so only the fields that
5307
+ // {@link isGenuineVaryingQueryValue} can't prove are real per-request
5308
+ // dependencies get excluded, never the whole substitution pass.
5309
+ const isProvenInvariant = isProvenInvariantFor(chainCapture);
5310
+ const filteredValueBindings = isProvenInvariant
5311
+ ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
5312
+ : valueBindings;
5313
+ const result = substituteThreadedValues(swapped, filteredValueBindings);
4747
5314
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
4748
- assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, withDrillParamBindings, actions.map((a) => a.capture));
5315
+ // The frozen-varying-param safety net exists to catch a
5316
+ // misconfigured drill (a param that genuinely needs joinFields/an
5317
+ // ancestor binding but has neither) — it does not apply once the
5318
+ // capture is already proven request-invariant: freezing an
5319
+ // undeclared, business-irrelevant varying key (a beacon nonce)
5320
+ // there is the INTENDED behavior, not a misconfiguration.
5321
+ if (!isProvenInvariant) {
5322
+ assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, withDrillParamBindings, allCaptures);
5323
+ }
4749
5324
  return withDrillParamBindings;
4750
5325
  };
4751
5326
  // Every chain step's response and produces are block-scoped to this
@@ -4771,6 +5346,9 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4771
5346
  for (const chainIndex of target.chain) {
4772
5347
  const chainStep = actions[chainIndex];
4773
5348
  const chainRendered = rendered[chainIndex];
5349
+ // The zero-variance guard lives inside `parameterize` itself (see
5350
+ // above) so it can skip only the threaded-value splice while still
5351
+ // letting a spec-declared drillParamBindings substitution apply.
4774
5352
  const paramUrl = parameterize(chainRendered.url, chainStep.capture);
4775
5353
  const paramHeaders = parameterize(chainRendered.headersExpr, chainStep.capture);
4776
5354
  const paramBody = parameterize(chainRendered.bodyArg, chainStep.capture);
@@ -4862,7 +5440,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4862
5440
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
4863
5441
  const lower = k.toLowerCase();
4864
5442
  if (lower === "api-token" || lower === "authorization") {
4865
- perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), payloadAccessorByValue)}\``);
5443
+ perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue)}\``);
4866
5444
  }
4867
5445
  }
4868
5446
  // G1+G2: include tenant-derived headers in the multipart fetch too.
@@ -5518,6 +6096,40 @@ function applyDrillParamBindings(spec, capture, text) {
5518
6096
  return acc.replace(paramRx, (_full, prefix) => `${prefix}${accessor}`);
5519
6097
  }, text);
5520
6098
  }
6099
+ /** True when `value` is a QUERY PARAM value in `capture.url` whose key
6100
+ * genuinely differs on at least one other same-endpoint occurrence in
6101
+ * `allCaptures` — i.e. a real per-request dependency (an item id, a page
6102
+ * cursor), not a coincidental byte match against an opaque path segment or a
6103
+ * key that happens to hold the same value on every occurrence. Used to
6104
+ * decide, field by field, whether a threaded-value splice into a capture
6105
+ * proven request-invariant ({@link isZeroVarianceRepeatCapture}) is a
6106
+ * legitimate substitution or the exact coincidence that guard exists to
6107
+ * catch — a capture can be "invariant" on one key (a fixed `qty`) while
6108
+ * still genuinely varying on another (`itemId`), so the invariance verdict
6109
+ * alone can't gate substitution at the whole-capture level. */
6110
+ function isGenuineVaryingQueryValue(value, capture, allCaptures) {
6111
+ let url;
6112
+ try {
6113
+ url = new URL(capture.url);
6114
+ }
6115
+ catch {
6116
+ return false;
6117
+ }
6118
+ const key = [...url.searchParams.entries()].find(([, v]) => v === value)?.[0];
6119
+ if (key === undefined)
6120
+ return false;
6121
+ const endpoint = endpointKey(capture.url);
6122
+ return allCaptures.some((c) => {
6123
+ if (c === capture || endpointKey(c.url) !== endpoint)
6124
+ return false;
6125
+ try {
6126
+ return new URL(c.url).searchParams.get(key) !== value;
6127
+ }
6128
+ catch {
6129
+ return false;
6130
+ }
6131
+ });
6132
+ }
5521
6133
  /** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
5522
6134
  * varying literal — shared by {@link parameterizeUrl} (below) and
5523
6135
  * `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
@@ -5967,6 +6579,11 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5967
6579
  const consumedIndices = new Set();
5968
6580
  for (const primaryArray of primaryCandidates) {
5969
6581
  const targets = [];
6582
+ // See FoldPlan.absorbedIndices — a candidate hitting the SAME endpoint
6583
+ // as a target already resolved for this array is a repeat raw capture
6584
+ // of that one per-item drill (threaded from a DIFFERENT primary item),
6585
+ // not an independent target, so it lands here instead of `targets`.
6586
+ const absorbedIndices = [];
5970
6587
  // Pruned to the (typically tiny) set of later action indices whose
5971
6588
  // request could possibly thread one of this array's own item values —
5972
6589
  // see buildRequestStringValueIndex's docstring — instead of every
@@ -5987,6 +6604,24 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5987
6604
  if (primaryMatchedItemIndex === -1)
5988
6605
  continue;
5989
6606
  const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
6607
+ // A genuinely per-item-varying repeated endpoint (the SAME drill
6608
+ // called once per primary item, each occurrence threading a
6609
+ // DIFFERENT item's own join value) is one logical target, not N — the
6610
+ // fold loop already re-issues the representative target's request
6611
+ // once per item via its own `item.<field>` accessor. Recognizing a
6612
+ // later candidate as a repeat of an ALREADY-RESOLVED target's
6613
+ // endpoint (rather than letting it become its own independent
6614
+ // target) is exactly the widening this structural heuristic needed:
6615
+ // previously every threading candidate became its own FoldTarget,
6616
+ // so N per-item captures of one endpoint fanned out into N separate
6617
+ // httpClient calls inside the loop instead of collapsing to one.
6618
+ const drillEndpointKey = endpointKey(drill.capture.url);
6619
+ const alreadyTargetedSameEndpoint = targets.some((t) => endpointKey(actions[t.drillStepIndex].capture.url) === drillEndpointKey);
6620
+ if (alreadyTargetedSameEndpoint) {
6621
+ absorbedIndices.push(drillIndex);
6622
+ consumedIndices.add(drillIndex);
6623
+ continue;
6624
+ }
5990
6625
  // Widened to a flat (non-array) object response when the drill step has
5991
6626
  // no object-array field of its own — see
5992
6627
  // findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
@@ -6049,8 +6684,9 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
6049
6684
  for (const chainIndex of chain)
6050
6685
  consumedIndices.add(chainIndex);
6051
6686
  }
6052
- if (targets.length > 0)
6053
- groups.push({ primaryArrayPath: primaryArray.path, targets });
6687
+ if (targets.length > 0) {
6688
+ groups.push({ primaryArrayPath: primaryArray.path, targets, absorbedIndices });
6689
+ }
6054
6690
  }
6055
6691
  return groups;
6056
6692
  }
@@ -6151,6 +6787,7 @@ function detectDrillDownFoldPlan(actions) {
6151
6787
  primaryStepIndex: freshestIndex,
6152
6788
  primaryArrayPath: freshestGroup.primaryArrayPath,
6153
6789
  targets: freshestGroup.targets,
6790
+ absorbedIndices: freshestGroup.absorbedIndices,
6154
6791
  });
6155
6792
  // A step already folded into this plan's chains — the drill step(s)
6156
6793
  // and everything threaded onward from them — was already merged
@@ -6165,6 +6802,12 @@ function detectDrillDownFoldPlan(actions) {
6165
6802
  for (const chainIndex of target.chain)
6166
6803
  addConsumed(chainIndex);
6167
6804
  }
6805
+ // Absorbed repeat occurrences (see FoldPlan.absorbedIndices) were never
6806
+ // part of any target's chain, so they must be consumed here too, or
6807
+ // they would surface as leftover raw indices and get emitted a second
6808
+ // time as their own single hardcoded calls.
6809
+ for (const absorbedIndex of freshestGroup.absorbedIndices)
6810
+ addConsumed(absorbedIndex);
6168
6811
  }
6169
6812
  }
6170
6813
  return plans;
@@ -6685,6 +7328,7 @@ function buildFoldPlanFromSpec(actions, spec) {
6685
7328
  chainTerminalIndex,
6686
7329
  },
6687
7330
  ],
7331
+ absorbedIndices: [],
6688
7332
  };
6689
7333
  break;
6690
7334
  }
@@ -6895,12 +7539,21 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
6895
7539
  * Runs directly off raw actions (not `resolveFoldPlan`, which needs
6896
7540
  * `isMultipart` — unavailable before `compileActionSteps` has run) since
6897
7541
  * fold-plan DETECTION depends only on each action's `capture`.
7542
+ *
7543
+ * Returns a value -> proven-consumer-captures map rather than a flat set: a
7544
+ * value's chain-proven threading relationship holds ONLY between the
7545
+ * specific chain hops that produced and consumed it, never globally across
7546
+ * every capture in the flow. Callers that bypass `MIN_STATE_VALUE_LENGTH`
7547
+ * for one of these values (see `indexStateValues`) must scope that bypass to
7548
+ * the returned consumer set, or a short value legitimately threaded between
7549
+ * two unrelated steps can coincidentally match inside a totally unrelated
7550
+ * capture's own URL/body and get spliced into it.
6898
7551
  */
6899
7552
  function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
6900
7553
  const structuralPlans = detectDrillDownFoldPlan(actions);
6901
7554
  const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
6902
7555
  const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
6903
- const values = new Set();
7556
+ const consumersByValue = new Map();
6904
7557
  for (const plan of plans) {
6905
7558
  for (const target of plan.targets) {
6906
7559
  for (let j = 0; j < target.chain.length; j++) {
@@ -6916,14 +7569,17 @@ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
6916
7569
  continue;
6917
7570
  const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
6918
7571
  for (const v of responseValues) {
6919
- if (!echoedValues.has(v) && laterRequestValues.has(v))
6920
- values.add(v);
7572
+ if (echoedValues.has(v) || !laterRequestValues.has(v))
7573
+ continue;
7574
+ const consumers = consumersByValue.get(v) ?? new Set();
7575
+ consumers.add(laterCapture);
7576
+ consumersByValue.set(v, consumers);
6921
7577
  }
6922
7578
  }
6923
7579
  }
6924
7580
  }
6925
7581
  }
6926
- return values;
7582
+ return consumersByValue;
6927
7583
  }
6928
7584
  function resolveFoldPlan(actions, foldReturnSpec = null) {
6929
7585
  const structuralPlans = detectDrillDownFoldPlan(actions);
@@ -7013,17 +7669,59 @@ function replaceByReference(value, target, replacement) {
7013
7669
  * `throw` at the analogous point, minus the throw, since shape inference
7014
7670
  * degrading gracefully is preferable to failing a generate run over it.
7015
7671
  */
7672
+ /** Folds one drill-down response's matching item onto `body`'s primary
7673
+ * array — the single-occurrence step {@link foldResponseBodyForShapeInference}
7674
+ * runs once for a target's own representative drill and again for each of
7675
+ * its {@link FoldPlan.absorbedIndices} siblings, so both call sites resolve
7676
+ * the merge identically. `matchedItem` must already be resolved by the
7677
+ * caller: the representative occurrence knows it via `primaryMatchedItemIndex`,
7678
+ * while an absorbed occurrence resolves it by re-threading its OWN request
7679
+ * against every primary item (see the call site) — a drill-down's RESPONSE
7680
+ * commonly never echoes the join field it was looked up by, so matching by
7681
+ * response content alone (as the representative branch's own `drillMatch`
7682
+ * fallback does when the response doesn't echo it) can't identify WHICH item
7683
+ * an absorbed occurrence belongs to in the first place. */
7684
+ function foldOneDrillOccurrence(body, primaryArrayPath, joinFields, drillItems, matchedItem) {
7685
+ const primaryItems = objectItemsAtPath(body, primaryArrayPath);
7686
+ if (!primaryItems)
7687
+ return body;
7688
+ const drillMatch = drillItems.find((d) => joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
7689
+ String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems[0];
7690
+ if (!drillMatch)
7691
+ return body;
7692
+ return replaceByReference(body, matchedItem, { ...drillMatch, ...matchedItem });
7693
+ }
7016
7694
  function foldResponseBodyForShapeInference(actionSteps, foldPlan, initialBody = actionSteps[foldPlan.primaryStepIndex].capture.responseBody) {
7017
7695
  return foldPlan.targets.reduce((body, target) => {
7018
7696
  const drillBody = actionSteps[target.chainTerminalIndex].capture.responseBody;
7019
7697
  const primaryItems = objectItemsAtPath(body, foldPlan.primaryArrayPath);
7020
7698
  const drillItems = objectItemsAtPath(drillBody, target.chainArrayPath);
7021
7699
  const matchedItem = primaryItems?.[target.primaryMatchedItemIndex];
7022
- const drillMatch = drillItems?.find((d) => target.joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
7023
- String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems?.[0];
7024
- if (!primaryItems || !matchedItem || !drillMatch)
7025
- return body;
7026
- return replaceByReference(body, matchedItem, { ...drillMatch, ...matchedItem });
7700
+ const bodyAfterOwnDrill = !drillItems || !matchedItem
7701
+ ? body
7702
+ : foldOneDrillOccurrence(body, foldPlan.primaryArrayPath, target.joinFields, drillItems, matchedItem);
7703
+ // Every absorbed occurrence of this SAME endpoint (a repeat raw capture
7704
+ // threaded from a DIFFERENT primary item — see FoldPlan.absorbedIndices)
7705
+ // is folded in too, so schema inference sees every sampled per-item
7706
+ // field, not just the single representative occurrence's — see the
7707
+ // `"merges by join key, not position"` regression this restores. Which
7708
+ // primary item an absorbed occurrence belongs to is re-derived from its
7709
+ // own REQUEST (mirroring the original structural scan's own matching),
7710
+ // not its response, since a drill response commonly never echoes the
7711
+ // join field back.
7712
+ const targetEndpointKey = endpointKey(actionSteps[target.drillStepIndex].capture.url);
7713
+ return foldPlan.absorbedIndices.reduce((innerBody, absorbedIndex) => {
7714
+ const absorbedCapture = actionSteps[absorbedIndex]?.capture;
7715
+ if (!absorbedCapture || endpointKey(absorbedCapture.url) !== targetEndpointKey) {
7716
+ return innerBody;
7717
+ }
7718
+ const absorbedDrillItems = objectItemsAtPath(absorbedCapture.responseBody, target.chainArrayPath);
7719
+ const innerPrimaryItems = objectItemsAtPath(innerBody, foldPlan.primaryArrayPath);
7720
+ const absorbedMatchedItem = innerPrimaryItems?.find((item) => findThreadedJoinFields([{ varName: "item", obj: item }], absorbedCapture).length > 0);
7721
+ if (!absorbedDrillItems || !absorbedMatchedItem)
7722
+ return innerBody;
7723
+ return foldOneDrillOccurrence(innerBody, foldPlan.primaryArrayPath, target.joinFields, absorbedDrillItems, absorbedMatchedItem);
7724
+ }, bodyAfterOwnDrill);
7027
7725
  }, initialBody);
7028
7726
  }
7029
7727
  /**
@@ -7738,6 +8436,13 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7738
8436
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
7739
8437
  const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
7740
8438
  const joinAccessor = (field) => scopedAccessor(itemVar, field);
8439
+ // Computed once per fold target instead of once per `parameterizeUrl`
8440
+ // call: `actionSteps` never changes across the calls this target's
8441
+ // chain steps make, so re-deriving this array on every one of
8442
+ // findThreadedJoinFields/isZeroVarianceRepeatCapture/
8443
+ // isGenuineVaryingQueryValue/assertNoFrozenVaryingDrillParams's own
8444
+ // calls below was O(actionSteps.length) work repeated 4x per call.
8445
+ const allCaptures = actionSteps.map((s) => s.capture);
7741
8446
  // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
7742
8447
  // `parameterize` performs — a plain split/join would also rewrite
7743
8448
  // unrelated substrings that happen to contain the join value.
@@ -7762,7 +8467,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7762
8467
  : rawUrl;
7763
8468
  const rawThreadedFields = dedupeThreadedFields([
7764
8469
  ...target.joinFields.map((field) => ({ varName: itemVar, field })),
7765
- ...findThreadedJoinFields(threadingScopes, chainCapture, actionSteps.map((s) => s.capture)),
8470
+ ...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
7766
8471
  ]);
7767
8472
  // Mirrors emitMultiStepExecuteHttp's identical rebind (see
7768
8473
  // isAncestorScoped above): a proven ancestor-scoped drill still
@@ -7787,7 +8492,13 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7787
8492
  : tf,
7788
8493
  }))
7789
8494
  : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
7790
- const result = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
8495
+ // ONE guarded regex-alternation pass over `withBase` for every
8496
+ // threaded field's value, longest first — see substituteThreadedValues's
8497
+ // doc for why a per-field sequential `.replace()` loop here (the bug
8498
+ // this replaces) can splice an unrelated value into an opaque URL
8499
+ // segment or nest a `${...}` placeholder.
8500
+ const valueBindings = threadedFieldPairs
8501
+ .map(({ valueField, accessorField }) => {
7791
8502
  const scopeObj = valueField.varName === itemVar
7792
8503
  ? firstItem
7793
8504
  : ancestorObjByVar.get(valueField.varName);
@@ -7797,12 +8508,31 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7797
8508
  : typeof value === "number" || typeof value === "boolean"
7798
8509
  ? String(value)
7799
8510
  : null;
7800
- if (stringValue === null)
7801
- return acc;
7802
- return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`);
7803
- }, withBase);
8511
+ return stringValue === null
8512
+ ? null
8513
+ : {
8514
+ value: stringValue,
8515
+ replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
8516
+ };
8517
+ })
8518
+ .filter((b) => b !== null);
8519
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
8520
+ // must never have a COINCIDENTAL threaded value spliced into it —
8521
+ // see emitMultiStepExecuteHttp's identical `parameterize` guard
8522
+ // ({@link isGenuineVaryingQueryValue}) for why this is decided
8523
+ // field by field rather than for the whole capture at once.
8524
+ const isProvenInvariant = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
8525
+ const filteredValueBindings = isProvenInvariant
8526
+ ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
8527
+ : valueBindings;
8528
+ const result = substituteThreadedValues(withBase, filteredValueBindings);
7804
8529
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
7805
- assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, withDrillParamBindings, actionSteps.map((s) => s.capture));
8530
+ // See emitMultiStepExecuteHttp's identical guard: the frozen-
8531
+ // varying-param safety net does not apply once the capture is
8532
+ // already proven request-invariant.
8533
+ if (!isProvenInvariant) {
8534
+ assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, withDrillParamBindings, allCaptures);
8535
+ }
7806
8536
  return withDrillParamBindings;
7807
8537
  };
7808
8538
  const chainLines = [];
@@ -7819,6 +8549,10 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7819
8549
  const chainStep = actionSteps[chainIndex];
7820
8550
  if (!chainStep)
7821
8551
  continue;
8552
+ // The zero-variance guard lives inside `parameterizeUrl` itself
8553
+ // (see above) so it can skip only the threaded-value splice while
8554
+ // still letting a spec-declared drillParamBindings substitution
8555
+ // apply.
7822
8556
  const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
7823
8557
  if (itemVarRefPattern.test(url))
7824
8558
  referencesItemVar = true;
@@ -9060,7 +9794,7 @@ async function main() {
9060
9794
  // producible state (see collectDependentDrillDownChainValues).
9061
9795
  const dependentDrillDownChainValues = actionCaptures.length > 1
9062
9796
  ? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
9063
- : new Set();
9797
+ : new Map();
9064
9798
  const stateIndex = actionCaptures.length > 1
9065
9799
  ? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
9066
9800
  : new Map();