@enricai/barnacle 1.12.49 → 1.12.51

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,8 @@ exports.resolveManifestActionSequence = resolveManifestActionSequence;
43
43
  exports.extractActionSequence = extractActionSequence;
44
44
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
45
45
  exports.dedupRedundantSameOperationCaptures = dedupRedundantSameOperationCaptures;
46
+ exports.isRedundantSameEndpointGroup = isRedundantSameEndpointGroup;
47
+ exports.assertBodyFieldSourceNameCorrelates = assertBodyFieldSourceNameCorrelates;
46
48
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
47
49
  exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
48
50
  exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
@@ -1504,6 +1506,13 @@ function resolveManifestActionSequence(runRoot, captures) {
1504
1506
  * a capture whose host fails {@link isAllowedFixtureHost} is dropped too —
1505
1507
  * `isNoiseUrl` alone lets a third-party telemetry/beacon POST masquerade as
1506
1508
  * a submission step, since it can look identical in shape to a real one.
1509
+ * A capture that recurs elsewhere with a byte-identical method/URL/body is
1510
+ * dropped too ({@link isZeroVarianceRepeatCapture}) — a same-host beacon
1511
+ * whose extension and host both look legitimate (e.g. a `.html` sensor
1512
+ * endpoint) still gives itself away by never varying across calls, which
1513
+ * `isNoiseUrl`'s substring/extension checks can't see and which the
1514
+ * structural-isolation pass below can even be fooled by (N identical copies
1515
+ * of the same path "vouch" for each other's tokens).
1507
1516
  *
1508
1517
  * When the flow declares submit patterns, only POSTs matching them survive —
1509
1518
  * this isolates the submission from same-origin page chrome (bootstrap, chatbot,
@@ -1552,6 +1561,8 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1552
1561
  return false;
1553
1562
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
1554
1563
  return false;
1564
+ if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
1565
+ return false;
1555
1566
  if (!matchesSubmit(capture))
1556
1567
  return false;
1557
1568
  if (hasHostProvenance &&
@@ -1573,11 +1584,28 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1573
1584
  // but a 1-2 capture pool has no "everything else" to be isolated from, so
1574
1585
  // skip it there rather than risk flagging a single-endpoint site's own
1575
1586
  // hyphenated path.
1587
+ //
1588
+ // A candidate's own same-pathname repeats only count as evidence against
1589
+ // itself (not for it) when its path carries a densely name-spaced,
1590
+ // marketing/tracking-shaped signal — more than one compound segment's
1591
+ // worth of tokens (e.g. `/site-banner/promotions-widget`, 4 tokens across
1592
+ // two compound segments) — OR when the repeats themselves carry no
1593
+ // business-relevant response state ({@link hasNoBusinessRelevantResponseState}):
1594
+ // a real own-backend endpoint (a polled toggles feed, a paged listing) is
1595
+ // often named with at most one compound segment, so path shape alone can't
1596
+ // tell it apart from a same-shaped, same-host, zero-business-value poll
1597
+ // (an availability/feature-flag ping that answers every call with nothing
1598
+ // a caller could not already know) — both are "one compound segment,
1599
+ // repeats identically." Response content is what actually distinguishes
1600
+ // them, so a candidate whose own repeats carry no business-relevant state
1601
+ // loses the self-vouching exemption regardless of its token count, while a
1602
+ // genuinely data-bearing single-compound-segment endpoint keeps it.
1576
1603
  const structurallyGated = hasHostProvenance && hostGated.length > 2
1577
1604
  ? hostGated.filter(({ capture }, i) => {
1578
1605
  const path = safeUrlPathname(capture.url);
1606
+ const denselyNameSpaced = (0, capture_filters_1.pathStructuralTokens)(path).size > 2 || (0, capture_filters_1.hasNoBusinessRelevantResponseState)(capture);
1579
1607
  const otherPaths = hostGated
1580
- .filter((_, j) => j !== i)
1608
+ .filter((h, j) => denselyNameSpaced ? safeUrlPathname(h.capture.url) !== path : j !== i)
1581
1609
  .map((h) => safeUrlPathname(h.capture.url));
1582
1610
  return !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths);
1583
1611
  })
@@ -1638,6 +1666,24 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1638
1666
  * Exported for tests: this predicate decides what a generated GraphQL plugin
1639
1667
  * will send at a live site.
1640
1668
  */
1669
+ /** REST HTTP methods that write/mutate server state rather than merely
1670
+ * reading it — a capture using one of these is never a re-readable
1671
+ * poll/listing, regardless of how flat its response body looks. */
1672
+ const MUTATING_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1673
+ /** A mutation capture: either GraphQL (identified by its parsed operation
1674
+ * query string starting with `mutation`) or REST (identified by a
1675
+ * non-idempotent HTTP method). Its response is a single mutated object
1676
+ * rather than a re-readable list/flag, so it must never be folded in with
1677
+ * genuinely idempotent reads by {@link isRedundantSameEndpointGroup} — a
1678
+ * flat-response REST POST (e.g. a wizard section save) is exactly as
1679
+ * non-poll-able as a GraphQL mutation, but `capture.query` is always null
1680
+ * for REST, so the GraphQL-only check alone would misclassify it as a
1681
+ * collapsible poll. */
1682
+ function isMutationCapture(capture) {
1683
+ if (capture.query !== null)
1684
+ return /^\s*mutation\b/.test(capture.query);
1685
+ return MUTATING_HTTP_METHODS.has(capture.method.toUpperCase());
1686
+ }
1641
1687
  function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null) {
1642
1688
  const matchesSubmit = compileSubmitMatcher(submitPatterns);
1643
1689
  const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
@@ -1648,7 +1694,7 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
1648
1694
  // only applies once the caller has actually resolved a notion of "own
1649
1695
  // backend" to check against.
1650
1696
  const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
1651
- const isMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
1697
+ const isMutation = isMutationCapture;
1652
1698
  const admitted = captures
1653
1699
  .map((capture, index) => ({ capture, index }))
1654
1700
  .filter(({ capture }) => {
@@ -1656,6 +1702,8 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
1656
1702
  return false;
1657
1703
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
1658
1704
  return false;
1705
+ if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
1706
+ return false;
1659
1707
  if (!matchesSubmit(capture))
1660
1708
  return false;
1661
1709
  if (hasHostProvenance &&
@@ -1688,7 +1736,7 @@ function responseShapeKey(capture) {
1688
1736
  const arrayField = findObjectArrayField(capture.responseBody);
1689
1737
  if (!arrayField)
1690
1738
  return null;
1691
- return `${endpointKey(capture.url)}${arrayField.path.join(".")}`;
1739
+ return `${endpointKey(capture.url)} ${arrayField.path.join(".")}`;
1692
1740
  }
1693
1741
  /**
1694
1742
  * Drops redundant re-issues of the primary GraphQL read operation from a
@@ -1750,9 +1798,28 @@ function collapseRedundantPatches(actions) {
1750
1798
  * counter, unpaired with an explicit page-size key) since that is the common
1751
1799
  * REST shape, unlike GraphQL's paired variables convention. */
1752
1800
  const PAGINATION_FIELD_NAME_PATTERN = /^(page|pagenum|pagenumber|pageindex|pageno|offset|skip|start|cursor)$/i;
1801
+ /** Request-field key names that name known client-generated scaffolding
1802
+ * (a monotonic sequence counter, a correlation/trace id, an idempotency
1803
+ * nonce) rather than genuine payload data. Gates {@link
1804
+ * isFieldValueThreadedElsewhere} on a FLAT (non-array) response's
1805
+ * BODY-carried varying field -- unlike an array-shaped listing/facet
1806
+ * re-query, a mutation's request-body field could just as easily be real
1807
+ * user-entered payload (an address line, a card's last4) that happens
1808
+ * never to be echoed back downstream, so that field additionally requires
1809
+ * its key name to look like scaffolding before trusting the "never echoed"
1810
+ * proof. A varying field that lives ONLY in the URL query string (never in
1811
+ * the body) skips this name requirement instead -- see {@link
1812
+ * isQueryStringOnlyKey}. */
1813
+ const SCAFFOLDING_FIELD_NAME_PATTERN = /^(req|request|correlation|trace|session|idempotency)?[-_]?(seq|id|key|nonce)$/i;
1753
1814
  /** Every query-string and (when JSON-object-shaped) request-body field on a
1754
1815
  * capture, merged into one comparable map -- REST pagination/facet state can
1755
- * live in either depending on the endpoint's own convention. */
1816
+ * live in either depending on the endpoint's own convention. Body fields are
1817
+ * flattened to their full leaf path (`paging.page`, not just `paging`) via
1818
+ * {@link walkAllPrimitiveLeaves} so a nested pagination/facet/scaffolding
1819
+ * object (`{"paging":{"page":1}}`) surfaces as its own comparable leaf
1820
+ * instead of collapsing into one opaque, always-varying JSON-stringified
1821
+ * key -- see {@link fieldKeyLeafName} for how callers recover the bare leaf
1822
+ * name a nested path's own key-name pattern match must test against. */
1756
1823
  function captureRequestFields(capture) {
1757
1824
  const fields = {};
1758
1825
  try {
@@ -1767,7 +1834,9 @@ function captureRequestFields(capture) {
1767
1834
  try {
1768
1835
  const parsed = JSON.parse(capture.requestPostData);
1769
1836
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1770
- Object.assign(fields, parsed);
1837
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
1838
+ fields[path.join(".")] = value;
1839
+ }
1771
1840
  }
1772
1841
  }
1773
1842
  catch {
@@ -1776,28 +1845,223 @@ function captureRequestFields(capture) {
1776
1845
  }
1777
1846
  return fields;
1778
1847
  }
1848
+ /** Recovers the bare field/leaf name (`page`) from a {@link
1849
+ * captureRequestFields} key that may be a dotted nested path (`paging.page`)
1850
+ * -- a flat top-level key is its own leaf name, so this is a no-op for the
1851
+ * pre-existing flat case. Used wherever a key is tested against a NAME
1852
+ * pattern ({@link PAGINATION_FIELD_NAME_PATTERN}, {@link
1853
+ * SCAFFOLDING_FIELD_NAME_PATTERN}) or looked up in {@link
1854
+ * requestAndResponseValuesByKey}'s index, both of which key by bare leaf
1855
+ * name, not by the nested path that disambiguates it during varying-key
1856
+ * detection. */
1857
+ function fieldKeyLeafName(key) {
1858
+ const segments = key.split(".");
1859
+ return segments[segments.length - 1] ?? key;
1860
+ }
1861
+ /** True when `key` is carried ONLY by the URL query string across every
1862
+ * capture in `group` -- never by a JSON request body. Used to widen the
1863
+ * flat-response scaffolding gate past its closed name allowlist for the
1864
+ * common case of a REST poll's tracking param, without extending that same
1865
+ * trust to a mutation's body-carried payload field (see the comment at its
1866
+ * call site in {@link isRedundantSameEndpointGroup}). */
1867
+ function isQueryStringOnlyKey(key, group) {
1868
+ return group.every((a) => {
1869
+ let inQuery = false;
1870
+ try {
1871
+ inQuery = new URL(a.capture.url).searchParams.has(key);
1872
+ }
1873
+ catch {
1874
+ return false;
1875
+ }
1876
+ if (!inQuery)
1877
+ return false;
1878
+ if (!a.capture.requestPostData)
1879
+ return true;
1880
+ try {
1881
+ const parsed = JSON.parse(a.capture.requestPostData);
1882
+ return (parsed === null ||
1883
+ typeof parsed !== "object" ||
1884
+ Array.isArray(parsed) ||
1885
+ !(key in parsed));
1886
+ }
1887
+ catch {
1888
+ return true;
1889
+ }
1890
+ });
1891
+ }
1892
+ /** Per-capture memoization cache for {@link requestAndResponseValuesByKey} --
1893
+ * without it, {@link isFieldValueThreadedElsewhere} re-parses the same
1894
+ * capture's JSON body and re-walks the same response-body leaves once per
1895
+ * (group, varying-key, group-member) combination it's compared against,
1896
+ * which is O(groups * keys * members * allActions) recomputations of
1897
+ * identical work instead of O(allActions). */
1898
+ const requestAndResponseValuesCache = new WeakMap();
1899
+ function requestAndResponseValuesByKey(capture) {
1900
+ const cached = requestAndResponseValuesCache.get(capture);
1901
+ if (cached)
1902
+ return cached;
1903
+ const byKey = new Map();
1904
+ const pathSegments = new Set();
1905
+ const add = (key, value) => {
1906
+ const values = byKey.get(key) ?? new Set();
1907
+ values.add(value);
1908
+ byKey.set(key, values);
1909
+ };
1910
+ try {
1911
+ const url = new URL(capture.url);
1912
+ for (const segment of url.pathname.split("/").filter(Boolean))
1913
+ pathSegments.add(segment);
1914
+ for (const [key, value] of url.searchParams)
1915
+ add(key, value);
1916
+ }
1917
+ catch {
1918
+ // Relative/invalid URLs carry no path/query signal to contribute.
1919
+ }
1920
+ if (capture.requestPostData) {
1921
+ try {
1922
+ const parsed = JSON.parse(capture.requestPostData);
1923
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
1924
+ if (value !== null && path.length > 0)
1925
+ add(path[path.length - 1], String(value));
1926
+ }
1927
+ }
1928
+ catch {
1929
+ // A non-JSON body carries no leaf values to contribute.
1930
+ }
1931
+ }
1932
+ for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
1933
+ if (value !== null && path.length > 0)
1934
+ add(path[path.length - 1], String(value));
1935
+ }
1936
+ const result = { byKey, pathSegments };
1937
+ requestAndResponseValuesCache.set(capture, result);
1938
+ return result;
1939
+ }
1940
+ const fieldValueIndexCache = new WeakMap();
1941
+ function fieldValueIndex(allActions) {
1942
+ const cached = fieldValueIndexCache.get(allActions);
1943
+ if (cached)
1944
+ return cached;
1945
+ const byKeyValue = new Map();
1946
+ const byPathSegment = new Map();
1947
+ for (const { capture } of allActions) {
1948
+ const { byKey, pathSegments } = requestAndResponseValuesByKey(capture);
1949
+ for (const [key, values] of byKey) {
1950
+ const valueMap = byKeyValue.get(key) ?? new Map();
1951
+ byKeyValue.set(key, valueMap);
1952
+ for (const value of values) {
1953
+ const captures = valueMap.get(value) ?? new Set();
1954
+ captures.add(capture);
1955
+ valueMap.set(value, captures);
1956
+ }
1957
+ }
1958
+ for (const segment of pathSegments) {
1959
+ const captures = byPathSegment.get(segment) ?? new Set();
1960
+ captures.add(capture);
1961
+ byPathSegment.set(segment, captures);
1962
+ }
1963
+ }
1964
+ const index = { byKeyValue, byPathSegment };
1965
+ fieldValueIndexCache.set(allActions, index);
1966
+ return index;
1967
+ }
1968
+ /** True when `value` -- one member's own value for `fieldKey`, the sole
1969
+ * varying request field of a same-endpoint group -- shows up under that
1970
+ * SAME field/leaf name in some capture OUTSIDE the group itself (any OTHER,
1971
+ * DIFFERENT-endpoint capture's query/body/response), proving some later
1972
+ * step reads or threads it. False means the value is either scaffolding the
1973
+ * client generated and nothing downstream ever consumes, OR a cursor the
1974
+ * group's OWN members hand to each other -- e.g. page 1's response minting
1975
+ * the exact cursor value page 2's request carries -- which is chained
1976
+ * pagination state, not distinct data a different step depends on, so an
1977
+ * echo confined to sibling occurrences of this SAME same-endpoint group
1978
+ * must not block collapsing it, OR a short scalar (a low-cardinality page
1979
+ * counter) that merely string-equals some unrelated field elsewhere by
1980
+ * coincidence -- requiring the match to occur under the SAME field name is
1981
+ * what tells genuine cross-step threading (an id echoed back under its own
1982
+ * name) apart from that coincidence, since an unrelated field publishing
1983
+ * the same short digit string under a DIFFERENT name proves nothing. This
1984
+ * is the structural signal {@link isRedundantSameEndpointGroup} uses to
1985
+ * widen collapsing past the literal {@link CACHE_BUSTER_QUERY_KEYS}/{@link
1986
+ * PAGINATION_FIELD_NAME_PATTERN} allowlists without hand-enumerating more
1987
+ * key-name shapes. A non-primitive or empty value can't be structurally
1988
+ * proven dead, so it's treated as load-bearing by default. */
1989
+ function isFieldValueThreadedElsewhere(fieldKey, value, groupCaptures, allActions) {
1990
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
1991
+ return true;
1992
+ }
1993
+ const stringValue = String(value);
1994
+ if (stringValue.length === 0)
1995
+ return true;
1996
+ const { byKeyValue, byPathSegment } = fieldValueIndex(allActions);
1997
+ const isOutsideGroup = (captures) => captures !== undefined && [...captures].some((capture) => !groupCaptures.has(capture));
1998
+ return (isOutsideGroup(byKeyValue.get(fieldKey)?.get(stringValue)) ||
1999
+ isOutsideGroup(byPathSegment.get(stringValue)));
2000
+ }
1779
2001
  /**
1780
2002
  * 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
2003
+ * resolves to the same {@link responseShapeKey}, OR (when the response has no
2004
+ * array field anywhere, e.g. a flat poll/flag-style object) every capture is
2005
+ * either a non-mutation, or a mutation whose response body is byte-identical
2006
+ * across every occurrence in the group, whose response independently
2007
+ * resolves via {@link findObjectArrayFieldOrWholeObject}'s whole-object
2008
+ * fallback -- this rules out a mutation POST with a genuinely varying
2009
+ * response (e.g. a wizard section save), whose flat response is a single
2010
+ * mutated object rather than a re-readable poll result, while still
2011
+ * admitting a flat zero-variance re-poll fired via a mutating method (e.g. a
2012
+ * feature-flag/heartbeat check fired via POST) -- AND EVERY request field
2013
+ * that varies once known non-semantic
2014
+ * noise keys ({@link CACHE_BUSTER_QUERY_KEYS}) are excluded from
2015
+ * consideration is EITHER pagination-shaped -- a paged listing/facet re-query
2016
+ * -- OR (when `allActions`, the full capture sequence, is supplied)
2017
+ * independently proven via {@link isFieldValueThreadedElsewhere} to never be
2018
+ * read by any capture OUTSIDE this same-endpoint group -- a cache-buster/
2019
+ * nonce/request-id shape the literal allowlists don't happen to name, OR a
2020
+ * cursor the group's own members hand to each other (page 1's response
2021
+ * minting the exact value page 2's request carries) -- or the group varies
2022
+ * in no field at all -- a polled toggles/feature-flag endpoint re-fired with
2023
+ * an identical request. A group can have any number of varying keys; each
2024
+ * one must clear its own pagination-or-dead check independently, so a page
2025
+ * cursor alongside an unrelated dead cache-buster key still collapses.
2026
+ * Without `allActions` (unit tests exercising this predicate in isolation,
2027
+ * with no flow context to check against) a non-pagination varying key can't
2028
+ * be structurally proven dead, so the group is left untouched -- the same
2029
+ * conservative outcome as before this widening. A group with any varying
2030
+ * field proven read by a DIFFERENT step outside the group (e.g. a per-item
2031
+ * drill's item-id, later echoed into that item's detail request) is
2032
+ * likewise left untouched: that variance carries the distinct per-item
2033
+ * state the existing fold-chain mechanism (`target.chain` in
1792
2034
  * `emitMultiStepExecuteHttp`) already hoists correctly once resolved, and
1793
2035
  * collapsing it here would erase the very state that hoisting depends on.
1794
2036
  */
1795
- function isRedundantSameEndpointGroup(group) {
2037
+ function isRedundantSameEndpointGroup(group, allActions) {
1796
2038
  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;
2039
+ if (shapeKey !== null) {
2040
+ if (!group.every((a) => responseShapeKey(a.capture) === shapeKey))
2041
+ return false;
2042
+ }
2043
+ else {
2044
+ // A flat (non-array) response never resolves a `responseShapeKey`, but a
2045
+ // zero-variance re-poll of a flag/toggle endpoint still needs a shape to
2046
+ // key on -- fall back to the whole-object candidate every group member
2047
+ // must independently resolve to. A GraphQL mutation (detected via the
2048
+ // parsed operation query) is always excluded, since its response is by
2049
+ // definition the result of a state change. A REST capture excluded only
2050
+ // because of its HTTP method (POST/PUT/PATCH/DELETE) is admitted anyway
2051
+ // when every occurrence's response body is byte-identical -- that is
2052
+ // proof the call carries no distinct mutated state at all (a
2053
+ // feature-flag/heartbeat check fired via POST), the same zero-variance
2054
+ // signal {@link isZeroVarianceRepeatCapture} already uses for noise
2055
+ // exclusion, generalized here for the collapse decision.
2056
+ const isGraphQLMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
2057
+ const responsesByteIdentical = group.every((a) => JSON.stringify(a.capture.responseBody) === JSON.stringify(group[0].capture.responseBody));
2058
+ const isFlatObject = (capture) => !isGraphQLMutation(capture) &&
2059
+ (!isMutationCapture(capture) || responsesByteIdentical) &&
2060
+ responseShapeKey(capture) === null &&
2061
+ findObjectArrayFieldOrWholeObject(capture.responseBody) !== null;
2062
+ if (!group.every((a) => isFlatObject(a.capture)))
2063
+ return false;
2064
+ }
1801
2065
  const fieldSets = group.map((a) => captureRequestFields(a.capture));
1802
2066
  const allKeys = new Set();
1803
2067
  for (const fields of fieldSets) {
@@ -1812,7 +2076,30 @@ function isRedundantSameEndpointGroup(group) {
1812
2076
  });
1813
2077
  if (varyingKeys.length === 0)
1814
2078
  return true;
1815
- return varyingKeys.length === 1 && PAGINATION_FIELD_NAME_PATTERN.test(varyingKeys[0]);
2079
+ const groupCaptures = new Set(group.map((a) => a.capture));
2080
+ return varyingKeys.every((key) => {
2081
+ const leafName = fieldKeyLeafName(key);
2082
+ if (PAGINATION_FIELD_NAME_PATTERN.test(leafName))
2083
+ return true;
2084
+ // A flat response's varying field name must still look like scaffolding
2085
+ // UNLESS it lives only in the URL query string (never the JSON body) --
2086
+ // a query-string param is the conventional home for ephemeral
2087
+ // client-generated metadata (cache-busters, correlation ids, poll
2088
+ // ticks) regardless of what the site happens to call it, whereas a
2089
+ // JSON body field is where a mutation's genuine submitted payload (an
2090
+ // address line, a card's last4) lives, and that ambiguity is exactly
2091
+ // why the name-pattern requirement stays for body fields: an unnamed
2092
+ // body field being "never echoed elsewhere" is no proof it's dead, only
2093
+ // that nothing downstream happened to read it back.
2094
+ if (shapeKey === null &&
2095
+ !SCAFFOLDING_FIELD_NAME_PATTERN.test(leafName) &&
2096
+ !isQueryStringOnlyKey(key, group)) {
2097
+ return false;
2098
+ }
2099
+ if (!allActions)
2100
+ return false;
2101
+ return fieldSets.every((fields) => !isFieldValueThreadedElsewhere(leafName, fields[key], groupCaptures, allActions));
2102
+ });
1816
2103
  }
1817
2104
  /**
1818
2105
  * REST counterpart of {@link dedupRedundantSameOperationCaptures}: collapses
@@ -1827,6 +2114,16 @@ function isRedundantSameEndpointGroup(group) {
1827
2114
  * whichever page's response survives — page 1 is what a browsing/drill flow
1828
2115
  * actually saw and drilled into first, so it is the occurrence downstream
1829
2116
  * join values are captured against, not the endpoint's final paged state.
2117
+ *
2118
+ * A real per-item drill can join against ANY page's item, though, not just
2119
+ * page 1's — so before the rest of the group is dropped, every OTHER
2120
+ * occurrence's own array-field items (at the same {@link responseShapeKey}
2121
+ * path proven identical across the group) are concatenated onto the kept
2122
+ * representative's response body. Without this, {@link
2123
+ * detectDrillDownFoldPlan}'s structural scan only ever sees page 1's items
2124
+ * (every later page having just been deleted), so a drill keyed off a
2125
+ * later page's item can never resolve a join match and falls through to a
2126
+ * hardcoded per-capture `httpClient` call instead of folding into the loop.
1830
2127
  */
1831
2128
  function collapseRedundantSameEndpointCaptures(actions) {
1832
2129
  const positionsByGroup = new Map();
@@ -1836,17 +2133,82 @@ function collapseRedundantSameEndpointCaptures(actions) {
1836
2133
  positions.push(i);
1837
2134
  positionsByGroup.set(key, positions);
1838
2135
  });
2136
+ const mergedRepresentativeByPosition = new Map();
1839
2137
  const drop = new Set();
1840
2138
  for (const positions of positionsByGroup.values()) {
1841
2139
  if (positions.length < 2)
1842
2140
  continue;
1843
2141
  const group = positions.map((i) => actions[i]);
1844
- if (!isRedundantSameEndpointGroup(group))
2142
+ if (!isRedundantSameEndpointGroup(group, actions))
1845
2143
  continue;
2144
+ const merged = mergeCollapsedGroupItemsIntoRepresentative(group);
2145
+ if (merged !== null)
2146
+ mergedRepresentativeByPosition.set(positions[0], merged);
1846
2147
  for (const position of positions.slice(1))
1847
2148
  drop.add(position);
1848
2149
  }
1849
- return actions.filter((_, i) => !drop.has(i));
2150
+ return actions
2151
+ .map((a, i) => mergedRepresentativeByPosition.get(i) ?? a)
2152
+ .filter((_, i) => !drop.has(i));
2153
+ }
2154
+ /**
2155
+ * Builds a REPLACEMENT for the kept representative's (`group[0]`) own
2156
+ * {@link ActionCapture} whose response body concatenates every OTHER group
2157
+ * member's array-field items at their shared {@link responseShapeKey} path
2158
+ * onto the representative's own items — see {@link
2159
+ * collapseRedundantSameEndpointCaptures}'s docstring for why. Returns `null`
2160
+ * (no replacement needed) when the group's shape key is `null` (the
2161
+ * flat/zero-variance-poll branch of {@link isRedundantSameEndpointGroup}, with
2162
+ * no array-field path to merge items at) or when no other member actually
2163
+ * contributes an item at that path.
2164
+ *
2165
+ * A NEW response body/capture/action is built rather than mutating the
2166
+ * representative's own objects in place, deliberately: {@link
2167
+ * findAllObjectArrayFields}'s `objectArrayFieldsCache` is keyed on response-body
2168
+ * object IDENTITY under the explicit invariant that a response body is never
2169
+ * mutated after it's produced — mutating `representative.capture.responseBody`
2170
+ * in place would poison that cache with whatever shape happened to be computed
2171
+ * (and cached) from it before this runs, silently discarding the merge for
2172
+ * every caller downstream that hits the stale cache entry instead of the
2173
+ * mutated array.
2174
+ */
2175
+ function mergeCollapsedGroupItemsIntoRepresentative(group) {
2176
+ const representative = group[0];
2177
+ const arrayField = findObjectArrayField(representative.capture.responseBody);
2178
+ if (!arrayField)
2179
+ return null;
2180
+ const mergedItems = arrayField.items.slice();
2181
+ let contributed = false;
2182
+ for (const other of group.slice(1)) {
2183
+ const otherArrayField = findObjectArrayField(other.capture.responseBody);
2184
+ if (!otherArrayField || otherArrayField.path.join(".") !== arrayField.path.join("."))
2185
+ continue;
2186
+ mergedItems.push(...otherArrayField.items);
2187
+ contributed = true;
2188
+ }
2189
+ if (!contributed)
2190
+ return null;
2191
+ const mergedBody = setValueAtPath(representative.capture.responseBody, arrayField.path, mergedItems);
2192
+ return { ...representative, capture: { ...representative.capture, responseBody: mergedBody } };
2193
+ }
2194
+ /**
2195
+ * Returns a shallow-cloned-along-the-path copy of `body` with the value at
2196
+ * `path` replaced by `newValue` — the immutable counterpart to mutating a
2197
+ * response body in place, used by {@link
2198
+ * mergeCollapsedGroupItemsIntoRepresentative} so the object-identity-keyed
2199
+ * {@link objectArrayFieldsCache} never sees the same object with two different
2200
+ * shapes. Only plain-object segments are supported (every real caller's
2201
+ * `arrayField.path` is a DFS-discovered chain of object keys, never an array
2202
+ * index), so an unresolvable segment returns `body` unchanged.
2203
+ */
2204
+ function setValueAtPath(body, path, newValue) {
2205
+ if (path.length === 0)
2206
+ return newValue;
2207
+ if (body === null || typeof body !== "object" || Array.isArray(body))
2208
+ return body;
2209
+ const [head, ...rest] = path;
2210
+ const record = body;
2211
+ return { ...record, [head]: setValueAtPath(record[head], rest, newValue) };
1850
2212
  }
1851
2213
  /**
1852
2214
  * Recursively walks a JSON value and yields every string leaf, paired with its
@@ -1900,6 +2262,199 @@ function jsonBodyLeafValues(requestPostData) {
1900
2262
  }
1901
2263
  return values;
1902
2264
  }
2265
+ /**
2266
+ * Same JSON body walk as {@link jsonBodyLeafValues}, but grouped by the JSON
2267
+ * key/array-index that carries each leaf value — the by-name correlation
2268
+ * `compileActionSteps`' consumption pre-scan needs so a produced value is
2269
+ * only treated as reused when the SOURCE field's name correlates with the
2270
+ * TARGET field it's found in (mirrors {@link
2271
+ * collectDependentDrillDownChainValues}'s sameNameMatch/arrayIndexMatch,
2272
+ * applied here as the general eligibility gate rather than only the
2273
+ * short-value length-floor exemption). Returns null under the same
2274
+ * conditions as `jsonBodyLeafValues`, for the same non-JSON fallback.
2275
+ */
2276
+ function jsonBodyLeafValuesByKey(requestPostData) {
2277
+ if (typeof requestPostData !== "string" || requestPostData.length === 0)
2278
+ return null;
2279
+ const parsed = (() => {
2280
+ try {
2281
+ return JSON.parse(requestPostData);
2282
+ }
2283
+ catch {
2284
+ return undefined;
2285
+ }
2286
+ })();
2287
+ if (parsed === undefined)
2288
+ return null;
2289
+ const byKey = new Map();
2290
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
2291
+ if (value === null || path.length === 0)
2292
+ continue;
2293
+ // An array ELEMENT carries no field name of its own (its last path
2294
+ // segment is a bare numeric index) — the array's own key, one or more
2295
+ // segments up, is the nearest name to correlate against (e.g.
2296
+ // `{"tokens":[12345678]}"` correlates on "tokens", not "0"). A leaf at
2297
+ // the top of an unnamed array (no non-numeric ancestor at all) has
2298
+ // truly no name; it keeps its numeric key so callers can still detect
2299
+ // it as name-free via {@link ARRAY_INDEX_KEY_PATTERN}.
2300
+ const namedSegment = [...path]
2301
+ .reverse()
2302
+ .find((segment) => !ARRAY_INDEX_KEY_PATTERN.test(segment));
2303
+ const key = namedSegment ?? path[path.length - 1];
2304
+ const values = byKey.get(key) ?? new Set();
2305
+ values.add(String(value));
2306
+ byKey.set(key, values);
2307
+ }
2308
+ return byKey;
2309
+ }
2310
+ /** Splits a `camelCase`/`snake_case`/`kebab-case` field name into its
2311
+ * constituent lowercase words, dropping words shorter than 3 characters (an
2312
+ * "id"/"no"/"ok"-shaped word is too generic on its own to prove two field
2313
+ * names name the same concept). Used by {@link keyNamesCorrelate}. */
2314
+ function keyNameWords(key) {
2315
+ return key
2316
+ .split(/(?=[A-Z])|[_\-\s]+/)
2317
+ .map((word) => word.toLowerCase())
2318
+ .filter((word) => word.length >= 3);
2319
+ }
2320
+ /** Words common enough as a naming SUFFIX/PREFIX that sharing one proves
2321
+ * nothing on its own — `startDate`/`endDate` and `firstName`/`lastName` each
2322
+ * share a word under this set's length-≥3 threshold while naming opposite
2323
+ * concepts. Used by {@link keyNamesCorrelate} to require a more specific
2324
+ * word overlap whenever both keys also carry a non-generic word to compare. */
2325
+ const GENERIC_KEY_WORDS = new Set([
2326
+ "name",
2327
+ "date",
2328
+ "type",
2329
+ "code",
2330
+ "email",
2331
+ "phone",
2332
+ "address",
2333
+ "flag",
2334
+ "count",
2335
+ "number",
2336
+ "value",
2337
+ "status",
2338
+ "key",
2339
+ "time",
2340
+ ]);
2341
+ /**
2342
+ * True when a SOURCE field name and a TARGET field name plausibly name the
2343
+ * same concept — an exact match, or a shared word (one a substring of the
2344
+ * other, so a plural/prefix variant like `token`/`tokens` or a compound like
2345
+ * `jobId`/`jobSeqNo` or `draftId`/`applicationDraftId` still correlates)
2346
+ * once both are split into their constituent camelCase words. This is the
2347
+ * general-purpose sibling of {@link collectDependentDrillDownChainValues}'s
2348
+ * stricter exact-key `sameNameMatch`, used by `compileActionSteps`' body-
2349
+ * value consumption gate where the source/target key casing and compounding
2350
+ * legitimately differ across endpoints.
2351
+ *
2352
+ * A shared {@link GENERIC_KEY_WORDS} word is insufficient PROOF when both
2353
+ * keys also carry a more specific, non-generic word — `startDate` and
2354
+ * `endDate` both reduce to `["start"]`/`["end"]` once `date` is set aside,
2355
+ * and those don't overlap, so the pair must NOT correlate despite sharing
2356
+ * `date`. A generic word is only trusted when one side has no non-generic
2357
+ * word to fall back on (e.g. `statusToken` vs. the bare `tokens` key).
2358
+ */
2359
+ function keyNamesCorrelate(sourceKey, targetKey) {
2360
+ if (sourceKey === targetKey)
2361
+ return true;
2362
+ const sourceWords = keyNameWords(sourceKey);
2363
+ const targetWords = keyNameWords(targetKey);
2364
+ const wordsMatch = (a, b) => a.includes(b) || b.includes(a);
2365
+ const sourceSpecific = sourceWords.filter((w) => !GENERIC_KEY_WORDS.has(w));
2366
+ const targetSpecific = targetWords.filter((w) => !GENERIC_KEY_WORDS.has(w));
2367
+ if (sourceSpecific.length > 0 && targetSpecific.length > 0) {
2368
+ return sourceSpecific.some((sw) => targetSpecific.some((tw) => wordsMatch(sw, tw)));
2369
+ }
2370
+ return sourceWords.some((sw) => targetWords.some((tw) => wordsMatch(sw, tw)));
2371
+ }
2372
+ /** A spliced `${...}` accessor/varName whose own derived name is
2373
+ * legitimately name-free — it can never be required to correlate with the
2374
+ * JSON key it lands under. A `payload.<field>` accessor matches its target
2375
+ * BY DEFINITION (the schema field IS the body key), and a bare loop
2376
+ * index/counter (`i`, `i0`, `idx0`) names a position, not a concept. Used by
2377
+ * {@link deriveSplicedSourceName}. */
2378
+ const NAME_FREE_ACCESSOR_PATTERN = /^payload\./;
2379
+ const ARRAY_INDEX_VAR_PATTERN = /^(?:i|idx)\d*$/;
2380
+ /** {@link deriveSplicedSourceName} only derives a correlatable name for an
2381
+ * accessor rooted at one of this file's own fold/drill per-item or
2382
+ * ancestor-scope bindings — `item`/`item0`/... (see {@link
2383
+ * pathToFoldLoopLines}'s `itemVar`) or `g0`/`g1`/... (its `groupVar`). These
2384
+ * are exactly the bindings {@link findThreadedJoinFields} and {@link
2385
+ * applyDrillParamBindings} thread a per-item/per-ancestor FIELD (as opposed
2386
+ * to a whole produced value) into, which is the specific "picks the wrong
2387
+ * source field for a given key" bug class this net closes. A top-level
2388
+ * chain-produced var (`r0`, `token`, a response-derived `const label = ...`)
2389
+ * is threaded by exact VALUE identity across steps, a different, already
2390
+ * value-gated mechanism this net intentionally leaves alone — genuinely
2391
+ * different names on either side of that kind of splice (a rotated `token`
2392
+ * landing under an `auth` key, a `label` re-sent as a `ref`) are expected,
2393
+ * not a bug. */
2394
+ const FOLD_SCOPED_ROOT_PATTERN = /^(?:item\d*|g\d+)\./;
2395
+ /**
2396
+ * Derives the "own name" a spliced `${...}` accessor carries for {@link
2397
+ * assertBodyFieldSourceNameCorrelates} to correlate against its enclosing
2398
+ * JSON key — the last dot-separated path segment (e.g. `g0.identifiers.sku`
2399
+ * -> `sku`, matching {@link keyNamesCorrelate}'s own source-key convention
2400
+ * elsewhere in this file). Returns `null` for anything not rooted at a
2401
+ * fold/drill per-item or ancestor binding (see {@link
2402
+ * FOLD_SCOPED_ROOT_PATTERN}'s docstring for why only those are in scope) or
2403
+ * that is otherwise legitimately name-free (see {@link
2404
+ * NAME_FREE_ACCESSOR_PATTERN} / {@link ARRAY_INDEX_VAR_PATTERN}).
2405
+ */
2406
+ function deriveSplicedSourceName(accessor) {
2407
+ const trimmed = accessor.trim();
2408
+ if (NAME_FREE_ACCESSOR_PATTERN.test(trimmed) || ARRAY_INDEX_VAR_PATTERN.test(trimmed)) {
2409
+ return null;
2410
+ }
2411
+ if (!FOLD_SCOPED_ROOT_PATTERN.test(trimmed))
2412
+ return null;
2413
+ // A non-identifier expression (a template literal, a ternary, a function
2414
+ // call) carries no single derivable name to correlate — only a bare
2415
+ // dotted-path accessor is in scope for this check.
2416
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(trimmed))
2417
+ return null;
2418
+ const segments = trimmed.split(".");
2419
+ return segments[segments.length - 1] ?? null;
2420
+ }
2421
+ /**
2422
+ * Mechanism-agnostic, generation-time safety net closing the door on ANY
2423
+ * fold/drill body-field/source-name correlation bug, regardless of which of
2424
+ * this file's several independent per-item/ancestor threading passes
2425
+ * (fold-item join fields, ancestor-scope rebinding, drill-param binding,
2426
+ * ...) produced the offending splice — architecturally the same kind of
2427
+ * final structural gate as {@link assertNoFrozenVaryingDrillParams}, not a
2428
+ * fix specific to one mechanism. Walks the fully-assembled `renderedBody`
2429
+ * for every `"<key>":${<accessor>}` pair whose accessor is rooted at a
2430
+ * fold/drill binding (see {@link deriveSplicedSourceName}) and requires its
2431
+ * own derived field name to plausibly name the same concept as the
2432
+ * enclosing JSON key ({@link keyNamesCorrelate}) it was spliced under.
2433
+ * Throws a site-agnostic description naming only the key/accessor pair
2434
+ * (never a specific site or plugin) so a SEVENTH recurrence of this bug
2435
+ * class, in a mechanism not yet built, fails generation loudly instead of
2436
+ * silently shipping a body field assigned from an unrelated per-item/
2437
+ * ancestor source field.
2438
+ *
2439
+ * Exported for unit testing — see `applyDrillParamBindings`/
2440
+ * `compileActionSteps` for this file's existing precedent of exporting an
2441
+ * otherwise-internal structural gate so it can be probed directly with a
2442
+ * synthetic `renderedBody` string, independent of the fold-plan-detection
2443
+ * machinery that decides which mechanism produces a given splice.
2444
+ */
2445
+ function assertBodyFieldSourceNameCorrelates(emitterName, renderedBody) {
2446
+ const pattern = /"([^"\\]+)"\s*:\s*"?\$\{([^{}]+)\}/g;
2447
+ for (const match of renderedBody.matchAll(pattern)) {
2448
+ const key = match[1];
2449
+ const accessor = match[2];
2450
+ const sourceName = deriveSplicedSourceName(accessor);
2451
+ if (sourceName === null)
2452
+ continue;
2453
+ if (keyNamesCorrelate(sourceName, key))
2454
+ continue;
2455
+ throw new Error(`${emitterName}: body field "${key}" is spliced from "\${${accessor}}", whose own inferred name ("${sourceName}") doesn't correlate with "${key}" — a field must be assigned from a source whose own name plausibly names the same concept as the key it lands under, not from a value that only coincidentally matches`);
2456
+ }
2457
+ }
1903
2458
  /**
1904
2459
  * Yields every primitive leaf (string, number, boolean, null) in the JSON
1905
2460
  * value with its path. Used by the body-literal substitution pass to find
@@ -3002,7 +3557,7 @@ function* walkSetCookiePairs(rawSetCookie) {
3002
3557
  */
