@enricai/barnacle 1.12.68 → 1.12.70

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.
@@ -34,6 +34,7 @@ exports.selectPayloadAction = selectPayloadAction;
34
34
  exports.selectReturnAction = selectReturnAction;
35
35
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
36
36
  exports.extractEntryUrlParams = extractEntryUrlParams;
37
+ exports.deriveRequestHeaders = deriveRequestHeaders;
37
38
  exports.gatherResponseBodySamples = gatherResponseBodySamples;
38
39
  exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
39
40
  exports.firstEndpointCapture = firstEndpointCapture;
@@ -49,6 +50,7 @@ exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
49
50
  exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
50
51
  exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
51
52
  exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
53
+ exports.applyStructuredValuePayloadSubstitutions = applyStructuredValuePayloadSubstitutions;
52
54
  exports.walkSetCookiePairs = walkSetCookiePairs;
53
55
  exports.indexStateValues = indexStateValues;
54
56
  exports.sanitizeFixtureIdentifier = sanitizeFixtureIdentifier;
@@ -1061,6 +1063,20 @@ const IGNORE_REQUEST_HEADERS = new Set([
1061
1063
  ":authority",
1062
1064
  ":scheme",
1063
1065
  ]);
1066
+ /**
1067
+ * Documented closed set of header-name fragments (matched case-insensitively,
1068
+ * same posture as {@link VOLATILE_TIMESTAMP_KEY_FRAGMENTS}) that mark a
1069
+ * header's VALUE as a per-session id the site mints once and resends on
1070
+ * every request — not a stable API-contract constant. A header whose name
1071
+ * matches must never freeze into {@link deriveRequestHeaders}' BASE_HEADERS
1072
+ * baseline: a frozen capture value would replay one recon session's id
1073
+ * across every real invocation, indistinguishable from a stale auth token.
1074
+ */
1075
+ const VOLATILE_HEADER_NAME_FRAGMENTS = ["correlation-id", "conversation-id"];
1076
+ function isVolatileHeaderName(headerName) {
1077
+ const lower = headerName.toLowerCase();
1078
+ return VOLATILE_HEADER_NAME_FRAGMENTS.some((frag) => lower.includes(frag));
1079
+ }
1064
1080
  /**
1065
1081
  * Derives BASE_HEADERS from the request headers the browser actually sent
1066
1082
  * during recon, filtered to those present in every capture whose endpoint
@@ -1074,6 +1090,11 @@ const IGNORE_REQUEST_HEADERS = new Set([
1074
1090
  * the submission-flow detector. This catches load-bearing site-specific
1075
1091
  * headers (a `X-CSRF-Token`, a `Job-Boards-API-Token`, an `API-ShortName`,
1076
1092
  * etc.) without the generator needing to know about any particular site.
1093
+ *
1094
+ * A {@link isVolatileHeaderName} match is excluded even when present on
1095
+ * every capture with an identical value — that's the signature of a
1096
+ * per-session id the site mints once and resends, not a stable constant.
1097
+ * `emitMultiStepExecuteHttp` re-mints it at call time instead.
1077
1098
  */
1078
1099
  function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null, ownBackendHostnames = [], fallbackDomain = null) {
1079
1100
  const successfulUrls = new Set(replays.filter((r) => r.success).map((r) => endpointKey(r.url)));
@@ -1107,6 +1128,8 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null,
1107
1128
  for (const [lower, count] of counts) {
1108
1129
  if (count < relevantCaptures.length)
1109
1130
  continue;
1131
+ if (isVolatileHeaderName(lower))
1132
+ continue;
1110
1133
  if (Object.keys(baseline).some((k) => k.toLowerCase() === lower))
1111
1134
  continue;
1112
1135
  for (const c of relevantCaptures) {
@@ -1119,8 +1142,16 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null,
1119
1142
  }
1120
1143
  return baseline;
1121
1144
  }
1122
- function isGraphQL(captures) {
1123
- return captures.some((c) => c.operationName !== null || (0, capture_filters_1.parsedOperationName)(c.query ?? "") !== null);
1145
+ /**
1146
+ * Classifies the flow as GraphQL only from own-backend-host traffic, so a
1147
+ * third-party host (chat widget, analytics SDK) making real GraphQL calls
1148
+ * never flips a plain REST/JSON backend's classification.
1149
+ */
1150
+ function isGraphQL(captures, ownBackendHostnames = [], fallbackDomain = null) {
1151
+ 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));
1124
1155
  }
1125
1156
  /**
1126
1157
  * The single own-backend capture the raw GraphQL fallback (no
@@ -1202,9 +1233,12 @@ function operationGroupKey(capture) {
1202
1233
  * never independently resolve a different primary.
1203
1234
  */
1204
1235
  function computeEmittedPrimaryAnchor(gql, endpointPath, gqlOperationName, gqlQuery) {
1205
- if (!gql)
1236
+ // Mirrors emitContractTs's own isGqlEmission gate: `gql` alone can be true
1237
+ // with a null gqlQuery (see gqlQuery's `?? null` fallback), in which case
1238
+ // emitContractTs falls through to REST emission — this anchor must agree.
1239
+ if (!gql || gqlQuery === null)
1206
1240
  return null;
1207
- const name = gqlOperationName ?? (0, capture_filters_1.parsedOperationName)(gqlQuery ?? "") ?? "anonymous";
1241
+ const name = gqlOperationName ?? (0, capture_filters_1.parsedOperationName)(gqlQuery) ?? "anonymous";
1208
1242
  return `${endpointPath}::${name}`;
1209
1243
  }
