@enricai/barnacle 1.12.48 → 1.12.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recon/capture-filters.d.ts +172 -5
- package/dist/recon/capture-filters.d.ts.map +1 -1
- package/dist/recon/capture-filters.js +308 -8
- package/dist/recon/capture-filters.js.map +1 -1
- package/dist/scraper/captcha-callback-capture.d.ts +11 -22
- package/dist/scraper/captcha-callback-capture.d.ts.map +1 -1
- package/dist/scraper/captcha-callback-capture.js +3 -89
- package/dist/scraper/captcha-callback-capture.js.map +1 -1
- package/dist/scraper/cdp-frame-init-script.d.ts +16 -0
- package/dist/scraper/cdp-frame-init-script.d.ts.map +1 -0
- package/dist/scraper/cdp-frame-init-script.js +67 -0
- package/dist/scraper/cdp-frame-init-script.js.map +1 -0
- package/dist/scraper/flow-runner.d.ts +58 -1
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +95 -9
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/session.d.ts.map +1 -1
- package/dist/scraper/session.js +3 -3
- package/dist/scraper/session.js.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.d.ts +31 -0
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +78 -0
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +90 -40
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +945 -82
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -43,6 +43,7 @@ exports.resolveManifestActionSequence = resolveManifestActionSequence;
|
|
|
43
43
|
exports.extractActionSequence = extractActionSequence;
|
|
44
44
|
exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
|
|
45
45
|
exports.dedupRedundantSameOperationCaptures = dedupRedundantSameOperationCaptures;
|
|
46
|
+
exports.isRedundantSameEndpointGroup = isRedundantSameEndpointGroup;
|
|
46
47
|
exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
|
|
47
48
|
exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
|
|
48
49
|
exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
|
|
@@ -1504,6 +1505,13 @@ function resolveManifestActionSequence(runRoot, captures) {
|
|
|
1504
1505
|
* a capture whose host fails {@link isAllowedFixtureHost} is dropped too —
|
|
1505
1506
|
* `isNoiseUrl` alone lets a third-party telemetry/beacon POST masquerade as
|
|
1506
1507
|
* a submission step, since it can look identical in shape to a real one.
|
|
1508
|
+
* A capture that recurs elsewhere with a byte-identical method/URL/body is
|
|
1509
|
+
* dropped too ({@link isZeroVarianceRepeatCapture}) — a same-host beacon
|
|
1510
|
+
* whose extension and host both look legitimate (e.g. a `.html` sensor
|
|
1511
|
+
* endpoint) still gives itself away by never varying across calls, which
|
|
1512
|
+
* `isNoiseUrl`'s substring/extension checks can't see and which the
|
|
1513
|
+
* structural-isolation pass below can even be fooled by (N identical copies
|
|
1514
|
+
* of the same path "vouch" for each other's tokens).
|
|
1507
1515
|
*
|
|
1508
1516
|
* When the flow declares submit patterns, only POSTs matching them survive —
|
|
1509
1517
|
* this isolates the submission from same-origin page chrome (bootstrap, chatbot,
|
|
@@ -1552,6 +1560,8 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1552
1560
|
return false;
|
|
1553
1561
|
if ((0, capture_filters_1.isNoiseUrl)(capture.url))
|
|
1554
1562
|
return false;
|
|
1563
|
+
if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
|
|
1564
|
+
return false;
|
|
1555
1565
|
if (!matchesSubmit(capture))
|
|
1556
1566
|
return false;
|
|
1557
1567
|
if (hasHostProvenance &&
|
|
@@ -1573,11 +1583,28 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1573
1583
|
// but a 1-2 capture pool has no "everything else" to be isolated from, so
|
|
1574
1584
|
// skip it there rather than risk flagging a single-endpoint site's own
|
|
1575
1585
|
// hyphenated path.
|
|
1586
|
+
//
|
|
1587
|
+
// A candidate's own same-pathname repeats only count as evidence against
|
|
1588
|
+
// itself (not for it) when its path carries a densely name-spaced,
|
|
1589
|
+
// marketing/tracking-shaped signal — more than one compound segment's
|
|
1590
|
+
// worth of tokens (e.g. `/site-banner/promotions-widget`, 4 tokens across
|
|
1591
|
+
// two compound segments) — OR when the repeats themselves carry no
|
|
1592
|
+
// business-relevant response state ({@link hasNoBusinessRelevantResponseState}):
|
|
1593
|
+
// a real own-backend endpoint (a polled toggles feed, a paged listing) is
|
|
1594
|
+
// often named with at most one compound segment, so path shape alone can't
|
|
1595
|
+
// tell it apart from a same-shaped, same-host, zero-business-value poll
|
|
1596
|
+
// (an availability/feature-flag ping that answers every call with nothing
|
|
1597
|
+
// a caller could not already know) — both are "one compound segment,
|
|
1598
|
+
// repeats identically." Response content is what actually distinguishes
|
|
1599
|
+
// them, so a candidate whose own repeats carry no business-relevant state
|
|
1600
|
+
// loses the self-vouching exemption regardless of its token count, while a
|
|
1601
|
+
// genuinely data-bearing single-compound-segment endpoint keeps it.
|
|
1576
1602
|
const structurallyGated = hasHostProvenance && hostGated.length > 2
|
|
1577
1603
|
? hostGated.filter(({ capture }, i) => {
|
|
1578
1604
|
const path = safeUrlPathname(capture.url);
|
|
1605
|
+
const denselyNameSpaced = (0, capture_filters_1.pathStructuralTokens)(path).size > 2 || (0, capture_filters_1.hasNoBusinessRelevantResponseState)(capture);
|
|
1579
1606
|
const otherPaths = hostGated
|
|
1580
|
-
.filter((
|
|
1607
|
+
.filter((h, j) => denselyNameSpaced ? safeUrlPathname(h.capture.url) !== path : j !== i)
|
|
1581
1608
|
.map((h) => safeUrlPathname(h.capture.url));
|
|
1582
1609
|
return !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths);
|
|
1583
1610
|
})
|
|
@@ -1638,6 +1665,24 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1638
1665
|
* Exported for tests: this predicate decides what a generated GraphQL plugin
|
|
1639
1666
|
* will send at a live site.
|
|
1640
1667
|
*/
|
|
1668
|
+
/** REST HTTP methods that write/mutate server state rather than merely
|
|
1669
|
+
* reading it — a capture using one of these is never a re-readable
|
|
1670
|
+
* poll/listing, regardless of how flat its response body looks. */
|
|
1671
|
+
const MUTATING_HTTP_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1672
|
+
/** A mutation capture: either GraphQL (identified by its parsed operation
|
|
1673
|
+
* query string starting with `mutation`) or REST (identified by a
|
|
1674
|
+
* non-idempotent HTTP method). Its response is a single mutated object
|
|
1675
|
+
* rather than a re-readable list/flag, so it must never be folded in with
|
|
1676
|
+
* genuinely idempotent reads by {@link isRedundantSameEndpointGroup} — a
|
|
1677
|
+
* flat-response REST POST (e.g. a wizard section save) is exactly as
|
|
1678
|
+
* non-poll-able as a GraphQL mutation, but `capture.query` is always null
|
|
1679
|
+
* for REST, so the GraphQL-only check alone would misclassify it as a
|
|
1680
|
+
* collapsible poll. */
|
|
1681
|
+
function isMutationCapture(capture) {
|
|
1682
|
+
if (capture.query !== null)
|
|
1683
|
+
return /^\s*mutation\b/.test(capture.query);
|
|
1684
|
+
return MUTATING_HTTP_METHODS.has(capture.method.toUpperCase());
|
|
1685
|
+
}
|
|
1641
1686
|
function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null) {
|
|
1642
1687
|
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1643
1688
|
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
@@ -1648,7 +1693,7 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
|
|
|
1648
1693
|
// only applies once the caller has actually resolved a notion of "own
|
|
1649
1694
|
// backend" to check against.
|
|
1650
1695
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1651
|
-
const isMutation =
|
|
1696
|
+
const isMutation = isMutationCapture;
|
|
1652
1697
|
const admitted = captures
|
|
1653
1698
|
.map((capture, index) => ({ capture, index }))
|
|
1654
1699
|
.filter(({ capture }) => {
|
|
@@ -1656,6 +1701,8 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
|
|
|
1656
1701
|
return false;
|
|
1657
1702
|
if ((0, capture_filters_1.isNoiseUrl)(capture.url))
|
|
1658
1703
|
return false;
|
|
1704
|
+
if ((0, capture_filters_1.isZeroVarianceRepeatCapture)(capture, captures))
|
|
1705
|
+
return false;
|
|
1659
1706
|
if (!matchesSubmit(capture))
|
|
1660
1707
|
return false;
|
|
1661
1708
|
if (hasHostProvenance &&
|
|
@@ -1688,7 +1735,7 @@ function responseShapeKey(capture) {
|
|
|
1688
1735
|
const arrayField = findObjectArrayField(capture.responseBody);
|
|
1689
1736
|
if (!arrayField)
|
|
1690
1737
|
return null;
|
|
1691
|
-
return `${endpointKey(capture.url)}
|
|
1738
|
+
return `${endpointKey(capture.url)} ${arrayField.path.join(".")}`;
|
|
1692
1739
|
}
|
|
1693
1740
|
/**
|
|
1694
1741
|
* Drops redundant re-issues of the primary GraphQL read operation from a
|
|
@@ -1744,6 +1791,424 @@ function collapseRedundantPatches(actions) {
|
|
|
1744
1791
|
return lastPatchByPath.get(path) === i;
|
|
1745
1792
|
});
|
|
1746
1793
|
}
|
|
1794
|
+
/** Request field name shapes that identify a paged/offset-style re-query --
|
|
1795
|
+
* the REST analog of {@link PAGE_SIZE_KEY_PATTERN}/{@link SKIP_KEY_PATTERN},
|
|
1796
|
+
* loosened to match a single paging cursor field on its own (a plain `page`
|
|
1797
|
+
* counter, unpaired with an explicit page-size key) since that is the common
|
|
1798
|
+
* REST shape, unlike GraphQL's paired variables convention. */
|
|
1799
|
+
const PAGINATION_FIELD_NAME_PATTERN = /^(page|pagenum|pagenumber|pageindex|pageno|offset|skip|start|cursor)$/i;
|
|
1800
|
+
/** Request-field key names that name known client-generated scaffolding
|
|
1801
|
+
* (a monotonic sequence counter, a correlation/trace id, an idempotency
|
|
1802
|
+
* nonce) rather than genuine payload data. Gates {@link
|
|
1803
|
+
* isFieldValueThreadedElsewhere} on a FLAT (non-array) response's
|
|
1804
|
+
* BODY-carried varying field -- unlike an array-shaped listing/facet
|
|
1805
|
+
* re-query, a mutation's request-body field could just as easily be real
|
|
1806
|
+
* user-entered payload (an address line, a card's last4) that happens
|
|
1807
|
+
* never to be echoed back downstream, so that field additionally requires
|
|
1808
|
+
* its key name to look like scaffolding before trusting the "never echoed"
|
|
1809
|
+
* proof. A varying field that lives ONLY in the URL query string (never in
|
|
1810
|
+
* the body) skips this name requirement instead -- see {@link
|
|
1811
|
+
* isQueryStringOnlyKey}. */
|
|
1812
|
+
const SCAFFOLDING_FIELD_NAME_PATTERN = /^(req|request|correlation|trace|session|idempotency)?[-_]?(seq|id|key|nonce)$/i;
|
|
1813
|
+
/** Every query-string and (when JSON-object-shaped) request-body field on a
|
|
1814
|
+
* capture, merged into one comparable map -- REST pagination/facet state can
|
|
1815
|
+
* live in either depending on the endpoint's own convention. Body fields are
|
|
1816
|
+
* flattened to their full leaf path (`paging.page`, not just `paging`) via
|
|
1817
|
+
* {@link walkAllPrimitiveLeaves} so a nested pagination/facet/scaffolding
|
|
1818
|
+
* object (`{"paging":{"page":1}}`) surfaces as its own comparable leaf
|
|
1819
|
+
* instead of collapsing into one opaque, always-varying JSON-stringified
|
|
1820
|
+
* key -- see {@link fieldKeyLeafName} for how callers recover the bare leaf
|
|
1821
|
+
* name a nested path's own key-name pattern match must test against. */
|
|
1822
|
+
function captureRequestFields(capture) {
|
|
1823
|
+
const fields = {};
|
|
1824
|
+
try {
|
|
1825
|
+
const url = new URL(capture.url);
|
|
1826
|
+
for (const [key, value] of url.searchParams)
|
|
1827
|
+
fields[key] = value;
|
|
1828
|
+
}
|
|
1829
|
+
catch {
|
|
1830
|
+
// Relative/invalid URLs carry no query-string signal to merge in.
|
|
1831
|
+
}
|
|
1832
|
+
if (capture.requestPostData) {
|
|
1833
|
+
try {
|
|
1834
|
+
const parsed = JSON.parse(capture.requestPostData);
|
|
1835
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1836
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
|
|
1837
|
+
fields[path.join(".")] = value;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
// A non-JSON body carries no per-field signal to merge in.
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
return fields;
|
|
1846
|
+
}
|
|
1847
|
+
/** Recovers the bare field/leaf name (`page`) from a {@link
|
|
1848
|
+
* captureRequestFields} key that may be a dotted nested path (`paging.page`)
|
|
1849
|
+
* -- a flat top-level key is its own leaf name, so this is a no-op for the
|
|
1850
|
+
* pre-existing flat case. Used wherever a key is tested against a NAME
|
|
1851
|
+
* pattern ({@link PAGINATION_FIELD_NAME_PATTERN}, {@link
|
|
1852
|
+
* SCAFFOLDING_FIELD_NAME_PATTERN}) or looked up in {@link
|
|
1853
|
+
* requestAndResponseValuesByKey}'s index, both of which key by bare leaf
|
|
1854
|
+
* name, not by the nested path that disambiguates it during varying-key
|
|
1855
|
+
* detection. */
|
|
1856
|
+
function fieldKeyLeafName(key) {
|
|
1857
|
+
const segments = key.split(".");
|
|
1858
|
+
return segments[segments.length - 1] ?? key;
|
|
1859
|
+
}
|
|
1860
|
+
/** True when `key` is carried ONLY by the URL query string across every
|
|
1861
|
+
* capture in `group` -- never by a JSON request body. Used to widen the
|
|
1862
|
+
* flat-response scaffolding gate past its closed name allowlist for the
|
|
1863
|
+
* common case of a REST poll's tracking param, without extending that same
|
|
1864
|
+
* trust to a mutation's body-carried payload field (see the comment at its
|
|
1865
|
+
* call site in {@link isRedundantSameEndpointGroup}). */
|
|
1866
|
+
function isQueryStringOnlyKey(key, group) {
|
|
1867
|
+
return group.every((a) => {
|
|
1868
|
+
let inQuery = false;
|
|
1869
|
+
try {
|
|
1870
|
+
inQuery = new URL(a.capture.url).searchParams.has(key);
|
|
1871
|
+
}
|
|
1872
|
+
catch {
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1875
|
+
if (!inQuery)
|
|
1876
|
+
return false;
|
|
1877
|
+
if (!a.capture.requestPostData)
|
|
1878
|
+
return true;
|
|
1879
|
+
try {
|
|
1880
|
+
const parsed = JSON.parse(a.capture.requestPostData);
|
|
1881
|
+
return (parsed === null ||
|
|
1882
|
+
typeof parsed !== "object" ||
|
|
1883
|
+
Array.isArray(parsed) ||
|
|
1884
|
+
!(key in parsed));
|
|
1885
|
+
}
|
|
1886
|
+
catch {
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
/** Per-capture memoization cache for {@link requestAndResponseValuesByKey} --
|
|
1892
|
+
* without it, {@link isFieldValueThreadedElsewhere} re-parses the same
|
|
1893
|
+
* capture's JSON body and re-walks the same response-body leaves once per
|
|
1894
|
+
* (group, varying-key, group-member) combination it's compared against,
|
|
1895
|
+
* which is O(groups * keys * members * allActions) recomputations of
|
|
1896
|
+
* identical work instead of O(allActions). */
|
|
1897
|
+
const requestAndResponseValuesCache = new WeakMap();
|
|
1898
|
+
function requestAndResponseValuesByKey(capture) {
|
|
1899
|
+
const cached = requestAndResponseValuesCache.get(capture);
|
|
1900
|
+
if (cached)
|
|
1901
|
+
return cached;
|
|
1902
|
+
const byKey = new Map();
|
|
1903
|
+
const pathSegments = new Set();
|
|
1904
|
+
const add = (key, value) => {
|
|
1905
|
+
const values = byKey.get(key) ?? new Set();
|
|
1906
|
+
values.add(value);
|
|
1907
|
+
byKey.set(key, values);
|
|
1908
|
+
};
|
|
1909
|
+
try {
|
|
1910
|
+
const url = new URL(capture.url);
|
|
1911
|
+
for (const segment of url.pathname.split("/").filter(Boolean))
|
|
1912
|
+
pathSegments.add(segment);
|
|
1913
|
+
for (const [key, value] of url.searchParams)
|
|
1914
|
+
add(key, value);
|
|
1915
|
+
}
|
|
1916
|
+
catch {
|
|
1917
|
+
// Relative/invalid URLs carry no path/query signal to contribute.
|
|
1918
|
+
}
|
|
1919
|
+
if (capture.requestPostData) {
|
|
1920
|
+
try {
|
|
1921
|
+
const parsed = JSON.parse(capture.requestPostData);
|
|
1922
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
|
|
1923
|
+
if (value !== null && path.length > 0)
|
|
1924
|
+
add(path[path.length - 1], String(value));
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
catch {
|
|
1928
|
+
// A non-JSON body carries no leaf values to contribute.
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
|
|
1932
|
+
if (value !== null && path.length > 0)
|
|
1933
|
+
add(path[path.length - 1], String(value));
|
|
1934
|
+
}
|
|
1935
|
+
const result = { byKey, pathSegments };
|
|
1936
|
+
requestAndResponseValuesCache.set(capture, result);
|
|
1937
|
+
return result;
|
|
1938
|
+
}
|
|
1939
|
+
const fieldValueIndexCache = new WeakMap();
|
|
1940
|
+
function fieldValueIndex(allActions) {
|
|
1941
|
+
const cached = fieldValueIndexCache.get(allActions);
|
|
1942
|
+
if (cached)
|
|
1943
|
+
return cached;
|
|
1944
|
+
const byKeyValue = new Map();
|
|
1945
|
+
const byPathSegment = new Map();
|
|
1946
|
+
for (const { capture } of allActions) {
|
|
1947
|
+
const { byKey, pathSegments } = requestAndResponseValuesByKey(capture);
|
|
1948
|
+
for (const [key, values] of byKey) {
|
|
1949
|
+
const valueMap = byKeyValue.get(key) ?? new Map();
|
|
1950
|
+
byKeyValue.set(key, valueMap);
|
|
1951
|
+
for (const value of values) {
|
|
1952
|
+
const captures = valueMap.get(value) ?? new Set();
|
|
1953
|
+
captures.add(capture);
|
|
1954
|
+
valueMap.set(value, captures);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
for (const segment of pathSegments) {
|
|
1958
|
+
const captures = byPathSegment.get(segment) ?? new Set();
|
|
1959
|
+
captures.add(capture);
|
|
1960
|
+
byPathSegment.set(segment, captures);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
const index = { byKeyValue, byPathSegment };
|
|
1964
|
+
fieldValueIndexCache.set(allActions, index);
|
|
1965
|
+
return index;
|
|
1966
|
+
}
|
|
1967
|
+
/** True when `value` -- one member's own value for `fieldKey`, the sole
|
|
1968
|
+
* varying request field of a same-endpoint group -- shows up under that
|
|
1969
|
+
* SAME field/leaf name in some capture OUTSIDE the group itself (any OTHER,
|
|
1970
|
+
* DIFFERENT-endpoint capture's query/body/response), proving some later
|
|
1971
|
+
* step reads or threads it. False means the value is either scaffolding the
|
|
1972
|
+
* client generated and nothing downstream ever consumes, OR a cursor the
|
|
1973
|
+
* group's OWN members hand to each other -- e.g. page 1's response minting
|
|
1974
|
+
* the exact cursor value page 2's request carries -- which is chained
|
|
1975
|
+
* pagination state, not distinct data a different step depends on, so an
|
|
1976
|
+
* echo confined to sibling occurrences of this SAME same-endpoint group
|
|
1977
|
+
* must not block collapsing it, OR a short scalar (a low-cardinality page
|
|
1978
|
+
* counter) that merely string-equals some unrelated field elsewhere by
|
|
1979
|
+
* coincidence -- requiring the match to occur under the SAME field name is
|
|
1980
|
+
* what tells genuine cross-step threading (an id echoed back under its own
|
|
1981
|
+
* name) apart from that coincidence, since an unrelated field publishing
|
|
1982
|
+
* the same short digit string under a DIFFERENT name proves nothing. This
|
|
1983
|
+
* is the structural signal {@link isRedundantSameEndpointGroup} uses to
|
|
1984
|
+
* widen collapsing past the literal {@link CACHE_BUSTER_QUERY_KEYS}/{@link
|
|
1985
|
+
* PAGINATION_FIELD_NAME_PATTERN} allowlists without hand-enumerating more
|
|
1986
|
+
* key-name shapes. A non-primitive or empty value can't be structurally
|
|
1987
|
+
* proven dead, so it's treated as load-bearing by default. */
|
|
1988
|
+
function isFieldValueThreadedElsewhere(fieldKey, value, groupCaptures, allActions) {
|
|
1989
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
1990
|
+
return true;
|
|
1991
|
+
}
|
|
1992
|
+
const stringValue = String(value);
|
|
1993
|
+
if (stringValue.length === 0)
|
|
1994
|
+
return true;
|
|
1995
|
+
const { byKeyValue, byPathSegment } = fieldValueIndex(allActions);
|
|
1996
|
+
const isOutsideGroup = (captures) => captures !== undefined && [...captures].some((capture) => !groupCaptures.has(capture));
|
|
1997
|
+
return (isOutsideGroup(byKeyValue.get(fieldKey)?.get(stringValue)) ||
|
|
1998
|
+
isOutsideGroup(byPathSegment.get(stringValue)));
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* A same-endpoint capture group qualifies for collapsing when every capture
|
|
2002
|
+
* resolves to the same {@link responseShapeKey}, OR (when the response has no
|
|
2003
|
+
* array field anywhere, e.g. a flat poll/flag-style object) every capture is
|
|
2004
|
+
* either a non-mutation, or a mutation whose response body is byte-identical
|
|
2005
|
+
* across every occurrence in the group, whose response independently
|
|
2006
|
+
* resolves via {@link findObjectArrayFieldOrWholeObject}'s whole-object
|
|
2007
|
+
* fallback -- this rules out a mutation POST with a genuinely varying
|
|
2008
|
+
* response (e.g. a wizard section save), whose flat response is a single
|
|
2009
|
+
* mutated object rather than a re-readable poll result, while still
|
|
2010
|
+
* admitting a flat zero-variance re-poll fired via a mutating method (e.g. a
|
|
2011
|
+
* feature-flag/heartbeat check fired via POST) -- AND EVERY request field
|
|
2012
|
+
* that varies once known non-semantic
|
|
2013
|
+
* noise keys ({@link CACHE_BUSTER_QUERY_KEYS}) are excluded from
|
|
2014
|
+
* consideration is EITHER pagination-shaped -- a paged listing/facet re-query
|
|
2015
|
+
* -- OR (when `allActions`, the full capture sequence, is supplied)
|
|
2016
|
+
* independently proven via {@link isFieldValueThreadedElsewhere} to never be
|
|
2017
|
+
* read by any capture OUTSIDE this same-endpoint group -- a cache-buster/
|
|
2018
|
+
* nonce/request-id shape the literal allowlists don't happen to name, OR a
|
|
2019
|
+
* cursor the group's own members hand to each other (page 1's response
|
|
2020
|
+
* minting the exact value page 2's request carries) -- or the group varies
|
|
2021
|
+
* in no field at all -- a polled toggles/feature-flag endpoint re-fired with
|
|
2022
|
+
* an identical request. A group can have any number of varying keys; each
|
|
2023
|
+
* one must clear its own pagination-or-dead check independently, so a page
|
|
2024
|
+
* cursor alongside an unrelated dead cache-buster key still collapses.
|
|
2025
|
+
* Without `allActions` (unit tests exercising this predicate in isolation,
|
|
2026
|
+
* with no flow context to check against) a non-pagination varying key can't
|
|
2027
|
+
* be structurally proven dead, so the group is left untouched -- the same
|
|
2028
|
+
* conservative outcome as before this widening. A group with any varying
|
|
2029
|
+
* field proven read by a DIFFERENT step outside the group (e.g. a per-item
|
|
2030
|
+
* drill's item-id, later echoed into that item's detail request) is
|
|
2031
|
+
* likewise left untouched: that variance carries the distinct per-item
|
|
2032
|
+
* state the existing fold-chain mechanism (`target.chain` in
|
|
2033
|
+
* `emitMultiStepExecuteHttp`) already hoists correctly once resolved, and
|
|
2034
|
+
* collapsing it here would erase the very state that hoisting depends on.
|
|
2035
|
+
*/
|
|
2036
|
+
function isRedundantSameEndpointGroup(group, allActions) {
|
|
2037
|
+
const shapeKey = responseShapeKey(group[0].capture);
|
|
2038
|
+
if (shapeKey !== null) {
|
|
2039
|
+
if (!group.every((a) => responseShapeKey(a.capture) === shapeKey))
|
|
2040
|
+
return false;
|
|
2041
|
+
}
|
|
2042
|
+
else {
|
|
2043
|
+
// A flat (non-array) response never resolves a `responseShapeKey`, but a
|
|
2044
|
+
// zero-variance re-poll of a flag/toggle endpoint still needs a shape to
|
|
2045
|
+
// key on -- fall back to the whole-object candidate every group member
|
|
2046
|
+
// must independently resolve to. A GraphQL mutation (detected via the
|
|
2047
|
+
// parsed operation query) is always excluded, since its response is by
|
|
2048
|
+
// definition the result of a state change. A REST capture excluded only
|
|
2049
|
+
// because of its HTTP method (POST/PUT/PATCH/DELETE) is admitted anyway
|
|
2050
|
+
// when every occurrence's response body is byte-identical -- that is
|
|
2051
|
+
// proof the call carries no distinct mutated state at all (a
|
|
2052
|
+
// feature-flag/heartbeat check fired via POST), the same zero-variance
|
|
2053
|
+
// signal {@link isZeroVarianceRepeatCapture} already uses for noise
|
|
2054
|
+
// exclusion, generalized here for the collapse decision.
|
|
2055
|
+
const isGraphQLMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
|
|
2056
|
+
const responsesByteIdentical = group.every((a) => JSON.stringify(a.capture.responseBody) === JSON.stringify(group[0].capture.responseBody));
|
|
2057
|
+
const isFlatObject = (capture) => !isGraphQLMutation(capture) &&
|
|
2058
|
+
(!isMutationCapture(capture) || responsesByteIdentical) &&
|
|
2059
|
+
responseShapeKey(capture) === null &&
|
|
2060
|
+
findObjectArrayFieldOrWholeObject(capture.responseBody) !== null;
|
|
2061
|
+
if (!group.every((a) => isFlatObject(a.capture)))
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
const fieldSets = group.map((a) => captureRequestFields(a.capture));
|
|
2065
|
+
const allKeys = new Set();
|
|
2066
|
+
for (const fields of fieldSets) {
|
|
2067
|
+
for (const key of Object.keys(fields))
|
|
2068
|
+
allKeys.add(key);
|
|
2069
|
+
}
|
|
2070
|
+
const varyingKeys = [...allKeys].filter((key) => {
|
|
2071
|
+
if (CACHE_BUSTER_QUERY_KEYS.has(key))
|
|
2072
|
+
return false;
|
|
2073
|
+
const values = new Set(fieldSets.map((fields) => JSON.stringify(fields[key])));
|
|
2074
|
+
return values.size > 1;
|
|
2075
|
+
});
|
|
2076
|
+
if (varyingKeys.length === 0)
|
|
2077
|
+
return true;
|
|
2078
|
+
const groupCaptures = new Set(group.map((a) => a.capture));
|
|
2079
|
+
return varyingKeys.every((key) => {
|
|
2080
|
+
const leafName = fieldKeyLeafName(key);
|
|
2081
|
+
if (PAGINATION_FIELD_NAME_PATTERN.test(leafName))
|
|
2082
|
+
return true;
|
|
2083
|
+
// A flat response's varying field name must still look like scaffolding
|
|
2084
|
+
// UNLESS it lives only in the URL query string (never the JSON body) --
|
|
2085
|
+
// a query-string param is the conventional home for ephemeral
|
|
2086
|
+
// client-generated metadata (cache-busters, correlation ids, poll
|
|
2087
|
+
// ticks) regardless of what the site happens to call it, whereas a
|
|
2088
|
+
// JSON body field is where a mutation's genuine submitted payload (an
|
|
2089
|
+
// address line, a card's last4) lives, and that ambiguity is exactly
|
|
2090
|
+
// why the name-pattern requirement stays for body fields: an unnamed
|
|
2091
|
+
// body field being "never echoed elsewhere" is no proof it's dead, only
|
|
2092
|
+
// that nothing downstream happened to read it back.
|
|
2093
|
+
if (shapeKey === null &&
|
|
2094
|
+
!SCAFFOLDING_FIELD_NAME_PATTERN.test(leafName) &&
|
|
2095
|
+
!isQueryStringOnlyKey(key, group)) {
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
if (!allActions)
|
|
2099
|
+
return false;
|
|
2100
|
+
return fieldSets.every((fields) => !isFieldValueThreadedElsewhere(leafName, fields[key], groupCaptures, allActions));
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* REST counterpart of {@link dedupRedundantSameOperationCaptures}: collapses
|
|
2105
|
+
* each same-endpoint (method + {@link endpointKey}) group that qualifies per
|
|
2106
|
+
* {@link isRedundantSameEndpointGroup} down to its FIRST occurrence. A paged
|
|
2107
|
+
* listing re-fired across pages/facets, or a polled toggles endpoint re-fired
|
|
2108
|
+
* with an identical request, carries no distinct state for downstream steps
|
|
2109
|
+
* to thread; every other same-endpoint group (including a per-item drill
|
|
2110
|
+
* varying by item id) is left exactly as extracted. Kept representative is
|
|
2111
|
+
* the first occurrence, not the last, because a later step's fold/join (see
|
|
2112
|
+
* `buildFoldPlanFromSpec`) resolves its item-level join values against
|
|
2113
|
+
* whichever page's response survives — page 1 is what a browsing/drill flow
|
|
2114
|
+
* actually saw and drilled into first, so it is the occurrence downstream
|
|
2115
|
+
* join values are captured against, not the endpoint's final paged state.
|
|
2116
|
+
*
|
|
2117
|
+
* A real per-item drill can join against ANY page's item, though, not just
|
|
2118
|
+
* page 1's — so before the rest of the group is dropped, every OTHER
|
|
2119
|
+
* occurrence's own array-field items (at the same {@link responseShapeKey}
|
|
2120
|
+
* path proven identical across the group) are concatenated onto the kept
|
|
2121
|
+
* representative's response body. Without this, {@link
|
|
2122
|
+
* detectDrillDownFoldPlan}'s structural scan only ever sees page 1's items
|
|
2123
|
+
* (every later page having just been deleted), so a drill keyed off a
|
|
2124
|
+
* later page's item can never resolve a join match and falls through to a
|
|
2125
|
+
* hardcoded per-capture `httpClient` call instead of folding into the loop.
|
|
2126
|
+
*/
|
|
2127
|
+
function collapseRedundantSameEndpointCaptures(actions) {
|
|
2128
|
+
const positionsByGroup = new Map();
|
|
2129
|
+
actions.forEach((a, i) => {
|
|
2130
|
+
const key = `${a.capture.method} ${endpointKey(a.capture.url)}`;
|
|
2131
|
+
const positions = positionsByGroup.get(key) ?? [];
|
|
2132
|
+
positions.push(i);
|
|
2133
|
+
positionsByGroup.set(key, positions);
|
|
2134
|
+
});
|
|
2135
|
+
const mergedRepresentativeByPosition = new Map();
|
|
2136
|
+
const drop = new Set();
|
|
2137
|
+
for (const positions of positionsByGroup.values()) {
|
|
2138
|
+
if (positions.length < 2)
|
|
2139
|
+
continue;
|
|
2140
|
+
const group = positions.map((i) => actions[i]);
|
|
2141
|
+
if (!isRedundantSameEndpointGroup(group, actions))
|
|
2142
|
+
continue;
|
|
2143
|
+
const merged = mergeCollapsedGroupItemsIntoRepresentative(group);
|
|
2144
|
+
if (merged !== null)
|
|
2145
|
+
mergedRepresentativeByPosition.set(positions[0], merged);
|
|
2146
|
+
for (const position of positions.slice(1))
|
|
2147
|
+
drop.add(position);
|
|
2148
|
+
}
|
|
2149
|
+
return actions
|
|
2150
|
+
.map((a, i) => mergedRepresentativeByPosition.get(i) ?? a)
|
|
2151
|
+
.filter((_, i) => !drop.has(i));
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Builds a REPLACEMENT for the kept representative's (`group[0]`) own
|
|
2155
|
+
* {@link ActionCapture} whose response body concatenates every OTHER group
|
|
2156
|
+
* member's array-field items at their shared {@link responseShapeKey} path
|
|
2157
|
+
* onto the representative's own items — see {@link
|
|
2158
|
+
* collapseRedundantSameEndpointCaptures}'s docstring for why. Returns `null`
|
|
2159
|
+
* (no replacement needed) when the group's shape key is `null` (the
|
|
2160
|
+
* flat/zero-variance-poll branch of {@link isRedundantSameEndpointGroup}, with
|
|
2161
|
+
* no array-field path to merge items at) or when no other member actually
|
|
2162
|
+
* contributes an item at that path.
|
|
2163
|
+
*
|
|
2164
|
+
* A NEW response body/capture/action is built rather than mutating the
|
|
2165
|
+
* representative's own objects in place, deliberately: {@link
|
|
2166
|
+
* findAllObjectArrayFields}'s `objectArrayFieldsCache` is keyed on response-body
|
|
2167
|
+
* object IDENTITY under the explicit invariant that a response body is never
|
|
2168
|
+
* mutated after it's produced — mutating `representative.capture.responseBody`
|
|
2169
|
+
* in place would poison that cache with whatever shape happened to be computed
|
|
2170
|
+
* (and cached) from it before this runs, silently discarding the merge for
|
|
2171
|
+
* every caller downstream that hits the stale cache entry instead of the
|
|
2172
|
+
* mutated array.
|
|
2173
|
+
*/
|
|
2174
|
+
function mergeCollapsedGroupItemsIntoRepresentative(group) {
|
|
2175
|
+
const representative = group[0];
|
|
2176
|
+
const arrayField = findObjectArrayField(representative.capture.responseBody);
|
|
2177
|
+
if (!arrayField)
|
|
2178
|
+
return null;
|
|
2179
|
+
const mergedItems = arrayField.items.slice();
|
|
2180
|
+
let contributed = false;
|
|
2181
|
+
for (const other of group.slice(1)) {
|
|
2182
|
+
const otherArrayField = findObjectArrayField(other.capture.responseBody);
|
|
2183
|
+
if (!otherArrayField || otherArrayField.path.join(".") !== arrayField.path.join("."))
|
|
2184
|
+
continue;
|
|
2185
|
+
mergedItems.push(...otherArrayField.items);
|
|
2186
|
+
contributed = true;
|
|
2187
|
+
}
|
|
2188
|
+
if (!contributed)
|
|
2189
|
+
return null;
|
|
2190
|
+
const mergedBody = setValueAtPath(representative.capture.responseBody, arrayField.path, mergedItems);
|
|
2191
|
+
return { ...representative, capture: { ...representative.capture, responseBody: mergedBody } };
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Returns a shallow-cloned-along-the-path copy of `body` with the value at
|
|
2195
|
+
* `path` replaced by `newValue` — the immutable counterpart to mutating a
|
|
2196
|
+
* response body in place, used by {@link
|
|
2197
|
+
* mergeCollapsedGroupItemsIntoRepresentative} so the object-identity-keyed
|
|
2198
|
+
* {@link objectArrayFieldsCache} never sees the same object with two different
|
|
2199
|
+
* shapes. Only plain-object segments are supported (every real caller's
|
|
2200
|
+
* `arrayField.path` is a DFS-discovered chain of object keys, never an array
|
|
2201
|
+
* index), so an unresolvable segment returns `body` unchanged.
|
|
2202
|
+
*/
|
|
2203
|
+
function setValueAtPath(body, path, newValue) {
|
|
2204
|
+
if (path.length === 0)
|
|
2205
|
+
return newValue;
|
|
2206
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
2207
|
+
return body;
|
|
2208
|
+
const [head, ...rest] = path;
|
|
2209
|
+
const record = body;
|
|
2210
|
+
return { ...record, [head]: setValueAtPath(record[head], rest, newValue) };
|
|
2211
|
+
}
|
|
1747
2212
|
/**
|
|
1748
2213
|
* Recursively walks a JSON value and yields every string leaf, paired with its
|
|
1749
2214
|
* JSON path. Numbers/booleans/nulls are skipped — only string leaves are
|
|
@@ -2898,7 +3363,7 @@ function* walkSetCookiePairs(rawSetCookie) {
|
|
|
2898
3363
|
*/
|
|
2899
3364
|
/** Exported for unit testing — lets tests exercise the produces[] walk (body
|
|
2900
3365
|
* AND header/cookie origins) directly against synthetic Capture sequences. */
|
|
2901
|
-
function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new
|
|
3366
|
+
function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set(), forceIncludeValues = new Map()) {
|
|
2902
3367
|
const index = new Map();
|
|
2903
3368
|
// Computed structurally off the SAME captures being indexed (no
|
|
2904
3369
|
// foldReturnSpec available at this layer) — a spec-declared fold's own
|
|
@@ -2907,6 +3372,18 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2907
3372
|
// call), so this indexes a chain-produced value regardless of whether the
|
|
2908
3373
|
// fold plan that confirmed it is structural or spec-declared.
|
|
2909
3374
|
const chainForceIncludeValues = collectDependentDrillDownChainValues(captures.map((capture) => ({ capture })), null);
|
|
3375
|
+
// The set of captures a short/force-included value is actually eligible to
|
|
3376
|
+
// be spliced into — the union of whatever `chainForceIncludeValues` and the
|
|
3377
|
+
// caller-supplied `forceIncludeValues` proved for that value. `undefined`
|
|
3378
|
+
// when the value isn't exemption-derived, meaning the eligibility
|
|
3379
|
+
// restriction doesn't apply (see `StateValue.eligibleConsumers`).
|
|
3380
|
+
const eligibleConsumersFor = (value) => {
|
|
3381
|
+
const chainConsumers = chainForceIncludeValues.get(value);
|
|
3382
|
+
const forceConsumers = forceIncludeValues.get(value);
|
|
3383
|
+
if (!chainConsumers && !forceConsumers)
|
|
3384
|
+
return undefined;
|
|
3385
|
+
return new Set([...(chainConsumers ?? []), ...(forceConsumers ?? [])]);
|
|
3386
|
+
};
|
|
2910
3387
|
// First pass: identify the earliest origin among ACTION captures for each
|
|
2911
3388
|
// value. Action-only earliest-origin tracking is what compileActionSteps'
|
|
2912
3389
|
// produces[] check needs — it ignores non-action captures (telemetry GETs,
|
|
@@ -2929,9 +3406,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2929
3406
|
// floor below: a cookie-sourced value the fold-chain detector already
|
|
2930
3407
|
// confirmed is threaded into a later hop's request is exactly as
|
|
2931
3408
|
// legitimate as a long one, so it must not be dropped for being short.
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
!forceIncludeValues.has(value))
|
|
3409
|
+
const isShort = value.length < MIN_STATE_VALUE_LENGTH;
|
|
3410
|
+
if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
|
|
2935
3411
|
continue;
|
|
2936
3412
|
if (value.length > MAX_COOKIE_STATE_VALUE_LENGTH)
|
|
2937
3413
|
continue;
|
|
@@ -2943,6 +3419,7 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2943
3419
|
originIndex: i,
|
|
2944
3420
|
path: [],
|
|
2945
3421
|
headerOrigin: { sourceHeader: "set-cookie", cookieName: name },
|
|
3422
|
+
eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
|
|
2946
3423
|
});
|
|
2947
3424
|
}
|
|
2948
3425
|
}
|
|
@@ -2969,6 +3446,9 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2969
3446
|
originIndex: i,
|
|
2970
3447
|
path: [],
|
|
2971
3448
|
headerOrigin: { sourceHeader: headerName },
|
|
3449
|
+
eligibleConsumers: headerValue.length < MIN_STATE_VALUE_LENGTH
|
|
3450
|
+
? eligibleConsumersFor(headerValue)
|
|
3451
|
+
: undefined,
|
|
2972
3452
|
});
|
|
2973
3453
|
}
|
|
2974
3454
|
}
|
|
@@ -2986,9 +3466,8 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2986
3466
|
if (rawValue === null)
|
|
2987
3467
|
continue;
|
|
2988
3468
|
const value = String(rawValue);
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
!forceIncludeValues.has(value))
|
|
3469
|
+
const isShort = value.length < MIN_STATE_VALUE_LENGTH;
|
|
3470
|
+
if (isShort && !chainForceIncludeValues.has(value) && !forceIncludeValues.has(value))
|
|
2992
3471
|
continue;
|
|
2993
3472
|
if (value.length > MAX_STATE_VALUE_LENGTH)
|
|
2994
3473
|
continue;
|
|
@@ -3013,7 +3492,12 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
3013
3492
|
!forceIncludeValues.has(value))
|
|
3014
3493
|
continue;
|
|
3015
3494
|
if (!index.has(value)) {
|
|
3016
|
-
index.set(value, {
|
|
3495
|
+
index.set(value, {
|
|
3496
|
+
value,
|
|
3497
|
+
originIndex: i,
|
|
3498
|
+
path,
|
|
3499
|
+
eligibleConsumers: isShort ? eligibleConsumersFor(value) : undefined,
|
|
3500
|
+
});
|
|
3017
3501
|
}
|
|
3018
3502
|
}
|
|
3019
3503
|
}
|
|
@@ -3216,6 +3700,13 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3216
3700
|
for (const { capture } of actions) {
|
|
3217
3701
|
const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
|
|
3218
3702
|
for (const sv of stateIndex.values()) {
|
|
3703
|
+
// A short value indexed only via the chain/force-include exemption
|
|
3704
|
+
// (see `StateValue.eligibleConsumers`) is a real dependency ONLY for
|
|
3705
|
+
// the specific capture(s) the chain detector proved it threads into —
|
|
3706
|
+
// everywhere else, a coincidental substring match (a digit inside an
|
|
3707
|
+
// unrelated opaque path segment) is not reuse and must not splice.
|
|
3708
|
+
if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
|
|
3709
|
+
continue;
|
|
3219
3710
|
if (capture.url.includes(sv.value)) {
|
|
3220
3711
|
usedValues.add(sv.value);
|
|
3221
3712
|
continue;
|
|
@@ -3242,6 +3733,8 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3242
3733
|
}
|
|
3243
3734
|
for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
|
|
3244
3735
|
for (const sv of stateIndex.values()) {
|
|
3736
|
+
if (sv.eligibleConsumers && !sv.eligibleConsumers.has(capture))
|
|
3737
|
+
continue;
|
|
3245
3738
|
if (!headerValue.includes(sv.value))
|
|
3246
3739
|
continue;
|
|
3247
3740
|
usedValues.add(sv.value);
|
|
@@ -3343,7 +3836,7 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3343
3836
|
name = `${pathToVarName(path)}${suffix}`;
|
|
3344
3837
|
}
|
|
3345
3838
|
seenNames.add(name);
|
|
3346
|
-
produces.push({ kind: "body", name, path });
|
|
3839
|
+
produces.push({ kind: "body", name, path, eligibleConsumers: sv.eligibleConsumers });
|
|
3347
3840
|
}
|
|
3348
3841
|
}
|
|
3349
3842
|
const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
@@ -3418,37 +3911,138 @@ function resolveResponsePathValue(responseBody, path) {
|
|
|
3418
3911
|
? String(cursor)
|
|
3419
3912
|
: null;
|
|
3420
3913
|
}
|
|
3914
|
+
/** Builds the shared word-boundary-guarded, longest-value-first alternation
|
|
3915
|
+
* pattern used by both {@link interpolateStateValues} and
|
|
3916
|
+
* {@link substituteThreadedValues}: a value can never win a match at a position
|
|
3917
|
+
* a longer value also matches, and a value flanked by an alphanumeric — or by a
|
|
3918
|
+
* `-`/`.` itself flanked by an alphanumeric — never matches inside an unrelated
|
|
3919
|
+
* opaque token (e.g. splicing a "12" into a hyphen-joined "SKU-12-9F3Z"
|
|
3920
|
+
* segment) while a standalone occurrence (e.g. "/items/42/") still matches. */
|
|
3921
|
+
function buildValueAlternationPattern(sortedValues) {
|
|
3922
|
+
return new RegExp(`(?<![A-Za-z0-9][-.])\\b(?:${sortedValues.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b(?![-.][A-Za-z0-9])`, "g");
|
|
3923
|
+
}
|
|
3924
|
+
/**
|
|
3925
|
+
* Finds every `${...}` span in `text` by brace-depth counting rather than a
|
|
3926
|
+
* non-nesting regex, so an ALREADY-nested placeholder (e.g. one produced by an
|
|
3927
|
+
* earlier, buggier pass, or in principle any `${a${b}c}` shape) is reported as
|
|
3928
|
+
* ONE span covering the outer `${` through its true matching `}` — never as
|
|
3929
|
+
* just the innermost `${b}` — because a regex excluding `{`/`}` from its body
|
|
3930
|
+
* (`/\$\{[^{}]*\}/`) cannot see past the first inner brace and would otherwise
|
|
3931
|
+
* leave the outer span's `a`/`c` text unprotected for a later pass to splice
|
|
3932
|
+
* into, compounding the corruption instead of guarding against it.
|
|
3933
|
+
*/
|
|
3934
|
+
function findBalancedPlaceholderSpans(text) {
|
|
3935
|
+
const spans = [];
|
|
3936
|
+
let searchFrom = 0;
|
|
3937
|
+
while (searchFrom < text.length) {
|
|
3938
|
+
const start = text.indexOf("${", searchFrom);
|
|
3939
|
+
if (start === -1)
|
|
3940
|
+
break;
|
|
3941
|
+
let depth = 1;
|
|
3942
|
+
let cursor = start + 2;
|
|
3943
|
+
while (cursor < text.length && depth > 0) {
|
|
3944
|
+
if (text[cursor] === "{")
|
|
3945
|
+
depth++;
|
|
3946
|
+
else if (text[cursor] === "}")
|
|
3947
|
+
depth--;
|
|
3948
|
+
cursor++;
|
|
3949
|
+
}
|
|
3950
|
+
spans.push([start, cursor]);
|
|
3951
|
+
searchFrom = cursor;
|
|
3952
|
+
}
|
|
3953
|
+
return spans;
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* Runs `pattern` over `text`, replacing each match via `bindingByValue`,
|
|
3957
|
+
* EXCEPT a match that overlaps a `${...}` placeholder already present in
|
|
3958
|
+
* `text`. A single call's own matches never overlap each other (`replace`/
|
|
3959
|
+
* `matchAll` scan left-to-right without revisiting consumed text), so within
|
|
3960
|
+
* one call this only matters when `text` is the OUTPUT of an earlier call on
|
|
3961
|
+
* this same mechanism — e.g. a fold's per-item pass re-running over Pass 1's
|
|
3962
|
+
* already-interpolated URL/header/body text with a different (per-item)
|
|
3963
|
+
* binding table. Without this guard, that second pass's value-equality match
|
|
3964
|
+
* has no way to know a span it's about to touch is actually the FIRST pass's
|
|
3965
|
+
* `${varName}` placeholder for an entirely different producer/consumer
|
|
3966
|
+
* relationship — it just sees literal characters that happen to equal one of
|
|
3967
|
+
* its own bound values (e.g. the digits inside `${warehouseSlot47}`,
|
|
3968
|
+
* coincidentally also this fold item's own field value) and splices its
|
|
3969
|
+
* replacement in anyway, producing an invalidly-nested `${a${b}c}` literal
|
|
3970
|
+
* that resolves to neither value at runtime. Skipping any match that overlaps
|
|
3971
|
+
* an existing placeholder keeps every substitution scoped to the pass that
|
|
3972
|
+
* actually owns that span, which is the producer/consumer relationship this
|
|
3973
|
+
* mechanism is supposed to encode — and guarantees the output can never open
|
|
3974
|
+
* a `${` before a prior `${...}` closes.
|
|
3975
|
+
*/
|
|
3976
|
+
function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue) {
|
|
3977
|
+
const protectedSpans = findBalancedPlaceholderSpans(text);
|
|
3978
|
+
let result = "";
|
|
3979
|
+
let cursor = 0;
|
|
3980
|
+
for (const match of text.matchAll(pattern)) {
|
|
3981
|
+
const start = match.index;
|
|
3982
|
+
const end = start + match[0].length;
|
|
3983
|
+
if (protectedSpans.some(([spanStart, spanEnd]) => start < spanEnd && end > spanStart))
|
|
3984
|
+
continue;
|
|
3985
|
+
result += text.slice(cursor, start) + (bindingByValue.get(match[0]) ?? match[0]);
|
|
3986
|
+
cursor = end;
|
|
3987
|
+
}
|
|
3988
|
+
return result + text.slice(cursor);
|
|
3989
|
+
}
|
|
3421
3990
|
/**
|
|
3422
3991
|
* Replaces occurrences of state values in `template` with `${varName}`
|
|
3423
3992
|
* interpolations. Returns a JS template-literal string fragment (no backticks).
|
|
3424
3993
|
*
|
|
3425
3994
|
* Algorithm: walk the producing steps' response bodies in order, harvest each
|
|
3426
|
-
* produced value's concrete string, and map it to the produces[].name
|
|
3427
|
-
*
|
|
3428
|
-
*
|
|
3429
|
-
*
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
const
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
const
|
|
3448
|
-
|
|
3449
|
-
|
|
3995
|
+
* produced value's concrete string, and map it to the produces[].name, then
|
|
3996
|
+
* merge in the payload accessors (state wins on collision — e.g. when an
|
|
3997
|
+
* Auth.UserName response value equals the user's submitted email). A single
|
|
3998
|
+
* word-boundary-anchored regex alternation (longest value first, so an 8-char
|
|
3999
|
+
* prefix never shadows the 36-char UUID it's a prefix of) is matched over the
|
|
4000
|
+
* ORIGINAL template text exactly once — see {@link buildValueAlternationPattern}
|
|
4001
|
+
* for the anchoring guarantee and {@link replaceGuardedAgainstExistingPlaceholders}
|
|
4002
|
+
* for why a match overlapping an already-emitted `${...}` is skipped rather
|
|
4003
|
+
* than spliced into.
|
|
4004
|
+
*/
|
|
4005
|
+
function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map()) {
|
|
4006
|
+
const varNameByValue = deriveStateVarByValue(priorSteps, targetCapture);
|
|
4007
|
+
const bindingByValue = new Map();
|
|
4008
|
+
for (const [value, accessor] of payloadAccessorByValue) {
|
|
4009
|
+
bindingByValue.set(value, `\${${accessor}}`);
|
|
4010
|
+
}
|
|
4011
|
+
for (const [value, varName] of varNameByValue) {
|
|
4012
|
+
bindingByValue.set(value, `\${${varName}}`);
|
|
4013
|
+
}
|
|
4014
|
+
if (bindingByValue.size === 0)
|
|
4015
|
+
return template;
|
|
4016
|
+
const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
|
|
4017
|
+
const pattern = buildValueAlternationPattern(sortedValues);
|
|
4018
|
+
return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue);
|
|
4019
|
+
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Rewrites every occurrence of a set of literal values to their accessor
|
|
4022
|
+
* expressions in ONE pass over `text`, matching {@link interpolateStateValues}'s
|
|
4023
|
+
* guarded shape — see {@link buildValueAlternationPattern} for the anchoring
|
|
4024
|
+
* guarantee. A per-value sequential `.replace()` loop would re-scan the
|
|
4025
|
+
* PROGRESSIVELY MUTATED result on every iteration, letting one field's inserted
|
|
4026
|
+
* `${...}` replacement text land inside a position a later field's regex still
|
|
4027
|
+
* matches — producing a nested `${...${...}}` placeholder. Doing it once over
|
|
4028
|
+
* the original text closes that class of bug within this call; when `text` is
|
|
4029
|
+
* itself the already-interpolated output of an EARLIER call on this mechanism
|
|
4030
|
+
* (a fold's per-item pass over Pass 1's rendered URL/header/body), {@link
|
|
4031
|
+
* replaceGuardedAgainstExistingPlaceholders} closes the same class of bug
|
|
4032
|
+
* across calls by refusing to match inside a placeholder that call already
|
|
4033
|
+
* emitted.
|
|
4034
|
+
*/
|
|
4035
|
+
function substituteThreadedValues(text, bindings) {
|
|
4036
|
+
if (bindings.length === 0)
|
|
4037
|
+
return text;
|
|
4038
|
+
const bindingByValue = new Map();
|
|
4039
|
+
for (const { value, replacement } of bindings) {
|
|
4040
|
+
if (!bindingByValue.has(value))
|
|
4041
|
+
bindingByValue.set(value, replacement);
|
|
3450
4042
|
}
|
|
3451
|
-
|
|
4043
|
+
const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
|
|
4044
|
+
const pattern = buildValueAlternationPattern(sortedValues);
|
|
4045
|
+
return replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue);
|
|
3452
4046
|
}
|
|
3453
4047
|
/**
|
|
3454
4048
|
* Finds the request-body coordinates a PRODUCING step must source from the
|
|
@@ -3625,13 +4219,22 @@ const MAX_URL_PARAM_DECODE_DEPTH = 3;
|
|
|
3625
4219
|
* Header/cookie-origin produces are skipped: they have no body path and their
|
|
3626
4220
|
* value never appears as a literal in a URL/body template (http-client's `bind`
|
|
3627
4221
|
* forwards it directly as a request header), so there is nothing to interpolate.
|
|
3628
|
-
|
|
3629
|
-
|
|
4222
|
+
*
|
|
4223
|
+
* `targetCapture` is the capture the returned bindings are about to be spliced
|
|
4224
|
+
* INTO. A produce whose value is chain/force-include-exempt (see
|
|
4225
|
+
* `BodyProduce.eligibleConsumers`) is a real dependency only for the specific
|
|
4226
|
+
* capture(s) the chain detector proved it threads into — everywhere else, a
|
|
4227
|
+
* coincidental substring match must not bind, or `interpolateStateValues`
|
|
4228
|
+
* splices it into an unrelated capture's URL/body/headers.
|
|
4229
|
+
*/
|
|
4230
|
+
function deriveStateVarByValue(priorSteps, targetCapture) {
|
|
3630
4231
|
const varNameByValue = new Map();
|
|
3631
4232
|
for (const step of priorSteps) {
|
|
3632
4233
|
for (const p of step.produces) {
|
|
3633
4234
|
if (p.kind === "header")
|
|
3634
4235
|
continue;
|
|
4236
|
+
if (p.eligibleConsumers && !p.eligibleConsumers.has(targetCapture))
|
|
4237
|
+
continue;
|
|
3635
4238
|
const value = resolveResponsePathValue(step.capture.responseBody, p.path);
|
|
3636
4239
|
if (value !== null)
|
|
3637
4240
|
varNameByValue.set(value, p.name);
|
|
@@ -4264,7 +4867,17 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4264
4867
|
const step = actions[i];
|
|
4265
4868
|
const cap = step.capture;
|
|
4266
4869
|
const prior = actions.slice(0, i);
|
|
4267
|
-
|
|
4870
|
+
// A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
|
|
4871
|
+
// must never be treated as an interpolation target at all: its URL is
|
|
4872
|
+
// provably fixed across every occurrence, so any state-value splice into
|
|
4873
|
+
// it can only be a coincidental match, never a real dependency — the same
|
|
4874
|
+
// failure shape already fixed for GET responses (see `indexStateValues`'s
|
|
4875
|
+
// isGet UUID-only floor) and for the fold/drill per-item pass. Rendering
|
|
4876
|
+
// its exact literal URL makes this hold even when a value's own
|
|
4877
|
+
// length/chain-eligibility scoping doesn't happen to catch the coincidence.
|
|
4878
|
+
const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, actions.map((a) => a.capture))
|
|
4879
|
+
? cap.url
|
|
4880
|
+
: interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue);
|
|
4268
4881
|
// Form-schema substitution runs first on the raw recon body so its
|
|
4269
4882
|
// field-id-anchored matches see the original JSON. State-threading and
|
|
4270
4883
|
// payload key-value passes then run on top. Option-id substitution runs
|
|
@@ -4300,7 +4913,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4300
4913
|
// must stay reachable for state threading, not get frozen as caller data.
|
|
4301
4914
|
const rawBodyWithStructuredSubs = parsedBody !== null
|
|
4302
4915
|
? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
|
|
4303
|
-
...deriveStateVarByValue(prior).keys(),
|
|
4916
|
+
...deriveStateVarByValue(prior, cap).keys(),
|
|
4304
4917
|
...(joinFieldValuesByStep.get(i) ?? []),
|
|
4305
4918
|
]))
|
|
4306
4919
|
: rawBodyWithFormSubs;
|
|
@@ -4327,14 +4940,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4327
4940
|
if (binding.producerIndex === i)
|
|
4328
4941
|
urlParamBindings.set(value, binding.accessor);
|
|
4329
4942
|
}
|
|
4330
|
-
for (const [value, varName] of deriveStateVarByValue(prior)) {
|
|
4943
|
+
for (const [value, varName] of deriveStateVarByValue(prior, cap)) {
|
|
4331
4944
|
urlParamBindings.set(value, varName);
|
|
4332
4945
|
}
|
|
4333
4946
|
const rawBodyWithUrlParams = parsedBody !== null
|
|
4334
4947
|
? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
|
|
4335
4948
|
: rawBodyWithProducerBoundary;
|
|
4336
4949
|
const bodyAfterStateAndKv = rawBodyWithUrlParams
|
|
4337
|
-
? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
|
|
4950
|
+
? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
|
|
4338
4951
|
: "";
|
|
4339
4952
|
// Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
|
|
4340
4953
|
// rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
|
|
@@ -4363,7 +4976,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4363
4976
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
4364
4977
|
const lower = k.toLowerCase();
|
|
4365
4978
|
if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
|
|
4366
|
-
perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
|
|
4979
|
+
perCallHeaders[k] = interpolateStateValues(v, prior, cap, payloadAccessorByValue);
|
|
4367
4980
|
}
|
|
4368
4981
|
}
|
|
4369
4982
|
// G1: emit baseUrl-derived headers (Origin, Referer) per-call from
|
|
@@ -4465,7 +5078,15 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4465
5078
|
// block-scoped to that loop and never escape to the rest of the function,
|
|
4466
5079
|
// so none of them may run through the outer `declaredNames`/produceLines
|
|
4467
5080
|
// bookkeeping below (that bookkeeping assumes function-scope declarations).
|
|
4468
|
-
|
|
5081
|
+
// Absorbed indices (see FoldPlan.absorbedIndices) are repeat raw captures
|
|
5082
|
+
// of a target's own endpoint, threaded from a DIFFERENT primary item — the
|
|
5083
|
+
// single representative target already re-issues that endpoint once per
|
|
5084
|
+
// fold-loop iteration, so these must be dropped from normal per-step
|
|
5085
|
+
// emission too, exactly like the chain indices they're folded in with.
|
|
5086
|
+
const foldChainIndices = new Set(foldPlans.flatMap((plan) => [
|
|
5087
|
+
...plan.targets.flatMap((target) => target.chain),
|
|
5088
|
+
...plan.absorbedIndices,
|
|
5089
|
+
]));
|
|
4469
5090
|
for (let i = 0; i < actions.length; i++) {
|
|
4470
5091
|
const step = actions[i];
|
|
4471
5092
|
const cap = step.capture;
|
|
@@ -4559,12 +5180,32 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4559
5180
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
4560
5181
|
const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
4561
5182
|
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
4562
|
-
//
|
|
4563
|
-
//
|
|
4564
|
-
//
|
|
4565
|
-
//
|
|
4566
|
-
//
|
|
4567
|
-
const
|
|
5183
|
+
// Computed once per fold target instead of once per `parameterize`
|
|
5184
|
+
// call: `actions` never changes across the url/headers/body calls a
|
|
5185
|
+
// single chain step makes (or across chain steps), so re-deriving
|
|
5186
|
+
// this array inside the closure was O(actions.length) work repeated
|
|
5187
|
+
// 3x per chain hop for no reason.
|
|
5188
|
+
const allCaptures = actions.map((a) => a.capture);
|
|
5189
|
+
// `isZeroVarianceRepeatCapture`'s verdict is a pure function of
|
|
5190
|
+
// `chainCapture` (and the now-hoisted `allCaptures`, which is fixed
|
|
5191
|
+
// for the whole target) — memoized here so the 3 `parameterize`
|
|
5192
|
+
// calls a single chain step makes (url, headers, body) each share
|
|
5193
|
+
// the one verdict computed for that step's `chainCapture` instead of
|
|
5194
|
+
// re-scanning `allCaptures` from scratch every time.
|
|
5195
|
+
const isProvenInvariantMemo = new Map();
|
|
5196
|
+
const isProvenInvariantFor = (chainCapture) => {
|
|
5197
|
+
const cached = isProvenInvariantMemo.get(chainCapture);
|
|
5198
|
+
if (cached !== undefined)
|
|
5199
|
+
return cached;
|
|
5200
|
+
const computed = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
|
|
5201
|
+
isProvenInvariantMemo.set(chainCapture, computed);
|
|
5202
|
+
return computed;
|
|
5203
|
+
};
|
|
5204
|
+
// Whole-value substitution runs via substituteThreadedValues: a single
|
|
5205
|
+
// guarded regex-alternation pass over the original text, not a
|
|
5206
|
+
// per-field sequential `.replace()` loop — see that function's doc for
|
|
5207
|
+
// why the sequential shape corrupts opaque path segments and can nest
|
|
5208
|
+
// `${...}` placeholders.
|
|
4568
5209
|
const parameterize = (text, chainCapture) => {
|
|
4569
5210
|
// A join field can reach the render either as the raw captured
|
|
4570
5211
|
// literal (URL query params) or as an already-generic
|
|
@@ -4581,7 +5222,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4581
5222
|
// invisible and gets frozen as a literal.
|
|
4582
5223
|
const rawThreadedFields = dedupeThreadedFields([
|
|
4583
5224
|
...target.joinFields.map((field) => ({ varName: itemVar, field })),
|
|
4584
|
-
...findThreadedJoinFields(threadingScopes, chainCapture,
|
|
5225
|
+
...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
|
|
4585
5226
|
]);
|
|
4586
5227
|
// A proven ancestor-scoped drill (see isAncestorScoped above) still
|
|
4587
5228
|
// rebinds fields findThreadedJoinFields left on itemVar purely
|
|
@@ -4609,7 +5250,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4609
5250
|
: tf,
|
|
4610
5251
|
}))
|
|
4611
5252
|
: rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
|
|
4612
|
-
|
|
5253
|
+
// Accessor swap first: rewrites an already-templated `${payload.X}`
|
|
5254
|
+
// reference (from applyPayloadKeyValueSubstitutions) to this field's
|
|
5255
|
+
// real accessor. Each target (`${payload.X}`) is a unique, fully
|
|
5256
|
+
// delimited string that the swap's own output (`${accessorField...}`,
|
|
5257
|
+
// never re-shaped into `${payload.X}` form) can't re-match, so a
|
|
5258
|
+
// sequential pass here carries none of the reentrancy risk the
|
|
5259
|
+
// literal-value pass below has.
|
|
5260
|
+
const swapped = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
|
|
4613
5261
|
const replacement = `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`;
|
|
4614
5262
|
// applyPayloadKeyValueSubstitutions only ever names a payload
|
|
4615
5263
|
// accessor after the DRILL REQUEST's own top-level JSON key
|
|
@@ -4622,11 +5270,18 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4622
5270
|
// reference behind once the literal value itself has already
|
|
4623
5271
|
// been replaced by the payload-key-value pass.
|
|
4624
5272
|
const lastSegment = valueField.field.split(".").pop();
|
|
4625
|
-
|
|
5273
|
+
return acc
|
|
4626
5274
|
.split(`\${payload.${valueField.field}}`)
|
|
4627
5275
|
.join(replacement)
|
|
4628
5276
|
.split(`\${payload.${lastSegment}}`)
|
|
4629
5277
|
.join(replacement);
|
|
5278
|
+
}, text);
|
|
5279
|
+
// Literal-value substitution: ONE guarded regex-alternation pass over
|
|
5280
|
+
// `swapped` for every threaded field's value, longest first — see
|
|
5281
|
+
// substituteThreadedValues's doc for why a per-field sequential pass
|
|
5282
|
+
// here (the bug this replaces) can nest `${...}` placeholders.
|
|
5283
|
+
const valueBindings = threadedFieldPairs
|
|
5284
|
+
.map(({ valueField, accessorField }) => {
|
|
4630
5285
|
const scopeObj = valueField.varName === itemVar
|
|
4631
5286
|
? firstItem
|
|
4632
5287
|
: ancestorObjByVar.get(valueField.varName);
|
|
@@ -4636,12 +5291,36 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4636
5291
|
: typeof value === "number" || typeof value === "boolean"
|
|
4637
5292
|
? String(value)
|
|
4638
5293
|
: null;
|
|
4639
|
-
return stringValue
|
|
4640
|
-
?
|
|
4641
|
-
:
|
|
4642
|
-
|
|
5294
|
+
return stringValue === null
|
|
5295
|
+
? null
|
|
5296
|
+
: {
|
|
5297
|
+
value: stringValue,
|
|
5298
|
+
replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
|
|
5299
|
+
};
|
|
5300
|
+
})
|
|
5301
|
+
.filter((b) => b !== null);
|
|
5302
|
+
// A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
|
|
5303
|
+
// must never have a COINCIDENTAL threaded value spliced into it —
|
|
5304
|
+
// but the invariance verdict is per-capture, not per-field: a
|
|
5305
|
+
// capture can be fixed on one key (a repeated `qty`) while still
|
|
5306
|
+
// genuinely varying on another (`itemId`), so only the fields that
|
|
5307
|
+
// {@link isGenuineVaryingQueryValue} can't prove are real per-request
|
|
5308
|
+
// dependencies get excluded, never the whole substitution pass.
|
|
5309
|
+
const isProvenInvariant = isProvenInvariantFor(chainCapture);
|
|
5310
|
+
const filteredValueBindings = isProvenInvariant
|
|
5311
|
+
? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
|
|
5312
|
+
: valueBindings;
|
|
5313
|
+
const result = substituteThreadedValues(swapped, filteredValueBindings);
|
|
4643
5314
|
const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
|
|
4644
|
-
|
|
5315
|
+
// The frozen-varying-param safety net exists to catch a
|
|
5316
|
+
// misconfigured drill (a param that genuinely needs joinFields/an
|
|
5317
|
+
// ancestor binding but has neither) — it does not apply once the
|
|
5318
|
+
// capture is already proven request-invariant: freezing an
|
|
5319
|
+
// undeclared, business-irrelevant varying key (a beacon nonce)
|
|
5320
|
+
// there is the INTENDED behavior, not a misconfiguration.
|
|
5321
|
+
if (!isProvenInvariant) {
|
|
5322
|
+
assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, withDrillParamBindings, allCaptures);
|
|
5323
|
+
}
|
|
4645
5324
|
return withDrillParamBindings;
|
|
4646
5325
|
};
|
|
4647
5326
|
// Every chain step's response and produces are block-scoped to this
|
|
@@ -4667,6 +5346,9 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4667
5346
|
for (const chainIndex of target.chain) {
|
|
4668
5347
|
const chainStep = actions[chainIndex];
|
|
4669
5348
|
const chainRendered = rendered[chainIndex];
|
|
5349
|
+
// The zero-variance guard lives inside `parameterize` itself (see
|
|
5350
|
+
// above) so it can skip only the threaded-value splice while still
|
|
5351
|
+
// letting a spec-declared drillParamBindings substitution apply.
|
|
4670
5352
|
const paramUrl = parameterize(chainRendered.url, chainStep.capture);
|
|
4671
5353
|
const paramHeaders = parameterize(chainRendered.headersExpr, chainStep.capture);
|
|
4672
5354
|
const paramBody = parameterize(chainRendered.bodyArg, chainStep.capture);
|
|
@@ -4758,7 +5440,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4758
5440
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
4759
5441
|
const lower = k.toLowerCase();
|
|
4760
5442
|
if (lower === "api-token" || lower === "authorization") {
|
|
4761
|
-
perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), payloadAccessorByValue)}\``);
|
|
5443
|
+
perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue)}\``);
|
|
4762
5444
|
}
|
|
4763
5445
|
}
|
|
4764
5446
|
// G1+G2: include tenant-derived headers in the multipart fetch too.
|
|
@@ -5414,6 +6096,40 @@ function applyDrillParamBindings(spec, capture, text) {
|
|
|
5414
6096
|
return acc.replace(paramRx, (_full, prefix) => `${prefix}${accessor}`);
|
|
5415
6097
|
}, text);
|
|
5416
6098
|
}
|
|
6099
|
+
/** True when `value` is a QUERY PARAM value in `capture.url` whose key
|
|
6100
|
+
* genuinely differs on at least one other same-endpoint occurrence in
|
|
6101
|
+
* `allCaptures` — i.e. a real per-request dependency (an item id, a page
|
|
6102
|
+
* cursor), not a coincidental byte match against an opaque path segment or a
|
|
6103
|
+
* key that happens to hold the same value on every occurrence. Used to
|
|
6104
|
+
* decide, field by field, whether a threaded-value splice into a capture
|
|
6105
|
+
* proven request-invariant ({@link isZeroVarianceRepeatCapture}) is a
|
|
6106
|
+
* legitimate substitution or the exact coincidence that guard exists to
|
|
6107
|
+
* catch — a capture can be "invariant" on one key (a fixed `qty`) while
|
|
6108
|
+
* still genuinely varying on another (`itemId`), so the invariance verdict
|
|
6109
|
+
* alone can't gate substitution at the whole-capture level. */
|
|
6110
|
+
function isGenuineVaryingQueryValue(value, capture, allCaptures) {
|
|
6111
|
+
let url;
|
|
6112
|
+
try {
|
|
6113
|
+
url = new URL(capture.url);
|
|
6114
|
+
}
|
|
6115
|
+
catch {
|
|
6116
|
+
return false;
|
|
6117
|
+
}
|
|
6118
|
+
const key = [...url.searchParams.entries()].find(([, v]) => v === value)?.[0];
|
|
6119
|
+
if (key === undefined)
|
|
6120
|
+
return false;
|
|
6121
|
+
const endpoint = endpointKey(capture.url);
|
|
6122
|
+
return allCaptures.some((c) => {
|
|
6123
|
+
if (c === capture || endpointKey(c.url) !== endpoint)
|
|
6124
|
+
return false;
|
|
6125
|
+
try {
|
|
6126
|
+
return new URL(c.url).searchParams.get(key) !== value;
|
|
6127
|
+
}
|
|
6128
|
+
catch {
|
|
6129
|
+
return false;
|
|
6130
|
+
}
|
|
6131
|
+
});
|
|
6132
|
+
}
|
|
5417
6133
|
/** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
|
|
5418
6134
|
* varying literal — shared by {@link parameterizeUrl} (below) and
|
|
5419
6135
|
* `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
|
|
@@ -5863,6 +6579,11 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
|
|
|
5863
6579
|
const consumedIndices = new Set();
|
|
5864
6580
|
for (const primaryArray of primaryCandidates) {
|
|
5865
6581
|
const targets = [];
|
|
6582
|
+
// See FoldPlan.absorbedIndices — a candidate hitting the SAME endpoint
|
|
6583
|
+
// as a target already resolved for this array is a repeat raw capture
|
|
6584
|
+
// of that one per-item drill (threaded from a DIFFERENT primary item),
|
|
6585
|
+
// not an independent target, so it lands here instead of `targets`.
|
|
6586
|
+
const absorbedIndices = [];
|
|
5866
6587
|
// Pruned to the (typically tiny) set of later action indices whose
|
|
5867
6588
|
// request could possibly thread one of this array's own item values —
|
|
5868
6589
|
// see buildRequestStringValueIndex's docstring — instead of every
|
|
@@ -5883,6 +6604,24 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
|
|
|
5883
6604
|
if (primaryMatchedItemIndex === -1)
|
|
5884
6605
|
continue;
|
|
5885
6606
|
const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
|
|
6607
|
+
// A genuinely per-item-varying repeated endpoint (the SAME drill
|
|
6608
|
+
// called once per primary item, each occurrence threading a
|
|
6609
|
+
// DIFFERENT item's own join value) is one logical target, not N — the
|
|
6610
|
+
// fold loop already re-issues the representative target's request
|
|
6611
|
+
// once per item via its own `item.<field>` accessor. Recognizing a
|
|
6612
|
+
// later candidate as a repeat of an ALREADY-RESOLVED target's
|
|
6613
|
+
// endpoint (rather than letting it become its own independent
|
|
6614
|
+
// target) is exactly the widening this structural heuristic needed:
|
|
6615
|
+
// previously every threading candidate became its own FoldTarget,
|
|
6616
|
+
// so N per-item captures of one endpoint fanned out into N separate
|
|
6617
|
+
// httpClient calls inside the loop instead of collapsing to one.
|
|
6618
|
+
const drillEndpointKey = endpointKey(drill.capture.url);
|
|
6619
|
+
const alreadyTargetedSameEndpoint = targets.some((t) => endpointKey(actions[t.drillStepIndex].capture.url) === drillEndpointKey);
|
|
6620
|
+
if (alreadyTargetedSameEndpoint) {
|
|
6621
|
+
absorbedIndices.push(drillIndex);
|
|
6622
|
+
consumedIndices.add(drillIndex);
|
|
6623
|
+
continue;
|
|
6624
|
+
}
|
|
5886
6625
|
// Widened to a flat (non-array) object response when the drill step has
|
|
5887
6626
|
// no object-array field of its own — see
|
|
5888
6627
|
// findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
|
|
@@ -5945,8 +6684,9 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
|
|
|
5945
6684
|
for (const chainIndex of chain)
|
|
5946
6685
|
consumedIndices.add(chainIndex);
|
|
5947
6686
|
}
|
|
5948
|
-
if (targets.length > 0)
|
|
5949
|
-
groups.push({ primaryArrayPath: primaryArray.path, targets });
|
|
6687
|
+
if (targets.length > 0) {
|
|
6688
|
+
groups.push({ primaryArrayPath: primaryArray.path, targets, absorbedIndices });
|
|
6689
|
+
}
|
|
5950
6690
|
}
|
|
5951
6691
|
return groups;
|
|
5952
6692
|
}
|
|
@@ -6005,7 +6745,7 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
6005
6745
|
// A re-queried primary (same endpoint hit more than once, per
|
|
6006
6746
|
// findRequeriedActions) can have MULTIPLE occurrences that each
|
|
6007
6747
|
// independently thread a join key into the SAME later drill-down —
|
|
6008
|
-
// e.g. two "
|
|
6748
|
+
// e.g. two "list-items" calls that both happen to contain the
|
|
6009
6749
|
// item the drill-down looks up. selectReturnAction/selectPayloadAction
|
|
6010
6750
|
// already establish freshest-wins for this exact re-queried-primary
|
|
6011
6751
|
// case, so the plan must anchor on the LAST such occurrence, not the
|
|
@@ -6047,6 +6787,7 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
6047
6787
|
primaryStepIndex: freshestIndex,
|
|
6048
6788
|
primaryArrayPath: freshestGroup.primaryArrayPath,
|
|
6049
6789
|
targets: freshestGroup.targets,
|
|
6790
|
+
absorbedIndices: freshestGroup.absorbedIndices,
|
|
6050
6791
|
});
|
|
6051
6792
|
// A step already folded into this plan's chains — the drill step(s)
|
|
6052
6793
|
// and everything threaded onward from them — was already merged
|
|
@@ -6061,6 +6802,12 @@ function detectDrillDownFoldPlan(actions) {
|
|
|
6061
6802
|
for (const chainIndex of target.chain)
|
|
6062
6803
|
addConsumed(chainIndex);
|
|
6063
6804
|
}
|
|
6805
|
+
// Absorbed repeat occurrences (see FoldPlan.absorbedIndices) were never
|
|
6806
|
+
// part of any target's chain, so they must be consumed here too, or
|
|
6807
|
+
// they would surface as leftover raw indices and get emitted a second
|
|
6808
|
+
// time as their own single hardcoded calls.
|
|
6809
|
+
for (const absorbedIndex of freshestGroup.absorbedIndices)
|
|
6810
|
+
addConsumed(absorbedIndex);
|
|
6064
6811
|
}
|
|
6065
6812
|
}
|
|
6066
6813
|
return plans;
|
|
@@ -6581,6 +7328,7 @@ function buildFoldPlanFromSpec(actions, spec) {
|
|
|
6581
7328
|
chainTerminalIndex,
|
|
6582
7329
|
},
|
|
6583
7330
|
],
|
|
7331
|
+
absorbedIndices: [],
|
|
6584
7332
|
};
|
|
6585
7333
|
break;
|
|
6586
7334
|
}
|
|
@@ -6791,12 +7539,21 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
|
|
|
6791
7539
|
* Runs directly off raw actions (not `resolveFoldPlan`, which needs
|
|
6792
7540
|
* `isMultipart` — unavailable before `compileActionSteps` has run) since
|
|
6793
7541
|
* fold-plan DETECTION depends only on each action's `capture`.
|
|
7542
|
+
*
|
|
7543
|
+
* Returns a value -> proven-consumer-captures map rather than a flat set: a
|
|
7544
|
+
* value's chain-proven threading relationship holds ONLY between the
|
|
7545
|
+
* specific chain hops that produced and consumed it, never globally across
|
|
7546
|
+
* every capture in the flow. Callers that bypass `MIN_STATE_VALUE_LENGTH`
|
|
7547
|
+
* for one of these values (see `indexStateValues`) must scope that bypass to
|
|
7548
|
+
* the returned consumer set, or a short value legitimately threaded between
|
|
7549
|
+
* two unrelated steps can coincidentally match inside a totally unrelated
|
|
7550
|
+
* capture's own URL/body and get spliced into it.
|
|
6794
7551
|
*/
|
|
6795
7552
|
function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
|
|
6796
7553
|
const structuralPlans = detectDrillDownFoldPlan(actions);
|
|
6797
7554
|
const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
|
|
6798
7555
|
const plans = structuralPlans.length > 0 ? structuralPlans : specPlan === null ? [] : [specPlan];
|
|
6799
|
-
const
|
|
7556
|
+
const consumersByValue = new Map();
|
|
6800
7557
|
for (const plan of plans) {
|
|
6801
7558
|
for (const target of plan.targets) {
|
|
6802
7559
|
for (let j = 0; j < target.chain.length; j++) {
|
|
@@ -6812,14 +7569,17 @@ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
|
|
|
6812
7569
|
continue;
|
|
6813
7570
|
const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
|
|
6814
7571
|
for (const v of responseValues) {
|
|
6815
|
-
if (
|
|
6816
|
-
|
|
7572
|
+
if (echoedValues.has(v) || !laterRequestValues.has(v))
|
|
7573
|
+
continue;
|
|
7574
|
+
const consumers = consumersByValue.get(v) ?? new Set();
|
|
7575
|
+
consumers.add(laterCapture);
|
|
7576
|
+
consumersByValue.set(v, consumers);
|
|
6817
7577
|
}
|
|
6818
7578
|
}
|
|
6819
7579
|
}
|
|
6820
7580
|
}
|
|
6821
7581
|
}
|
|
6822
|
-
return
|
|
7582
|
+
return consumersByValue;
|
|
6823
7583
|
}
|
|
6824
7584
|
function resolveFoldPlan(actions, foldReturnSpec = null) {
|
|
6825
7585
|
const structuralPlans = detectDrillDownFoldPlan(actions);
|
|
@@ -6909,17 +7669,59 @@ function replaceByReference(value, target, replacement) {
|
|
|
6909
7669
|
* `throw` at the analogous point, minus the throw, since shape inference
|
|
6910
7670
|
* degrading gracefully is preferable to failing a generate run over it.
|
|
6911
7671
|
*/
|
|
7672
|
+
/** Folds one drill-down response's matching item onto `body`'s primary
|
|
7673
|
+
* array — the single-occurrence step {@link foldResponseBodyForShapeInference}
|
|
7674
|
+
* runs once for a target's own representative drill and again for each of
|
|
7675
|
+
* its {@link FoldPlan.absorbedIndices} siblings, so both call sites resolve
|
|
7676
|
+
* the merge identically. `matchedItem` must already be resolved by the
|
|
7677
|
+
* caller: the representative occurrence knows it via `primaryMatchedItemIndex`,
|
|
7678
|
+
* while an absorbed occurrence resolves it by re-threading its OWN request
|
|
7679
|
+
* against every primary item (see the call site) — a drill-down's RESPONSE
|
|
7680
|
+
* commonly never echoes the join field it was looked up by, so matching by
|
|
7681
|
+
* response content alone (as the representative branch's own `drillMatch`
|
|
7682
|
+
* fallback does when the response doesn't echo it) can't identify WHICH item
|
|
7683
|
+
* an absorbed occurrence belongs to in the first place. */
|
|
7684
|
+
function foldOneDrillOccurrence(body, primaryArrayPath, joinFields, drillItems, matchedItem) {
|
|
7685
|
+
const primaryItems = objectItemsAtPath(body, primaryArrayPath);
|
|
7686
|
+
if (!primaryItems)
|
|
7687
|
+
return body;
|
|
7688
|
+
const drillMatch = drillItems.find((d) => joinFields.every((f) => String(readValueAtPath(d, f.split("."))) ===
|
|
7689
|
+
String(readValueAtPath(matchedItem, f.split("."))))) ?? drillItems[0];
|
|
7690
|
+
if (!drillMatch)
|
|
7691
|
+
return body;
|
|
7692
|
+
return replaceByReference(body, matchedItem, { ...drillMatch, ...matchedItem });
|
|
7693
|
+
}
|
|
6912
7694
|
function foldResponseBodyForShapeInference(actionSteps, foldPlan, initialBody = actionSteps[foldPlan.primaryStepIndex].capture.responseBody) {
|
|
6913
7695
|
return foldPlan.targets.reduce((body, target) => {
|
|
6914
7696
|
const drillBody = actionSteps[target.chainTerminalIndex].capture.responseBody;
|
|
6915
7697
|
const primaryItems = objectItemsAtPath(body, foldPlan.primaryArrayPath);
|
|
6916
7698
|
const drillItems = objectItemsAtPath(drillBody, target.chainArrayPath);
|
|
6917
7699
|
const matchedItem = primaryItems?.[target.primaryMatchedItemIndex];
|
|
6918
|
-
const
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
7700
|
+
const bodyAfterOwnDrill = !drillItems || !matchedItem
|
|
7701
|
+
? body
|
|
7702
|
+
: foldOneDrillOccurrence(body, foldPlan.primaryArrayPath, target.joinFields, drillItems, matchedItem);
|
|
7703
|
+
// Every absorbed occurrence of this SAME endpoint (a repeat raw capture
|
|
7704
|
+
// threaded from a DIFFERENT primary item — see FoldPlan.absorbedIndices)
|
|
7705
|
+
// is folded in too, so schema inference sees every sampled per-item
|
|
7706
|
+
// field, not just the single representative occurrence's — see the
|
|
7707
|
+
// `"merges by join key, not position"` regression this restores. Which
|
|
7708
|
+
// primary item an absorbed occurrence belongs to is re-derived from its
|
|
7709
|
+
// own REQUEST (mirroring the original structural scan's own matching),
|
|
7710
|
+
// not its response, since a drill response commonly never echoes the
|
|
7711
|
+
// join field back.
|
|
7712
|
+
const targetEndpointKey = endpointKey(actionSteps[target.drillStepIndex].capture.url);
|
|
7713
|
+
return foldPlan.absorbedIndices.reduce((innerBody, absorbedIndex) => {
|
|
7714
|
+
const absorbedCapture = actionSteps[absorbedIndex]?.capture;
|
|
7715
|
+
if (!absorbedCapture || endpointKey(absorbedCapture.url) !== targetEndpointKey) {
|
|
7716
|
+
return innerBody;
|
|
7717
|
+
}
|
|
7718
|
+
const absorbedDrillItems = objectItemsAtPath(absorbedCapture.responseBody, target.chainArrayPath);
|
|
7719
|
+
const innerPrimaryItems = objectItemsAtPath(innerBody, foldPlan.primaryArrayPath);
|
|
7720
|
+
const absorbedMatchedItem = innerPrimaryItems?.find((item) => findThreadedJoinFields([{ varName: "item", obj: item }], absorbedCapture).length > 0);
|
|
7721
|
+
if (!absorbedDrillItems || !absorbedMatchedItem)
|
|
7722
|
+
return innerBody;
|
|
7723
|
+
return foldOneDrillOccurrence(innerBody, foldPlan.primaryArrayPath, target.joinFields, absorbedDrillItems, absorbedMatchedItem);
|
|
7724
|
+
}, bodyAfterOwnDrill);
|
|
6923
7725
|
}, initialBody);
|
|
6924
7726
|
}
|
|
6925
7727
|
/**
|
|
@@ -7634,6 +8436,13 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
7634
8436
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
7635
8437
|
const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
7636
8438
|
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
8439
|
+
// Computed once per fold target instead of once per `parameterizeUrl`
|
|
8440
|
+
// call: `actionSteps` never changes across the calls this target's
|
|
8441
|
+
// chain steps make, so re-deriving this array on every one of
|
|
8442
|
+
// findThreadedJoinFields/isZeroVarianceRepeatCapture/
|
|
8443
|
+
// isGenuineVaryingQueryValue/assertNoFrozenVaryingDrillParams's own
|
|
8444
|
+
// calls below was O(actionSteps.length) work repeated 4x per call.
|
|
8445
|
+
const allCaptures = actionSteps.map((s) => s.capture);
|
|
7637
8446
|
// Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
|
|
7638
8447
|
// `parameterize` performs — a plain split/join would also rewrite
|
|
7639
8448
|
// unrelated substrings that happen to contain the join value.
|
|
@@ -7658,7 +8467,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
7658
8467
|
: rawUrl;
|
|
7659
8468
|
const rawThreadedFields = dedupeThreadedFields([
|
|
7660
8469
|
...target.joinFields.map((field) => ({ varName: itemVar, field })),
|
|
7661
|
-
...findThreadedJoinFields(threadingScopes, chainCapture,
|
|
8470
|
+
...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
|
|
7662
8471
|
]);
|
|
7663
8472
|
// Mirrors emitMultiStepExecuteHttp's identical rebind (see
|
|
7664
8473
|
// isAncestorScoped above): a proven ancestor-scoped drill still
|
|
@@ -7683,7 +8492,13 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
7683
8492
|
: tf,
|
|
7684
8493
|
}))
|
|
7685
8494
|
: rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
|
|
7686
|
-
|
|
8495
|
+
// ONE guarded regex-alternation pass over `withBase` for every
|
|
8496
|
+
// threaded field's value, longest first — see substituteThreadedValues's
|
|
8497
|
+
// doc for why a per-field sequential `.replace()` loop here (the bug
|
|
8498
|
+
// this replaces) can splice an unrelated value into an opaque URL
|
|
8499
|
+
// segment or nest a `${...}` placeholder.
|
|
8500
|
+
const valueBindings = threadedFieldPairs
|
|
8501
|
+
.map(({ valueField, accessorField }) => {
|
|
7687
8502
|
const scopeObj = valueField.varName === itemVar
|
|
7688
8503
|
? firstItem
|
|
7689
8504
|
: ancestorObjByVar.get(valueField.varName);
|
|
@@ -7693,12 +8508,31 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
7693
8508
|
: typeof value === "number" || typeof value === "boolean"
|
|
7694
8509
|
? String(value)
|
|
7695
8510
|
: null;
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
8511
|
+
return stringValue === null
|
|
8512
|
+
? null
|
|
8513
|
+
: {
|
|
8514
|
+
value: stringValue,
|
|
8515
|
+
replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
|
|
8516
|
+
};
|
|
8517
|
+
})
|
|
8518
|
+
.filter((b) => b !== null);
|
|
8519
|
+
// A capture proven request-invariant ({@link isZeroVarianceRepeatCapture})
|
|
8520
|
+
// must never have a COINCIDENTAL threaded value spliced into it —
|
|
8521
|
+
// see emitMultiStepExecuteHttp's identical `parameterize` guard
|
|
8522
|
+
// ({@link isGenuineVaryingQueryValue}) for why this is decided
|
|
8523
|
+
// field by field rather than for the whole capture at once.
|
|
8524
|
+
const isProvenInvariant = (0, capture_filters_1.isZeroVarianceRepeatCapture)(chainCapture, allCaptures);
|
|
8525
|
+
const filteredValueBindings = isProvenInvariant
|
|
8526
|
+
? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
|
|
8527
|
+
: valueBindings;
|
|
8528
|
+
const result = substituteThreadedValues(withBase, filteredValueBindings);
|
|
7700
8529
|
const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
|
|
7701
|
-
|
|
8530
|
+
// See emitMultiStepExecuteHttp's identical guard: the frozen-
|
|
8531
|
+
// varying-param safety net does not apply once the capture is
|
|
8532
|
+
// already proven request-invariant.
|
|
8533
|
+
if (!isProvenInvariant) {
|
|
8534
|
+
assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, withDrillParamBindings, allCaptures);
|
|
8535
|
+
}
|
|
7702
8536
|
return withDrillParamBindings;
|
|
7703
8537
|
};
|
|
7704
8538
|
const chainLines = [];
|
|
@@ -7715,6 +8549,10 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
7715
8549
|
const chainStep = actionSteps[chainIndex];
|
|
7716
8550
|
if (!chainStep)
|
|
7717
8551
|
continue;
|
|
8552
|
+
// The zero-variance guard lives inside `parameterizeUrl` itself
|
|
8553
|
+
// (see above) so it can skip only the threaded-value splice while
|
|
8554
|
+
// still letting a spec-declared drillParamBindings substitution
|
|
8555
|
+
// apply.
|
|
7718
8556
|
const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
|
|
7719
8557
|
if (itemVarRefPattern.test(url))
|
|
7720
8558
|
referencesItemVar = true;
|
|
@@ -8481,11 +9319,23 @@ function describeOffendingFieldSources(fields, pool) {
|
|
|
8481
9319
|
* The narrowing pass's core relevance decision, isolated for direct
|
|
8482
9320
|
* testing: every capture in `resolvedPool` — other than `primaryCapture`,
|
|
8483
9321
|
* which must never be dropped — whose own top-level response JSON owns at
|
|
8484
|
-
* least one of `offendingFields
|
|
9322
|
+
* least one of `offendingFields`, PLUS any other same-host capture whose
|
|
9323
|
+
* path is in the same structural family ({@link isSamePathFamily}) as one
|
|
9324
|
+
* of those field-owning captures. This is the actual "is this capture
|
|
8485
9325
|
* structurally part of the resolved chain or incidental noise" call;
|
|
8486
9326
|
* {@link healUnreferencedUrlFieldsOnce} only wires it into the regenerate
|
|
8487
9327
|
* retry loop.
|
|
8488
9328
|
*
|
|
9329
|
+
* The family-broadening step exists because a noise endpoint's alternate
|
|
9330
|
+
* path/query-string variant (e.g. a `/default` GET variant of a POST the
|
|
9331
|
+
* field check already caught) commonly carries none of the fields that
|
|
9332
|
+
* flagged its sibling — it is caught by structural isolation alone, the
|
|
9333
|
+
* same signal {@link isStructurallyIsolatedCapture} uses for pool-relative
|
|
9334
|
+
* isolation, generalized here to "related to an already-known noise path"
|
|
9335
|
+
* rather than "isolated from the whole pool". Without it, that variant
|
|
9336
|
+
* survives to the emitted contract as its own hard-coded call, requiring a
|
|
9337
|
+
* required-URL-field trigger of its own it may never have.
|
|
9338
|
+
*
|
|
8489
9339
|
* Exported for tests: this predicate decides which captures the required-
|
|
8490
9340
|
* URL-field guard's self-heal excludes before regenerating.
|
|
8491
9341
|
*/
|
|
@@ -8499,6 +9349,19 @@ function identifyNoiseCapturesForFields(offendingFields, resolvedPool, primaryCa
|
|
|
8499
9349
|
noiseCaptures.add(capture);
|
|
8500
9350
|
}
|
|
8501
9351
|
}
|
|
9352
|
+
const noiseUrls = [...noiseCaptures].map((capture) => ({
|
|
9353
|
+
path: safeUrlPathname(capture.url),
|
|
9354
|
+
hostname: captureHostname(capture.url),
|
|
9355
|
+
}));
|
|
9356
|
+
for (const { capture } of resolvedPool) {
|
|
9357
|
+
if (capture === primaryCapture || noiseCaptures.has(capture))
|
|
9358
|
+
continue;
|
|
9359
|
+
const path = safeUrlPathname(capture.url);
|
|
9360
|
+
const hostname = captureHostname(capture.url);
|
|
9361
|
+
const isSameHostFamilyMatch = noiseUrls.some((noise) => noise.hostname === hostname && (0, capture_filters_1.isSamePathFamily)(path, noise.path));
|
|
9362
|
+
if (isSameHostFamilyMatch)
|
|
9363
|
+
noiseCaptures.add(capture);
|
|
9364
|
+
}
|
|
8502
9365
|
return noiseCaptures;
|
|
8503
9366
|
}
|
|
8504
9367
|
/**
|
|
@@ -8836,7 +9699,7 @@ async function main() {
|
|
|
8836
9699
|
// heuristic extraction finds.
|
|
8837
9700
|
const unfilteredHeuristicActionCaptures = gql
|
|
8838
9701
|
? dedupRedundantSameOperationCaptures(graphqlActionSequence, primaryGraphQLOperation)
|
|
8839
|
-
: collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain));
|
|
9702
|
+
: collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)));
|
|
8840
9703
|
// A flow-declared submitEndpointPattern is authoritative: it may match only
|
|
8841
9704
|
// the final step's URL (the natural way to describe "the button that
|
|
8842
9705
|
// finishes the wizard") even though the earlier steps of the same chain
|
|
@@ -8931,7 +9794,7 @@ async function main() {
|
|
|
8931
9794
|
// producible state (see collectDependentDrillDownChainValues).
|
|
8932
9795
|
const dependentDrillDownChainValues = actionCaptures.length > 1
|
|
8933
9796
|
? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
|
|
8934
|
-
: new
|
|
9797
|
+
: new Map();
|
|
8935
9798
|
const stateIndex = actionCaptures.length > 1
|
|
8936
9799
|
? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
8937
9800
|
: new Map();
|