3003
3558
  /** Exported for unit testing — lets tests exercise the produces[] walk (body
3004
3559
  * AND header/cookie origins) directly against synthetic Capture sequences. */
3005
- function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Set()) {
3560
+ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Map()) {
3006
3561
  const index = new Map();
3007
3562
  // Computed structurally off the SAME captures being indexed (no
3008
3563
  // foldReturnSpec available at this layer) — a spec-declared fold's own
@@ -3011,6 +3566,18 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3011
3566
  // call), so this indexes a chain-produced value regardless of whether the
3012
3567
  // fold plan that confirmed it is structural or spec-declared.
3013
3568
  const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
3569
+ // The set of captures a short/force-included value is actually eligible to
3570
+ // be spliced into — the union of whatever `chainForceIncludeValues` and the
3571
+ // caller-supplied `forceIncludeValues` proved for that value. `undefined`
3572
+ // when the value isn't exemption-derived, meaning the eligibility
3573
+ // restriction doesn't apply (see `StateValue.eligibleConsumers`).
3574
+ const eligibleConsumersFor = (value) => {
3575
+ const chainConsumers = chainForceIncludeValues.get(value);
3576
+ const forceConsumers = forceIncludeValues.get(value);
3577
+ if (!chainConsumers && !forceConsumers)
3578
+ return undefined;
3579
+ return new Set([...(chainConsumers ?? []), ...(forceConsumers ?? [])]);
3580
+ };
3014
3581
  // First pass: identify the earliest origin among ACTION captures for each
3015
3582
  // value. Action-only earliest-origin tracking is what compileActionSteps'
3016
3583
  // produces[] check needs — it ignores non-action captures (telemetry GETs,
@@ -3033,9 +3600,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3033
3600
  // floor below: a cookie-sourced value the fold-chain detector already
3034
3601
  // confirmed is threaded into a later hop's request is exactly as
3035
3602
  // 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))
3603
+ const isShort = value.length < MIN_STATE_VALUE_LENGTH;
3604
+ if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
3039
3605
  continue;
3040
3606
  if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
3041
3607
  continue;
@@ -3047,6 +3613,7 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3047
3613
  originIndex: i,
3048
3614
  path: [],
3049
3615
  headerOrigin: { sourceHeader: "set-cookie", cookieName: name },
3616
+ eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
3050
3617
  });