1210
1244
  /**
@@ -1561,8 +1595,26 @@ function resolveManifestActionSequence(runRoot, captures) {
1561
1595
  * Exported for tests: this predicate decides what a generated plugin will POST
1562
1596
  * at a live site, and it is the only gate between a browser's incidental
1563
1597
  * chatter and the emitted hot path.
1564
- */
1565
- function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null) {
1598
+ *
1599
+ * `applyFinalAnchorNarrowing` lets a caller that will itself truncate the
1600
+ * result via {@link truncateActionSequenceAtSubmitPattern} opt out of the
1601
+ * final structural-relevance anchor pass, which can drop a genuine earlier
1602
+ * chain step that shares no token with the terminal submit endpoint.
1603
+ */
1604
+ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null,
1605
+ // The final structural-relevance anchor pass below (guarded on
1606
+ // hasSubmitEndpointAnchor) is tuned for isolating a submission from
1607
+ // same-URL incidental chrome, not for pruning a genuine multi-step chain:
1608
+ // an earlier plain single-word step (e.g. `/lodging/hold/`) that shares no
1609
+ // compound token with the terminal submit endpoint reads as "unrelated" to
1610
+ // it and gets dropped, even though it's a real prior step whose body the
1611
+ // caller still needs. truncateActionSequenceAtSubmitPattern's own
1612
+ // index-slice already does the right, less-aggressive narrowing (keep
1613
+ // everything up to and including the last submit/fold match) — so a caller
1614
+ // that immediately truncates this result opts out with `false` to keep
1615
+ // earlier chain steps intact while still getting the structural-isolation
1616
+ // exemption above.
1617
+ applyFinalAnchorNarrowing = true) {
1566
1618
  const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
1567
1619
  // Callers with no host-provenance data (the exported function's unit
1568
1620
  // tests) pass neither ownBackendHostnames nor fallbackDomain — in that
@@ -1584,6 +1636,14 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1584
1636
  // the submission from same-URL chrome" behavior).
1585
1637
  const hasSubmitEndpointAnchor = hasHostProvenance && submitPatterns !== null && submitPatterns.endpoint !== null;
1586
1638
  const matchesSubmit = compileSubmitMatcher(hasSubmitEndpointAnchor ? { endpoint: null, body: submitPatterns.body } : submitPatterns);
1639
+ // Compiled once, ahead of structural isolation below, so a capture matching
1640
+ // the flow's own declared submitEndpointPattern can be exempted there the
1641
+ // same way a declared foldReturn endpointPattern already is — both are
1642
+ // author-declared, both are authoritative by construction, so the isolation
1643
+ // pass (which exists to catch INCIDENTAL same-host noise the flow never
1644
+ // named) must not treat one differently from the other.
1645
+ const declaredSubmitEndpointRx = submitPatterns?.endpoint != null ? new RegExp(submitPatterns.endpoint) : null;
1646
+ const matchesDeclaredSubmitEndpoint = (capture) => declaredSubmitEndpointRx?.test(capture.url) ?? false;
1587
1647
  const hostGated = captures
1588
1648
  .map((capture, index) => ({ capture, index }))
1589
1649
  .filter(({ capture }) => {
@@ -1607,9 +1667,14 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1607
1667
  // (e.g. a marketing/promotions endpoint fired incidentally by a page load,
1608
1668
  // admitted above because it too is a 2xx own-backend POST). Drop a
1609
1669
  // structurally-isolated member of the pool first — a compound-path capture
1610
- // (e.g. `/site-banner`) sharing no token with anything else host-gated in,
1611
- // regardless of whether a submitEndpointPattern is declared. Guarded to
1612
- // pools of 3+: a real chain's own steps are usually plain single-word
1670
+ // (e.g. `/site-banner`) sharing no token with anything else host-gated in
1671
+ // — unless it matches the flow's own declared submitEndpointPattern (see
1672
+ // the exemption below): a densely-tokenized real submission path (e.g.
1673
+ // `available-sailings`) can look exactly as isolated as incidental noise
1674
+ // by path shape alone, and dropping it here would strip it out before the
1675
+ // structural-relevance anchor pass further down ever gets to treat it as
1676
+ // authoritative. Guarded to pools of 3+: a real chain's own steps are
1677
+ // usually plain single-word
1613
1678
  // paths with no tokens of their own (never flagged, see
1614
1679
  // isStructurallyIsolatedCapture), so this only ever removes a capture that
1615
1680
  // is BOTH compound-path AND unrelated to every other admitted capture —
@@ -1640,11 +1705,14 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1640
1705
  .filter((h, j) => denselyNameSpaced ? safeUrlPathname(h.capture.url) !== path : j !== i)
1641
1706
  .map((h) => safeUrlPathname(h.capture.url));
1642
1707
  // A capture admitted via the flow's own declared foldReturnSpec
1643
- // endpointPattern (the drill-down target) is authoritative by
1644
- // construction — the flow author named it explicitly — so it is
1708
+ // endpointPattern (the drill-down target), or matching the flow's
1709
+ // own declared submitEndpointPattern, is authoritative by
1710
+ // construction — the flow author named it explicitly — so both are
1645
1711
  // exempt from token-overlap isolation, which exists to catch
1646
1712
  // INCIDENTAL same-host captures the flow never declared.
1647
- return matchesFoldReturn(capture) || !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths);
1713
+ return (matchesFoldReturn(capture) ||
1714
+ matchesDeclaredSubmitEndpoint(capture) ||
1715
+ !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths));
1648
1716
  })
1649
1717
  : hostGated;
1650
1718
  // Narrow further using structural relevance, anchored on whichever
@@ -1654,15 +1722,14 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
1654
1722
  // authoritative reference, so this pass is a no-op — mirroring
1655
1723
  // compileSubmitMatcher's own null-pattern passthrough — rather than
1656
1724
  // guessing at a reference set.
1657
- if (!hasSubmitEndpointAnchor)
1725
+ if (!hasSubmitEndpointAnchor || !applyFinalAnchorNarrowing)
1658
1726
  return structurallyGated;
1659
- const endpointRx = new RegExp(submitPatterns.endpoint);
1660
1727
  const referencePaths = structurallyGated
1661
- .filter(({ capture }) => endpointRx.test(capture.url))
1728
+ .filter(({ capture }) => matchesDeclaredSubmitEndpoint(capture))
1662
1729
  .map(({ capture }) => safeUrlPathname(capture.url));
1663
1730
  if (referencePaths.length === 0)
1664
1731
  return structurallyGated;
