@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.
- package/dist/recon/capture-filters.d.ts +124 -0
- package/dist/recon/capture-filters.d.ts.map +1 -1
- package/dist/recon/capture-filters.js +209 -0
- 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 +21 -0
- package/dist/scraper/cdp-frame-init-script.d.ts.map +1 -0
- package/dist/scraper/cdp-frame-init-script.js +72 -0
- package/dist/scraper/cdp-frame-init-script.js.map +1 -0
- package/dist/scraper/flow-runner.d.ts +14 -1
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +23 -2
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/session.d.ts.map +1 -1
- package/dist/scraper/session.js +7 -3
- package/dist/scraper/session.js.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.d.ts +40 -20
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +52 -22
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +114 -40
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +1731 -199
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +2 -2
|
@@ -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((
|
|
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 =
|
|
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)}
|
|
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
|
-
|
|
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} (
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
* re-
|
|
1789
|
-
*
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
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
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3037
|
-
|
|
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
|
-
|
|
3094
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
|
3531
|
-
*
|
|
3532
|
-
*
|
|
3533
|
-
*
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3868
|
-
|
|
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 (
|
|
4788
|
+
if (value === null)
|
|
3891
4789
|
continue;
|
|
3892
|
-
//
|
|
3893
|
-
|
|
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
|
-
|
|
3896
|
-
|
|
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
|
|
3900
|
-
// can add
|
|
3901
|
-
//
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
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
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
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
|
-
? `(${
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4667
|
-
//
|
|
4668
|
-
//
|
|
4669
|
-
//
|
|
4670
|
-
//
|
|
4671
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4744
|
-
?
|
|
4745
|
-
:
|
|
4746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
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 (
|
|
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
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
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
|
-
|
|
5380
|
-
|
|
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
|
|
5662
|
-
|
|
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 }) =>
|
|
5666
|
-
|
|
5667
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
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
|
|
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
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|