3051
3618
  }
3052
3619
  }
@@ -3073,6 +3640,9 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3073
3640
  originIndex: i,
3074
3641
  path: [],
3075
3642
  headerOrigin: { sourceHeader: headerName },
3643
+ eligibleConsumers: headerValue.length < MIN_STATE_VALUE_LENGTH
3644
+ ? eligibleConsumersFor(headerValue)
3645
+ : undefined,
3076
3646
  });
3077
3647
  }
3078
3648
  }
@@ -3090,9 +3660,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3090
3660
  if (rawValue === null)
3091
3661
  continue;
3092
3662
  const value = String(rawValue);
3093
- if (value.length < MIN_STATE_VALUE_LENGTH &&
3094
- !chainForceIncludeValues.has(value) &&
3095
- !forceIncludeValues.has(value))
3663
+ const isShort = value.length < MIN_STATE_VALUE_LENGTH;
3664
+ if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
3096
3665
  continue;
3097
3666
  if (value.length > MAX_STATE_VALUE_LENGTH)
3098
3667
  continue;
@@ -3117,7 +3686,12 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3117
3686
  !forceIncludeValues.has(value))
3118
3687
  continue;
3119
3688
  if (!index.has(value)) {
3120
- index.set(value, { value, originIndex: i, path });
3689
+ index.set(value, {
3690
+ value,
3691
+ originIndex: i,
3692
+ path,
3693
+ eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
3694
+ });
3121
3695
  }
3122
3696
  }
3123
3697
  }
@@ -3129,6 +3703,51 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
3129
3703
  function isValidJsIdentifier(s) {
3130
3704
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
3131
3705
  }
3706
+ /**
3707
+ * Collapses a JSON path (e.g. `["formData", "firstName"]`) into a single flat
3708
+ * payload field name (`formDataFirstName`). The payload schema `emitContractTs`
3709
+ * builds is always a flat, single-level `z.object({...})` — no pass anywhere
3710
+ * in this file constructs a nested Zod shape — so a `payload.<field>` accessor
3711
+ * must always resolve a single top-level identifier, never a dotted/bracketed
3712
+ * chain. This is the single place that turns a (possibly multi-segment)
3713
+ * request-body path into that field name, so the accessor text emitted into a
3714
+ * template and the field registered in the schema can never diverge: both
3715
+ * always derive from this same flat name. A non-identifier segment (an array
3716
+ * index, a key with punctuation) is sanitized via {@link fieldNameToPascalCase}
3717
+ * rather than dropped, so every path still yields a usable field name.
3718
+ */
3719
+ function pathToPayloadFieldName(path) {
3720
+ return path
3721
+ .map((segment, index) => {
3722
+ const clean = isValidJsIdentifier(segment)
3723
+ ? segment
3724
+ : (fieldNameToPascalCase(segment, null) ?? `Field${index}`);
3725
+ return index === 0 ? clean : clean.charAt(0).toUpperCase() + clean.slice(1);
3726
+ })
3727
+ .join("");
3728
+ }
3729
+ /**
3730
+ * Builds the `payload.<...>` accessor and the field name to register for a
3731
+ * request-body leaf path. Array-index segments (`["sorts", "0"]`) are NOT
3732
+ * flattened: the array itself (`sorts`) is registered as a single field
3733
+ * elsewhere as a whole, and the element access stays a bracket-indexed
3734
+ * `pathToAccessor` suffix into that same field — flattening it (`sorts0`)
3735
+ * would target a field the schema never declares. Only the object-key
3736
+ * segments before the first array index are collapsed via
3737
+ * {@link pathToPayloadFieldName}; segments from the first array index onward
3738
+ * are rendered with {@link pathToAccessor} against that flat prefix.
3739
+ */
3740
+ function payloadAccessorForPath(path) {
3741
+ const arrayIndexPos = path.findIndex((segment) => /^\d+$/.test(segment));
3742
+ if (arrayIndexPos === -1) {
3743
+ const field = pathToPayloadFieldName(path);
3744
+ return { accessor: `payload.${field}`, field };
3745
+ }
3746
+ const objectPath = path.slice(0, arrayIndexPos);
3747
+ const field = objectPath.length > 0 ? pathToPayloadFieldName(objectPath) : (path[0] ?? "");
3748
+ const suffix = pathToAccessor(path.slice(arrayIndexPos), { assertNonNull: true });
3749
+ return { accessor: `payload.${field}${suffix}`, field };
3750
+ }
3132
3751
  /** Derives a valid camelCase identifier from a fixture filename (e.g.
3133
3752
  * "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
3134
3753
  * "acmeMetricsConfig") for use in generated `loadFixture` const lines. */