1665
- return structurallyGated.filter(({ capture }) => endpointRx.test(capture.url) ||
1732
+ return structurallyGated.filter(({ capture }) => matchesDeclaredSubmitEndpoint(capture) ||
1666
1733
  (0, capture_filters_1.isStructurallyRelevantCapture)(safeUrlPathname(capture.url), referencePaths));
1667
1734
  }
1668
1735
  /**
@@ -3469,25 +3536,45 @@ function locateFormEnvelopePath(parsedBody) {
3469
3536
  * value the request depends on, breaking the fold at exactly the case an
3470
3537
  * ARRAY/OBJECT-wrapped join field represents.
3471
3538
  *
3539
+ * A body with no detected form envelope worth swallowing into (a facet/
3540
+ * search body — all scalars, if any, sit directly at the root) still needs
3541
+ * this treatment for its OWN top-level array/object fields: those are folded
3542
+ * in as a second envelope candidate — the body root itself — deduplicated
3543
+ * against the primary envelope's own children so a key is never visited (and
3544
+ * never inferred/registered) twice.
3545
+ *
3472
3546
  * Site-agnostic: operates only on the recon body's own shape.
3473
- */
3474
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateValues = new Set()) {
3475
- if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
3476
- return template;
3477
- }
3478
- // Resolve the envelope object whose non-scalar children are caller structures.
3479
- const envelopePath = locateFormEnvelopePath(parsedBody);
3480
- let envelope = parsedBody;
3481
- for (const seg of envelopePath) {
3482
- if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
3483
- envelope = envelope[seg];
3484
- }
3485
- }
3486
- if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
3487
- return template;
3488
- }
3547
+ *
3548
+ * A top-level-ARRAY-shaped body (e.g. a cruise-line multi-room search that
3549
+ * batches per-room criteria as `[{...}, {...}]`) is walked element by
3550
+ * element, applying this same envelope logic to each object element in
3551
+ * textual order — otherwise a structured field living inside an array
3552
+ * element would be invisible to this pass entirely.
3553
+ */
3554
+ /**
3555
+ * Applies the envelope-object substitution pass for ONE object, searching the
3556
+ * template for each key's span starting at `searchFrom` rather than from the
3557
+ * start of the string. This offset is what lets the caller walk a
3558
+ * top-level-ARRAY-shaped body element by element: each array element's own
3559
+ * key spans sit textually after the previous element's, so anchoring the
3560
+ * search there stops an earlier element's already-rewritten (or still-frozen)
3561
+ * span from swallowing a later element's identically-named key.
3562
+ */
3563
+ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, priorStepStateValues, searchFrom, envelopePath, rootBody) {
3489
3564
  let result = template;
3490
- for (const [key, value] of Object.entries(envelope)) {
3565
+ let cursor = searchFrom;
3566
+ // The body root's own entries are eligible too, whenever the located
3567
+ // envelope isn't the root itself — a facet/search body's top-level
3568
+ // array/object fields (e.g. a quantity/id breakdown) are otherwise never
3569
+ // visited at all when some deeper, primitive-richer object (e.g. a
3570
+ // pagination/sort block) outranks the root as the "form envelope". Root
3571
+ // keys already present on the envelope are skipped so nothing is visited
3572
+ // (or registered) twice.
3573
+ const envelopeEntries = Object.entries(envelope);
3574
+ const rootEntries = envelopePath.length === 0
3575
+ ? []
3576
+ : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope));
3577
+ for (const [key, value] of [...envelopeEntries, ...rootEntries]) {
3491
3578
  const isNonEmptyArray = Array.isArray(value) && value.length > 0;
3492
3579
  const isNestedObject = value !== null &&
3493
3580
  typeof value === "object" &&
@@ -3501,7 +3588,7 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
3501
3588
  continue;
3502
3589
  }
3503
3590
  const keyMarker = `"${key}":`;
3504
- const markerIdx = result.indexOf(keyMarker);
3591
+ const markerIdx = result.indexOf(keyMarker, searchFrom);
3505
3592
  if (markerIdx === -1)
3506
3593
  continue;
3507
3594
  const spanStart = markerIdx + keyMarker.length;
@@ -3540,10 +3627,55 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
3540
3627
  continue;
3541
3628
  const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
3542
3629
  result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
3630
+ cursor = Math.max(cursor, spanStart + replacement.length);
3543
3631
  if (!outStructuredKeys.has(key)) {
3544
3632
  outStructuredKeys.set(key, inferZodSchema(value));
3545
3633
  }
3546
3634
  }
3635
+ return { result, nextSearchFrom: cursor };
3636
+ }
3637
+ /**
3638
+ * Resolves the envelope object for a single object-shaped body (or array
3639
+ * element) via {@link locateFormEnvelopePath} and applies the substitution
3640
+ * pass to it. Shared by the top-level-object and top-level-array branches of
3641
+ * {@link applyStructuredValuePayloadSubstitutions} below.
3642
+ */
3643
+ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, priorStepStateValues, searchFrom) {
3644
+ const envelopePath = locateFormEnvelopePath(objectBody);
3645
+ let envelope = objectBody;
3646
+ for (const seg of envelopePath) {
3647
+ if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
3648
+ envelope = envelope[seg];
3649
+ }
3650
+ }
3651
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
3652
+ return { result: template, nextSearchFrom: searchFrom };
3653
+ }
3654
+ return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, priorStepStateValues, searchFrom, envelopePath, objectBody);
3655
+ }
3656
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateValues = new Set()) {
3657
+ if (parsedBody === null || typeof parsedBody !== "object") {
3658
+ return template;
3659
+ }
3660
+ // A cruise-line-style multi-room/multi-guest search body commonly batches
3661
+ // per-element criteria as a top-level JSON ARRAY rather than a single
3662
+ // object. Walk each element in textual order (via the growing searchFrom
3663
+ // cursor) instead of bailing out here — otherwise any structured field
3664
+ // living inside an array element is invisible to this pass entirely, no
3665
+ // matter how correctly a root-level object body would have handled it.
3666
+ if (Array.isArray(parsedBody)) {
3667
+ let result = template;
3668
+ let cursor = 0;
3669
+ for (const element of parsedBody) {
3670
+ if (element === null || typeof element !== "object" || Array.isArray(element))
3671
+ continue;
3672
+ const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, priorStepStateValues, cursor);
3673
+ result = nextResult;
3674
+ cursor = nextSearchFrom;
3675
+ }
3676
+ return result;
3677
+ }
3678
+ const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, priorStepStateValues, 0);
3547
3679
  return result;
