@enricai/barnacle 1.12.71 → 1.12.73

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.
@@ -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
- function deriveBaseUrl(captures, ownBackendHostnames) {
1004
- const candidates = captures.filter((c) => {
1005
- if (ownBackendHostnames.length > 0) {
1006
- return (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, null);
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
- return !(0, capture_filters_1.isNoiseUrl)(c.url);
1009
- });
1010
- for (const c of candidates) {
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
- function isGraphQL(captures, ownBackendHostnames = [], fallbackDomain = null) {
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
- return captures.some((c) => (!hasHostProvenance ||
1153
- (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain)) &&
1154
- (c.operationName !== null || (0, capture_filters_1.parsedOperationName)(c.query ?? "") !== null));
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) => !hasHostProvenance ||
1423
- (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, fallbackDomain);
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" && !matchesFoldReturn(capture))
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 `ship:disney-wish|theme:merry`). This
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, actions.map((a) => a.capture))
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
- lines.push(...ancestorOpenLines);
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
@@ -7234,6 +7606,54 @@ function collectRequestStringValues(capture, allCaptures) {
7234
7606
  }
7235
7607
  return values;
7236
7608
  }
7609
+ /**
7610
+ * True when a varying body leaf's apparent ambiguity is actually explained
7611
+ * by another field in the same request bodies: some sibling leaf path's
7612
+ * value determines the target field's value as a true function (each
7613
+ * distinct sibling value maps to exactly one distinct field value) across
7614
+ * `capture` plus every same-endpoint capture. A page-size field that always
7615
+ * reads 25 when `tier` is `"standard"` and 100 when `tier` is `"premium"`
7616
+ * is not an unexplained ambiguity — `tier` explains it.
7617
+ */
7618
+ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, siblingPaths) {
7619
+ const allBodies = [parsedBody, ...otherBodies];
7620
+ for (const siblingPath of siblingPaths) {
7621
+ const pathSegments = siblingPath.split(".");
7622
+ const pairs = [];
7623
+ let sawUnexplainableField = false;
7624
+ for (const body of allBodies) {
7625
+ const fieldRaw = readValueAtPath(body, path);
7626
+ if (fieldRaw === undefined)
7627
+ continue;
7628
+ const siblingRaw = readValueAtPath(body, pathSegments);
7629
+ if (siblingRaw === undefined) {
7630
+ // This body has a value for the field we're trying to explain but no
7631
+ // value for the candidate sibling — the sibling can't vouch for it,
7632
+ // so don't let a function fitted over the OTHER bodies paper over it.
7633
+ sawUnexplainableField = true;
7634
+ break;
7635
+ }
7636
+ pairs.push({ siblingValue: String(siblingRaw), fieldValue: String(fieldRaw) });
7637
+ }
7638
+ if (sawUnexplainableField)
7639
+ continue;
7640
+ const distinctSiblingValues = new Set(pairs.map((p) => p.siblingValue));
7641
+ if (distinctSiblingValues.size < 2)
7642
+ continue;
7643
+ const siblingToField = new Map();
7644
+ const isFunction = pairs.every(({ siblingValue, fieldValue }) => {
7645
+ const existing = siblingToField.get(siblingValue);
7646
+ if (existing === undefined) {
7647
+ siblingToField.set(siblingValue, fieldValue);
7648
+ return true;
7649
+ }
7650
+ return existing === fieldValue;
7651
+ });
7652
+ if (isFunction && siblingToField.size === distinctSiblingValues.size)
7653
+ return true;
7654
+ }
7655
+ return false;
7656
+ }
7237
7657
  /**
7238
7658
  * Finds every query param or JSON body leaf on `capture`'s own request that
7239
7659
  * (a) was left as a literal in `renderedText` — never swapped for a
@@ -7295,6 +7715,19 @@ function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
7295
7715
  }
7296
7716
  })();
7297
7717
  if (parsedBody !== undefined) {
7718
+ const otherBodies = sameEndpointCaptures
7719
+ .map((c) => {
7720
+ try {
7721
+ return typeof c.requestPostData === "string" && c.requestPostData.length > 0
7722
+ ? JSON.parse(c.requestPostData)
7723
+ : undefined;
7724
+ }
7725
+ catch {
7726
+ return undefined;
7727
+ }
7728
+ })
7729
+ .filter((b) => b !== undefined);
7730
+ const allPaths = Array.from(walkAllPrimitiveLeaves(parsedBody)).map(({ path }) => path.join("."));
7298
7731
  for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
7299
7732
  if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
7300
7733
  continue;
@@ -7302,20 +7735,11 @@ function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
7302
7735
  if (!isFrozenLiteral(stringValue))
7303
7736
  continue;
7304
7737
  const fieldPath = path.join(".");
7305
- const differing = sameEndpointCaptures
7306
- .map((c) => {
7307
- try {
7308
- const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
7309
- ? JSON.parse(c.requestPostData)
7310
- : undefined;
7311
- return otherBody === undefined ? undefined : readValueAtPath(otherBody, path);
7312
- }
7313
- catch {
7314
- return undefined;
7315
- }
7316
- })
7738
+ const differing = otherBodies
7739
+ .map((otherBody) => readValueAtPath(otherBody, path))
7317
7740
  .find((v) => v !== undefined && String(v) !== stringValue);
7318
- if (differing !== undefined) {
7741
+ if (differing !== undefined &&
7742
+ !isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, new Set(allPaths.filter((p) => p !== fieldPath)))) {
7319
7743
  frozen.push({
7320
7744
  location: "body field",
7321
7745
  key: fieldPath,
@@ -8727,7 +9151,20 @@ exactPrimaryStepIndex = null,
8727
9151
  // joinFields override independently for EACH structural target, keyed by
8728
9152
  // its own drill endpoint, instead of only the one this function's
8729
9153
  // freshest-first scan happens to land on first.
8730
- restrictToDrillEndpointKey = null) {
9154
+ restrictToDrillEndpointKey = null,
9155
+ // When true, a capture that is BOTH the primary (resultsPath resolves on
9156
+ // its own response) AND its own endpointPattern match is accepted as a
9157
+ // self-fold — no separate later drill-down call exists at all, the
9158
+ // flow's whole per-item collection already lives in one response. Only
9159
+ // ever set by resolveFoldPlan's bare (no-structural-competition) call:
9160
+ // mergeSpecPlanOntoSamePrimary's calls deliberately leave this `false`,
9161
+ // since there a structurally-detected plan ALREADY anchors a different,
9162
+ // real primary/drill pair — allowing self-fold there would let an
9163
+ // unrelated capture that merely echoes `resultsPath`'s key name on its
9164
+ // OWN response (e.g. a per-item drill-down's "details" array, matched
9165
+ // purely by coincidental naming) hijack or append onto that plan instead
9166
+ // of being correctly left unmerged.
9167
+ allowSelfFold = false) {
8731
9168
  const rawPrimaryArrayPath = spec.resultsPath.split(".");
8732
9169
  const matchesFoldReturnEndpoint = compileFoldReturnEndpointMatcher(spec);
8733
9170
  // Built once and shared across every primaryStepIndex — see
@@ -8772,7 +9209,15 @@ restrictToDrillEndpointKey = null) {
8772
9209
  // carries one of this primary's join values (see
8773
9210
  // {@link collectDrillStepIndicesWorthTrying}), because testing every
8774
9211
  // primary/drill pair is quadratic in the capture count.
8775
- const firstDrillOffset = firstIndexGreaterThan(allMatchingDrillStepIndices, primaryStepIndex);
9212
+ //
9213
+ // `primaryStepIndex - 1` (not `primaryStepIndex`) when `allowSelfFold`,
9214
+ // so a capture that is BOTH the primary and its own endpointPattern
9215
+ // match is still admitted — a self-fold, where `resultsPath` nests
9216
+ // entirely inside the SAME capture's response and there is no separate
9217
+ // later drill-down call at all. `firstIndexGreaterThan` finds the first
9218
+ // index strictly greater than its argument, so subtracting 1 widens the
9219
+ // admitted range to include `primaryStepIndex` itself.
9220
+ const firstDrillOffset = firstIndexGreaterThan(allMatchingDrillStepIndices, allowSelfFold ? primaryStepIndex - 1 : primaryStepIndex);
8776
9221
  if (candidateEntryIndicesDescending.length === 0 &&
8777
9222
  firstDrillOffset === allMatchingDrillStepIndices.length) {
8778
9223
  continue;
@@ -8830,7 +9275,18 @@ restrictToDrillEndpointKey = null) {
8830
9275
  null);
8831
9276
  }
8832
9277
  if (spec.drillResultsPath === undefined) {
8833
- return findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null;
9278
+ // Self-fold: the matched "drill" IS the primary capture itself
9279
+ // (entryIndex === drillStepIndex === primaryStepIndex), so the
9280
+ // array to fold is the already-resolved `primaryArrayPath` —
9281
+ // reusing it is the only way to land on the author's declared
9282
+ // (possibly multi-wildcard-level) nested array; re-deriving it via
9283
+ // `findObjectArrayFieldOrWholeObject`'s first-DFS-match would
9284
+ // instead land on whichever object-array field happens to come
9285
+ // first in key order (e.g. a shallower ancestor array), silently
9286
+ // discarding the declared `resultsPath`'s own nesting depth.
9287
+ return drillStepIndex === primaryStepIndex
9288
+ ? primaryArrayPath
9289
+ : (findObjectArrayFieldOrWholeObject(drill.capture.responseBody)?.path ?? null);
8834
9290
  }
8835
9291
  const path = resolveDeclaredArrayPath(spec.drillResultsPath.split("."), drill.capture.responseBody);
8836
9292
  return objectItemsAtPath(drill.capture.responseBody, path) ? path : null;
@@ -9404,7 +9860,13 @@ primaryIdentityAnchor = null) {
9404
9860
  : (() => {
9405
9861
  if (foldReturnSpec === null)
9406
9862
  return [];
9407
- const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec, primaryIdentityAnchor);
9863
+ // No structural plan exists anywhere in the action sequence to
9864
+ // compete with, so a capture that is both its own endpointPattern
9865
+ // match AND the resolved resultsPath primary is unambiguously a
9866
+ // self-fold — allowed only here, never from
9867
+ // mergeSpecPlanOntoSamePrimary (see buildFoldPlanFromSpec's
9868
+ // `allowSelfFold` docstring).
9869
+ const specPlan = buildFoldPlanFromSpec(actions, foldReturnSpec, primaryIdentityAnchor, null, null, true);
9408
9870
  return specPlan === null ? [] : [specPlan];
9409
9871
  })();
9410
9872
  return plans.flatMap((plan) => {
@@ -9741,8 +10203,12 @@ ${foldMergeLines.length > 0 ? `${foldMergeLines.join("\n")}\n` : ""} const tr
9741
10203
  * can never drift out of sync.
9742
10204
  */