@@ -3162,6 +3781,26 @@ function pathToAccessor(path, opts = { assertNonNull: true }) {
3162
3781
  .map((p) => isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]${opts.assertNonNull ? "!" : ""}`)
3163
3782
  .join("");
3164
3783
  }
3784
+ /**
3785
+ * Builds a JS access expression reading `path` off `varName`, where `varName`
3786
+ * is a runtime value typed `Record<string, unknown>` (an itemVar, ancestor
3787
+ * loop var, or fold-match candidate) — NOT the real Zod-inferred payload type
3788
+ * {@link pathToAccessor} targets. A single-segment path is a plain `.prop` /
3789
+ * `["prop"]` access, typed `unknown` by the index signature, which compiles
3790
+ * fine wherever the caller only interpolates or `String()`s it. But chaining
3791
+ * a SECOND segment off that same access (`item.identifiers.sku`) fails to
3792
+ * typecheck (TS18046 "is of type 'unknown'") because the index signature's
3793
+ * `unknown` return doesn't itself support further property access — so every
3794
+ * intermediate hop (all but the last segment) is re-asserted back to
3795
+ * `Record<string, unknown>` before the next access.
3796
+ */
3797
+ function unknownValueAccessor(varName, path) {
3798
+ return path.reduce((expr, segment, index) => {
3799
+ const accessor = isValidJsIdentifier(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
3800
+ const isLast = index === path.length - 1;
3801
+ return isLast ? `${expr}${accessor}` : `(${expr}${accessor} as Record<string, unknown>)`;
3802
+ }, varName);
3803
+ }
3165
3804
  /**
3166
3805
  * Builds a nested TypeScript assertion type matching a JSON path. e.g.
3167
3806
  * ["Auth","Token"] -> `{ Auth: { Token: string } }`
@@ -3319,7 +3958,15 @@ function compileActionSteps(actions, stateIndex) {
3319
3958
  // so we only "produce" the values that are actually consumed downstream.
3320
3959
  for (const { capture } of actions) {
3321
3960
  const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
3961
+ const bodyLeafValuesByKey = jsonBodyLeafValuesByKey(capture.requestPostData);
3322
3962
  for (const sv of stateIndex.values()) {
3963
+ // A short value indexed only via the chain/force-include exemption
3964
+ // (see `StateValue.eligibleConsumers`) is a real dependency ONLY for
3965
+ // the specific capture(s) the chain detector proved it threads into —
3966
+ // everywhere else, a coincidental substring match (a digit inside an
3967
+ // unrelated opaque path segment) is not reuse and must not splice.
3968
+ if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
3969
+ continue;
3323
3970
  if (capture.url.includes(sv.value)) {
3324
3971
  usedValues.add(sv.value);
3325
3972
  continue;
@@ -3339,13 +3986,31 @@ function compileActionSteps(actions, stateIndex) {
3339
3986
  if (bodyLeafValues === null) {
3340
3987
  if (capture.requestPostData?.includes(sv.value))
3341
3988
  usedValues.add(sv.value);
3989
+ continue;
3342
3990
  }
3343
- else if (bodyLeafValues.some((leaf) => leaf.includes(sv.value))) {
3991
+ // A produced value's SOURCE key name (the last segment of its response
3992
+ // JSON path) must correlate with the TARGET field it's found under —
3993
+ // same discipline `collectDependentDrillDownChainValues` already
3994
+ // applies to the short-value length-floor exemption (sameNameMatch),
3995
+ // now the universal gate rather than only that narrower one. A
3996
+ // name-free source (a bare array index, or a header/cookie origin with
3997
+ // no body accessor at all) is exempted, exactly as arrayIndexMatch
3998
+ // exempts a name-free source there — there's no name to correlate.
3999
+ const sourceKeyName = sv.headerOrigin ? undefined : sv.path.at(-1);
4000
+ const sourceIsNameFree = sv.headerOrigin !== undefined ||
4001
+ sourceKeyName === undefined ||
4002
+ ARRAY_INDEX_KEY_PATTERN.test(sourceKeyName);
4003
+ const matches = sourceIsNameFree
4004
+ ? bodyLeafValues.some((leaf) => leaf.includes(sv.value))
4005
+ : [...(bodyLeafValuesByKey?.entries() ?? [])].some(([targetKey, leaves]) => keyNamesCorrelate(sourceKeyName, targetKey) &&
4006
+ [...leaves].some((leaf) => leaf.includes(sv.value)));
4007
+ if (matches)
3344
4008
  usedValues.add(sv.value);
3345
- }
3346
4009
  }
3347
4010
  for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
3348
4011
  for (const sv of stateIndex.values()) {
4012
+ if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
4013
+ continue;
3349
4014
  if (!headerValue.includes(sv.value))
3350
4015
  continue;
3351
4016
  usedValues.add(sv.value);
@@ -3447,7 +4112,7 @@ function compileActionSteps(actions, stateIndex) {
3447
4112
  name = `${pathToVarName(path)}${suffix}`;
3448
4113
  }
3449
4114
  seenNames.add(name);
3450
- produces.push({ kind: "body", name, path });
4115
+ produces.push({ kind: "body", name, path, eligibleConsumers: sv.eligibleConsumers });
3451
4116
  }
3452
4117
  }
3453
4118
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -3522,37 +4187,251 @@ function resolveResponsePathValue(responseBody, path) {
3522
4187
  ? String(cursor)
3523
4188
  : null;
3524
4189
  }
4190
+ /** Builds the shared word-boundary-guarded, longest-value-first alternation
4191
+ * pattern used by both {@link interpolateStateValues} and
4192
+ * {@link substituteThreadedValues}: a value can never win a match at a position
4193
+ * a longer value also matches, and a value flanked by an alphanumeric — or by a
4194
+ * `-`/`.` itself flanked by an alphanumeric — never matches inside an unrelated
4195
+ * opaque token (e.g. splicing a "12" into a hyphen-joined "SKU-12-9F3Z"
4196
+ * segment) while a standalone occurrence (e.g. "/items/42/") still matches. */
4197
+ function buildValueAlternationPattern(sortedValues) {
4198
+ return new RegExp(`(?<![A-Za-z0-9][-.])\\b(?:${sortedValues.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b(?![-.][A-Za-z0-9])`, "g");
4199
+ }
4200
+ /** Normalizes a JSON key or a produced var name to a bare comparable token —
4201
+ * lowercased, non-alphanumeric stripped, trailing disambiguation digits
4202
+ * (the `seenNames`-collision suffix `compileActionSteps` appends, e.g.
4203
+ * `displayOrder2`) dropped — so `sortOrder` and `SortOrder`/`sort_order`/
4204
+ * `sortOrder2` all normalize to the same token for {@link keysCorrelate}. */
4205
+ function normalizeCorrelationToken(raw) {
4206
+ return raw
4207
+ .toLowerCase()
4208
+ .replace(/[^a-z0-9]/g, "")
4209
+ .replace(/\d+$/, "");
4210
+ }
4211
+ /**
4212
+ * True when `sourceName` (a produced state var's own field name, e.g. the
4213
+ * `p.name` a produce was declared under) plausibly names the same coordinate
4214
+ * as `targetKey` (the JSON key a candidate splice would land under). Used to
4215
+ * gate {@link interpolateStateValues}'s substitution of a value that was only
4216
+ * indexed via the chain/force-include short-value exemption (see
4217
+ * `StateValue.eligibleConsumers`) — such a value cleared the ELIGIBILITY gate
4218
+ * via a name-free signal (a bare array index, a URL path segment), which says
4219
+ * nothing about whether the specific body key it's about to be spliced into
4220
+ * has anything to do with its own origin field. Requiring exact-token or
4221
+ * meaningful-substring correlation here is the second, independent check the
4222
+ * report calls for: an eligible value must still name/shape-correlate with
4223
+ * its actual splice target, not just have cleared chain detection for SOME
4224
+ * key in the target capture.
4225
+ */
4226
+ function keysCorrelate(sourceName, targetKey) {
4227
+ const a = normalizeCorrelationToken(sourceName);
4228
+ const b = normalizeCorrelationToken(targetKey);
4229
+ if (a.length === 0 || b.length === 0)
4230
+ return false;
4231
+ if (a === b)
4232
+ return true;
4233
+ const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
4234
+ return shorter.length >= 3 && longer.includes(shorter);
4235
+ }
4236
+ /** Finds the JSON key immediately governing the value at `matchStart` in a
4237
+ * (possibly partially-rewritten) JSON body template — the nearest preceding
4238
+ * `"key":` slot opener. Textual, not AST-based (matching this file's existing
4239
+ * `.split(target).join(replacement)` discipline elsewhere), which is
4240
+ * sufficient here: it only needs to identify the enclosing leaf's own key for
4241
+ * {@link keysCorrelate}'s name check, not to fully parse the document. */
4242
+ function findEnclosingJsonKey(text, matchStart) {
4243
+ const keyPattern = /"([^"\\]+)"\s*:\s*"?/g;
4244
+ let lastKey = null;
4245
+ let lastValueStart = -1;
4246
+ for (const m of text.matchAll(keyPattern)) {
4247
+ if (m.index === undefined || m.index >= matchStart)
4248
+ break;
4249
+ lastKey = m[1] ?? null;
4250
+ lastValueStart = m.index + m[0].length;
4251
+ }
4252
+ return lastValueStart <= matchStart ? lastKey : null;
4253
+ }
4254
+ /**
4255
+ * Finds every `${...}` span in `text` by brace-depth counting rather than a
4256
+ * non-nesting regex, so an ALREADY-nested placeholder (e.g. one produced by an
4257
+ * earlier, buggier pass, or in principle any `${a${b}c}` shape) is reported as
4258
+ * ONE span covering the outer `${` through its true matching `}` — never as
4259
+ * just the innermost `${b}` — because a regex excluding `{`/`}` from its body
4260
+ * (`/\$\{[^{}]*\}/`) cannot see past the first inner brace and would otherwise
4261
+ * leave the outer span's `a`/`c` text unprotected for a later pass to splice
4262
+ * into, compounding the corruption instead of guarding against it.
4263
+ */
4264
+ function findBalancedPlaceholderSpans(text) {
4265
+ const spans = [];
4266
+ let searchFrom = 0;
4267
+ while (searchFrom < text.length) {
4268
+ const start = text.indexOf("${", searchFrom);
4269
+ if (start === -1)
4270
+ break;
4271
+ let depth = 1;
4272
+ let cursor = start + 2;
4273
+ while (cursor < text.length && depth > 0) {
4274
+ if (text[cursor] === "{")
4275
+ depth++;
4276
+ else if (text[cursor] === "}")
4277
+ depth--;
4278
+ cursor++;
4279
+ }
4280
+ spans.push([start, cursor]);
4281
+ searchFrom = cursor;
4282
+ }
4283
+ return spans;
4284
+ }
4285
+ /**
4286
+ * Runs `pattern` over `text`, replacing each match via `bindingByValue`,
4287
+ * EXCEPT a match that overlaps a `${...}` placeholder already present in
4288
+ * `text`. A single call's own matches never overlap each other (`replace`/
4289
+ * `matchAll` scan left-to-right without revisiting consumed text), so within
4290
+ * one call this only matters when `text` is the OUTPUT of an earlier call on
4291
+ * this same mechanism — e.g. a fold's per-item pass re-running over Pass 1's
4292
+ * already-interpolated URL/header/body text with a different (per-item)
4293
+ * binding table. Without this guard, that second pass's value-equality match
4294
+ * has no way to know a span it's about to touch is actually the FIRST pass's
4295
+ * `${varName}` placeholder for an entirely different producer/consumer
4296
+ * relationship — it just sees literal characters that happen to equal one of
4297
+ * its own bound values (e.g. the digits inside `${warehouseSlot47}`,
4298
+ * coincidentally also this fold item's own field value) and splices its
4299
+ * replacement in anyway, producing an invalidly-nested `${a${b}c}` literal
4300
+ * that resolves to neither value at runtime. Skipping any match that overlaps
4301
+ * an existing placeholder keeps every substitution scoped to the pass that
4302
+ * actually owns that span, which is the producer/consumer relationship this
4303
+ * mechanism is supposed to encode — and guarantees the output can never open
4304
+ * a `${` before a prior `${...}` closes.
4305
+ *
4306
+ * `keyCorrelationGuard`, when supplied, additionally requires a JSON-key
4307
+ * name/shape correlation before splicing a value flagged `restrictedValues`
4308
+ * — see {@link keysCorrelate}'s docstring for why: such a value cleared
4309
+ * ELIGIBILITY via a name-free chain signal (a bare array index, a URL path
4310
+ * segment) that says nothing about the specific key it's about to land
4311
+ * under. A match on a value not in `restrictedValues` (an unrestricted
4312
+ * payload accessor or a normally-length-qualified state value) is spliced
4313
+ * exactly as before — this guard only closes the coincidence-threading gap
4314
+ * for values that needed the short-value exemption to be indexed at all.
4315
+ */
4316
+ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue, keyCorrelationGuard) {
4317
+ const protectedSpans = findBalancedPlaceholderSpans(text);
4318
+ const matches = [...text.matchAll(pattern)].filter((match) => !protectedSpans.some(([spanStart, spanEnd]) => match.index < spanEnd && match.index + match[0].length > spanStart));
4319
+ const hasCorrelatedTargetByValue = new Map();
4320
+ if (keyCorrelationGuard) {
4321
+ for (const match of matches) {
4322
+ const value = match[0];
4323
+ if (!keyCorrelationGuard.restrictedValues.has(value))
4324
+ continue;
4325
+ if (keyCorrelationGuard.unconditionalValues.has(value))
4326
+ continue;
4327
+ if (hasCorrelatedTargetByValue.get(value))
4328
+ continue;
4329
+ const sourceName = keyCorrelationGuard.sourceNameByValue.get(value);
4330
+ const targetKey = findEnclosingJsonKey(text, match.index);
4331
+ if (sourceName !== undefined && targetKey !== null && keysCorrelate(sourceName, targetKey)) {
4332
+ hasCorrelatedTargetByValue.set(value, true);
4333
+ }
4334
+ }
4335
+ }
4336
+ let result = "";
4337
+ let cursor = 0;
4338
+ for (const match of matches) {
4339
+ const start = match.index;
4340
+ const end = start + match[0].length;
4341
+ const value = match[0];
4342
+ const isGated = keyCorrelationGuard?.restrictedValues.has(value) &&
4343
+ (keyCorrelationGuard.unconditionalValues.has(value) || hasCorrelatedTargetByValue.get(value));
4344
+ if (isGated) {
4345
+ const sourceName = keyCorrelationGuard?.sourceNameByValue.get(value);
4346
+ const targetKey = findEnclosingJsonKey(text, start);
4347
+ if (sourceName === undefined || targetKey === null || !keysCorrelate(sourceName, targetKey)) {
4348
+ result += text.slice(cursor, end);
4349
+ cursor = end;
4350
+ continue;
4351
+ }
4352
+ }
4353
+ result += text.slice(cursor, start) + (bindingByValue.get(value) ?? value);
4354
+ cursor = end;
4355
+ }
4356
+ return result + text.slice(cursor);
4357
+ }
3525
4358
  /**
3526
4359
  * Replaces occurrences of state values in `template` with `${varName}`
3527
4360
  * interpolations. Returns a JS template-literal string fragment (no backticks).
3528
4361
  *
3529
4362
  * 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}}`);
4363
+ * produced value's concrete string, and map it to the produces[].name, then
4364
+ * merge in the payload accessors (state wins on collision — e.g. when an
4365
+ * Auth.UserName response value equals the user's submitted email). A single
4366
+ * word-boundary-anchored regex alternation (longest value first, so an 8-char
4367
+ * prefix never shadows the 36-char UUID it's a prefix of) is matched over the
4368
+ * ORIGINAL template text exactly once — see {@link buildValueAlternationPattern}
4369
+ * for the anchoring guarantee and {@link replaceGuardedAgainstExistingPlaceholders}
4370
+ * for why a match overlapping an already-emitted `${...}` is skipped rather
4371
+ * than spliced into.
4372
+ *
4373
+ * `isJsonBody` gates the additional {@link keysCorrelate} check
4374
+ * `replaceGuardedAgainstExistingPlaceholders` applies to `restricted` state
4375
+ * bindings (see {@link StateVarBinding}) — a value indexed only via the
4376
+ * chain/force-include short-value exemption must also name/shape-correlate
4377
+ * with the JSON key it's about to be spliced into, not merely have cleared
4378
+ * eligibility for SOME key in this capture. Only a JSON request body has
4379
+ * "keys" to correlate against; a URL or a raw header value has none, and a
4380
+ * bare-value splice into either is exactly the name-free URL-path-segment
4381
+ * threading the eligibility gate already intends to allow, so callers
4382
+ * rendering those pass `false` (the default).
4383
+ */
4384
+ function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map(), isJsonBody = false) {
4385
+ const stateBindings = deriveStateVarByValue(priorSteps, targetCapture);
4386
+ const bindingByValue = new Map();
4387
+ for (const [value, accessor] of payloadAccessorByValue) {
4388
+ bindingByValue.set(value, `\${${accessor}}`);
4389
+ }
4390
+ const restrictedValues = new Set();
4391
+ const unconditionalValues = new Set();
4392
+ const sourceNameByValue = new Map();
4393
+ for (const [value, binding] of stateBindings) {
4394
+ bindingByValue.set(value, `\${${binding.varName}}`);
4395
+ sourceNameByValue.set(value, binding.sourceName);
4396
+ if (binding.restricted)
4397
+ restrictedValues.add(value);
4398
+ if (binding.unconditional)
4399
+ unconditionalValues.add(value);
4400
+ }
4401
+ if (bindingByValue.size === 0)
4402
+ return template;
4403
+ const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
4404
+ const pattern = buildValueAlternationPattern(sortedValues);
4405
+ return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue, isJsonBody && restrictedValues.size > 0
4406
+ ? { restrictedValues, sourceNameByValue, unconditionalValues }
4407
+ : undefined);
4408
+ }
4409
+ /**
4410
+ * Rewrites every occurrence of a set of literal values to their accessor
4411
+ * expressions in ONE pass over `text`, matching {@link interpolateStateValues}'s
4412
+ * guarded shape — see {@link buildValueAlternationPattern} for the anchoring
4413
+ * guarantee. A per-value sequential `.replace()` loop would re-scan the
4414
+ * PROGRESSIVELY MUTATED result on every iteration, letting one field's inserted
4415
+ * `${...}` replacement text land inside a position a later field's regex still
4416
+ * matches — producing a nested `${...${...}}` placeholder. Doing it once over
4417
+ * the original text closes that class of bug within this call; when `text` is
4418
+ * itself the already-interpolated output of an EARLIER call on this mechanism
4419
+ * (a fold's per-item pass over Pass 1's rendered URL/header/body), {@link
4420
+ * replaceGuardedAgainstExistingPlaceholders} closes the same class of bug
4421
+ * across calls by refusing to match inside a placeholder that call already
4422
+ * emitted.
4423
+ */
4424
+ function substituteThreadedValues(text, bindings) {
4425
+ if (bindings.length === 0)
4426
+ return text;
4427
+ const bindingByValue = new Map();
4428
+ for (const { value, replacement } of bindings) {
4429
+ if (!bindingByValue.has(value))
4430
+ bindingByValue.set(value, replacement);
3554
4431
  }
3555
- return result;
4432
+ const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
4433
+ const pattern = buildValueAlternationPattern(sortedValues);
4434
+ return replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue);
3556
4435
  }
3557
4436
  /**
3558
4437
  * Finds the request-body coordinates a PRODUCING step must source from the
@@ -3720,25 +4599,43 @@ function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScope
3720
4599
  * before giving up. Real captures observed a doubly-encoded value (`%2520`); the
3721
4600
  * extra headroom costs one cheap `decodeURIComponent` per level and stops runaway. */
3722
4601
  const MAX_URL_PARAM_DECODE_DEPTH = 3;
3723
- /**
3724
- * Maps each response-produced value to the `${var}` name later steps thread it as.
3725
- * Shared by {@link interpolateStateValues} (the body/URL substitution) and the
3726
- * URL-param pass, so a threaded coordinate (e.g. a jobId a prior step produced)
3727
- * resolves to the same var in both — one source of truth, they can never diverge.
3728
- *
3729
- * Header/cookie-origin produces are skipped: they have no body path and their
3730
- * value never appears as a literal in a URL/body template (http-client's `bind`
3731
- * forwards it directly as a request header), so there is nothing to interpolate.
3732
- */
3733
- function deriveStateVarByValue(priorSteps) {
4602
+ function deriveStateVarByValue(priorSteps, targetCapture) {
3734
4603
  const varNameByValue = new Map();
3735
4604
  for (const step of priorSteps) {
3736
4605
  for (const p of step.produces) {
3737
4606
  if (p.kind === "header")
3738
4607
  continue;
4608
+ if (p.eligibleConsumers && !p.eligibleConsumers.has(targetCapture))
4609
+ continue;
3739
4610
  const value = resolveResponsePathValue(step.capture.responseBody, p.path);
3740
- if (value !== null)
3741
- varNameByValue.set(value, p.name);
4611
+ if (value !== null) {
4612
+ // A source path with NO identifier segment anywhere (every segment a
4613
+ // bare array index — e.g. a top-level array response `[42]`, path
4614
+ // `["0"]`) has no name of its own to correlate against at all; that's
4615
+ // the genuinely name-free case `collectDependentDrillDownChainValues`'s
4616
+ // arrayIndexMatch exists for (see its docstring), not a named field
4617
+ // that merely sits inside an array. Only a source WITH a real
4618
+ // ancestor name (e.g. `flags` in `["flags","0"]`) must correlate —
4619
+ // its name existing at all is exactly the signal a target key
4620
+ // coincidence has to match to be a genuine splice, not a bare
4621
+ // array-index/path-segment eligibility coincidence.
4622
+ const sourceHasName = p.path.some((segment) => isValidJsIdentifier(segment));
4623
+ varNameByValue.set(value, {
4624
+ varName: p.name,
4625
+ sourceName: p.name,
4626
+ // Requiring name/shape correlation at the splice site is not just
4627
+ // for the chain/force-include short-value exemption — a
4628
+ // naturally-length-qualified value that legitimately correlates
4629
+ // with ONE downstream key (which is what got it produced at all,
4630
+ // see `compileActionSteps`' `keyNamesCorrelate` pre-scan) must not
4631
+ // also splice into an unrelated, differently-named key that merely
4632
+ // coincides in value. `sourceHasName` gates this the same way it
4633
+ // gates the chain-derived case: a name-free source (a bare array
4634
+ // index) has nothing to correlate, so it stays unrestricted.
4635
+ restricted: sourceHasName,
4636
+ unconditional: p.eligibleConsumers !== undefined && sourceHasName,
4637
+ });
4638
+ }
3742
4639
  }
3743
4640
  }
3744
4641
  return varNameByValue;
@@ -3861,21 +4758,22 @@ function applyUrlParamPayloadSubstitutions(template, parsedBody, bindings) {
3861
4758
  * top-level keys also become caller-supplied payload fields. Used in Phase F
3862
4759
  * to parameterize fields like SourceCode that appear in r1's body but not
3863
4760
  * r0's (inputBody).
4761
+ *
4762
+ * Registration is keyed per (key, value) pair, not per key alone: a field
4763
+ * name reused across two-plus steps with a DIFFERENT literal value on each
4764
+ * occurrence must have EVERY one of its own occurrences registered and
4765
+ * substituted, not just whichever occurrence this function's own body-array
4766
+ * walk reaches first. A first-seen-value-wins table would only ever match
4767
+ * (and thus only ever register) the ONE step whose literal happens to equal
4768
+ * that first-seen value — every other step's own `"key":<its own value>`
4769
+ * text would silently never become a `${payload.key}` reference at all here,
4770
+ * even though the field genuinely IS one this step's own request sends as
4771
+ * caller-supplied data.
3864
4772
  */
3865
4773
  function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map()) {
3866
4774
  const merged = [];
3867
- const seenKeys = new Set();
3868
- // Track keys from inputBody (r0) separately so we know which ones are NEW.
3869
- // Only NEW keys need to be added to discovered-form-fields — inputBody's
3870
- // own keys stay internal to the site request template, not the public
3871
- // payload schema (see basePayloadSchemaExpr in emitContractTs).
3872
- if (inputBody !== null && typeof inputBody === "object" && !Array.isArray(inputBody)) {
3873
- for (const { path } of walkAllPrimitiveLeaves(inputBody)) {
3874
- if (path.length === 1)
3875
- seenKeys.add(path[0]);
3876
- }
3877
- }
3878
- const inputBodyKeys = new Set(seenKeys);
4775
+ const seenPairs = new Set();
4776
+ const seenValueByKey = new Map();
3879
4777
  const allBodies = [inputBody, ...additionalBodies];
3880
4778
  for (const body of allBodies) {
3881
4779
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
@@ -3887,26 +4785,45 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
3887
4785
  const key = path[0];
3888
4786
  if (!isValidJsIdentifier(key))
3889
4787
  continue;
3890
- if (seenKeys.has(key) && body !== inputBody)
4788
+ if (value === null)
3891
4789
  continue;
3892
- // For inputBody first pass: don't dedupe (we need all values).
3893
- if (body === inputBody && !inputBodyKeys.has(key))
4790
+ // Dedupe identical (key, value) pairs only — a repeated occurrence of
4791
+ // the SAME literal value for a key across bodies needs no second
4792
+ // substitution pass, but a DIFFERENT value under the same key is its
4793
+ // own distinct step's own occurrence and must still get one. EXCEPT a
4794
+ // pagination-cursor-shaped key ({@link PAGINATION_FIELD_NAME_PATTERN},
4795
+ // e.g. `page`/`offset`/`cursor`) whose value differs from the
4796
+ // first-seen one: that shape is a same-endpoint re-query bump (see
4797
+ // {@link isRedundantSameEndpointGroup}'s pagination-vs-payload
4798
+ // distinction), not a genuinely different step's own caller data —
4799
+ // aliasing both occurrences to the SAME `payload.<key>` accessor would
4800
+ // make the generated re-query call replay the FIRST page's request
4801
+ // instead of advancing to the next one, so the later occurrence stays
4802
+ // an unsubstituted literal, matching this key's pre-fix behavior.
4803
+ const priorValue = seenValueByKey.get(key);
4804
+ if (priorValue !== undefined &&
4805
+ priorValue !== value &&
4806
+ PAGINATION_FIELD_NAME_PATTERN.test(key)) {
3894
4807
  continue;
3895
- seenKeys.add(key);
3896
- if (value === null)
4808
+ }
4809
+ seenValueByKey.set(key, value);
4810
+ const pairKey = `${key} ${typeof value} ${value}`;
4811
+ if (seenPairs.has(pairKey))
3897
4812
  continue;
4813
+ seenPairs.add(pairKey);
3898
4814
  merged.push([key, value]);
3899
- // Record only the NEW keys (not in inputBody) so the contract emitter
3900
- // can add them to the payload schema — inputBody's own keys stay
3901
- // internal to the site request template (see basePayloadSchemaExpr).
3902
- if (!inputBodyKeys.has(key)) {
3903
- if (typeof value === "string")
3904
- outAdditionalKeys.set(key, "string");
3905
- else if (typeof value === "number")
3906
- outAdditionalKeys.set(key, "number");
3907
- else if (typeof value === "boolean")
3908
- outAdditionalKeys.set(key, "boolean");
3909
- }
4815
+ // Record every substituted key, including inputBody's own, so the
4816
+ // contract emitter can add it to the payload schema. inputBody keys
4817
+ // that ARE covered by basePayloadSchemaExpr (the ApplicantContactSchema
4818
+ // case) are filtered back out at the emitContractTs merge point via
4819
+ // isReservedByApplicantContactSchema — this function has no visibility
4820
+ // into that flag, so it must not special-case inputBody's own keys.
4821
+ if (typeof value === "string")
4822
+ outAdditionalKeys.set(key, "string");
4823
+ else if (typeof value === "number")
4824
+ outAdditionalKeys.set(key, "number");
4825
+ else if (typeof value === "boolean")
4826
+ outAdditionalKeys.set(key, "boolean");
3910
4827
  }
3911
4828
  }
3912
4829
  let result = template;
@@ -4102,11 +5019,17 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
4102
5019
  // then fall back to the bare last segment.
4103
5020
  const lastSegment = segments[segments.length - 1];
4104
5021
  const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
4105
- const optionalBracketAccessor = segments
4106
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
4107
- .join("");
5022
+ // Every intermediate hop off the (unknown-typed) candidate needs
5023
+ // re-asserting back to `Record<string, unknown>` before the next bracket
5024
+ // access — see {@link unknownValueAccessor}'s doc for why a bare chain of
5025
+ // `?.[...]` accessors fails to typecheck past the first segment.
5026
+ const nestedAccessor = segments.reduce((expr, segment, index) => {
5027
+ const isLast = index === segments.length - 1;
5028
+ const accessor = index === 0 ? bracket(segment) : `?.[${JSON.stringify(segment)}]`;
5029
+ return isLast ? `${expr}${accessor}` : `(${expr}${accessor} as Record<string, unknown>)`;
5030
+ }, varName);
4108
5031
  return segments.length > 1
4109
- ? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
5032
+ ? `(${nestedAccessor} ?? ${varName}${bracket(lastSegment)})`
4110
5033
  : `${varName}${bracket(lastSegment)}`;
4111
5034
  };
4112
5035
  const joinCondition = target.joinFields
@@ -4129,7 +5052,18 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
4129
5052
  }
4130
5053
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
4131
5054
  * without going through the full emitContractTs pipeline. */
4132
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null) {
5055
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null,
5056
+ /**
5057
+ * PascalCase plugin name used to cast the final `return { data: ... }`
5058
+ * back to `${pascalName}Response` — every intermediate `httpClient` call
5059
+ * this function emits is bound `as Record<string, unknown>` so per-item
5060
+ * fold/merge code can probe arbitrary fields, but that cast otherwise
5061
+ * widens the returned primary var past the richer response type the
5062
+ * caller's own schema inference already promised, which fails to
5063
+ * typecheck. `null` (the test-facing default) skips the cast, preserving
5064
+ * prior output for callers that don't exercise the full pipeline.
5065
+ */
5066
+ pascalName = null) {
4133
5067
  // Walk the first action's request body to map each leaf string value to its
4134
5068
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
4135
5069
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -4147,8 +5081,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4147
5081
  for (const { value, path } of walkStringLeaves(inputBody)) {
4148
5082
  if (value.length < MIN_STATE_VALUE_LENGTH)
4149
5083
  continue;
4150
- const accessor = `payload${pathToAccessor(path)}`;
5084
+ const { accessor, field: accessorField } = payloadAccessorForPath(path);
4151
5085
  payloadAccessorByValue.set(value, accessor);
5086
+ if (isValidJsIdentifier(accessorField))
5087
+ outDiscoveredFields.add(accessorField);
4152
5088
  // Phase F: register a lowercase variant for UUID-shaped values so case-
4153
5089
  // variant URL path segments (e.g. r9 echoes the requisition UUID in
4154
5090
  // lowercase even though r0's body had it uppercase) still get
@@ -4368,7 +5304,17 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4368
5304
  const step = actions[i];
4369
5305
  const cap = step.capture;
4370
5306
  const prior = actions.slice(0, i);
4371
- const url = interpolateStateValues(cap.url, prior, payloadAccessorByValue);
5307
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
5308
+ // must never be treated as an interpolation target at all: its URL is
5309
+ // provably fixed across every occurrence, so any state-value splice into
5310
+ // it can only be a coincidental match, never a real dependency — the same
5311
+ // failure shape already fixed for GET responses (see `indexStateValues`'s
5312
+ // isGet UUID-only floor) and for the fold/drill per-item pass. Rendering
5313
+ // its exact literal URL makes this hold even when a value's own
5314
+ // length/chain-eligibility scoping doesn't happen to catch the coincidence.
5315
+ const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, actions.map((a) => a.capture))
5316
+ ? cap.url
5317
+ : interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue);
4372
5318
  // Form-schema substitution runs first on the raw recon body so its
4373
5319
  // field-id-anchored matches see the original JSON. State-threading and
4374
5320
  // payload key-value passes then run on top. Option-id substitution runs
@@ -4404,7 +5350,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4404
5350
  // must stay reachable for state threading, not get frozen as caller data.
4405
5351
  const rawBodyWithStructuredSubs = parsedBody !== null
4406
5352
  ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
4407
- ...deriveStateVarByValue(prior).keys(),
5353
+ ...deriveStateVarByValue(prior, cap).keys(),
4408
5354
  ...(joinFieldValuesByStep.get(i) ?? []),
4409
5355
  ]))