3548
3680
  }
3549
3681
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
@@ -5098,21 +5230,106 @@ responseBodiesForCapacitySignal = []) {
5098
5230
  }
5099
5231
  }
5100
5232
  let result = template;
5233
+ const exactlyBoundKeys = new Set();
5101
5234
  for (const [key, value] of merged) {
5102
5235
  const accessor = `payload.${key}`;
5103
5236
  if (typeof value === "string") {
5104
5237
  const target = `"${key}":${JSON.stringify(value)}`;
5105
5238
  const replacement = `"${key}":"\${${accessor}}"`;
5106
5239
  result = result.split(target).join(replacement);
5240
+ exactlyBoundKeys.add(key);
5107
5241
  }
5108
5242
  else if (typeof value === "boolean" || typeof value === "number") {
5109
5243
  const target = `"${key}":${JSON.stringify(value)}`;
5110
5244
  const replacement = `"${key}":\${${accessor}}`;
5111
5245
  result = result.split(target).join(replacement);
5246
+ exactlyBoundKeys.add(key);
5247
+ }
5248
+ }
5249
+ // A scalar facet field's captured value doesn't only ever surface as its
5250
+ // OWN exact `"<field>":<value>` pair (handled above) — the same value can
5251
+ // be packed inside an UNRELATED key's delimited facet string (e.g. a
5252
+ // `filters`/`variables` blob shaped `ship:disney-wish|theme:merry`). This
5253
+ // consults the SAME field↔captured-value correlation table just built
5254
+ // (the scalar string entries of `merged`) via the identical case-
5255
+ // insensitive splice {@link renderGqlVariablesExpr} already applies to the
5256
+ // primary GQL operation's variables, so every body/variables template this
5257
+ // function renders — not just that one call site — threads a facet field
5258
+ // into `${payload.<field>}` wherever its value is actually load-bearing.
5259
+ const scalarFieldNames = merged
5260
+ .filter((entry) => typeof entry[1] === "string")
5261
+ .map(([key]) => key);
5262
+ if (scalarFieldNames.length > 0) {
5263
+ const splicedStrings = new Set();
5264
+ for (const body of allBodies) {
5265
+ if (body === undefined || body === null || typeof body !== "object")
5266
+ continue;
5267
+ for (const { value, path } of walkAllPrimitiveLeaves(body)) {
5268
+ if (typeof value !== "string" || value.length === 0)
5269
+ continue;
5270
+ if (splicedStrings.has(value))
5271
+ continue;
5272
+ // Already handled exactly above — don't double-bind the field's own
5273
+ // top-level occurrence.
5274
+ if (path.length === 1 && exactlyBoundKeys.has(path[0]))
5275
+ continue;
5276
+ const spliced = spliceFacetsIntoStringVariable(value, scalarFieldNames);
5277
+ if (spliced === null)
5278
+ continue;
5279
+ const target = JSON.stringify(value);
5280
+ if (!result.includes(target))
5281
+ continue;
5282
+ splicedStrings.add(value);
5283
+ result = result.split(target).join(spliced);
5284
+ }
5112
5285
  }
5113
5286
  }
5114
5287
  return result;
5115
5288
  }
5289
+ /**
5290
+ * REST-body counterpart to {@link renderGqlVariablesExpr}'s facet-splice
5291
+ * branch: a top-level string leaf that packs `key:value` facet segments
5292
+ * correlating (case-insensitively) with `payloadFieldNames` gets its
5293
+ * correlated segments spliced with `payload.<field>` in place — via
5294
+ * {@link spliceFacetsIntoStringVariable}, the SAME generic, site-agnostic
5295
+ * function the GraphQL variables emitter already uses — instead of surviving
5296
+ * as either a single opaque `payload.<key>` accessor (once
5297
+ * {@link applyPayloadKeyValueSubstitutions} runs) or a frozen recon-time
5298
+ * literal. Runs on the ORIGINAL captured JSON text, matching that function's
5299
+ * own `"<key>":<JSON.stringify(value)>` target/replacement idiom, so the
5300
+ * composite string's constituent fields — not just its own key name — become
5301
+ * independently bindable. No-op when `payloadFieldNames` is empty (no flow
5302
+ * steps declared any field to correlate against) or when a leaf's value has
5303
+ * no `key:value` segment matching one of them.
5304
+ */
5305
+ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames) {
5306
+ if (payloadFieldNames.size === 0)
5307
+ return template;
5308
+ if (inputBody === undefined ||
5309
+ inputBody === null ||
5310
+ typeof inputBody !== "object" ||
5311
+ Array.isArray(inputBody)) {
5312
+ return template;
5313
+ }
5314
+ const fields = [...payloadFieldNames];
5315
+ let result = template;
5316
+ for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5317
+ if (path.length !== 1)
5318
+ continue;
5319
+ const key = path[0];
5320
+ if (!isValidJsIdentifier(key))
5321
+ continue;
5322
+ if (typeof value !== "string")
5323
+ continue;
5324
+ const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
5325
+ if (spliced === null)
5326
+ continue;
5327
+ const target = `"${key}":${JSON.stringify(value)}`;
5328
+ const replacement = `"${key}":"\${${spliced}}"`;
5329
+ result = result.split(target).join(replacement);
5330
+ }
5331
+ return result;
5332
+ }
5116
5333
  /**
5117
5334
  * Documented closed set of JSON-key-name fragments (matched case-insensitively)
5118
5335
  * that mark a value as a per-request TIMESTAMP the plugin must generate fresh at
@@ -5345,7 +5562,15 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
5345
5562
  * typecheck. `null` (the test-facing default) skips the cast, preserving
5346
5563
  * prior output for callers that don't exercise the full pipeline.
5347
5564
  */