9743
10205
  function buildContractChecklist(opts) {
9744
- const { pascal, gql, omitExecuteHttp, multiStepBody } = opts;
9745
- const queryChecklistLine = !omitExecuteHttp && gql
10206
+ const { pascal, gql, gqlQuery, omitExecuteHttp, multiStepBody } = opts;
10207
+ // Same isGqlEmission decision emitContractTs treats as the single source of
10208
+ // truth for every GraphQL-emission decision — `gql` alone can be true with
10209
+ // a null `gqlQuery`, which must not produce a QUERY checklist line either.
10210
+ const isGqlEmission = gql && gqlQuery !== null;
10211
+ const queryChecklistLine = !omitExecuteHttp && isGqlEmission
9746
10212
  ? `Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
9747
10213
  : "";
9748
10214
  // Multi-step flows validate each call against its own per-call inferred
@@ -9923,7 +10389,7 @@ function emitContractTs(opts) {
9923
10389
  // A GraphQL primary with a resolved drill-down fold has no other REST
9924
10390
  // client to issue the drill request(s) with — getGql only ever speaks
9925
10391
  // GraphQL to the primary endpoint.
9926
- const needsFoldHttpClient = gql && singlePrimaryFoldPlans.length > 0;
10392
+ const needsFoldHttpClient = isGqlEmission && singlePrimaryFoldPlans.length > 0;
9927
10393
  // Every field source below (the base extend's own keys, form-schema
9928
10394
  // discovery, browser-flow splicing, option/raw-option enums, additional
9929
10395
  // body keys, and structured keys) is merged into a SINGLE `.extend({...})`
@@ -10243,7 +10709,7 @@ function emitContractTs(opts) {
10243
10709
  ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
10244
10710
  : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
10245
10711
  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`
10712
+ ? `\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
10713
  : "";
10248
10714
  const gqlCacheBlock = omitExecuteHttp
10249
10715
  ? ""
@@ -11658,7 +12124,7 @@ async function main() {
11658
12124
  * already written its output and there is nothing left for main() to do.
11659
12125
  */
11660
12126
  function generateFromCaptures(activeCaptures) {
11661
- const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames);
12127
+ const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames, submitPatterns);
11662
12128
  // Gate on the flow's declared own-backend hosts (or the registrable-domain
11663
12129
  // fallback of baseUrl) via the same predicate recon-http.ts applies at
11664
12130
  // write time, so a stale aux/ directory from before that filter existed
@@ -11667,9 +12133,10 @@ async function main() {
11667
12133
  // manifest entry is unverifiable provenance, not proven safe, so it is
11668
12134
  // excluded rather than assumed to have passed the write-time filter.
11669
12135
  const fallbackDomain = baseUrl.length > 0 ? (0, capture_filters_1.registrableDomain)(new URL(baseUrl).hostname) : null;
12136
+ const primaryHost = baseUrl.length > 0 ? new URL(baseUrl).hostname : null;
11670
12137
  const auxFiles = auxManifest
11671
12138
  .filter((entry) => {
11672
- const allowed = (0, capture_filters_1.isAllowedFixtureHost)(entry.hostname, ownBackendHostnames, fallbackDomain);
12139
+ const allowed = (0, capture_filters_1.isAllowedFixtureHost)(entry.hostname, ownBackendHostnames, fallbackDomain, primaryHost);
11673
12140
  if (!allowed) {
11674
12141
  logger.warn(`excluding aux fixture '${entry.filename}' — host '${entry.hostname}' is not an own-backend host`);
11675
12142
  }
@@ -11692,18 +12159,18 @@ async function main() {
11692
12159
  for (const f of unmanifestedFiles) {
11693
12160
  logger.warn(`excluding aux fixture '${f}' — no aux-manifest.json entry, provenance unverifiable`);
11694
12161
  }
11695
- const baseHeaders = deriveRequestHeaders(activeCaptures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain);
12162
+ const baseHeaders = deriveRequestHeaders(activeCaptures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain, primaryHost);
11696
12163
  const minTime = deriveMinTime(rateLimits);
11697
12164
  const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
11698
12165
  const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
11699
- const gql = isGraphQL(activeCaptures, ownBackendHostnames, fallbackDomain);
12166
+ const gql = isGraphQL(activeCaptures, ownBackendHostnames, fallbackDomain, primaryHost);
11700
12167
  // Hoisted so both the primary-operation gate below and rawActionCaptures
11701
12168
  // (further down) read the same computed sequence instead of calling the
11702
12169
  // extractor twice. Computed unfiltered (submitPatterns: null) — a
11703
12170
  // flow-declared submit pattern must truncate this sequence, not filter
11704
12171
  // it, so every gate reading it sees the full host-gated chain.
11705
12172
  const graphqlActionSequence = gql
11706
- ? extractGraphQLActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)
12173
+ ? extractGraphQLActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain, primaryHost)
11707
12174
  : [];
11708
12175
  // A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
11709
12176
  // doc comment) can put 2+ entries in graphqlActionSequence with none of them
@@ -11713,7 +12180,7 @@ async function main() {
11713
12180
  // out primaryGraphQLOperation below or flip isSubmissionFlow further down.
11714
12181
  const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
11715
12182
  const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
11716
- ? selectPrimaryGraphQLOperation(activeCaptures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns)
12183
+ ? selectPrimaryGraphQLOperation(activeCaptures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost)
11717
12184
  : null;
11718
12185
  if (primaryGraphQLOperation &&
11719
12186
  primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
@@ -11727,20 +12194,20 @@ async function main() {
11727
12194
  // capture could otherwise win the endpoint/body fallback while an
11728
12195
  // unrelated capture supplies the query text.
11729
12196
  const fallbackGraphQLCapture = gql
11730
- ? firstGraphQLCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns)
12197
+ ? firstGraphQLCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost)
11731
12198
  : null;
11732
12199
  const gqlQuery = primaryGraphQLOperation?.capture.query ?? fallbackGraphQLCapture?.query ?? null;
11733
12200
  const endpointPath = primaryGraphQLOperation?.endpointPath ??
11734
12201
  (fallbackGraphQLCapture
11735
12202
  ? safeUrlPathname(fallbackGraphQLCapture.url)
11736
- : firstEndpointPath(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns));
12203
+ : firstEndpointPath(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost));
11737
12204
  // Derived from the primary operation's own Phase-1 capture, never from
11738
12205
  // replay array order -- a replay's body reflects whichever endpoint fired
11739
12206
  // first, not necessarily the primary operation, and only exists once
11740
12207
  // recon:http has run.
11741
12208
  const winningCapture = primaryGraphQLOperation?.capture ??
11742
12209
  fallbackGraphQLCapture ??
11743
- firstEndpointCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns);
12210
+ firstEndpointCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns, primaryHost);
11744
12211
  const responseBody = winningCapture?.responseBody ?? null;
11745
12212
  // Every 2xx capture sharing the winning capture's operation identity, not
11746
12213
  // just the one that happened to win selection -- a paginated/re-filtered
@@ -11765,7 +12232,7 @@ async function main() {
11765
12232
  // drill-down the heuristic sequence found past the submit step.
11766
12233
  const unfilteredHeuristicActionCaptures = gql
11767
12234
  ? dedupRedundantSameOperationCaptures(graphqlActionSequence, primaryGraphQLOperation)
11768
- : collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)));
12235
+ : collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain, true, primaryHost)));
11769
12236
  // A flow-declared submitEndpointPattern is authoritative: it may match only
11770
12237
  // the final step's URL (the natural way to describe "the button that
11771
12238
  // finishes the wizard") even though the earlier steps of the same chain
@@ -11788,8 +12255,8 @@ async function main() {
11788
12255
  // narrowing (REST) / per-capture submit match (GraphQL) keep the
11789
12256
  // genuine captures amid that noise, so truncation has them to find.
11790
12257
  const patternedExtractedActionCaptures = gql
11791
- ? dedupRedundantSameOperationCaptures(extractGraphQLActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain), primaryGraphQLOperation)
11792
- : collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, false)));
12258
+ ? dedupRedundantSameOperationCaptures(extractGraphQLActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, primaryHost), primaryGraphQLOperation)
12259
+ : collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, false, primaryHost)));
11793
12260
  const patternedHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
11794
12261
  ? unfilteredHeuristicActionCaptures
11795
12262
  : truncateActionSequenceAtSubmitPattern(patternedExtractedActionCaptures, submitPatterns, foldReturnSpec);
@@ -12036,22 +12503,32 @@ async function main() {
12036
12503
  // diagnostic must consult the SAME resolution each path actually applies,
12037
12504
  // or it falsely reports "no fold plan resolved" for every multi-step flow
12038
12505
  // with a working foldReturn.
12039
- const effectiveFoldPlanCount = multiStepBody
12040
- ? resolveFoldPlan(actionSteps, foldReturnSpec).length
12041
- : resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody, emittedPrimaryAnchor)
12042
- .length;
12506
+ const effectiveFoldPlans = multiStepBody
12507
+ ? resolveFoldPlan(actionSteps, foldReturnSpec)
12508
+ : resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody, emittedPrimaryAnchor);
12509
+ const effectiveFoldPlanCount = effectiveFoldPlans.length;
12043
12510
  if (foldReturnSpec !== null && effectiveFoldPlanCount === 0) {
12044
12511
  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
12512
  }
12046
12513
  // The quieter failure: structural plans resolved (so the count above is
12047
- // non-zero) but the DECLARED spec itself resolved nothing, so its
12048
- // joinFields never reached any emitted target and only heuristic join
12049
- // guesses were emitted. Without this the two cases are indistinguishable
12050
- // in the output. Anchored exactly as the emitted resolution above is, so
12051
- // the diagnostic never disagrees with what actually went into the file.
12052
- if (foldReturnSpec !== null &&
12053
- effectiveFoldPlanCount > 0 &&
12054
- buildFoldPlanFromSpec(actionSteps, foldReturnSpec, multiStepBody ? null : emittedPrimaryAnchor) === null) {
12514
+ // non-zero) but none of their targets ended up carrying the DECLARED
12515
+ // joinFields, so only heuristic join guesses were emitted. Read straight
12516
+ // off `effectiveFoldPlans` — the exact plans/targets that actually made
12517
+ // it into the emitted output — rather than re-deriving success via a
12518
+ // second, independent, unrestricted `buildFoldPlanFromSpec` call: that
12519
+ // call re-scans every primary/drill occurrence from scratch with none of
12520
+ // `mergeSpecPlanOntoSamePrimary`'s per-occurrence, per-target override
12521
+ // robustness (see its own docstring), so under a noisy archive with
12522
+ // several occurrences of the same re-issued/paginated primary it can
12523
+ // legitimately resolve to `null` even while the declared joinFields WERE
12524
+ // already applied onto the actual emitted target via that per-occurrence
12525
+ // override — producing this exact false "declared spec resolved no fold
12526
+ // plan" warning despite the declared field being the one genuinely
12527
+ // emitted. Comparing against what was actually resolved can never
12528
+ // disagree with what went into the file, by construction.
12529
+ const declaredJoinFieldsApplied = foldReturnSpec !== null &&
12530
+ effectiveFoldPlans.some((plan) => plan.targets.some((target) => JSON.stringify(target.joinFields) === JSON.stringify(foldReturnSpec.joinFields)));
12531
+ if (foldReturnSpec !== null && effectiveFoldPlanCount > 0 && !declaredJoinFieldsApplied) {
12055
12532
  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
12533
  }
12057
12534
  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})`);