4410
5356
  : rawBodyWithFormSubs;
@@ -4431,14 +5377,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4431
5377
  if (binding.producerIndex === i)
4432
5378
  urlParamBindings.set(value, binding.accessor);
4433
5379
  }
4434
- for (const [value, varName] of deriveStateVarByValue(prior)) {
4435
- urlParamBindings.set(value, varName);
5380
+ for (const [value, binding] of deriveStateVarByValue(prior, cap)) {
5381
+ urlParamBindings.set(value, binding.varName);
4436
5382
  }
4437
5383
  const rawBodyWithUrlParams = parsedBody !== null
4438
5384
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
4439
5385
  : rawBodyWithProducerBoundary;
4440
5386
  const bodyAfterStateAndKv = rawBodyWithUrlParams
4441
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
5387
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
4442
5388
  : "";
4443
5389
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
4444
5390
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -4467,7 +5413,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4467
5413
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
4468
5414
  const lower = k.toLowerCase();
4469
5415
  if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
4470
- perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
5416
+ perCallHeaders[k] = interpolateStateValues(v, prior, cap, payloadAccessorByValue);
4471
5417
  }
4472
5418
  }
4473
5419
  // G1: emit baseUrl-derived headers (Origin, Referer) per-call from
@@ -4569,7 +5515,15 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4569
5515
  // block-scoped to that loop and never escape to the rest of the function,
4570
5516
  // so none of them may run through the outer `declaredNames`/produceLines
4571
5517
  // bookkeeping below (that bookkeeping assumes function-scope declarations).