5348
- pascalName = null) {
5565
+ pascalName = null,
5566
+ /** Flow steps + vocabulary the browser flow's own `payload.<field>` splices
5567
+ * are correlated against (see {@link computeFlowPayloadFieldNames}) — threaded
5568
+ * here so the REST body's own facet-packed string leaves (e.g. a `filters`
5569
+ * field packing `category:widgets|priceRange:10~50`) correlate against the
5570
+ * SAME declared field set {@link renderGqlVariablesExpr}'s GraphQL path
5571
+ * already uses, instead of never being decomposed at all. Defaults preserve
5572
+ * prior (no facet-splice) output for callers that don't pass a flow. */
5573
+ flowSteps = [], vocabulary = undefined) {
5349
5574
  // Walk the first action's request body to map each leaf string value to its
5350
5575
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
5351
5576
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -5358,6 +5583,12 @@ pascalName = null) {
5358
5583
  // produce nonsense substitutions. Values below the threshold stay literal
5359
5584
  // in the emitted template — fine for short enum-like fields that rarely
5360
5585
  // need to vary at runtime.
5586
+ // Declared payload fields the flow's OWN step instructions correlate to —
5587
+ // the same set {@link emitBrowserFlowTs} derives for its `payload.<field>`
5588
+ // splices, recomputed here (pure function of flowSteps/vocabulary, so it
5589
+ // can never drift from the browser flow's own set) so the facet-splice pass
5590
+ // below can correlate a REST body's facet-packed string leaves against it.
5591
+ const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
5361
5592
  const payloadAccessorByValue = new Map();
5362
5593
  if (inputBody !== undefined && inputBody !== null) {
5363
5594
  for (const { value, path } of walkStringLeaves(inputBody)) {
@@ -5612,6 +5843,22 @@ pascalName = null) {
5612
5843
  }
5613
5844
  }
5614
5845
  }
5846
+ // A per-session correlation id (see {@link isVolatileHeaderName}) must never
5847
+ // freeze into a literal — every action re-issues it via the SAME hoisted
5848
+ // `const`, mirroring `threadedTxnId` above: one mint, reused across the
5849
+ // whole flow, not a fresh mint per step (a real per-session id, unlike the
5850
+ // per-call volatile body UUIDs {@link applyVolatileFieldSubstitutions}
5851
+ // regenerates). Keyed by lowercased header name so `Correlation-Id` and
5852
+ // `X-Conversation-Id` each get their own hoisted local if a flow carries both.
5853
+ const volatileHeaderVarNames = new Map();
5854
+ for (const { capture } of actions) {
5855
+ for (const headerName of Object.keys(capture.requestHeaders)) {
5856
+ const lower = headerName.toLowerCase();
5857
+ if (!isVolatileHeaderName(lower) || volatileHeaderVarNames.has(lower))
5858
+ continue;
5859
+ volatileHeaderVarNames.set(lower, `${headerNameToPayloadFieldName(headerName).replace(/^./, (c) => c.toLowerCase())}Value`);
5860
+ }
5861
+ }
5615
5862
  // Pass 1: render every step's emitted strings; collect referenced var names.
5616
5863
  const rendered = [];
5617
5864
  for (let i = 0; i < actions.length; i++) {
@@ -5653,6 +5900,18 @@ pascalName = null) {
5653
5900
  return null;
5654
5901
  }
5655
5902
  })();
5903
+ // Facet splice — a top-level string leaf packing `key:value` facet
5904
+ // segments (e.g. `filters: "category:widgets|priceRange:10~50"`) gets its
5905
+ // correlated segments spliced with `payload.<field>` here, BEFORE any
5906
+ // other pass can freeze the whole string as one opaque `payload.filters`
5907
+ // accessor (see applyPayloadKeyValueSubstitutions) or swallow it as an
5908
+ // unparameterized structured value. Mirrors renderGqlVariablesExpr's own
5909
+ // facet-splice branch for GraphQL variables — same correlation function,
5910
+ // same declared field set, just applied to REST body text instead of a
5911
+ // variables object literal.
5912
+ const rawBodyWithFacetSplices = parsedBody !== null
5913
+ ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames)
5914
+ : rawBodyWithFormSubs;
5656
5915
  // Mechanism B — parameterize whole nested caller structures
5657
5916
  // (experienceData/educationData history, opaque eventData) BEFORE value
5658
5917
  // substitution reaches inside them: swallowing the entire array/object first
