@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.
- package/dist/scripts/recon-browser.js +2 -2
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +37 -9
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +441 -80
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1612
|
-
//
|
|
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)
|
|
1644
|
-
//
|
|
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) ||
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
8753
|
-
//
|
|
8754
|
-
//
|
|
8755
|
-
//
|
|
8756
|
-
//
|
|
8757
|
-
//
|
|
8758
|
-
//
|
|
8759
|
-
//
|
|
8760
|
-
//
|
|
8761
|
-
//
|
|
8762
|
-
//
|
|
8763
|
-
//
|
|
8764
|
-
|
|
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 = !
|
|
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
|
-
:
|
|
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 &&
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
:
|
|
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
|
-
|
|
10631
|
-
|
|
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
|
|
10634
|
-
//
|
|
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(
|
|
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-
|