4572
- const foldChainIndices = new Set(foldPlans.flatMap((plan) => plan.targets.flatMap((target) => target.chain)));
5518
+ // Absorbed indices (see FoldPlan.absorbedIndices) are repeat raw captures
5519
+ // of a target's own endpoint, threaded from a DIFFERENT primary item — the
5520
+ // single representative target already re-issues that endpoint once per
5521
+ // fold-loop iteration, so these must be dropped from normal per-step
5522
+ // emission too, exactly like the chain indices they're folded in with.
5523
+ const foldChainIndices = new Set(foldPlans.flatMap((plan) => [
5524
+ ...plan.targets.flatMap((target) => target.chain),
5525
+ ...plan.absorbedIndices,
5526
+ ]));
4573
5527
  for (let i = 0; i < actions.length; i++) {
4574
5528
  const step = actions[i];
4575
5529
  const cap = step.capture;
@@ -4661,14 +5615,41 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4661
5615
  // colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
4662
5616
  // single-target case keeps the original unsuffixed names.
4663
5617
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
4664
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
5618
+ // Only `itemVar` (and fold-match candidates) are `Record<string,
5619
+ // unknown>`-typed — ancestor loop vars keep the real response-derived
5620
+ // type, so re-asserting THEIR intermediate hops would be both
5621
+ // unnecessary and, worse, would replace a real property access with
5622
+ // an opaque cast in the emitted URL/body text.
5623
+ const scopedAccessor = (varName, field) => varName === itemVar
5624
+ ? unknownValueAccessor(varName, field.split("."))
5625
+ : `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4665
5626
  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);
5627
+ // Computed once per fold target instead of once per `parameterize`
5628
+ // call: `actions` never changes across the url/headers/body calls a
5629
+ // single chain step makes (or across chain steps), so re-deriving
5630
+ // this array inside the closure was O(actions.length) work repeated
5631
+ // 3x per chain hop for no reason.
5632
+ const allCaptures = actions.map((a) => a.capture);
5633
+ // `isZeroVarianceRepeatCapture`'s verdict is a pure function of
5634
+ // `chainCapture` (and the now-hoisted `allCaptures`, which is fixed
5635
+ // for the whole target) — memoized here so the 3 `parameterize`
5636
+ // calls a single chain step makes (url, headers, body) each share
5637
+ // the one verdict computed for that step's `chainCapture` instead of
5638
+ // re-scanning `allCaptures` from scratch every time.
5639
+ const isProvenInvariantMemo = new Map();
5640
+ const isProvenInvariantFor = (chainCapture) => {
5641
+ const cached = isProvenInvariantMemo.get(chainCapture);
5642
+ if (cached !== undefined)
5643
+ return cached;
5644
+ const computed = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
5645
+ isProvenInvariantMemo.set(chainCapture, computed);
5646
+ return computed;
5647
+ };
5648
+ // Whole-value substitution runs via substituteThreadedValues: a single
5649
+ // guarded regex-alternation pass over the original text, not a
5650
+ // per-field sequential `.replace()` loop — see that function's doc for
5651
+ // why the sequential shape corrupts opaque path segments and can nest
5652
+ // `${...}` placeholders.
4672
5653
  const parameterize = (text, chainCapture) => {
4673
5654
  // A join field can reach the render either as the raw captured
4674
5655
  // literal (URL query params) or as an already-generic
@@ -4685,7 +5666,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4685
5666
  // invisible and gets frozen as a literal.
4686
5667
  const rawThreadedFields = dedupeThreadedFields([
4687
5668
  ...target.joinFields.map((field) => ({ varName: itemVar, field })),
4688
- ...findThreadedJoinFields(threadingScopes, chainCapture, actions.map((a) => a.capture)),
5669
+ ...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
4689
5670
  ]);
4690
5671
  // A proven ancestor-scoped drill (see isAncestorScoped above) still
4691
5672
  // rebinds fields findThreadedJoinFields left on itemVar purely
@@ -4713,7 +5694,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4713
5694
  : tf,
4714
5695
  }))
4715
5696
  : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
4716
- const result = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
5697
+ // Accessor swap first: rewrites an already-templated `${payload.X}`
5698
+ // reference (from applyPayloadKeyValueSubstitutions) to this field's
5699
+ // real accessor. Each target (`${payload.X}`) is a unique, fully
5700
+ // delimited string that the swap's own output (`${accessorField...}`,
5701
+ // never re-shaped into `${payload.X}` form) can't re-match, so a
5702
+ // sequential pass here carries none of the reentrancy risk the
5703
+ // literal-value pass below has.
5704
+ const swapped = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
4717
5705
  const replacement = `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`;
4718
5706
  // applyPayloadKeyValueSubstitutions only ever names a payload
4719
5707
  // accessor after the DRILL REQUEST's own top-level JSON key
@@ -4726,11 +5714,18 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4726
5714
  // reference behind once the literal value itself has already
4727
5715
  // been replaced by the payload-key-value pass.
4728
5716
  const lastSegment = valueField.field.split(".").pop();
4729
- const withAccessorSwapped = acc
5717
+ return acc
4730
5718
  .split(`\${payload.${valueField.field}}`)
4731
5719
  .join(replacement)
4732
5720
  .split(`\${payload.${lastSegment}}`)
4733
5721
  .join(replacement);
5722
+ }, text);
5723
+ // Literal-value substitution: ONE guarded regex-alternation pass over
5724
+ // `swapped` for every threaded field's value, longest first — see
5725
+ // substituteThreadedValues's doc for why a per-field sequential pass
5726
+ // here (the bug this replaces) can nest `${...}` placeholders.
5727
+ const valueBindings = threadedFieldPairs
5728
+ .map(({ valueField, accessorField }) => {
4734
5729
  const scopeObj = valueField.varName === itemVar
4735
5730
  ? firstItem
4736
5731
  : ancestorObjByVar.get(valueField.varName);
@@ -4740,12 +5735,36 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4740
5735
  : typeof value === "number" || typeof value === "boolean"
4741
5736
  ? String(value)
4742
5737
  : null;
4743
- return stringValue !== null
4744
- ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4745
- : withAccessorSwapped;
4746
- }, text);
5738
+ return stringValue === null
5739
+ ? null
5740
+ : {
5741
+ value: stringValue,
5742
+ replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
5743
+ };
5744
+ })
5745
+ .filter((b) => b !== null);
5746
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
5747
+ // must never have a COINCIDENTAL threaded value spliced into it —
5748
+ // but the invariance verdict is per-capture, not per-field: a
5749
+ // capture can be fixed on one key (a repeated `qty`) while still
5750
+ // genuinely varying on another (`itemId`), so only the fields that
5751
+ // {@link isGenuineVaryingQueryValue} can't prove are real per-request
5752
+ // dependencies get excluded, never the whole substitution pass.
5753
+ const isProvenInvariant = isProvenInvariantFor(chainCapture);
5754
+ const filteredValueBindings = isProvenInvariant
5755
+ ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
5756
+ : valueBindings;
5757
+ const result = substituteThreadedValues(swapped, filteredValueBindings);
4747
5758
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
4748
- assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, withDrillParamBindings, actions.map((a) => a.capture));
5759
+ // The frozen-varying-param safety net exists to catch a
5760
+ // misconfigured drill (a param that genuinely needs joinFields/an
5761
+ // ancestor binding but has neither) — it does not apply once the
5762
+ // capture is already proven request-invariant: freezing an
5763
+ // undeclared, business-irrelevant varying key (a beacon nonce)
5764
+ // there is the INTENDED behavior, not a misconfiguration.
5765
+ if (!isProvenInvariant) {
5766
+ assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, withDrillParamBindings, allCaptures);
5767
+ }
4749
5768
  return withDrillParamBindings;
4750
5769
  };
4751
5770
  // Every chain step's response and produces are block-scoped to this
@@ -4771,6 +5790,9 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4771
5790
  for (const chainIndex of target.chain) {
4772
5791
  const chainStep = actions[chainIndex];
4773
5792
  const chainRendered = rendered[chainIndex];
5793
+ // The zero-variance guard lives inside `parameterize` itself (see
5794
+ // above) so it can skip only the threaded-value splice while still
5795
+ // letting a spec-declared drillParamBindings substitution apply.
4774
5796
  const paramUrl = parameterize(chainRendered.url, chainStep.capture);
4775
5797
  const paramHeaders = parameterize(chainRendered.headersExpr, chainStep.capture);
4776
5798
  const paramBody = parameterize(chainRendered.bodyArg, chainStep.capture);
@@ -4862,7 +5884,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4862
5884
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
4863
5885
  const lower = k.toLowerCase();
4864
5886
  if (lower === "api-token" || lower === "authorization") {
4865
- perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), payloadAccessorByValue)}\``);
5887
+ perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue)}\``);
4866
5888
  }
4867
5889
  }
4868
5890
  // G1+G2: include tenant-derived headers in the multipart fetch too.
@@ -4949,16 +5971,27 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4949
5971
  ...new Set(foldPlans.map((plan) => actions[plan.primaryStepIndex].varName)),
4950
5972
  ];
4951
5973
  const everyPrimaryIsPlainObject = foldPlans.every((plan) => isPlainObject(actions[plan.primaryStepIndex].capture.responseBody));
5974
+ // Every intermediate `httpClient` call above is bound `as Record<string,
5975
+ // unknown>` regardless of its own `schema:`, so per-item fold/merge code
5976
+ // can probe arbitrary fields without a per-step assertion type. That
5977
+ // widened intermediate type doesn't match `${pascalName}Response` — the
5978
+ // richer type schema inference already promised for THIS returned value —
5979
+ // so the return itself needs its own assertion back to that promised
5980
+ // type; `Record<string, unknown>` and the real inferred object type share
5981
+ // no ancestry TS can see, so a plain `as` needs the `as unknown as` detour.
5982
+ const castToResponseType = (expr) => pascalName ? `${expr} as unknown as ${pascalName}Response` : expr;
4952
5983
  if (uniquePrimaryVarNames.length > 1 && everyPrimaryIsPlainObject) {
4953
- lines.push(` return { data: mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")}) };`);
5984
+ lines.push(` return { data: ${castToResponseType(`mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")})`)} };`);
4954
5985
  }
4955
5986
  else {
4956
5987
  const returnVar = lastFoldPlan
4957
5988
  ? actions[lastFoldPlan.primaryStepIndex].varName
4958
5989
  : (returnAction?.varName ?? "undefined");
4959
- lines.push(` return { data: ${returnVar} };`);
5990
+ lines.push(` return { data: ${castToResponseType(returnVar)} };`);
4960
5991
  }
4961
- return lines.join("\n");
5992
+ const renderedMultiStepBody = lines.join("\n");
5993
+ assertBodyFieldSourceNameCorrelates("emitMultiStepExecuteHttp", renderedMultiStepBody);
5994
+ return renderedMultiStepBody;
4962
5995
  }
4963
5996
  function summariseResponseShape(value) {
4964
5997
  if (value === null || typeof value !== "object")
@@ -5319,13 +6352,96 @@ function findObjectArrayFieldOrWholeObject(value, path = []) {
5319
6352
  * other capture of this endpoint exists in `allCaptures`, variance can't be
5320
6353
  * observed either way, so every value is kept (unfiltered, matching the
5321
6354
  * behavior when `allCaptures` is omitted). */
5322
- function collectRequestStringValues(capture, allCaptures) {
5323
- const sameEndpointCaptures = allCaptures
5324
- ? allCaptures.filter((c) => c !== capture && endpointKey(c.url) === endpointKey(capture.url))
5325
- : [];
5326
- const varies = (own, others) => !allCaptures || sameEndpointCaptures.length === 0
6355
+ /** Per-`allCaptures`-array grouping of captures by {@link endpointKey}, built
6356
+ * once per distinct `allCaptures` identity rather than re-filtering the
6357
+ * whole array on every {@link sameEndpointCapturesFor} call — the O(n)
6358
+ * per-call filter otherwise makes every caller that invokes it once per
6359
+ * capture (e.g. {@link findThreadedJoinFields}) O(n^2) overall. */
6360
+ const endpointGroupsCache = new WeakMap();
6361
+ function endpointGroupsFor(allCaptures) {
6362
+ const cached = endpointGroupsCache.get(allCaptures);
6363
+ if (cached)
6364
+ return cached;
6365
+ const groups = new Map();
6366
+ for (const c of allCaptures) {
6367
+ const key = endpointKey(c.url);
6368
+ const group = groups.get(key) ?? [];
6369
+ group.push(c);
6370
+ groups.set(key, group);
6371
+ }
6372
+ endpointGroupsCache.set(allCaptures, groups);
6373
+ return groups;
6374
+ }
6375
+ function sameEndpointCapturesFor(capture, allCaptures) {
6376
+ if (!allCaptures)
6377
+ return [];
6378
+ const group = endpointGroupsFor(allCaptures).get(endpointKey(capture.url)) ?? [];
6379
+ return group.filter((c) => c !== capture);
6380
+ }
6381
+ /** True when `own` differs from at least one same-endpoint sibling's value at
6382
+ * the same location — the cross-capture variance test {@link
6383
+ * collectRequestStringValues} and {@link collectRequestBodyValuesByKey} both
6384
+ * apply to their respective candidate values. When no sibling capture exists
6385
+ * (or `allCaptures` was omitted), variance can't be observed either way, so
6386
+ * every value passes (matching the unfiltered behavior when `allCaptures` is
6387
+ * omitted). */
6388
+ function requestValueVaries(own, others, sameEndpointCapturesLength) {
6389
+ return sameEndpointCapturesLength === 0
5327
6390
  ? true
5328
6391
  : others.some((other) => other !== undefined && other !== own);
6392
+ }
6393
+ /** Memoizes `JSON.parse(capture.requestPostData)` per capture — the same
6394
+ * capture is re-parsed once per SIBLING lookup by every same-endpoint
6395
+ * caller in {@link collectRequestBodyValuesByKey}'s leaf loop, so without
6396
+ * this cache a group of N same-endpoint captures re-parses each sibling's
6397
+ * body N times over (once per outer capture in the group). `undefined`
6398
+ * means "not a parseable JSON body", the same non-JSON signal the
6399
+ * unmemoized inline parse used to produce. */
6400
+ const parsedRequestBodyCache = new WeakMap();
6401
+ const PARSE_FAILED = Symbol("parse-failed");
6402
+ function parsedRequestBodyFor(capture) {
6403
+ if (parsedRequestBodyCache.has(capture)) {
6404
+ const cached = parsedRequestBodyCache.get(capture);
6405
+ return cached === PARSE_FAILED ? undefined : cached;
6406
+ }
6407
+ const parsed = (() => {
6408
+ if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
6409
+ return PARSE_FAILED;
6410
+ }
6411
+ try {
6412
+ return JSON.parse(capture.requestPostData);
6413
+ }
6414
+ catch {
6415
+ return PARSE_FAILED;
6416
+ }
6417
+ })();
6418
+ parsedRequestBodyCache.set(capture, parsed);
6419
+ return parsed === PARSE_FAILED ? undefined : parsed;
6420
+ }
6421
+ /** Per-(capture, allCaptures-identity) memoization for {@link
6422
+ * collectRequestUrlValues} and {@link collectRequestBodyValuesByKey} —
6423
+ * {@link findThreadedJoinFields} calls both fresh on every invocation and is
6424
+ * itself invoked once per fold/drill-loop item across several call sites, so
6425
+ * without caching the same capture's URL/body values are recomputed (and,
6426
+ * for the body, re-walked and every sibling re-parsed) once per call. */
6427
+ const requestUrlValuesCache = new WeakMap();
6428
+ const requestBodyValuesByKeyCache = new WeakMap();
6429
+ const NO_ALL_CAPTURES = Object.freeze([]);
6430
+ /** The path-segment and query-parameter values present in `capture`'s own
6431
+ * URL — the name-free half of {@link collectRequestStringValues}'s candidate
6432
+ * set. Kept separate from the JSON body's leaf values so callers needing a
6433
+ * by-key correlation gate on the body (e.g. {@link findThreadedJoinFields})
6434
+ * can still treat URL/query matches as the name-free signal they've always
6435
+ * been — a REST-style `/orders/{id}` path segment or query param carries no
6436
+ * JSON key to correlate against in the first place. */
6437
+ function collectRequestUrlValues(capture, allCaptures) {
6438
+ const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
6439
+ const perCaptureCache = requestUrlValuesCache.get(capture) ?? new WeakMap();
6440
+ requestUrlValuesCache.set(capture, perCaptureCache);
6441
+ const cached = perCaptureCache.get(cacheKey);
6442
+ if (cached)
6443
+ return cached;
6444
+ const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
5329
6445
  const values = new Set();
5330
6446
  try {
5331
6447
  const url = new URL(capture.url);
@@ -5340,45 +6456,82 @@ function collectRequestStringValues(capture, allCaptures) {
5340
6456
  return undefined;
5341
6457
  }
5342
6458
  });
5343
- if (varies(value, otherValues))
6459
+ if (requestValueVaries(value, otherValues, sameEndpointCaptures.length))
5344
6460
  values.add(value);
5345
6461
  }
5346
6462
  }
5347
6463
  catch {
5348
6464
  // Relative or malformed URL — no query params or path segments to contribute.
5349
6465
  }
5350
- const parsedBody = (() => {
5351
- try {
5352
- return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
5353
- ? JSON.parse(capture.requestPostData)
5354
- : undefined;
5355
- }
5356
- catch {
5357
- return undefined;
5358
- }
5359
- })();
5360
- if (parsedBody !== undefined) {
5361
- for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
5362
- if (value === null)
5363
- continue;
5364
- const stringValue = String(value);
5365
- const otherValues = sameEndpointCaptures.map((c) => {
5366
- try {
5367
- const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
5368
- ? JSON.parse(c.requestPostData)
5369
- : undefined;
5370
- if (otherBody === undefined)
5371
- return undefined;
5372
- const otherValue = readValueAtPath(otherBody, path);
5373
- return otherValue === undefined ? undefined : String(otherValue);
5374
- }
5375
- catch {
6466
+ perCaptureCache.set(cacheKey, values);
6467
+ return values;
6468
+ }
6469
+ /** Same JSON-body walk and cross-capture variance gate as {@link
6470
+ * collectRequestStringValues}'s body block, but grouped by the JSON
6471
+ * key/array-index that carries each leaf value (see {@link
6472
+ * jsonBodyLeafValuesByKey}'s same grouping) — the by-key candidate set
6473
+ * {@link findThreadedJoinFields} correlates a threaded field's own name
6474
+ * against, so a value that only coincidentally equals something in an
6475
+ * UNRELATED body field can't be threaded onto it. Returns `null` when
6476
+ * `capture.requestPostData` isn't parseable JSON, the same non-JSON signal
6477
+ * {@link jsonBodyLeafValuesByKey} returns. */
6478
+ function collectRequestBodyValuesByKey(capture, allCaptures) {
6479
+ if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
6480
+ return null;
6481
+ }
6482
+ const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
6483
+ const perCaptureCache = requestBodyValuesByKeyCache.get(capture) ?? new WeakMap();
6484
+ requestBodyValuesByKeyCache.set(capture, perCaptureCache);
6485
+ if (perCaptureCache.has(cacheKey))
6486
+ return perCaptureCache.get(cacheKey);
6487
+ const parsedBody = parsedRequestBodyFor(capture);
6488
+ if (parsedBody === undefined) {
6489
+ perCaptureCache.set(cacheKey, null);
6490
+ return null;
6491
+ }
6492
+ const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
6493
+ const byKey = new Map();
6494
+ for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
6495
+ if (value === null || path.length === 0)
6496
+ continue;
6497
+ const stringValue = String(value);
6498
+ const otherValues = sameEndpointCaptures.map((c) => {
6499
+ try {
6500
+ const otherBody = parsedRequestBodyFor(c);
6501
+ if (otherBody === undefined)
5376
6502
  return undefined;
5377
- }
5378
- });
5379
- if (varies(stringValue, otherValues))
5380
- values.add(stringValue);
5381
- }
6503
+ const otherValue = readValueAtPath(otherBody, path);
6504
+ return otherValue === undefined ? undefined : String(otherValue);
6505
+ }
6506
+ catch {
6507
+ return undefined;
6508
+ }
6509
+ });
6510
+ if (!requestValueVaries(stringValue, otherValues, sameEndpointCaptures.length))
6511
+ continue;
6512
+ const namedSegment = [...path]
6513
+ .reverse()
6514
+ .find((segment) => !ARRAY_INDEX_KEY_PATTERN.test(segment));
6515
+ const key = namedSegment ?? path[path.length - 1];
6516
+ const values = byKey.get(key) ?? new Set();
6517
+ values.add(stringValue);
6518
+ byKey.set(key, values);
6519
+ }
6520
+ perCaptureCache.set(cacheKey, byKey);
6521
+ return byKey;
6522
+ }
6523
+ function collectRequestStringValues(capture, allCaptures) {
6524
+ // Copied rather than mutated in place: collectRequestUrlValues now returns
6525
+ // a cached Set shared across every caller of this exact (capture,
6526
+ // allCaptures) pair, so merging body values directly into it would leak
6527
+ // them into every OTHER caller relying on collectRequestUrlValues' own
6528
+ // URL-only contract (e.g. findThreadedJoinFields's separate URL/body
6529
+ // gating).
6530
+ const values = new Set(collectRequestUrlValues(capture, allCaptures));
6531
+ const bodyValuesByKey = collectRequestBodyValuesByKey(capture, allCaptures);
6532
+ for (const leafValues of bodyValuesByKey?.values() ?? []) {
6533
+ for (const value of leafValues)
6534
+ values.add(value);
5382
6535
  }
5383
6536
  return values;
5384
6537
  }