@@ -5663,7 +5922,7 @@ pascalName = null) {
5663
5922
  // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
5664
5923
  // must stay reachable for state threading, not get frozen as caller data.
5665
5924
  const rawBodyWithStructuredSubs = parsedBody !== null
5666
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys, new Set([
5925
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, outStructuredKeys, new Set([
5667
5926
  ...deriveStateVarByValue(prior, cap).keys(),
5668
5927
  ...(joinFieldValuesByStep.get(i) ?? []),
5669
5928
  ]))
@@ -5726,6 +5985,24 @@ pascalName = null) {
5726
5985
  const perCallHeaders = {};
5727
5986
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
5728
5987
  const lower = k.toLowerCase();
5988
+ // A captured `Cookie` header is a session jar, not real request state —
5989
+ // the only sanctioned path for a cookie value to reach a real request
5990
+ // is `collectHeaderBindings`'s `bind` mechanism, driven by an actual
5991
+ // Set-Cookie response. Never emit this header from the capture, even
5992
+ // partially: any splice-and-keep logic here reintroduces a frozen
5993
+ // recon-session literal at finer grain.
5994
+ if (lower === "cookie") {
5995
+ continue;
5996
+ }
5997
+ // A correlation/conversation-id-shaped header is never the captured
5998
+ // literal, threaded state, or an interpolation target — it's re-issued
5999
+ // per call from the flow-wide hoisted mint (see volatileHeaderVarNames
6000
+ // above), regardless of what interpolateStateValues would otherwise do.
6001
+ const volatileVarName = volatileHeaderVarNames.get(lower);
6002
+ if (volatileVarName !== undefined) {
6003
+ perCallHeaders[k] = `\${${volatileVarName}}`;
6004
+ continue;
6005
+ }
5729
6006
  const interpolated = interpolateStateValues(v, prior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
5730
6007
  // Authorization/Api-Token and a structurally-detected join-carrying
5731
6008
  // header are always emitted per-call (even when interpolation finds
@@ -5825,6 +6102,15 @@ pascalName = null) {
5825
6102
  lines.push(` const txnId = crypto.randomUUID();`);
5826
6103
  lines.push("");
5827
6104
  }
6105
+ // Mint each correlation/conversation-id-shaped header ONCE, same rationale
6106
+ // as txnId above — a frozen capture value would replay one recon session's
6107
+ // id across every real invocation. Emitted only when actually referenced.
6108
+ for (const varName of new Set(volatileHeaderVarNames.values())) {
6109
+ if (!referencedNames.has(varName))
6110
+ continue;
6111
+ lines.push(` const ${varName} = crypto.randomUUID();`);
6112
+ lines.push("");
6113
+ }
5828
6114
  // Surface any captured literal that no pass could bind, so a reviewer knows
5829
6115
  // exactly which slots still carry recon data. Comment only — never blocks emit.
5830
6116
  if (unboundLiteralKeys.size > 0) {
@@ -6273,9 +6559,24 @@ pascalName = null) {
6273
6559
  // Extract just the per-call header overrides (API-Token etc.) from the
6274
6560
  // rendered headers expression to merge with BASE_HEADERS.
6275
6561
  const perCallHeaderEntries = [];
6562
+ const multipartPrior = actions.slice(0, i);
6276
6563
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
6277
6564
  const lower = k.toLowerCase();
6278
- const interpolated = interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
6565
+ // Mirrors the non-multipart per-call header builder above: never
6566
+ // emit a captured `Cookie` header, even partially. The only
6567
+ // sanctioned path for a cookie to reach a real request is
6568
+ // `collectHeaderBindings`'s `bind` mechanism.
6569
+ if (lower === "cookie") {
6570
+ continue;
6571
+ }
6572
+ // Mirrors the non-multipart per-call header builder above: re-issue
6573
+ // from the flow-wide hoisted mint instead of the captured literal.
6574
+ const volatileVarName = volatileHeaderVarNames.get(lower);
6575
+ if (volatileVarName !== undefined) {
6576
+ perCallHeaderEntries.push(`${JSON.stringify(k)}: \`\${${volatileVarName}}\``);
6577
+ continue;
6578
+ }
6579
+ const interpolated = interpolateStateValues(v, multipartPrior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
6279
6580
  // Mirrors the non-multipart per-call header builder above: any
6280
6581
  // header name (not just Authorization/Api-Token) that interpolation
6281
6582
  // recognizes as a prior step's produced state var must thread
@@ -8748,31 +9049,30 @@ primaryIdentityAnchor = null) {
8748
9049
  specPlan,
8749
9050
  ];
8750
9051
  }
8751
- // Keyed by the (primaryStepIndex, primaryArrayPath) pair, not
8752
- // primaryStepIndex alone — a structural plan only ever consumed ITS OWN
8753
- // array on that step, not the whole step. A spec whose resultsPath names
8754
- // a second, structurally-undetected array on that exact same primary step
8755
- // has already been proven independent by the samePrimaryPlan lookup above
8756
- // (its primaryArrayPath differs from every structural plan's), so keying
8757
- // solely on the step index would wrongly treat it as already consumed and
8758
- // silently drop it. consumedIndices tracks only the steps a plan folds
8759
- // FROM (each target's drillStepIndex), not its whole replay chain — a
8760
- // target's chain threads through steps it depends on to reach per-item
8761
- // data, but only its drillStepIndex is the call it actually folds from,
8762
- // so sweeping the whole chain would falsely claim steps the spec never
8763
- // contends for.
8764
- const consumedIndices = new Set();
9052
+ // Keyed by the (primaryStepIndex, primaryArrayPath) pair, not raw step
9053
+ // indices: that pair is the only thing that identifies whether the spec
9054
+ // describes a primary a structural plan ALREADY owns. A structural
9055
+ // target's own `drillStepIndex` (or any index in its replay chain)
9056
+ // coinciding with a step the spec's own resolution happens to touch is
9057
+ // not, on its own, evidence of a real conflict — an unrelated,
9058
+ // independent primary/drill pair can legitimately enter through (or
9059
+ // replay) the exact same underlying capture the spec's chain does (e.g.
9060
+ // a shared session/auth bootstrap call reused by two unrelated
9061
+ // features), and both targets replaying it per their own items is
9062
+ // exactly what {@link resolveSpecMatchedPrimaryItemIndexAlongChain}'s
9063
+ // multi-hop resolution expects — see the shared-bootstrap-hop-noise
9064
+ // regression test. Excluding on raw index overlap (as an earlier version
9065
+ // of this check did, for BOTH the spec's `drillStepIndex` and its whole
9066
+ // `chain`) silently dropped a real, resolvable declared join whenever
9067
+ // some unrelated noisy structural plan merely touched the same index by
9068
+ // coincidence, which is exactly the "declared fold rejected" failure
9069
+ // mode this function exists to prevent.
8765
9070
  const consumedPrimarySteps = new Set();
8766
9071
  for (const plan of structuralPlansWithPerOccurrenceOverrides) {
8767
9072
  const planPrimaryEndpointKey = endpointKey(actions[plan.primaryStepIndex].capture.url);
8768
9073
  consumedPrimarySteps.add(`${planPrimaryEndpointKey}:${JSON.stringify(plan.primaryArrayPath)}`);
8769
- for (const target of plan.targets) {
8770
- consumedIndices.add(target.drillStepIndex);
8771
- }
8772
9074
  }
8773
- const specConsumesOnlyItsOwnIndices = !consumedIndices.has(specPlan.primaryStepIndex) &&
8774
- !consumedPrimarySteps.has(`${specPrimaryEndpointKey}:${JSON.stringify(specPlan.primaryArrayPath)}`) &&
8775
- specPlan.targets.every((target) => target.chain.every((chainIndex) => !consumedIndices.has(chainIndex)));
9075
+ const specConsumesOnlyItsOwnIndices = !consumedPrimarySteps.has(`${specPrimaryEndpointKey}:${JSON.stringify(specPlan.primaryArrayPath)}`);
8776
9076
  if (specConsumesOnlyItsOwnIndices)
8777
9077
  return [...structuralPlansWithPerOccurrenceOverrides, specPlan];
8778
9078
  // The spec's primary endpoint/array is already consumed by a structural
@@ -9420,6 +9720,11 @@ function buildContractChecklist(opts) {
9420
9720
  function emitContractTs(opts) {
9421
9721
  const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
9422
9722
  const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
9723
+ /** Single source of truth for every GraphQL-emission decision below
9724
+ * (clientImport, gqlCacheBlock, queryConst, and both executeHttpBody
9725
+ * fetch-call sites) — `gql` alone can be true with a null `gqlQuery`,
9726
+ * which would emit a `${PASCAL}_QUERY` reference with no declaration. */
9727
+ const isGqlEmission = gql && gqlQuery !== null;
9423
9728
  /** Rendered `meta.browserFallbackGate` literal: `false` verbatim, a
9424
9729
  * list-derived arrow-function predicate matching against `error.name`, or
9425
9730
  * "" (omitted) when the flow declared no gate — preserving byte-identical
@@ -9858,8 +10163,17 @@ function emitContractTs(opts) {
9858
10163
  : "";
9859
10164
  // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
9860
10165
  // the generated file on first lint:fix.
10166
+ //
10167
+ // A header value shaped like a UUID (e.g. a per-session correlation/
10168
+ // conversation id captured during recon) is minted fresh per call rather
10169
+ // than frozen as the recon capture's literal — the same volatility
10170
+ // convention applyVolatileFieldSubstitutions already applies to UUID body
10171
+ // leaves. The `${…}` fragment is assembled by concatenation, not a literal
10172
+ // template-curly, so Biome's noTemplateCurlyInString doesn't flag THIS
10173
+ // file's own source.
10174
+ const headersUuidGen = `$${"{"}crypto.randomUUID()${"}"}`;
9861
10175
  const headersLiteral = Object.entries(baseHeaders)
9862
- .map(([k, v]) => ` ${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`)
10176
+ .map(([k, v]) => ` ${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${UUID_REGEX.test(v) ? `\`${headersUuidGen}\`` : JSON.stringify(v)}`)
9863
10177
  .join(",\n");
9864
10178
  const fixtureImport = auxFiles.length > 0 ? `// import { loadFixture } from "${ENGINE_PKG}/scraper/fixtures";\n` : "";
9865
10179
  // Browser-flow-only plugins have no direct-HTTP hot path, so none of the
@@ -9868,15 +10182,15 @@ function emitContractTs(opts) {
9868
10182
  // `noUnusedVariables`.
9869
10183
  const clientImport = omitExecuteHttp
9870
10184
  ? ""
9871
- : gql
10185
+ : isGqlEmission
9872
10186
  ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
9873
10187
  : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
9874
- const queryConst = !omitExecuteHttp && gql && gqlQuery
10188
+ const queryConst = !omitExecuteHttp && isGqlEmission
9875
10189
  ? `\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`
9876
10190
  : "";
9877
10191
  const gqlCacheBlock = omitExecuteHttp
9878
10192
  ? ""
9879
- : gql
10193
+ : isGqlEmission
9880
10194
  ? `
9881
10195
  type GqlFn = (operationName: string, query: string, variables: Record<string, unknown>) => Promise<${pascal}Response>;
9882
10196
 
@@ -10225,7 +10539,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
10225
10539
  signal: paginationSignal,
10226
10540
  foldMergeLines: paginatedFoldMergeLines,
10227
10541
  valueConstraints,
10228
- fetchCall: gql
10542
+ fetchCall: isGqlEmission
10229
10543
  ? {
10230
10544
  kind: "gql",
10231
10545
  gqlOperationNameExpr,
@@ -10233,7 +10547,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
10233
10547
  }
10234
10548
  : { kind: "rest", endpointPath },
10235
10549
  })
10236
- : gql
10550
+ : isGqlEmission
10237
10551
  ? ` const data = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${pascal.toUpperCase()}_QUERY, ${gqlVariablesExpr});
10238
10552
  ${dataFoldMergeBlock} return { data };`
10239
10553
  : ` const data = await httpClient(\`\${context.baseUrl}${endpointPath}\`, {
@@ -10627,11 +10941,19 @@ function emitConfigManifest(opts) {
10627
10941
  * every `payload.<field>` the flow references also appears in the contract's
10628
10942
  * payload schema (both are driven by this same set).
10629
10943
  */
10630
- function emitBrowserFlowTs(opts) {
10631
- const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, env = process.env, } = opts;
10944
+ /**
10945
+ * Resolves the payload field (or composite persona field pair) each flow
10946
+ * step's instruction correlates to — the same per-step correlation
10947
+ * {@link emitBrowserFlowTs}'s step-literal pass performs to decide its own
10948
+ * `payload.<field>` splices, factored out to a pure function of
10949
+ * `flowSteps`/`vocabulary` so {@link emitMultiStepExecuteHttp}'s REST
10950
+ * facet-splice pass can correlate against the exact same declared field set
10951
+ * instead of re-deriving a second, potentially-drifting one.
10952
+ */
10953
+ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
10632
10954
  const payloadFieldNames = new Set();
10633
- // Widest wins, mirroring the request-surface precedent above: a field is
10634
- // only optional if EVERY step that registers it does so as optional.
10955
+ // Widest wins: a field is only optional if EVERY step that registers it
10956
+ // does so as optional.
10635
10957
  const optionalPayloadFieldNames = new Set();
10636
10958
  const requiredPayloadFieldNames = new Set();
10637
10959
  const registerFieldOptionality = (field, optional) => {
@@ -10642,6 +10964,39 @@ function emitBrowserFlowTs(opts) {
10642
10964
  optionalPayloadFieldNames.delete(field);
10643
10965
  }
10644
10966
  };
10967
+ const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
10968
+ for (const step of flowSteps) {
10969
+ const isObj = typeof step !== "string";
10970
+ const instruction = isObj ? step.step : step;
10971
+ // navigateTo/emailStep/password-token steps bypass field resolution
10972
+ // entirely — see emitBrowserFlowTs's stepLiterals pass for why.
10973
+ if (isObj && step.navigateTo !== undefined)
10974
+ continue;
10975
+ if (isObj && step.emailStep === true)
10976
+ continue;
10977
+ if (instruction.includes(RECON_PASSWORD_TOKEN))
10978
+ continue;
10979
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
10980
+ const composite = field === null && !(isObj && step.payloadFieldNone)
10981
+ ? resolveCompositePersonaFields(instruction, knownFieldValues)
10982
+ : null;
10983
+ const optional = isObj ? step.optional === true : false;
10984
+ if (field !== null) {
10985
+ payloadFieldNames.add(field);
10986
+ registerFieldOptionality(field, optional);
10987
+ }
10988
+ if (composite !== null) {
10989
+ payloadFieldNames.add(composite.fieldA);
10990
+ payloadFieldNames.add(composite.fieldB);
10991
+ registerFieldOptionality(composite.fieldA, optional);
10992
+ registerFieldOptionality(composite.fieldB, optional);
10993
+ }
10994
+ }
10995
+ return { payloadFieldNames, optionalPayloadFieldNames };
10996
+ }
10997
+ function emitBrowserFlowTs(opts) {
10998
+ const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, env = process.env, } = opts;
10999
+ const { payloadFieldNames, optionalPayloadFieldNames } = computeFlowPayloadFieldNames(flowSteps, vocabulary, env);
10645
11000
  const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
10646
11001
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
10647
11002
  let usesThrowawayPassword = false;
@@ -10690,16 +11045,6 @@ function emitBrowserFlowTs(opts) {
10690
11045
  ? resolveCompositePersonaFields(instruction, knownFieldValues)
10691
11046
  : null;
10692
11047
  const optional = isObj ? step.optional === true : false;
10693
- if (field !== null) {
10694
- payloadFieldNames.add(field);
10695
- registerFieldOptionality(field, optional);
10696
- }
10697
- if (composite !== null) {
10698
- payloadFieldNames.add(composite.fieldA);
10699
- payloadFieldNames.add(composite.fieldB);
10700
- registerFieldOptionality(composite.fieldA, optional);
10701
- registerFieldOptionality(composite.fieldB, optional);
10702
- }
10703
11048
  const instructionExpr = composite !== null
10704
11049
  ? buildCompositeStepInstructionExpr(instruction, composite.fieldA, composite.fieldB)
10705
11050
  : buildStepInstructionExpr(instruction, field);
@@ -11294,7 +11639,7 @@ async function main() {
11294
11639
  const minTime = deriveMinTime(rateLimits);
11295
11640
  const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
11296
11641
  const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
11297
- const gql = isGraphQL(activeCaptures);
11642
+ const gql = isGraphQL(activeCaptures, ownBackendHostnames, fallbackDomain);
11298
11643
  // Hoisted so both the primary-operation gate below and rawActionCaptures
11299
11644
  // (further down) read the same computed sequence instead of calling the
11300
11645
  // extractor twice. Computed unfiltered (submitPatterns: null) — a
@@ -11372,9 +11717,25 @@ async function main() {
11372
11717
  // keeps that whole chain; the gap between this and the unfiltered
11373
11718
  // sequence is logged below for visibility, but the declared pattern is
11374
11719
  // never overridden by the richer unfiltered sequence.
11720
+ //
11721
+ // Truncated against a pool RE-EXTRACTED with the declared pattern passed
11722
+ // through (not against unfilteredHeuristicActionCaptures, which is built
11723
+ // with submitPatterns forced to null purely to get an unbiased baseline
11724
+ // for the disagreement check below). extractActionSequence's own
11725
+ // noise/structural-isolation passes have no visibility into the declared
11726
+ // pattern when it's withheld, so on an archive noisy enough to make a
11727
+ // genuine submission capture look structurally isolated, that capture
11728
+ // can be dropped before truncation ever sees it — a real match then
11729
+ // reads as "0 captures" even though the archive contains plenty. Passing
11730
+ // submitPatterns through lets extractActionSequence's own submit-anchor
11731
+ // narrowing (REST) / per-capture submit match (GraphQL) keep the
11732
+ // genuine captures amid that noise, so truncation has them to find.
11733
+ const patternedExtractedActionCaptures = gql
11734
+ ? dedupRedundantSameOperationCaptures(extractGraphQLActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain), primaryGraphQLOperation)
11735
+ : collapseRedundantSameEndpointCaptures(collapseRedundantPatches(extractActionSequence(activeCaptures, submitPatterns, foldReturnSpec, ownBackendHostnames, fallbackDomain, false)));
11375
11736
  const patternedHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
11376
11737
  ? unfilteredHeuristicActionCaptures
11377
- : truncateActionSequenceAtSubmitPattern(unfilteredHeuristicActionCaptures, submitPatterns, foldReturnSpec);
11738
+ : truncateActionSequenceAtSubmitPattern(patternedExtractedActionCaptures, submitPatterns, foldReturnSpec);
11378
11739
  const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
11379
11740
  if (patternUndercounts && requireSubmitEndpointMatch) {
11380
11741
  // Distinct wording from the non-required case below: this pattern is never
@@ -11582,7 +11943,7 @@ async function main() {
11582
11943
  const multiStepBody = browserFlowOnly
11583
11944
  ? undefined
11584
11945
  : isSubmissionFlow
11585
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredPayloadAccessorFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec, pascal)
11946
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredPayloadAccessorFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec, pascal, flowSteps, vocabulary)
11586
11947
  : undefined;
11587
11948
  // Explicit merge — every field emitMultiStepExecuteHttp registered as a
11588
11949
  // `payload.<field>` accessor (BaseUrl, persona, entryUrlParams, tenant-