@enricai/barnacle 1.12.71 → 1.12.72
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 +8 -1
- package/dist/recon/capture-filters.d.ts.map +1 -1
- package/dist/recon/capture-filters.js +97 -26
- package/dist/recon/capture-filters.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +26 -39
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/submit-control.d.ts +23 -1
- package/dist/scraper/submit-control.d.ts.map +1 -1
- package/dist/scraper/submit-control.js +40 -3
- package/dist/scraper/submit-control.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +148 -6
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +492 -67
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -33,8 +33,10 @@ exports.detectAggregateUnitBasisFindings = detectAggregateUnitBasisFindings;
|
|
|
33
33
|
exports.selectPayloadAction = selectPayloadAction;
|
|
34
34
|
exports.selectReturnAction = selectReturnAction;
|
|
35
35
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
36
|
+
exports.deriveBaseUrl = deriveBaseUrl;
|
|
36
37
|
exports.extractEntryUrlParams = extractEntryUrlParams;
|
|
37
38
|
exports.deriveRequestHeaders = deriveRequestHeaders;
|
|
39
|
+
exports.isGraphQL = isGraphQL;
|
|
38
40
|
exports.gatherResponseBodySamples = gatherResponseBodySamples;
|
|
39
41
|
exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
|
|
40
42
|
exports.firstEndpointCapture = firstEndpointCapture;
|
|
@@ -827,6 +829,26 @@ function endpointKey(url) {
|
|
|
827
829
|
return url;
|
|
828
830
|
}
|
|
829
831
|
}
|
|
832
|
+
const RESOURCE_ID_SEGMENT = /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{24,})$/i;
|
|
833
|
+
/**
|
|
834
|
+
* {@link endpointKey} with numeric/UUID/long-hex path segments collapsed, so
|
|
835
|
+
* `PATCH /orders/17` and `PATCH /orders/18` count as one recurring REST
|
|
836
|
+
* endpoint rather than two one-off paths.
|
|
837
|
+
*/
|
|
838
|
+
function endpointTemplateKey(url) {
|
|
839
|
+
const key = endpointKey(url);
|
|
840
|
+
try {
|
|
841
|
+
const u = new URL(key);
|
|
842
|
+
const pathname = u.pathname
|
|
843
|
+
.split("/")
|
|
844
|
+
.map((segment) => (RESOURCE_ID_SEGMENT.test(segment) ? ":id" : segment))
|
|
845
|
+
.join("/");
|
|
846
|
+
return `${u.origin}${pathname}`;
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return key;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
830
852
|
/** Empty or absent response bodies carry no evidence of an endpoint's purpose. */
|
|
831
853
|
function isVoidResponse(body) {
|
|
832
854
|
if (body === null || body === undefined || body === "")
|
|
@@ -992,22 +1014,182 @@ function capturePathname(url) {
|
|
|
992
1014
|
return "";
|
|
993
1015
|
}
|
|
994
1016
|
}
|
|
1017
|
+
/**
|
|
1018
|
+
* A page-navigation response (an HTML document, not JSON/API traffic) is
|
|
1019
|
+
* never itself the flow's backend -- it is where the flow's real API calls
|
|
1020
|
+
* happen to start from. Array order routinely puts the landing navigation
|
|
1021
|
+
* first, so leaving it in the "first non-noise capture" candidate pool lets
|
|
1022
|
+
* an incidental cross-host landing page win `baseUrl`/`primaryHost` over the
|
|
1023
|
+
* flow's genuine, JSON-responding backend traffic.
|
|
1024
|
+
*/
|
|
1025
|
+
function isHtmlNavigationCapture(capture) {
|
|
1026
|
+
const contentType = Object.entries(capture.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
|
|
1027
|
+
return typeof contentType === "string" && contentType.toLowerCase().includes("text/html");
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Vocabulary for a host label DEDICATED to authentication/identity, generic
|
|
1031
|
+
* across sites (no vendor names) -- e.g. `login.example.com`, `sso.example.net`.
|
|
1032
|
+
* A path segment like `/api/login` on the real backend does not match, since
|
|
1033
|
+
* this only inspects the host's first label, not the path.
|
|
1034
|
+
*/
|
|
1035
|
+
const AUTH_HOST_LABEL = /^(login|signin|sign-in|logon|auth|oauth|sso|idp|id|account|accounts)$/i;
|
|
1036
|
+
/**
|
|
1037
|
+
* Vocabulary for a host label DEDICATED to marketing/landing/redirect
|
|
1038
|
+
* bounces, generic across sites (no vendor names) -- e.g. `www.example.com`,
|
|
1039
|
+
* `landing.example.net`, `go.example.org`. Same structural role as
|
|
1040
|
+
* {@link AUTH_HOST_LABEL}: a same-company host whose label names it as a
|
|
1041
|
+
* marketing/redirect surface rather than an API backend, never itself the
|
|
1042
|
+
* flow's own backend even when it is not also auth/login-labeled.
|
|
1043
|
+
*/
|
|
1044
|
+
const MARKETING_HOST_LABEL = /^(www|home|landing|marketing|promo|redirect|link|go)$/i;
|
|
1045
|
+
/**
|
|
1046
|
+
* A same-company redirect -- an authentication/identity bounce (see
|
|
1047
|
+
* {@link AUTH_HOST_LABEL}) OR a marketing/landing bounce (see
|
|
1048
|
+
* {@link MARKETING_HOST_LABEL}) -- is never itself the flow's own backend,
|
|
1049
|
+
* even though it routinely lands on a different registrable domain than the
|
|
1050
|
+
* flow's real traffic and can complete first due to async timing (e.g. a
|
|
1051
|
+
* mid-session re-auth bounce, or a same-company marketing redirect, that
|
|
1052
|
+
* resolves before the in-flight primary request does). Left in the "first
|
|
1053
|
+
* non-noise capture" candidate pool, such a capture anchors
|
|
1054
|
+
* `baseUrl`/`primaryHost` on the wrong eTLD+1 -- and because
|
|
1055
|
+
* {@link deriveBaseUrl}'s same-domain GraphQL-dominance vote is scoped to
|
|
1056
|
+
* THAT anchor's own registrable domain, nothing downstream ever gets a
|
|
1057
|
+
* chance to correct onto the real backend. Matched on the host's first label
|
|
1058
|
+
* only (see {@link AUTH_HOST_LABEL} and {@link MARKETING_HOST_LABEL}), the
|
|
1059
|
+
* same structural, content-based precedent as {@link isHtmlNavigationCapture}
|
|
1060
|
+
* rather than a count/percentage threshold tuned to any one archive.
|
|
1061
|
+
*
|
|
1062
|
+
* A host label alone can't tell a one-off bounce apart from a genuine
|
|
1063
|
+
* own-backend host that happens to be named `login.`/`www.` (real APIs are
|
|
1064
|
+
* named that too). So this only flags the capture as a redirect when its
|
|
1065
|
+
* host is NOT the pool's most-represented host -- a real backend, named
|
|
1066
|
+
* `login.`/`www.` or not, always racks up the most captures; a bounce that
|
|
1067
|
+
* merely sorts first never does.
|
|
1068
|
+
*/
|
|
1069
|
+
function isSameCompanyRedirectCapture(capture, pool) {
|
|
1070
|
+
const host = captureHostname(capture.url);
|
|
1071
|
+
const firstLabel = host.split(".")[0] ?? "";
|
|
1072
|
+
if (!AUTH_HOST_LABEL.test(firstLabel) && !MARKETING_HOST_LABEL.test(firstLabel))
|
|
1073
|
+
return false;
|
|
1074
|
+
const hostCounts = new Map();
|
|
1075
|
+
for (const c of pool) {
|
|
1076
|
+
const h = captureHostname(c.url);
|
|
1077
|
+
hostCounts.set(h, (hostCounts.get(h) ?? 0) + 1);
|
|
1078
|
+
}
|
|
1079
|
+
const maxCount = Math.max(...hostCounts.values());
|
|
1080
|
+
return hostCounts.get(host) !== maxCount;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Groups captures by exact host and returns the origin of the group with the
|
|
1084
|
+
* most captures, so a minority host's capture landing earlier in array order
|
|
1085
|
+
* (e.g. a redirect completing before the dominant host's traffic due to async
|
|
1086
|
+
* ordering) can never win over the group that genuinely dominates by count.
|
|
1087
|
+
* Ties keep the first-encountered group's order to stay deterministic.
|
|
1088
|
+
*/
|
|
1089
|
+
function dominantHostOrigin(candidates) {
|
|
1090
|
+
const groups = new Map();
|
|
1091
|
+
const groupOrder = [];
|
|
1092
|
+
for (const c of candidates) {
|
|
1093
|
+
const host = captureHostname(c.url);
|
|
1094
|
+
if (!groups.has(host)) {
|
|
1095
|
+
groups.set(host, []);
|
|
1096
|
+
groupOrder.push(host);
|
|
1097
|
+
}
|
|
1098
|
+
groups.get(host).push(c);
|
|
1099
|
+
}
|
|
1100
|
+
const dominantHost = groupOrder.reduce((best, host) => {
|
|
1101
|
+
if (best === null)
|
|
1102
|
+
return host;
|
|
1103
|
+
return groups.get(host).length > groups.get(best).length ? host : best;
|
|
1104
|
+
}, null);
|
|
1105
|
+
const dominantGroup = dominantHost !== null ? groups.get(dominantHost) : [];
|
|
1106
|
+
return firstCaptureOrigin(dominantGroup);
|
|
1107
|
+
}
|
|
995
1108
|
/**
|
|
996
1109
|
* `baseUrl` itself is what {@link registrableDomain}'s fallback would be
|
|
997
1110
|
* derived FROM, so at this point in generation the registrable-domain gate
|
|
998
1111
|
* doesn't exist yet: when the flow declares no `ownBackendHostnames`, the
|
|
999
1112
|
* best available signal is {@link isNoiseUrl}'s conservative exclusion of
|
|
1000
1113
|
* known third-party asset/tracking hosts (the same fallback
|
|
1001
|
-
* `selectAuxFixtureCandidates` uses regardless of host-provenance data)
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1114
|
+
* `selectAuxFixtureCandidates` uses regardless of host-provenance data), plus
|
|
1115
|
+
* excluding HTML page-navigation responses (see {@link isHtmlNavigationCapture}).
|
|
1116
|
+
* Same-company auth/identity and marketing/landing redirects (see
|
|
1117
|
+
* {@link isSameCompanyRedirectCapture}) are excluded from the ANCHOR pick
|
|
1118
|
+
* specifically -- such a redirect is never itself the flow's own backend, no
|
|
1119
|
+
* matter which registrable domain it lands on or how many captures it racks
|
|
1120
|
+
* up completing an SSO or marketing bounce -- but they stay in the wider
|
|
1121
|
+
* `pool` once a real anchor domain is chosen, so a genuine own-backend host
|
|
1122
|
+
* whose label happens to match {@link AUTH_HOST_LABEL} or
|
|
1123
|
+
* {@link MARKETING_HOST_LABEL} (e.g. an `accounts.` or `www.` subdomain that
|
|
1124
|
+
* is itself the real API, not a bounce) can still win the same-domain
|
|
1125
|
+
* dominance vote below.
|
|
1126
|
+
* `baseUrl` doubles as `primaryHost`'s source once resolved, so an
|
|
1127
|
+
* array-order artifact (e.g. a genuine minority-subdomain GraphQL capture
|
|
1128
|
+
* sorting before the flow's dominant GraphQL backend traffic, on a sibling
|
|
1129
|
+
* subdomain of the same registrable domain) would starve every downstream
|
|
1130
|
+
* `primaryHost` narrowing of its real target if the first non-noise capture
|
|
1131
|
+
* always won outright. But capture count alone can't distinguish an
|
|
1132
|
+
* own-backend host from a chatty unrelated one -- #bugfix-005 pins
|
|
1133
|
+
* first-non-noise-capture-wins for the general case, since a same-domain
|
|
1134
|
+
* trailing noise batch (e.g. session-refresh redirects, or a third-party
|
|
1135
|
+
* GraphQL telemetry beacon on an unrelated domain) can vastly outnumber a
|
|
1136
|
+
* real, already-complete primary flow. The dominance vote therefore only
|
|
1137
|
+
* ever runs when the first non-noise capture's OWN registrable-domain group
|
|
1138
|
+
* itself contains GraphQL-shaped traffic (a capture whose `query` parses as
|
|
1139
|
+
* a GraphQL operation) --
|
|
1140
|
+
* exactly the #493 scenario, a genuine own-domain GraphQL host split across
|
|
1141
|
+
* subdomains -- and even then the vote counts every same-domain capture
|
|
1142
|
+
* (GraphQL and REST alike), not just the GraphQL-shaped ones, so it can't be
|
|
1143
|
+
* hijacked by an unrelated cross-domain GraphQL noise source that happens to
|
|
1144
|
+
* outnumber the real flow. Absent same-domain GraphQL traffic OR a declared
|
|
1145
|
+
* submit pattern (see below), the first non-noise, non-navigation capture
|
|
1146
|
+
* wins exactly as before.
|
|
1147
|
+
*
|
|
1148
|
+
* A flow's own declared `submitEndpointPattern`/`submitBodyPattern` is a
|
|
1149
|
+
* second, independent trigger for the same cross-host dominance vote: when
|
|
1150
|
+
* the anchor's own registrable-domain group contains no capture matching the
|
|
1151
|
+
* declared pattern, but a DIFFERENT registrable domain in the pool does, that
|
|
1152
|
+
* other domain is the flow's real backend -- the flow author declared this
|
|
1153
|
+
* pattern specifically to name its own submission traffic, so a match
|
|
1154
|
+
* elsewhere in the pool outranks whichever host merely happened to capture
|
|
1155
|
+
* first. This is strictly narrower than a raw capture-count vote (the kind
|
|
1156
|
+
* #bugfix-005 deliberately rejected as a general rule -- see
|
|
1157
|
+
* "does not let a chatty host outvote the first non-noise capture" in
|
|
1158
|
+
* recon-generate.test.ts, where NO pattern is declared and the first capture
|
|
1159
|
+
* must still win over a larger, unrelated cross-domain group): it only ever
|
|
1160
|
+
* fires when the flow explicitly named the traffic shape it's after, and even
|
|
1161
|
+
* then only switches domain, never host -- {@link dominantHostOrigin} still
|
|
1162
|
+
* picks the exact host by count within that now-confirmed domain.
|
|
1163
|
+
*/
|
|
1164
|
+
function deriveBaseUrl(captures, ownBackendHostnames, submitPatterns = null) {
|
|
1165
|
+
if (ownBackendHostnames.length === 0) {
|
|
1166
|
+
const nonNoise = captures.filter((c) => !(0, capture_filters_1.isNoiseUrl)(c.url) && !isHtmlNavigationCapture(c));
|
|
1167
|
+
const pool = nonNoise.length > 0 ? nonNoise : captures.filter((c) => !(0, capture_filters_1.isNoiseUrl)(c.url));
|
|
1168
|
+
const anchorPool = pool.filter((c) => !isSameCompanyRedirectCapture(c, pool));
|
|
1169
|
+
const anchorSource = anchorPool.length > 0 ? anchorPool : pool;
|
|
1170
|
+
const anchorHost = captureHostname(firstCaptureOrigin(anchorSource));
|
|
1171
|
+
const anchorDomain = (0, capture_filters_1.registrableDomain)(anchorHost);
|
|
1172
|
+
const sameDomainCandidates = pool.filter((c) => (0, capture_filters_1.registrableDomain)(captureHostname(c.url)) === anchorDomain);
|
|
1173
|
+
const hasDeclaredSubmitPattern = submitPatterns !== null && (submitPatterns.endpoint !== null || submitPatterns.body !== null);
|
|
1174
|
+
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1175
|
+
const crossDomainSubmitMatch = hasDeclaredSubmitPattern && !sameDomainCandidates.some(matchesSubmit)
|
|
1176
|
+
? pool.find(matchesSubmit)
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (crossDomainSubmitMatch !== undefined) {
|
|
1179
|
+
const submitDomain = (0, capture_filters_1.registrableDomain)(captureHostname(crossDomainSubmitMatch.url));
|
|
1180
|
+
const submitDomainCandidates = pool.filter((c) => (0, capture_filters_1.registrableDomain)(captureHostname(c.url)) === submitDomain);
|
|
1181
|
+
return dominantHostOrigin(submitDomainCandidates);
|
|
1007
1182
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1183
|
+
const sameDomainHasGraphql = sameDomainCandidates.some((c) => (0, capture_filters_1.parsedOperationName)(c.query ?? "") !== null);
|
|
1184
|
+
if (!sameDomainHasGraphql)
|
|
1185
|
+
return firstCaptureOrigin(anchorSource);
|
|
1186
|
+
return dominantHostOrigin(sameDomainCandidates);
|
|
1187
|
+
}
|
|
1188
|
+
const candidates = captures.filter((c) => (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, null));
|
|
1189
|
+
return dominantHostOrigin(candidates);
|
|
1190
|
+
}
|
|
1191
|
+
function firstCaptureOrigin(captures) {
|
|
1192
|
+
for (const c of captures) {
|
|
1011
1193
|
try {
|
|
1012
1194
|
const u = new URL(c.url);
|
|
1013
1195
|
return `${u.protocol}//${u.host}`;
|
|
@@ -1096,7 +1278,13 @@ function isVolatileHeaderName(headerName) {
|
|
|
1096
1278
|
* per-session id the site mints once and resends, not a stable constant.
|
|
1097
1279
|
* `emitMultiStepExecuteHttp` re-mints it at call time instead.
|
|
1098
1280
|
*/
|
|
1099
|
-
function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null, ownBackendHostnames = [], fallbackDomain = null
|
|
1281
|
+
function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null, ownBackendHostnames = [], fallbackDomain = null,
|
|
1282
|
+
// Threaded into extractActionSequence below so its action-capture header
|
|
1283
|
+
// source is narrowed to the flow's dominant own-backend host the same way
|
|
1284
|
+
// every other extractActionSequence caller already is — otherwise the
|
|
1285
|
+
// header baseline could be derived from a minority-host capture's headers
|
|
1286
|
+
// instead of the primary host's.
|
|
1287
|
+
primaryHost = null) {
|
|
1100
1288
|
const successfulUrls = new Set(replays.filter((r) => r.success).map((r) => endpointKey(r.url)));
|
|
1101
1289
|
// Prefer ACTION captures (non-GET 2xx, non-noise) as the authoritative
|
|
1102
1290
|
// header source. Replay-matched static-asset GETs lack the API-specific
|
|
@@ -1105,7 +1293,7 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null,
|
|
|
1105
1293
|
// (multi-step submission flows), use them. For sites where the flow is a
|
|
1106
1294
|
// single REST call (no detectable action sequence), fall back to the
|
|
1107
1295
|
// replay-matched captures.
|
|
1108
|
-
const actionCaptures = extractActionSequence(captures, submitPatterns, null, ownBackendHostnames, fallbackDomain).map((a) => a.capture);
|
|
1296
|
+
const actionCaptures = extractActionSequence(captures, submitPatterns, null, ownBackendHostnames, fallbackDomain, true, primaryHost).map((a) => a.capture);
|
|
1109
1297
|
const replayMatchedCaptures = captures.filter((c) => successfulUrls.has(endpointKey(c.url)));
|
|
1110
1298
|
const relevantCaptures = actionCaptures.length > 0 ? actionCaptures : replayMatchedCaptures;
|
|
1111
1299
|
const counts = new Map();
|
|
@@ -1146,12 +1334,139 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null,
|
|
|
1146
1334
|
* Classifies the flow as GraphQL only from own-backend-host traffic, so a
|
|
1147
1335
|
* third-party host (chat widget, analytics SDK) making real GraphQL calls
|
|
1148
1336
|
* never flips a plain REST/JSON backend's classification.
|
|
1149
|
-
|
|
1150
|
-
|
|
1337
|
+
*
|
|
1338
|
+
* Founding evidence must be an actual query/mutation document — a REST/JSON
|
|
1339
|
+
* backend can plausibly send its own unrelated body field literally named
|
|
1340
|
+
* `operationName` (or a `query` field holding a plain search string), and a
|
|
1341
|
+
* bare `operationName` with no GraphQL-shaped `query` is exactly what an
|
|
1342
|
+
* Automatic-Persisted-Query re-issue looks like too, so it's evidence of
|
|
1343
|
+
* nothing on its own. It only counts once a genuine document has already
|
|
1344
|
+
* established the classification from elsewhere in the same host-scoped set.
|
|
1345
|
+
*
|
|
1346
|
+
* Evidence is further narrowed to `primaryHost` — the same host
|
|
1347
|
+
* {@link deriveBaseUrl} already resolved the generated client's base URL
|
|
1348
|
+
* from — so a thin trickle of genuine GraphQL traffic against a minority
|
|
1349
|
+
* own-backend host can never flip a REST-majority flow's classification.
|
|
1350
|
+
*
|
|
1351
|
+
* Existence of a single parseable document isn't enough either: a REST body
|
|
1352
|
+
* field literally named `query` can coincidentally satisfy
|
|
1353
|
+
* {@link parsedOperationName}'s regex once out of many unrelated REST
|
|
1354
|
+
* captures sharing that same field name. The parsed-document count must be
|
|
1355
|
+
* at least half of the host-scoped voting pool — mirroring
|
|
1356
|
+
* {@link dominantHostOrigin}'s count-based idiom — so one coincidental match
|
|
1357
|
+
* can never outvote a REST-majority flow. The voting pool is no longer just
|
|
1358
|
+
* the query-bearing subset: a genuine own-backend REST anti-vote — a
|
|
1359
|
+
* request keyed by its URL, not a body, carrying neither `query` nor
|
|
1360
|
+
* `operationName` — now joins the pool too, so one recurring endpoint whose
|
|
1361
|
+
* body coincidentally (or even genuinely) uses a `query`-shaped field can no
|
|
1362
|
+
* longer become a trivial majority of a tiny query-only subset while
|
|
1363
|
+
* remaining a small minority of the site's real own-backend REST traffic.
|
|
1364
|
+
* Anti-vote eligibility spans every HTTP method, not just GET: a catalog/
|
|
1365
|
+
* order site's real own-backend evidence is often POST/PUT/PATCH submissions
|
|
1366
|
+
* (an order-status update, a cart PATCH) with no parsed GraphQL document and
|
|
1367
|
+
* no `operationName`, and restricting the pool to GET alone would make that
|
|
1368
|
+
* evidence invisible no matter how many such captures exist. `captures`
|
|
1369
|
+
* here is the raw, pre-noise-filter capture set, so {@link isNoiseUrl} gates
|
|
1370
|
+
* eligibility directly: a same-origin POST body (an analytics beacon, a
|
|
1371
|
+
* heartbeat ping) is exactly the shape isNoiseUrl excludes as noise, not
|
|
1372
|
+
* real REST traffic — admitting it to the vote would let sheer beacon
|
|
1373
|
+
* volume dilute a genuinely GraphQL-dominant flow. isNoiseUrl only knows
|
|
1374
|
+
* vendor/host-shaped noise, though, so a non-GET candidate must also look
|
|
1375
|
+
* like a real submission: it carries a request body and its method+endpoint
|
|
1376
|
+
* recurs (resource-ID segments collapsed). A bodiless POST ping and a spray of one-off opaque beacon paths
|
|
1377
|
+
* (`/eP8-00`, `/eP8-01`, …) fail that shape and stay out of the vote, while
|
|
1378
|
+
* a recurring order-status PATCH still counts. A same-origin capture
|
|
1379
|
+
* {@link isZeroVarianceRepeatCapture} proves is a request-invariant repeat
|
|
1380
|
+
* is reduced to at most one representative per
|
|
1381
|
+
* endpoint rather than counted for every occurrence — repeat volume alone
|
|
1382
|
+
* must never inflate the anti-vote, the same reason a chatty query-bearing
|
|
1383
|
+
* client isn't allowed to inflate the pro-vote — but that representative is
|
|
1384
|
+
* only dropped from the pool entirely when
|
|
1385
|
+
* {@link hasNoBusinessRelevantResponseState} shows the repeat carries no
|
|
1386
|
+
* data of its own (a beacon/heartbeat). A REST endpoint whose real response
|
|
1387
|
+
* is simply the same value every time it's called (a static category list,
|
|
1388
|
+
* a site-config fetch) is still genuine own-backend evidence and must not be
|
|
1389
|
+
* erased down to zero just because it happens not to vary — that would let
|
|
1390
|
+
* a host whose only queryable traffic is a handful of genuinely-parsed
|
|
1391
|
+
* GraphQL documents win by default the moment its REST traffic is
|
|
1392
|
+
* repetitive, independent of the REST traffic's actual share of the flow.
|
|
1393
|
+
* Bare-`operationName`/APQ-reissue-shaped captures (`operationName` set,
|
|
1394
|
+
* `query` falsy) are excluded from the pool regardless of method: per the
|
|
1395
|
+
* corroboration rule above they carry no signal of their own, so diluting
|
|
1396
|
+
* the pool with a chatty APQ client's re-issue traffic would swamp its own
|
|
1397
|
+
* genuine originating document out of the majority.
|
|
1398
|
+
*
|
|
1399
|
+
* The half-or-more threshold (rather than a strict majority) matters for the
|
|
1400
|
+
* single-primary + single-drill-down shape every foldReturn flow produces:
|
|
1401
|
+
* one genuine GraphQL document paired with exactly one GET REST drill-down
|
|
1402
|
+
* capture is a 1-to-1 pool, and that drill-down capture is real corroborated
|
|
1403
|
+
* REST evidence, not a coincidental parse — it must not by itself veto a
|
|
1404
|
+
* flow that has no other own-backend traffic at all.
|
|
1405
|
+
*/
|
|
1406
|
+
function isGraphQL(captures, ownBackendHostnames = [], fallbackDomain = null, primaryHost = null) {
|
|
1151
1407
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1408
|
+
const scoped = captures.filter((c) => {
|
|
1409
|
+
if (hasHostProvenance &&
|
|
1410
|
+
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain, primaryHost))
|
|
1411
|
+
return false;
|
|
1412
|
+
return true;
|
|
1413
|
+
});
|
|
1414
|
+
const nonGetEndpointCounts = scoped.reduce((counts, c) => {
|
|
1415
|
+
if (c.method.toUpperCase() === "GET")
|
|
1416
|
+
return counts;
|
|
1417
|
+
const key = `${c.method.toUpperCase()} ${endpointTemplateKey(c.url)}`;
|
|
1418
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1419
|
+
return counts;
|
|
1420
|
+
}, new Map());
|
|
1421
|
+
const isSubmissionShapedNonGet = (c) => c.requestPostData !== null &&
|
|
1422
|
+
c.requestPostData !== "" &&
|
|
1423
|
+
(nonGetEndpointCounts.get(`${c.method.toUpperCase()} ${endpointTemplateKey(c.url)}`) ?? 0) >= 2;
|
|
1424
|
+
const restAntiVoteCandidates = scoped.filter((c) => !c.query &&
|
|
1425
|
+
c.operationName === null &&
|
|
1426
|
+
!(0, capture_filters_1.isNoiseUrl)(c.url) &&
|
|
1427
|
+
(c.method.toUpperCase() === "GET" || isSubmissionShapedNonGet(c)));
|
|
1428
|
+
const rescuedInvariantEndpoints = new Set();
|
|
1429
|
+
const restAntiVotes = restAntiVoteCandidates.filter((c) => {
|
|
1430
|
+
if (!(0, capture_filters_1.isZeroVarianceRepeatCapture)(c, scoped))
|
|
1431
|
+
return true;
|
|
1432
|
+
if ((0, capture_filters_1.hasNoBusinessRelevantResponseState)(c))
|
|
1433
|
+
return false;
|
|
1434
|
+
const key = endpointKey(c.url);
|
|
1435
|
+
if (rescuedInvariantEndpoints.has(key))
|
|
1436
|
+
return false;
|
|
1437
|
+
rescuedInvariantEndpoints.add(key);
|
|
1438
|
+
return true;
|
|
1439
|
+
});
|
|
1440
|
+
// Mirrors the restAntiVotes rescue above: a query-bearing endpoint called
|
|
1441
|
+
// thousands of times is reduced to at most one representative per
|
|
1442
|
+
// isZeroVarianceRepeatCapture-collapsed endpoint, the same way REST's
|
|
1443
|
+
// anti-vote side already is. Without this, a REST archive's dominant,
|
|
1444
|
+
// repetitive own-backend traffic collapses to a handful of anti-votes
|
|
1445
|
+
// while a smaller set of genuinely-varying query-bearing captures on the
|
|
1446
|
+
// same endpoint keeps full per-capture weight, letting raw operation
|
|
1447
|
+
// count — not real traffic share — decide the classification. Unlike the
|
|
1448
|
+
// REST side, a rescued representative is never dropped entirely via
|
|
1449
|
+
// {@link hasNoBusinessRelevantResponseState}: that check exists to tell a
|
|
1450
|
+
// beacon/heartbeat (no self-declared identity) apart from real REST
|
|
1451
|
+
// evidence, but a query-bearing capture already carries an intentional,
|
|
1452
|
+
// structured document — it is evidence of a GraphQL-shaped endpoint
|
|
1453
|
+
// existing even when its response happens to echo back only values the
|
|
1454
|
+
// request itself supplied.
|
|
1455
|
+
const rescuedInvariantQueryEndpoints = new Set();
|
|
1456
|
+
const proVotes = scoped
|
|
1457
|
+
.filter((c) => c.query)
|
|
1458
|
+
.filter((c) => {
|
|
1459
|
+
if (!(0, capture_filters_1.isZeroVarianceRepeatCapture)(c, scoped))
|
|
1460
|
+
return true;
|
|
1461
|
+
const key = endpointKey(c.url);
|
|
1462
|
+
if (rescuedInvariantQueryEndpoints.has(key))
|
|
1463
|
+
return false;
|
|
1464
|
+
rescuedInvariantQueryEndpoints.add(key);
|
|
1465
|
+
return true;
|
|
1466
|
+
});
|
|
1467
|
+
const votingPool = [...proVotes, ...restAntiVotes];
|
|
1468
|
+
const parsedCount = votingPool.reduce((count, c) => ((0, capture_filters_1.parsedOperationName)(c.query ?? "") !== null ? count + 1 : count), 0);
|
|
1469
|
+
return parsedCount > 0 && parsedCount * 2 >= votingPool.length;
|
|
1155
1470
|
}
|
|
1156
1471
|
/**
|
|
1157
1472
|
* The single own-backend capture the raw GraphQL fallback (no
|
|
@@ -1171,11 +1486,11 @@ function isGraphQL(captures, ownBackendHostnames = [], fallbackDomain = null) {
|
|
|
1171
1486
|
* flow's own mutation capture (its real submission step, resolved
|
|
1172
1487
|
* elsewhere) must not hijack this read-op resolution.
|
|
1173
1488
|
*/
|
|
1174
|
-
function firstGraphQLCapture(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null) {
|
|
1489
|
+
function firstGraphQLCapture(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null, primaryHost = null) {
|
|
1175
1490
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1176
1491
|
const ownBackendCandidates = captures.filter((c) => c.query &&
|
|
1177
1492
|
(!hasHostProvenance ||
|
|
1178
|
-
(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain)));
|
|
1493
|
+
(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain, primaryHost)));
|
|
1179
1494
|
if (submitPatterns === null ||
|
|
1180
1495
|
(submitPatterns.endpoint === null && submitPatterns.body === null)) {
|
|
1181
1496
|
return ownBackendCandidates[0] ?? null;
|
|
@@ -1300,8 +1615,12 @@ function isPopulatedVariableValue(value) {
|
|
|
1300
1615
|
* {@link buildKnownFieldValues}'s known-value guard applied, so an operational
|
|
1301
1616
|
* Select answer (e.g. a device-type dropdown) never contributes a spurious
|
|
1302
1617
|
* facet match to the ranking.
|
|
1618
|
+
*
|
|
1619
|
+
* `primaryHost`, when given, further narrows candidates to the flow's
|
|
1620
|
+
* dominant own-backend host — see {@link isAllowedFixtureHost} — so a
|
|
1621
|
+
* minority own-backend host's query can never outrank the primary host's.
|
|
1303
1622
|
*/
|
|
1304
|
-
function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = process.env, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null) {
|
|
1623
|
+
function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = process.env, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null, primaryHost = null) {
|
|
1305
1624
|
// Callers with no host-provenance data (the exported function's unit
|
|
1306
1625
|
// tests) pass neither ownBackendHostnames nor fallbackDomain — in that
|
|
1307
1626
|
// case isAllowedFixtureHost would reject every candidate, so the gate
|
|
@@ -1313,7 +1632,7 @@ function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = pr
|
|
|
1313
1632
|
c.query !== null &&
|
|
1314
1633
|
!/^\s*mutation\b/.test(c.query) &&
|
|
1315
1634
|
(!hasHostProvenance ||
|
|
1316
|
-
(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain)));
|
|
1635
|
+
(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain, primaryHost)));
|
|
1317
1636
|
// A declared submitEndpointPattern is authoritative for single-endpoint
|
|
1318
1637
|
// primary selection too: it must not be silently ignored just because
|
|
1319
1638
|
// this scoring path (not the multi-step submission path) is the one that
|
|
@@ -1411,16 +1730,26 @@ function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = pr
|
|
|
1411
1730
|
* Resolves the same primary-endpoint capture that {@link firstEndpointPath}
|
|
1412
1731
|
* derives a path string from, so non-GraphQL flows can also read that
|
|
1413
1732
|
* capture's own `.responseBody` instead of an array-order-first replay.
|
|
1733
|
+
*
|
|
1734
|
+
* `primaryHost` narrows the pool the same way {@link isGraphQL} narrows its
|
|
1735
|
+
* own evidence: a minority own-backend host's capture (a redirect target
|
|
1736
|
+
* with its own, unrelated POST) must never win the REST hot path's endpoint
|
|
1737
|
+
* over the primary host's own GET/POST traffic just because it happens to
|
|
1738
|
+
* be the only non-GET capture in the archive.
|
|
1414
1739
|
*/
|
|
1415
|
-
function firstEndpointCapture(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null) {
|
|
1740
|
+
function firstEndpointCapture(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null, primaryHost = null) {
|
|
1416
1741
|
// Callers with no host-provenance data (unit tests exercising the
|
|
1417
1742
|
// chronological-first fallback in isolation) pass neither argument -- in
|
|
1418
1743
|
// that case isAllowedFixtureHost would reject every candidate, so the
|
|
1419
1744
|
// gate only applies once the caller has actually resolved a notion of
|
|
1420
1745
|
// "own backend" to check against. Mirrors selectPrimaryGraphQLOperation.
|
|
1421
1746
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1422
|
-
const allowed = (c) =>
|
|
1423
|
-
|
|
1747
|
+
const allowed = (c) => {
|
|
1748
|
+
if (hasHostProvenance &&
|
|
1749
|
+
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain, primaryHost))
|
|
1750
|
+
return false;
|
|
1751
|
+
return true;
|
|
1752
|
+
};
|
|
1424
1753
|
const nonGetCaptures = restrictToSubmitPattern(captures.filter((c) => c.method !== "GET" && allowed(c)), submitPatterns);
|
|
1425
1754
|
for (const c of nonGetCaptures) {
|
|
1426
1755
|
try {
|
|
@@ -1450,9 +1779,9 @@ function safeUrlPathname(url) {
|
|
|
1450
1779
|
return "/api/search";
|
|
1451
1780
|
}
|
|
1452
1781
|
}
|
|
1453
|
-
function firstEndpointPath(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null) {
|
|
1782
|
+
function firstEndpointPath(captures, ownBackendHostnames = [], fallbackDomain = null, submitPatterns = null, primaryHost = null) {
|
|
1454
1783
|
try {
|
|
1455
|
-
const capture = firstEndpointCapture(captures, ownBackendHostnames, fallbackDomain, submitPatterns);
|
|
1784
|
+
const capture = firstEndpointCapture(captures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost);
|
|
1456
1785
|
return capture ? new URL(capture.url).pathname : "/api/search";
|
|
1457
1786
|
}
|
|
1458
1787
|
catch {
|
|
@@ -1614,7 +1943,14 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1614
1943
|
// that immediately truncates this result opts out with `false` to keep
|
|
1615
1944
|
// earlier chain steps intact while still getting the structural-isolation
|
|
1616
1945
|
// exemption above.
|
|
1617
|
-
applyFinalAnchorNarrowing = true
|
|
1946
|
+
applyFinalAnchorNarrowing = true,
|
|
1947
|
+
// Threaded straight into isAllowedFixtureHost the same way isGraphQL and
|
|
1948
|
+
// deriveBaseUrl already narrow to the flow's dominant own-backend host —
|
|
1949
|
+
// without it, a minority-host capture that happens to match the declared
|
|
1950
|
+
// submitEndpointPattern can survive host-gating alongside the primary
|
|
1951
|
+
// host's genuine matches and starve out the submission this sequence is
|
|
1952
|
+
// meant to isolate.
|
|
1953
|
+
primaryHost = null) {
|
|
1618
1954
|
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
1619
1955
|
// Callers with no host-provenance data (the exported function's unit
|
|
1620
1956
|
// tests) pass neither ownBackendHostnames nor fallbackDomain — in that
|
|
@@ -1641,13 +1977,25 @@ applyFinalAnchorNarrowing = true) {
|
|
|
1641
1977
|
// same way a declared foldReturn endpointPattern already is — both are
|
|
1642
1978
|
// author-declared, both are authoritative by construction, so the isolation
|
|
1643
1979
|
// pass (which exists to catch INCIDENTAL same-host noise the flow never
|
|
1644
|
-
// named) must not treat one differently from the other.
|
|
1980
|
+
// named) must not treat one differently from the other. A declared
|
|
1981
|
+
// submitBodyPattern is exempted the same way when there is no declared
|
|
1982
|
+
// submitEndpointPattern to already cover it: a flow that only declares a
|
|
1983
|
+
// body pattern (no endpoint pattern) is just as author-declared and
|
|
1984
|
+
// authoritative, so its genuine matches must not be dropped as
|
|
1985
|
+
// "structurally isolated" once same-host noise outnumbers them.
|
|
1645
1986
|
const declaredSubmitEndpointRx = submitPatterns?.endpoint != null ? new RegExp(submitPatterns.endpoint) : null;
|
|
1646
1987
|
const matchesDeclaredSubmitEndpoint = (capture) => declaredSubmitEndpointRx?.test(capture.url) ?? false;
|
|
1988
|
+
const declaredSubmitBodyRx = submitPatterns?.endpoint == null && submitPatterns?.body != null
|
|
1989
|
+
? new RegExp(submitPatterns.body)
|
|
1990
|
+
: null;
|
|
1991
|
+
const matchesDeclaredSubmitBody = (capture) => declaredSubmitBodyRx?.test(capture.requestPostData ?? "") ?? false;
|
|
1647
1992
|
const hostGated = captures
|
|
1648
1993
|
.map((capture, index) => ({ capture, index }))
|
|
1649
1994
|
.filter(({ capture }) => {
|
|
1650
|
-
if (capture.method === "GET" &&
|
|
1995
|
+
if (capture.method === "GET" &&
|
|
1996
|
+
!matchesFoldReturn(capture) &&
|
|
1997
|
+
!matchesDeclaredSubmitEndpoint(capture) &&
|
|
1998
|
+
!matchesDeclaredSubmitBody(capture))
|
|
1651
1999
|
return false;
|
|
1652
2000
|
if (capture.status < 200 || capture.status >= 300)
|
|
1653
2001
|
return false;
|
|
@@ -1658,7 +2006,7 @@ applyFinalAnchorNarrowing = true) {
|
|
|
1658
2006
|
if (!matchesSubmit(capture))
|
|
1659
2007
|
return false;
|
|
1660
2008
|
if (hasHostProvenance &&
|
|
1661
|
-
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(capture.url), ownBackendHostnames, fallbackDomain))
|
|
2009
|
+
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(capture.url), ownBackendHostnames, fallbackDomain, primaryHost))
|
|
1662
2010
|
return false;
|
|
1663
2011
|
return true;
|
|
1664
2012
|
});
|
|
@@ -1712,6 +2060,7 @@ applyFinalAnchorNarrowing = true) {
|
|
|
1712
2060
|
// INCIDENTAL same-host captures the flow never declared.
|
|
1713
2061
|
return (matchesFoldReturn(capture) ||
|
|
1714
2062
|
matchesDeclaredSubmitEndpoint(capture) ||
|
|
2063
|
+
matchesDeclaredSubmitBody(capture) ||
|
|
1715
2064
|
!(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths));
|
|
1716
2065
|
})
|
|
1717
2066
|
: hostGated;
|
|
@@ -1797,7 +2146,11 @@ function isMutationCapture(capture) {
|
|
|
1797
2146
|
function isGraphQLMutationCapture(capture) {
|
|
1798
2147
|
return capture.query !== null && /^\s*mutation\b/.test(capture.query);
|
|
1799
2148
|
}
|
|
1800
|
-
function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null
|
|
2149
|
+
function extractGraphQLActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null,
|
|
2150
|
+
// See extractActionSequence's matching param: narrows host-gating to the
|
|
2151
|
+
// flow's dominant own-backend host so a minority-host capture can never
|
|
2152
|
+
// pass alongside it.
|
|
2153
|
+
primaryHost = null) {
|
|
1801
2154
|
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1802
2155
|
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
1803
2156
|
const matchesFoldReturnResults = compileFoldReturnResultsMatcher(foldReturnSpec);
|
|
@@ -1820,7 +2173,7 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
|
|
|
1820
2173
|
if (!matchesSubmit(capture))
|
|
1821
2174
|
return false;
|
|
1822
2175
|
if (hasHostProvenance &&
|
|
1823
|
-
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(capture.url), ownBackendHostnames, fallbackDomain))
|
|
2176
|
+
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(capture.url), ownBackendHostnames, fallbackDomain, primaryHost))
|
|
1824
2177
|
return false;
|
|
1825
2178
|
if (isMutation(capture))
|
|
1826
2179
|
return true;
|
|
@@ -5249,7 +5602,7 @@ responseBodiesForCapacitySignal = []) {
|
|
|
5249
5602
|
// A scalar facet field's captured value doesn't only ever surface as its
|
|
5250
5603
|
// OWN exact `"<field>":<value>` pair (handled above) — the same value can
|
|
5251
5604
|
// be packed inside an UNRELATED key's delimited facet string (e.g. a
|
|
5252
|
-
// `filters`/`variables` blob shaped `
|
|
5605
|
+
// `filters`/`variables` blob shaped `color:midnight-blue|size:large`). This
|
|
5253
5606
|
// consults the SAME field↔captured-value correlation table just built
|
|
5254
5607
|
// (the scalar string entries of `merged`) via the identical case-
|
|
5255
5608
|
// insensitive splice {@link renderGqlVariablesExpr} already applies to the
|
|
@@ -5861,6 +6214,11 @@ flowSteps = [], vocabulary = undefined) {
|
|
|
5861
6214
|
}
|
|
5862
6215
|
// Pass 1: render every step's emitted strings; collect referenced var names.
|
|
5863
6216
|
const rendered = [];
|
|
6217
|
+
// Computed once for the whole loop instead of once per iteration: `actions`
|
|
6218
|
+
// never changes across iterations, so re-deriving this array inside the
|
|
6219
|
+
// loop defeats isZeroVarianceRepeatCapture's reference-keyed cache (see the
|
|
6220
|
+
// identical hoist and rationale at lines 7216 and 12427).
|
|
6221
|
+
const allCaptures = actions.map((a) => a.capture);
|
|
5864
6222
|
for (let i = 0; i < actions.length; i++) {
|
|
5865
6223
|
const step = actions[i];
|
|
5866
6224
|
const cap = step.capture;
|
|
@@ -5873,7 +6231,7 @@ flowSteps = [], vocabulary = undefined) {
|
|
|
5873
6231
|
// isGet UUID-only floor) and for the fold/drill per-item pass. Rendering
|
|
5874
6232
|
// its exact literal URL makes this hold even when a value's own
|
|
5875
6233
|
// length/chain-eligibility scoping doesn't happen to catch the coincidence.
|
|
5876
|
-
const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap,
|
|
6234
|
+
const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, allCaptures)
|
|
5877
6235
|
? cap.url
|
|
5878
6236
|
: interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i, new Set(), joinFieldValuesByStep.get(i) ?? new Set());
|
|
5879
6237
|
// Form-schema substitution runs first on the raw recon body so its
|
|
@@ -6180,7 +6538,17 @@ flowSteps = [], vocabulary = undefined) {
|
|
|
6180
6538
|
// intermediate array's binding stays addressable inside the innermost
|
|
6181
6539
|
// loop body — see pathToFoldLoopLines's docstring.
|
|
6182
6540
|
const { ancestorOpenLines, itemOpenLines, itemCloseLines, ancestorCloseLines, ancestorVars } = pathToFoldLoopLines(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
|
|
6183
|
-
|
|
6541
|
+
// A target whose chain starts at the primary step itself is a
|
|
6542
|
+
// self-fold (resultsPath nests entirely inside the primary's own
|
|
6543
|
+
// response — see the `allowSelfFold` branch above): its "chain fetch"
|
|
6544
|
+
// below is the primary's OWN `const ${primaryStep.varName} = await
|
|
6545
|
+
// httpClient(...)` call, which `ancestorOpenLines` already reads via
|
|
6546
|
+
// `primaryStep.varName` in its very loop expression. That fetch must
|
|
6547
|
+
// run BEFORE `ancestorOpenLines` is pushed below, not spliced inside
|
|
6548
|
+
// it the way a genuine ancestor-scoped drill fetch (a DIFFERENT, later
|
|
6549
|
+
// endpoint) is — otherwise the emitted loop reads `primaryStep.varName`
|
|
6550
|
+
// before its own declaration.
|
|
6551
|
+
const selfFoldFetchLines = [];
|
|
6184
6552
|
// Chain fetches for targets whose params + joinFields never reference
|
|
6185
6553
|
// itemVar are spliced here, above the item loop but inside the
|
|
6186
6554
|
// ancestor loop(s) — fetched once per ancestor tuple and reused by
|
|
@@ -6501,12 +6869,16 @@ flowSteps = [], vocabulary = undefined) {
|
|
|
6501
6869
|
if (chainUsesHeaderThreading)
|
|
6502
6870
|
hasItemScopedHeaderThreading = true;
|
|
6503
6871
|
}
|
|
6872
|
+
else if (target.chain[0] === foldPlan.primaryStepIndex) {
|
|
6873
|
+
selfFoldFetchLines.push(...chainLines);
|
|
6874
|
+
itemScopedLines.push(...matchLines);
|
|
6875
|
+
}
|
|
6504
6876
|
else {
|
|
6505
6877
|
hoistedChainLines.push(...chainLines);
|
|
6506
6878
|
itemScopedLines.push(...matchLines);
|
|
6507
6879
|
}
|
|
6508
6880
|
}
|
|
6509
|
-
lines.push(...hoistedChainLines, ...emitItemLoopLines(itemOpenLines, itemScopedLines, itemCloseLines, itemVar, hasItemScopedFetch && !hasItemScopedHeaderThreading), ...ancestorCloseLines, "");
|
|
6881
|
+
lines.push(...selfFoldFetchLines, ...ancestorOpenLines, ...hoistedChainLines, ...emitItemLoopLines(itemOpenLines, itemScopedLines, itemCloseLines, itemVar, hasItemScopedFetch && !hasItemScopedHeaderThreading), ...ancestorCloseLines, "");
|
|
6510
6882
|
continue;
|
|
6511
6883
|
}
|
|
6512
6884
|
// Every other chain step (already fully emitted, inline, by the fold
|
|
@@ -8727,7 +9099,20 @@ exactPrimaryStepIndex = null,
|
|
|
8727
9099
|
// joinFields override independently for EACH structural target, keyed by
|
|
8728
9100
|
// its own drill endpoint, instead of only the one this function's
|
|
8729
9101
|
// freshest-first scan happens to land on first.
|
|
8730
|
-
restrictToDrillEndpointKey = null
|
|
9102
|
+
restrictToDrillEndpointKey = null,
|
|
9103
|
+
// When true, a capture that is BOTH the primary (resultsPath resolves on
|
|
9104
|
+
// its own response) AND its own endpointPattern match is accepted as a
|
|
9105
|
+
// self-fold — no separate later drill-down call exists at all, the
|
|
9106
|
+
// flow's whole per-item collection already lives in one response. Only
|
|
9107
|
+
// ever set by resolveFoldPlan's bare (no-structural-competition) call:
|
|
9108
|
+
// mergeSpecPlanOntoSamePrimary's calls deliberately leave this `false`,
|
|
9109
|
+
// since there a structurally-detected plan ALREADY anchors a different,
|
|
9110
|
+
// real primary/drill pair — allowing self-fold there would let an
|
|
9111
|
+
// unrelated capture that merely echoes `resultsPath`'s key name on its
|
|
9112
|
+
// OWN response (e.g. a per-item drill-down's "details" array, matched
|
|
9113
|
+
// purely by coincidental naming) hijack or append onto that plan instead
|
|
9114
|
+
// of being correctly left unmerged.
|
|
9115
|
+
allowSelfFold = false) {
|
|
8731
9116
|
const rawPrimaryArrayPath = spec.resultsPath.split(".");
|
|
8732
9117
|
const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
|
|
8733
9118
|
// Built once and shared across every primaryStepIndex — see
|
|
@@ -8772,7 +9157,15 @@ restrictToDrillEndpointKey = null) {
|
|
|
8772
9157
|
// carries one of this primary's join values (see
|
|
8773
9158
|
// {@link collectDrillStepIndicesWorthTrying}), because testing every
|
|
8774
9159
|
// primary/drill pair is quadratic in the capture count.
|
|
8775
|
-
|
|
9160
|
+
//
|
|
9161
|
+
// `primaryStepIndex - 1` (not `primaryStepIndex`) when `allowSelfFold`,
|
|
9162
|
+
// so a capture that is BOTH the primary and its own endpointPattern
|
|
9163
|
+
// match is still admitted — a self-fold, where `resultsPath` nests
|
|
9164
|
+
// entirely inside the SAME capture's response and there is no separate
|
|
9165
|
+
// later drill-down call at all. `firstIndexGreaterThan` finds the first
|
|
9166
|
+
// index strictly greater than its argument, so subtracting 1 widens the
|
|
9167
|
+
// admitted range to include `primaryStepIndex` itself.
|
|
9168
|
+
const firstDrillOffset = firstIndexGreaterThan(allMatchingDrillStepIndices, allowSelfFold ? primaryStepIndex - 1 : primaryStepIndex);
|
|
8776
9169
|
if (candidateEntryIndicesDescending.length === 0 &&
|
|
8777
9170
|
firstDrillOffset === allMatchingDrillStepIndices.length) {
|
|
8778
9171
|
continue;
|
|
@@ -8830,7 +9223,18 @@ restrictToDrillEndpointKey = null) {
|
|
|
8830
9223
|
null);
|
|
8831
9224
|
}
|
|
8832
9225
|
if (spec.drillResultsPath === undefined) {
|
|
8833
|
-
|
|
9226
|
+
// Self-fold: the matched "drill" IS the primary capture itself
|
|
9227
|
+
// (entryIndex === drillStepIndex === primaryStepIndex), so the
|
|
9228
|
+
// array to fold is the already-resolved `primaryArrayPath` —
|
|
9229
|
+
// reusing it is the only way to land on the author's declared
|
|
9230
|
+
// (possibly multi-wildcard-level) nested array; re-deriving it via
|
|
9231
|
+
// `findObjectArrayFieldOrWholeObject`'s first-DFS-match would
|
|
9232
|
+
// instead land on whichever object-array field happens to come
|
|
9233
|
+
// first in key order (e.g. a shallower ancestor array), silently
|
|
9234
|
+
// discarding the declared `resultsPath`'s own nesting depth.
|
|
9235
|
+
return drillStepIndex === primaryStepIndex
|
|
9236
|
+
? primaryArrayPath
|
|
9237
|
+
: (findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null);
|
|
8834
9238
|
}
|
|
8835
9239
|
const path = resolveDeclaredArrayPath(spec.drillResultsPath.split("."), drill.capture.responseBody);
|
|
8836
9240
|
return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
|
|
@@ -9404,7 +9808,13 @@ primaryIdentityAnchor = null) {
|
|
|
9404
9808
|
: (() => {
|
|
9405
9809
|
if (foldReturnSpec === null)
|
|
9406
9810
|
return [];
|
|
9407
|
-
|
|
9811
|
+
// No structural plan exists anywhere in the action sequence to
|
|
9812
|
+
// compete with, so a capture that is both its own endpointPattern
|
|
9813
|
+
// match AND the resolved resultsPath primary is unambiguously a
|
|
9814
|
+
// self-fold — allowed only here, never from
|
|
9815
|
+
// mergeSpecPlanOntoSamePrimary (see buildFoldPlanFromSpec's
|
|
9816
|
+
// `allowSelfFold` docstring).
|
|
9817
|
+
const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec, primaryIdentityAnchor, null, null, true);
|
|
9408
9818
|
return specPlan === null ? [] : [specPlan];
|
|
9409
9819
|
})();
|
|
9410
9820
|
return plans.flatMap((plan) => {
|
|
@@ -9741,8 +10151,12 @@ ${foldMergeLines.length > 0 ? `${foldMergeLines.join("\n")}\n` : ""} const tr
|
|
|
9741
10151
|
* can never drift out of sync.
|
|
9742
10152
|
*/
|
|
9743
10153
|
function buildContractChecklist(opts) {
|
|
9744
|
-
const { pascal, gql, omitExecuteHttp, multiStepBody } = opts;
|
|
9745
|
-
|
|
10154
|
+
const { pascal, gql, gqlQuery, omitExecuteHttp, multiStepBody } = opts;
|
|
10155
|
+
// Same isGqlEmission decision emitContractTs treats as the single source of
|
|
10156
|
+
// truth for every GraphQL-emission decision — `gql` alone can be true with
|
|
10157
|
+
// a null `gqlQuery`, which must not produce a QUERY checklist line either.
|
|
10158
|
+
const isGqlEmission = gql && gqlQuery !== null;
|
|
10159
|
+
const queryChecklistLine = !omitExecuteHttp && isGqlEmission
|
|
9746
10160
|
? `Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
|
|
9747
10161
|
: "";
|
|
9748
10162
|
// Multi-step flows validate each call against its own per-call inferred
|
|
@@ -9923,7 +10337,7 @@ function emitContractTs(opts) {
|
|
|
9923
10337
|
// A GraphQL primary with a resolved drill-down fold has no other REST
|
|
9924
10338
|
// client to issue the drill request(s) with — getGql only ever speaks
|
|
9925
10339
|
// GraphQL to the primary endpoint.
|
|
9926
|
-
const needsFoldHttpClient =
|
|
10340
|
+
const needsFoldHttpClient = isGqlEmission && singlePrimaryFoldPlans.length > 0;
|
|
9927
10341
|
// Every field source below (the base extend's own keys, form-schema
|
|
9928
10342
|
// discovery, browser-flow splicing, option/raw-option enums, additional
|
|
9929
10343
|
// body keys, and structured keys) is merged into a SINGLE `.extend({...})`
|
|
@@ -10243,7 +10657,7 @@ function emitContractTs(opts) {
|
|
|
10243
10657
|
? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
|
|
10244
10658
|
: `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
|
|
10245
10659
|
const queryConst = !omitExecuteHttp && isGqlEmission
|
|
10246
|
-
? `\n// Lifted verbatim from recon capture. The adjacent response schema is drift-tolerant by construction (dropped __typename, .loose() objects), so this query text is not hand-trimmed.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
|
|
10660
|
+
? `\n// Lifted verbatim from recon capture. The adjacent response schema is drift-tolerant by construction (dropped __typename, .loose() objects), so this query text is not hand-trimmed.\nconst ${pascal.toUpperCase()}_QUERY = \`${escapeForTemplateLiteral(gqlQuery.trim())}\`;\n`
|
|
10247
10661
|
: "";
|
|
10248
10662
|
const gqlCacheBlock = omitExecuteHttp
|
|
10249
10663
|
? ""
|
|
@@ -11658,7 +12072,7 @@ async function main() {
|
|
|
11658
12072
|
* already written its output and there is nothing left for main() to do.
|
|
11659
12073
|
*/
|
|
11660
12074
|
function generateFromCaptures(activeCaptures) {
|
|
11661
|
-
const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames);
|
|
12075
|
+
const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames, submitPatterns);
|
|
11662
12076
|
// Gate on the flow's declared own-backend hosts (or the registrable-domain
|
|
11663
12077
|
// fallback of baseUrl) via the same predicate recon-http.ts applies at
|
|
11664
12078
|
// write time, so a stale aux/ directory from before that filter existed
|
|
@@ -11667,9 +12081,10 @@ async function main() {
|
|
|
11667
12081
|
// manifest entry is unverifiable provenance, not proven safe, so it is
|
|
11668
12082
|
// excluded rather than assumed to have passed the write-time filter.
|
|
11669
12083
|
const fallbackDomain = baseUrl.length > 0 ? (0, capture_filters_1.registrableDomain)(new URL(baseUrl).hostname) : null;
|
|
12084
|
+
const primaryHost = baseUrl.length > 0 ? new URL(baseUrl).hostname : null;
|
|
11670
12085
|
const auxFiles = auxManifest
|
|
11671
12086
|
.filter((entry) => {
|
|
11672
|
-
const allowed = (0, capture_filters_1.isAllowedFixtureHost)(entry.hostname, ownBackendHostnames, fallbackDomain);
|
|
12087
|
+
const allowed = (0, capture_filters_1.isAllowedFixtureHost)(entry.hostname, ownBackendHostnames, fallbackDomain, primaryHost);
|
|
11673
12088
|
if (!allowed) {
|
|
11674
12089
|
logger.warn(`excluding aux fixture '${entry.filename}' — host '${entry.hostname}' is not an own-backend host`);
|
|
11675
12090
|
}
|
|
@@ -11692,18 +12107,18 @@ async function main() {
|
|
|
11692
12107
|
for (const f of unmanifestedFiles) {
|
|
11693
12108
|
logger.warn(`excluding aux fixture '${f}' — no aux-manifest.json entry, provenance unverifiable`);
|
|
11694
12109
|
}
|
|
11695
|
-
const baseHeaders = deriveRequestHeaders(activeCaptures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain);
|
|
12110
|
+
const baseHeaders = deriveRequestHeaders(activeCaptures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain, primaryHost);
|
|
11696
12111
|
const minTime = deriveMinTime(rateLimits);
|
|
11697
12112
|
const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
|
|
11698
12113
|
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
11699
|
-
const gql = isGraphQL(activeCaptures, ownBackendHostnames, fallbackDomain);
|
|
12114
|
+
const gql = isGraphQL(activeCaptures, ownBackendHostnames, fallbackDomain, primaryHost);
|
|
11700
12115
|
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
11701
12116
|
// (further down) read the same computed sequence instead of calling the
|
|
11702
12117
|
// extractor twice. Computed unfiltered (submitPatterns: null) — a
|
|
11703
12118
|
// flow-declared submit pattern must truncate this sequence, not filter
|
|
11704
12119
|
// it, so every gate reading it sees the full host-gated chain.
|
|
11705
12120
|
const graphqlActionSequence = gql
|
|
11706
|
-
? extractGraphQLActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)
|
|
12121
|
+
? extractGraphQLActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain, primaryHost)
|
|
11707
12122
|
: [];
|
|
11708
12123
|
// A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
|
|
11709
12124
|
// doc comment) can put 2+ entries in graphqlActionSequence with none of them
|
|
@@ -11713,7 +12128,7 @@ async function main() {
|
|
|
11713
12128
|
// out primaryGraphQLOperation below or flip isSubmissionFlow further down.
|
|
11714
12129
|
const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
|
|
11715
12130
|
const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
|
|
11716
|
-
? selectPrimaryGraphQLOperation(activeCaptures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
12131
|
+
? selectPrimaryGraphQLOperation(activeCaptures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost)
|
|
11717
12132
|
: null;
|
|
11718
12133
|
if (primaryGraphQLOperation &&
|
|
11719
12134
|
primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
|
|
@@ -11727,20 +12142,20 @@ async function main() {
|
|
|
11727
12142
|
// capture could otherwise win the endpoint/body fallback while an
|
|
11728
12143
|
// unrelated capture supplies the query text.
|
|
11729
12144
|
const fallbackGraphQLCapture = gql
|
|
11730
|
-
? firstGraphQLCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
12145
|
+
? firstGraphQLCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost)
|
|
11731
12146
|
: null;
|
|
11732
12147
|
const gqlQuery = primaryGraphQLOperation?.capture.query ?? fallbackGraphQLCapture?.query ?? null;
|
|
11733
12148
|
const endpointPath = primaryGraphQLOperation?.endpointPath ??
|
|
11734
12149
|
(fallbackGraphQLCapture
|
|
11735
12150
|
? safeUrlPathname(fallbackGraphQLCapture.url)
|
|
11736
|
-
: firstEndpointPath(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns));
|
|
12151
|
+
: firstEndpointPath(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost));
|
|
11737
12152
|
// Derived from the primary operation's own Phase-1 capture, never from
|
|
11738
12153
|
// replay array order -- a replay's body reflects whichever endpoint fired
|
|
11739
12154
|
// first, not necessarily the primary operation, and only exists once
|
|
11740
12155
|
// recon:http has run.
|
|
11741
12156
|
const winningCapture = primaryGraphQLOperation?.capture ??
|
|
11742
12157
|
fallbackGraphQLCapture ??
|
|
11743
|
-
firstEndpointCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns);
|
|
12158
|
+
firstEndpointCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost);
|
|
11744
12159
|
const responseBody = winningCapture?.responseBody ?? null;
|
|
11745
12160
|
// Every 2xx capture sharing the winning capture's operation identity, not
|
|
11746
12161
|
// just the one that happened to win selection -- a paginated/re-filtered
|
|
@@ -11765,7 +12180,7 @@ async function main() {
|
|
|
11765
12180
|
// drill-down the heuristic sequence found past the submit step.
|
|
11766
12181
|
const unfilteredHeuristicActionCaptures = gql
|
|
11767
12182
|
? dedupRedundantSameOperationCaptures(graphqlActionSequence, primaryGraphQLOperation)
|
|
11768
|
-
: collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)));
|
|
12183
|
+
: collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain, true, primaryHost)));
|
|
11769
12184
|
// A flow-declared submitEndpointPattern is authoritative: it may match only
|
|
11770
12185
|
// the final step's URL (the natural way to describe "the button that
|
|
11771
12186
|
// finishes the wizard") even though the earlier steps of the same chain
|
|
@@ -11788,8 +12203,8 @@ async function main() {
|
|
|
11788
12203
|
// narrowing (REST) / per-capture submit match (GraphQL) keep the
|
|
11789
12204
|
// genuine captures amid that noise, so truncation has them to find.
|
|
11790
12205
|
const patternedExtractedActionCaptures = gql
|
|
11791
|
-
? dedupRedundantSameOperationCaptures(extractGraphQLActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain), primaryGraphQLOperation)
|
|
11792
|
-
: collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, false)));
|
|
12206
|
+
? dedupRedundantSameOperationCaptures(extractGraphQLActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, primaryHost), primaryGraphQLOperation)
|
|
12207
|
+
: collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, false, primaryHost)));
|
|
11793
12208
|
const patternedHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
|
|
11794
12209
|
? unfilteredHeuristicActionCaptures
|
|
11795
12210
|
: truncateActionSequenceAtSubmitPattern(patternedExtractedActionCaptures, submitPatterns, foldReturnSpec);
|
|
@@ -12036,22 +12451,32 @@ async function main() {
|
|
|
12036
12451
|
// diagnostic must consult the SAME resolution each path actually applies,
|
|
12037
12452
|
// or it falsely reports "no fold plan resolved" for every multi-step flow
|
|
12038
12453
|
// with a working foldReturn.
|
|
12039
|
-
const
|
|
12040
|
-
? resolveFoldPlan(actionSteps, foldReturnSpec)
|
|
12041
|
-
: resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody, emittedPrimaryAnchor)
|
|
12042
|
-
|
|
12454
|
+
const effectiveFoldPlans = multiStepBody
|
|
12455
|
+
? resolveFoldPlan(actionSteps, foldReturnSpec)
|
|
12456
|
+
: resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody, emittedPrimaryAnchor);
|
|
12457
|
+
const effectiveFoldPlanCount = effectiveFoldPlans.length;
|
|
12043
12458
|
if (foldReturnSpec !== null && effectiveFoldPlanCount === 0) {
|
|
12044
12459
|
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
|
|
12045
12460
|
}
|
|
12046
12461
|
// The quieter failure: structural plans resolved (so the count above is
|
|
12047
|
-
// non-zero) but
|
|
12048
|
-
// joinFields
|
|
12049
|
-
//
|
|
12050
|
-
//
|
|
12051
|
-
//
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12462
|
+
// non-zero) but none of their targets ended up carrying the DECLARED
|
|
12463
|
+
// joinFields, so only heuristic join guesses were emitted. Read straight
|
|
12464
|
+
// off `effectiveFoldPlans` — the exact plans/targets that actually made
|
|
12465
|
+
// it into the emitted output — rather than re-deriving success via a
|
|
12466
|
+
// second, independent, unrestricted `buildFoldPlanFromSpec` call: that
|
|
12467
|
+
// call re-scans every primary/drill occurrence from scratch with none of
|
|
12468
|
+
// `mergeSpecPlanOntoSamePrimary`'s per-occurrence, per-target override
|
|
12469
|
+
// robustness (see its own docstring), so under a noisy archive with
|
|
12470
|
+
// several occurrences of the same re-issued/paginated primary it can
|
|
12471
|
+
// legitimately resolve to `null` even while the declared joinFields WERE
|
|
12472
|
+
// already applied onto the actual emitted target via that per-occurrence
|
|
12473
|
+
// override — producing this exact false "declared spec resolved no fold
|
|
12474
|
+
// plan" warning despite the declared field being the one genuinely
|
|
12475
|
+
// emitted. Comparing against what was actually resolved can never
|
|
12476
|
+
// disagree with what went into the file, by construction.
|
|
12477
|
+
const declaredJoinFieldsApplied = foldReturnSpec !== null &&
|
|
12478
|
+
effectiveFoldPlans.some((plan) => plan.targets.some((target) => JSON.stringify(target.joinFields) === JSON.stringify(foldReturnSpec.joinFields)));
|
|
12479
|
+
if (foldReturnSpec !== null && effectiveFoldPlanCount > 0 && !declaredJoinFieldsApplied) {
|
|
12055
12480
|
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but the declared spec resolved no fold plan — only structurally-detected fold plans (with their own guessed join fields) were emitted, so the declared joinFields were not applied; check that the endpointPattern names a capture in the action sequence and resultsPath resolves on the primary response`);
|
|
12056
12481
|
}
|
|
12057
12482
|
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|