@@ -5518,6 +6671,40 @@ function applyDrillParamBindings(spec, capture, text) {
5518
6671
  return acc.replace(paramRx, (_full, prefix) => `${prefix}${accessor}`);
5519
6672
  }, text);
5520
6673
  }
6674
+ /** True when `value` is a QUERY PARAM value in `capture.url` whose key
6675
+ * genuinely differs on at least one other same-endpoint occurrence in
6676
+ * `allCaptures` — i.e. a real per-request dependency (an item id, a page
6677
+ * cursor), not a coincidental byte match against an opaque path segment or a
6678
+ * key that happens to hold the same value on every occurrence. Used to
6679
+ * decide, field by field, whether a threaded-value splice into a capture
6680
+ * proven request-invariant ({@link isZeroVarianceRepeatCapture}) is a
6681
+ * legitimate substitution or the exact coincidence that guard exists to
6682
+ * catch — a capture can be "invariant" on one key (a fixed `qty`) while
6683
+ * still genuinely varying on another (`itemId`), so the invariance verdict
6684
+ * alone can't gate substitution at the whole-capture level. */
6685
+ function isGenuineVaryingQueryValue(value, capture, allCaptures) {
6686
+ let url;
6687
+ try {
6688
+ url = new URL(capture.url);
6689
+ }
6690
+ catch {
6691
+ return false;
6692
+ }
6693
+ const key = [...url.searchParams.entries()].find(([, v]) => v === value)?.[0];
6694
+ if (key === undefined)
6695
+ return false;
6696
+ const endpoint = endpointKey(capture.url);
6697
+ return allCaptures.some((c) => {
6698
+ if (c === capture || endpointKey(c.url) !== endpoint)
6699
+ return false;
6700
+ try {
6701
+ return new URL(c.url).searchParams.get(key) !== value;
6702
+ }
6703
+ catch {
6704
+ return false;
6705
+ }
6706
+ });
6707
+ }
5521
6708
  /** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
5522
6709
  * varying literal — shared by {@link parameterizeUrl} (below) and
5523
6710
  * `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
@@ -5658,13 +6845,36 @@ function dedupeThreadedFields(fields) {
5658
6845
  * where narrowing by variance is a different concern than the URL/body
5659
6846
  * over-threading this gate exists to prevent. */
5660
6847
  function findThreadedJoinFields(scopes, drillCapture, allCaptures) {
5661
- const requestValues = collectRequestStringValues(drillCapture, allCaptures);
5662
- if (requestValues.size === 0)
6848
+ const urlValues = collectRequestUrlValues(drillCapture, allCaptures);
6849
+ const bodyValuesByKey = collectRequestBodyValuesByKey(drillCapture, allCaptures);
6850
+ if (urlValues.size === 0 && (bodyValuesByKey === null || bodyValuesByKey.size === 0))
5663
6851
  return [];
6852
+ // A candidate field's value must EITHER surface name-free in the drill
6853
+ // request's own URL/query (a path segment or query param carries no JSON
6854
+ // key to correlate against, matching interpolateStateValues' isJsonBody
6855
+ // distinction — see its docstring) OR surface in the JSON body under a
6856
+ // key that plausibly names the same concept as the field's own last path
6857
+ // segment (via keyNamesCorrelate, the same discipline compileActionSteps'
6858
+ // pre-scan already applies to body-value reuse). A value that ONLY
6859
+ // coincidentally equals an unrelated body field's value — no URL match,
6860
+ // no name correlation to the body key it landed under — is not threading;
6861
+ // it's the value-coincidence bug this gate exists to close.
5664
6862
  return scopes.flatMap(({ varName, obj }) => [...walkItemFieldPaths(obj)]
5665
- .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
5666
- (typeof v === "number" && requestValues.has(String(v))) ||
5667
- (typeof v === "boolean" && requestValues.has(String(v))))
6863
+ .filter(({ path, value: v }) => {
6864
+ const stringValue = typeof v === "string" && v.length > 0
6865
+ ? v
6866
+ : typeof v === "number" || typeof v === "boolean"
6867
+ ? String(v)
6868
+ : null;
6869
+ if (stringValue === null)
6870
+ return false;
6871
+ if (urlValues.has(stringValue))
6872
+ return true;
6873
+ if (bodyValuesByKey === null)
6874
+ return false;
6875
+ const sourceKeyName = path.at(-1);
6876
+ return [...bodyValuesByKey.entries()].some(([targetKey, leaves]) => leaves.has(stringValue) && keyNamesCorrelate(sourceKeyName, targetKey));
6877
+ })
5668
6878
  .map(({ path }) => ({ varName, field: path.join(".") })));
5669
6879
  }
5670
6880
  /** True when `target`'s own drill/chain-terminal response resolves onto MORE
@@ -5967,6 +7177,11 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5967
7177
  const consumedIndices = new Set();
5968
7178
  for (const primaryArray of primaryCandidates) {
5969
7179
  const targets = [];
7180
+ // See FoldPlan.absorbedIndices — a candidate hitting the SAME endpoint
7181
+ // as a target already resolved for this array is a repeat raw capture
7182
+ // of that one per-item drill (threaded from a DIFFERENT primary item),
7183
+ // not an independent target, so it lands here instead of `targets`.
7184
+ const absorbedIndices = [];
5970
7185
  // Pruned to the (typically tiny) set of later action indices whose
5971
7186
  // request could possibly thread one of this array's own item values —
5972
7187
  // see buildRequestStringValueIndex's docstring — instead of every
@@ -5987,6 +7202,24 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5987
7202
  if (primaryMatchedItemIndex === -1)
5988
7203
  continue;
5989
7204
  const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
7205
+ // A genuinely per-item-varying repeated endpoint (the SAME drill
7206
+ // called once per primary item, each occurrence threading a
7207
+ // DIFFERENT item's own join value) is one logical target, not N — the
7208
+ // fold loop already re-issues the representative target's request
7209
+ // once per item via its own `item.<field>` accessor. Recognizing a
7210
+ // later candidate as a repeat of an ALREADY-RESOLVED target's
7211
+ // endpoint (rather than letting it become its own independent
7212
+ // target) is exactly the widening this structural heuristic needed:
7213
+ // previously every threading candidate became its own FoldTarget,
7214
+ // so N per-item captures of one endpoint fanned out into N separate
7215
+ // httpClient calls inside the loop instead of collapsing to one.
7216
+ const drillEndpointKey = endpointKey(drill.capture.url);
7217
+ const alreadyTargetedSameEndpoint = targets.some((t) => endpointKey(actions[t.drillStepIndex].capture.url) === drillEndpointKey);
7218
+ if (alreadyTargetedSameEndpoint) {
7219
+ absorbedIndices.push(drillIndex);
7220
+ consumedIndices.add(drillIndex);
7221
+ continue;
7222
+ }
5990
7223
  // Widened to a flat (non-array) object response when the drill step has
5991
7224
  // no object-array field of its own — see
5992
7225
  // findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
@@ -6049,8 +7282,9 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
6049
7282
  for (const chainIndex of chain)
6050
7283
  consumedIndices.add(chainIndex);
6051
7284
  }
6052
- if (targets.length > 0)
6053
- groups.push({ primaryArrayPath: primaryArray.path, targets });
7285
+ if (targets.length > 0) {
7286
+ groups.push({ primaryArrayPath: primaryArray.path, targets, absorbedIndices });
7287
+ }
6054
7288
  }
6055
7289
  return groups;
6056
7290
  }
@@ -6151,6 +7385,7 @@ function detectDrillDownFoldPlan(actions) {
6151
7385
  primaryStepIndex: freshestIndex,
6152
7386
  primaryArrayPath: freshestGroup.primaryArrayPath,
6153
7387
  targets: freshestGroup.targets,
7388
+ absorbedIndices: freshestGroup.absorbedIndices,
6154
7389
  });
6155
7390
  // A step already folded into this plan's chains — the drill step(s)
6156
7391
  // and everything threaded onward from them — was already merged
@@ -6165,6 +7400,12 @@ function detectDrillDownFoldPlan(actions) {
6165
7400
  for (const chainIndex of target.chain)
6166
7401
  addConsumed(chainIndex);
6167
7402
  }
7403
+ // Absorbed repeat occurrences (see FoldPlan.absorbedIndices) were never
7404
+ // part of any target's chain, so they must be consumed here too, or
7405
+ // they would surface as leftover raw indices and get emitted a second
7406
+ // time as their own single hardcoded calls.
7407
+ for (const absorbedIndex of freshestGroup.absorbedIndices)
7408
+ addConsumed(absorbedIndex);
6168
7409
  }
6169
7410
  }
6170
7411
  return plans;
@@ -6685,6 +7926,7 @@ function buildFoldPlanFromSpec(actions, spec) {
6685
7926
  chainTerminalIndex,
6686
7927
  },
6687
7928
  ],
7929
+ absorbedIndices: [],
6688
7930
  };
6689
7931
  break;
6690
7932
  }
@@ -6892,15 +8134,131 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
6892
8134
  * in one response — keeps this exact to the shape state-threading is
6893
8135
  * actually needed for.
6894
8136
  *
8137
+ * A later hop's request is only counted as threading a prior hop's response
8138
+ * value when the two agree on the field/header NAME that carries it (or the
8139
+ * value shows up as a bare, name-free URL PATH segment on the later
8140
+ * request) — the same discipline {@link requestAndResponseValuesByKey}/
8141
+ * {@link isFieldValueThreadedElsewhere} already enforce for the identical
8142
+ * hazard elsewhere in this file. Bare cross-capture value equality alone
8143
+ * (what this used before) lets a deeply-nested, unrelated response scalar —
8144
+ * a UI sort-order integer, an unrelated feature-flag boolean — that merely
8145
+ * happens to numerically coincide with some later request field's true
8146
+ * value get proven "threaded" and then, via `indexStateValues`'
8147
+ * `MIN_STATE_VALUE_LENGTH` exemption below, spliced into that unrelated
8148
+ * field. Requiring the SAME name on both sides is what tells a value a
8149
+ * later step genuinely re-reads under its own name apart from that
8150
+ * coincidence.
8151
+ *
6895
8152
  * Runs directly off raw actions (not `resolveFoldPlan`, which needs
6896
8153
  * `isMultipart` — unavailable before `compileActionSteps` has run) since
6897
8154
  * fold-plan DETECTION depends only on each action's `capture`.
6898
- */
8155
+ *
8156
+ * Returns a value -> proven-consumer-captures map rather than a flat set: a
8157
+ * value's chain-proven threading relationship holds ONLY between the
8158
+ * specific chain hops that produced and consumed it, never globally across
8159
+ * every capture in the flow. Callers that bypass `MIN_STATE_VALUE_LENGTH`
8160
+ * for one of these values (see `indexStateValues`) must scope that bypass to
8161
+ * the returned consumer set, or a short value legitimately threaded between
8162
+ * two unrelated steps can coincidentally match inside a totally unrelated
8163
+ * capture's own URL/body and get spliced into it.
8164
+ */
8165
+ /** Per-capture memoized: every response BODY leaf, grouped by the field NAME
8166
+ * that carries it — gives {@link collectDependentDrillDownChainValues} the
8167
+ * same name-correlation signal {@link requestAndResponseValuesByKey} already
8168
+ * provides elsewhere in this file, instead of the bare, name-blind value set
8169
+ * {@link collectResponseLeafValues} supplies. Deliberately BODY-only, unlike
8170
+ * {@link collectResponseLeafValues}: a response HEADER (and especially a
8171
+ * `Set-Cookie` token mint) is already a strong structural signal on its own
8172
+ * — issuing a header/cookie at all is a deliberate server action, unlike an
8173
+ * arbitrary deeply-nested body scalar that merely happens to be present — so
8174
+ * header-sourced values keep the pre-existing bare-value match further down
8175
+ * in {@link collectDependentDrillDownChainValues} rather than being held to
8176
+ * a body-field's name correlation. */
8177
+ const responseBodyValuesByKeyCache = new WeakMap();
8178
+ function responseBodyValuesByKey(capture) {
8179
+ const cached = responseBodyValuesByKeyCache.get(capture);
8180
+ if (cached)
8181
+ return cached;
8182
+ const byKey = new Map();
8183
+ const add = (key, value) => {
8184
+ const values = byKey.get(key) ?? new Set();
8185
+ values.add(value);
8186
+ byKey.set(key, values);
8187
+ };
8188
+ for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
8189
+ if (value !== null && path.length > 0)
8190
+ add(path[path.length - 1], String(value));
8191
+ }
8192
+ responseBodyValuesByKeyCache.set(capture, byKey);
8193
+ return byKey;
8194
+ }
8195
+ /** Per-capture memoized request-side twin of {@link responseBodyValuesByKey}:
8196
+ * every URL query param, JSON body leaf, and request header value, grouped
8197
+ * by field/param/header NAME (header names lower-cased, since HTTP header
8198
+ * names are case-insensitive and a capture's minted header casing need not
8199
+ * match the later request's own casing of the same header), plus bare URL
8200
+ * PATH segments kept name-free in {@link
8201
+ * RequestAndResponseValues.pathSegments} — a REST-style detail fetch threads
8202
+ * an id through its URL PATH, not a named field, so requiring a name match
8203
+ * there too would blind chain-value correlation to that shape of genuine
8204
+ * threading. Headers are included here (unlike {@link
8205
+ * responseBodyValuesByKey}) so a body-sourced response value that a later
8206
+ * hop re-sends as a request HEADER under the matching name still
8207
+ * correlates. */
8208
+ const requestValuesByKeyCache = new WeakMap();
8209
+ function requestValuesByKeyIncludingHeaders(capture) {
8210
+ const cached = requestValuesByKeyCache.get(capture);
8211
+ if (cached)
8212
+ return cached;
8213
+ const byKey = new Map();
8214
+ const pathSegments = new Set();
8215
+ const add = (key, value) => {
8216
+ const values = byKey.get(key) ?? new Set();
8217
+ values.add(value);
8218
+ byKey.set(key, values);
8219
+ };
8220
+ try {
8221
+ const url = new URL(capture.url);
8222
+ for (const segment of url.pathname.split("/").filter(Boolean))
8223
+ pathSegments.add(segment);
8224
+ for (const [key, value] of url.searchParams)
8225
+ add(key, value);
8226
+ }
8227
+ catch {
8228
+ // Relative/invalid URLs carry no path/query signal to contribute.
8229
+ }
8230
+ if (capture.requestPostData) {
8231
+ try {
8232
+ const parsed = JSON.parse(capture.requestPostData);
8233
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
8234
+ if (value !== null && path.length > 0)
8235
+ add(path[path.length - 1], String(value));
8236
+ }
8237
+ }
8238
+ catch {
8239
+ // A non-JSON body carries no leaf values to contribute.
8240
+ }
8241
+ }
8242
+ for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
8243
+ add(headerName.toLowerCase(), headerValue);
8244
+ }
8245
+ const result = { byKey, pathSegments };
8246
+ requestValuesByKeyCache.set(capture, result);
8247
+ return result;
8248
+ }
8249
+ /** Matches a JSON path segment that's a bare array INDEX ("0", "12", ...)
8250
+ * rather than an object field/header NAME. An array index carries no
8251
+ * semantic meaning of its own — a top-level array response (`[42]`) or a
8252
+ * value nested inside a request array (`{"tokens":[42]}`) has no field name
8253
+ * to correlate on either side — so {@link collectDependentDrillDownChainValues}
8254
+ * treats a leaf keyed by one as name-free, the same way it already treats a
8255
+ * bare URL path segment, instead of requiring an impossible name match. */
8256
+ const ARRAY_INDEX_KEY_PATTERN = /^\d+$/;
6899
8257
  function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
6900
8258
  const structuralPlans = detectDrillDownFoldPlan(actions);
6901
8259
  const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
6902
8260
  const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
6903
- const values = new Set();
8261
+ const consumersByValue = new Map();
6904
8262
  for (const plan of plans) {
6905
8263
  for (const target of plan.targets) {
6906
8264
  for (let j = 0; j < target.chain.length; j++) {
@@ -6908,22 +8266,67 @@ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
6908
8266
  const priorCapture = actions[priorIndex]?.capture;
6909
8267
  if (!priorCapture)
6910
8268
  continue;
6911
- const responseValues = collectResponseLeafValues(priorCapture);
8269
+ const priorResponseBodyByKey = responseBodyValuesByKey(priorCapture);
8270
+ // Header/cookie-origin response values are matched by bare value
8271
+ // further down, not by name — see {@link responseBodyValuesByKey}'s
8272
+ // docstring for why a header/cookie mint doesn't need that
8273
+ // correlation to already be a trustworthy threading signal.
8274
+ const priorHeaderValues = new Set(Object.values(priorCapture.responseHeaders));
6912
8275
  const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
6913
8276
  for (let k = j + 1; k < target.chain.length; k++) {
6914
8277
  const laterCapture = actions[target.chain[k]]?.capture;
6915
8278
  if (!laterCapture)
6916
8279
  continue;
8280
+ const { byKey: laterRequestByKey, pathSegments: laterPathSegments } = requestValuesByKeyIncludingHeaders(laterCapture);
6917
8281
  const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
6918
- for (const v of responseValues) {
6919
- if (!echoedValues.has(v) && laterRequestValues.has(v))
6920
- values.add(v);
8282
+ // Reverse index (value -> every later-side key it appears under),
8283
+ // built once per (priorCapture, laterCapture) pair rather than
8284
+ // once per value, so the array-index name-free fallback below
8285
+ // doesn't re-walk laterRequestByKey per value.
8286
+ const laterKeysByValue = new Map();
8287
+ for (const [k2, vs] of laterRequestByKey) {
8288
+ for (const v of vs) {
8289
+ const keys = laterKeysByValue.get(v) ?? new Set();
8290
+ keys.add(k2);
8291
+ laterKeysByValue.set(v, keys);
8292
+ }
8293
+ }
8294
+ const addConsumer = (v) => {
8295
+ const consumers = consumersByValue.get(v) ?? new Set();
8296
+ consumers.add(laterCapture);
8297
+ consumersByValue.set(v, consumers);
8298
+ };
8299
+ for (const [key, values] of priorResponseBodyByKey) {
8300
+ const priorKeyIsArrayIndex = ARRAY_INDEX_KEY_PATTERN.test(key);
8301
+ for (const v of values) {
8302
+ if (echoedValues.has(v))
8303
+ continue;
8304
+ const laterKeysForValue = laterKeysByValue.get(v);
8305
+ const sameNameMatch = laterKeysForValue?.has(key) ?? false;
8306
+ const pathSegmentMatch = laterPathSegments.has(v);
8307
+ // Only the SOURCE side being name-free (an array element with
8308
+ // no field name of its own) exempts this from name matching —
8309
+ // a genuinely NAMED source field must still correlate by name
8310
+ // even if it happens to land inside a later array element,
8311
+ // otherwise a named `sortOrder` could dodge correlation just
8312
+ // by coincidentally equaling a value inside an unrelated
8313
+ // later-side array (`{"tokens":[7]}`).
8314
+ const arrayIndexMatch = priorKeyIsArrayIndex && laterKeysForValue !== undefined;
8315
+ if (!sameNameMatch && !pathSegmentMatch && !arrayIndexMatch)
8316
+ continue;
8317
+ addConsumer(v);
8318
+ }
8319
+ }
8320
+ for (const v of priorHeaderValues) {
8321
+ if (echoedValues.has(v) || !laterRequestValues.has(v))
8322
+ continue;
8323
+ addConsumer(v);
6921
8324
  }
6922
8325
  }
6923
8326
  }
6924
8327
  }
6925
8328
  }
6926
- return values;
8329
+ return consumersByValue;
6927
8330
  }
6928
8331
  function resolveFoldPlan(actions, foldReturnSpec = null) {
6929
8332
  const structuralPlans = detectDrillDownFoldPlan(actions);
@@ -7013,17 +8416,59 @@ function replaceByReference(value, target, replacement) {
7013
8416
  * `throw` at the analogous point, minus the throw, since shape inference
7014
8417
  * degrading gracefully is preferable to failing a generate run over it.
7015
8418
  */
8419
+ /** Folds one drill-down response's matching item onto `body`'s primary
8420
+ * array — the single-occurrence step {@link foldResponseBodyForShapeInference}
8421
+ * runs once for a target's own representative drill and again for each of
8422
+ * its {@link FoldPlan.absorbedIndices} siblings, so both call sites resolve
8423
+ * the merge identically. `matchedItem` must already be resolved by the
8424
+ * caller: the representative occurrence knows it via `primaryMatchedItemIndex`,
8425
+ * while an absorbed occurrence resolves it by re-threading its OWN request
8426
+ * against every primary item (see the call site) — a drill-down's RESPONSE
8427
+ * commonly never echoes the join field it was looked up by, so matching by
8428
+ * response content alone (as the representative branch's own `drillMatch`
8429
+ * fallback does when the response doesn't echo it) can't identify WHICH item
8430
+ * an absorbed occurrence belongs to in the first place. */
8431
+ function foldOneDrillOccurrence(body, primaryArrayPath, joinFields, drillItems, matchedItem) {
8432
+ const primaryItems = objectItemsAtPath(body, primaryArrayPath);
8433
+ if (!primaryItems)
8434
+ return body;
8435
+ const drillMatch = drillItems.find((d) => joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
8436
+ String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems[0];
8437
+ if (!drillMatch)
8438
+ return body;
8439
+ return replaceByReference(body, matchedItem, { ...drillMatch, ...matchedItem });
8440
+ }
7016
8441
  function foldResponseBodyForShapeInference(actionSteps, foldPlan, initialBody = actionSteps[foldPlan.primaryStepIndex].capture.responseBody) {
7017
8442
  return foldPlan.targets.reduce((body, target) => {
7018
8443
  const drillBody = actionSteps[target.chainTerminalIndex].capture.responseBody;
7019
8444
  const primaryItems = objectItemsAtPath(body, foldPlan.primaryArrayPath);
7020
8445
  const drillItems = objectItemsAtPath(drillBody, target.chainArrayPath);
7021
8446
  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 });
8447
+ const bodyAfterOwnDrill = !drillItems || !matchedItem
8448
+ ? body
8449
+ : foldOneDrillOccurrence(body, foldPlan.primaryArrayPath, target.joinFields, drillItems, matchedItem);
8450
+ // Every absorbed occurrence of this SAME endpoint (a repeat raw capture
8451
+ // threaded from a DIFFERENT primary item — see FoldPlan.absorbedIndices)
8452
+ // is folded in too, so schema inference sees every sampled per-item
8453
+ // field, not just the single representative occurrence's — see the
8454
+ // `"merges by join key, not position"` regression this restores. Which
8455
+ // primary item an absorbed occurrence belongs to is re-derived from its
8456
+ // own REQUEST (mirroring the original structural scan's own matching),
8457
+ // not its response, since a drill response commonly never echoes the
8458
+ // join field back.
8459
+ const targetEndpointKey = endpointKey(actionSteps[target.drillStepIndex].capture.url);
8460
+ return foldPlan.absorbedIndices.reduce((innerBody, absorbedIndex) => {
8461
+ const absorbedCapture = actionSteps[absorbedIndex]?.capture;
8462
+ if (!absorbedCapture || endpointKey(absorbedCapture.url) !== targetEndpointKey) {
8463
+ return innerBody;
8464
+ }
8465
+ const absorbedDrillItems = objectItemsAtPath(absorbedCapture.responseBody, target.chainArrayPath);
8466
+ const innerPrimaryItems = objectItemsAtPath(innerBody, foldPlan.primaryArrayPath);
8467
+ const absorbedMatchedItem = innerPrimaryItems?.find((item) => findThreadedJoinFields([{ varName: "item", obj: item }], absorbedCapture).length > 0);
8468
+ if (!absorbedDrillItems || !absorbedMatchedItem)
8469
+ return innerBody;
8470
+ return foldOneDrillOccurrence(innerBody, foldPlan.primaryArrayPath, target.joinFields, absorbedDrillItems, absorbedMatchedItem);
8471
+ }, bodyAfterOwnDrill);
7027
8472
  }, initialBody);
7028
8473
  }
7029
8474
  /**
@@ -7514,6 +8959,33 @@ function emitContractTs(opts) {
7514
8959
  const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
7515
8960
  addExtendField(name, ` ${key}: ${value},`);
7516
8961
  }
8962
+ // Closing-the-loop safety net: every discovered-field source above tracks
8963
+ // its own registration as it splices a `payload.<field>` accessor into the
8964
+ // emitted body/url/headers text, but that tracking is scattered across N
8965
+ // independent passes (form-schema discovery, option mappings, additional
8966
+ // body keys, structured keys, drill-param bindings, and — inside
8967
+ // emitMultiStepExecuteHttp's fold-loop `parameterize` closure — threaded
8968
+ // join-field rebinding), any one of which can add an accessor to the
8969
+ // rendered text without remembering to register it in the matching map
8970
+ // above. Rather than trust each source to stay perfectly in sync with the
8971
+ // text it emits, derive completeness from the actual rendered output: scan
8972
+ // `multiStepBody` (already fully assembled at this point — every chain
8973
+ // step's url/headers/body substitutions are done) for every
8974
+ // `payload.<field>` reference and union in any name the sources above
8975
+ // missed, with a conservative `z.string()` default. This closes the gap at
8976
+ // its structural root regardless of which upstream pass forgot to record a
8977
+ // field, instead of adding a fifth registration site that could itself be
8978
+ // forgotten by a future pass.
8979
+ if (multiStepBody) {
8980
+ const bodyReferencedFields = new Set([...multiStepBody.matchAll(/\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]));
8981
+ for (const name of [...bodyReferencedFields].sort()) {
8982
+ if (extendFields.has(name))
8983
+ continue;
8984
+ if (isReservedByApplicantContactSchema(name))
8985
+ continue;
8986
+ addExtendField(name, ` ${name}: z.string(),`);
8987
+ }
8988
+ }
7517
8989
  // The structural walk over the captured request body that used to BE the
7518
8990
  // public payload schema (see basePayloadSchemaExpr above) is still the
7519
8991
  // right starting point for the plugin author's internal builder — it's
@@ -7736,8 +9208,22 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7736
9208
  // referencesItemVar (below) never has a chance to hoist it.
7737
9209
  const isAncestorScoped = isFoldTargetAncestorScoped(target, actionSteps.map((s) => ({ capture: s.capture })), primaryItemsWithAncestors, fullAncestors);
7738
9210
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
7739
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
9211
+ // Only `itemVar` (and fold-match candidates) are `Record<string,
9212
+ // unknown>`-typed — ancestor loop vars keep the real response-derived
9213
+ // type, so re-asserting THEIR intermediate hops would be both
9214
+ // unnecessary and, worse, would replace a real property access with
9215
+ // an opaque cast in the emitted URL/body text.
9216
+ const scopedAccessor = (varName, field) => varName === itemVar
9217
+ ? unknownValueAccessor(varName, field.split("."))
9218
+ : `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
7740
9219
  const joinAccessor = (field) => scopedAccessor(itemVar, field);
9220
+ // Computed once per fold target instead of once per `parameterizeUrl`
9221
+ // call: `actionSteps` never changes across the calls this target's
9222
+ // chain steps make, so re-deriving this array on every one of
9223
+ // findThreadedJoinFields/isZeroVarianceRepeatCapture/
9224
+ // isGenuineVaryingQueryValue/assertNoFrozenVaryingDrillParams's own
9225
+ // calls below was O(actionSteps.length) work repeated 4x per call.
9226
+ const allCaptures = actionSteps.map((s) => s.capture);
7741
9227
  // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
7742
9228
  // `parameterize` performs — a plain split/join would also rewrite
7743
9229
  // unrelated substrings that happen to contain the join value.
@@ -7762,7 +9248,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7762
9248
  : rawUrl;
7763
9249
  const rawThreadedFields = dedupeThreadedFields([
7764
9250
  ...target.joinFields.map((field) => ({ varName: itemVar, field })),
7765
- ...findThreadedJoinFields(threadingScopes, chainCapture, actionSteps.map((s) => s.capture)),
9251
+ ...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
7766
9252
  ]);
7767
9253
  // Mirrors emitMultiStepExecuteHttp's identical rebind (see
7768
9254
  // isAncestorScoped above): a proven ancestor-scoped drill still
@@ -7787,7 +9273,13 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7787
9273
  : tf,
7788
9274
  }))
7789
9275
  : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
7790
- const result = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
9276
+ // ONE guarded regex-alternation pass over `withBase` for every
9277
+ // threaded field's value, longest first — see substituteThreadedValues's
9278
+ // doc for why a per-field sequential `.replace()` loop here (the bug
9279
+ // this replaces) can splice an unrelated value into an opaque URL
9280
+ // segment or nest a `${...}` placeholder.
9281
+ const valueBindings = threadedFieldPairs
9282
+ .map(({ valueField, accessorField }) => {
7791
9283
  const scopeObj = valueField.varName === itemVar
7792
9284
  ? firstItem
7793
9285
  : ancestorObjByVar.get(valueField.varName);
@@ -7797,12 +9289,31 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7797
9289
  : typeof value === "number" || typeof value === "boolean"
7798
9290
  ? String(value)
7799
9291
  : 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);
9292
+ return stringValue === null
9293
+ ? null
9294
+ : {
9295
+ value: stringValue,
9296
+ replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
9297
+ };
9298
+ })
9299
+ .filter((b) => b !== null);
9300
+ // A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
9301
+ // must never have a COINCIDENTAL threaded value spliced into it —
9302
+ // see emitMultiStepExecuteHttp's identical `parameterize` guard
9303
+ // ({@link isGenuineVaryingQueryValue}) for why this is decided
9304
+ // field by field rather than for the whole capture at once.
9305
+ const isProvenInvariant = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
9306
+ const filteredValueBindings = isProvenInvariant
9307
+ ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
9308
+ : valueBindings;
9309
+ const result = substituteThreadedValues(withBase, filteredValueBindings);
7804
9310
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
7805
- assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, withDrillParamBindings, actionSteps.map((s) => s.capture));
9311
+ // See emitMultiStepExecuteHttp's identical guard: the frozen-
9312
+ // varying-param safety net does not apply once the capture is
9313
+ // already proven request-invariant.
9314
+ if (!isProvenInvariant) {
9315
+ assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, withDrillParamBindings, allCaptures);
9316
+ }
7806
9317
  return withDrillParamBindings;
7807
9318
  };
7808
9319
  const chainLines = [];
@@ -7819,6 +9330,10 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
7819
9330
  const chainStep = actionSteps[chainIndex];
7820
9331
  if (!chainStep)
7821
9332
  continue;
9333
+ // The zero-variance guard lives inside `parameterizeUrl` itself
9334
+ // (see above) so it can skip only the threaded-value splice while
9335
+ // still letting a spec-declared drillParamBindings substitution
9336
+ // apply.
7822
9337
  const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
7823
9338
  if (itemVarRefPattern.test(url))
7824
9339
  referencesItemVar = true;
@@ -9060,7 +10575,7 @@ async function main() {
9060
10575
  // producible state (see collectDependentDrillDownChainValues).
9061
10576
  const dependentDrillDownChainValues = actionCaptures.length > 1
9062
10577
  ? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
9063
- : new Set();
10578
+ : new Map();
9064
10579
  const stateIndex = actionCaptures.length > 1
9065
10580
  ? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
9066
10581
  : new Map();
@@ -9089,6 +10604,16 @@ async function main() {
9089
10604
  : undefined;
9090
10605
  const errorSignals = detectErrorSignals(actionSteps);
9091
10606
  const discoveredFormFields = new Set();
10607
+ // emitMultiStepExecuteHttp's outDiscoveredFields parameter is a generic
10608
+ // payload-accessor accumulator — BaseUrl substitution, persona/producer-
10609
+ // boundary bindings, entryUrlParams, tenant-subdomain headers, and
10610
+ // walkStringLeaves-derived accessors all write into it, independent of
10611
+ // form-schema discovery. It gets its own Set (rather than aliasing
10612
+ // discoveredFormFields positionally) so the two concerns stay separately
10613
+ // named; the explicit merge below is what actually wires its fields into
10614
+ // emitContractTs's schema — never an incidental byproduct of sharing one
10615
+ // reference across unrelated call sites.
10616
+ const discoveredPayloadAccessorFields = new Set();
9092
10617
  const discoveredOptionFields = new Set();
9093
10618
  // Phase E: maps label-derived raw-option payload field name (e.g.
9094
10619
  // "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
@@ -9161,8 +10686,15 @@ async function main() {
9161
10686
  const multiStepBody = browserFlowOnly
9162
10687
  ? undefined
9163
10688
  : isSubmissionFlow
9164
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
10689
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredPayloadAccessorFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec, pascal)
9165
10690
  : undefined;
10691
+ // Explicit merge — every field emitMultiStepExecuteHttp registered as a
10692
+ // `payload.<field>` accessor (BaseUrl, persona, entryUrlParams, tenant-
10693
+ // subdomain headers, walkStringLeaves) flows into the same discovered-
10694
+ // fields set emitContractTs's schema `.extend()` reads from below.
10695
+ for (const field of discoveredPayloadAccessorFields) {
10696
+ discoveredFormFields.add(field);
10697
+ }
9166
10698
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
9167
10699
  const headerBindings = collectHeaderBindings(actionSteps);
9168
10700
  // Shape inference targets the SAME call executeHttp returns — see