@enricai/barnacle 1.12.45 → 1.12.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recon/capture-filters.d.ts +31 -0
- package/dist/recon/capture-filters.d.ts.map +1 -1
- package/dist/recon/capture-filters.js +81 -0
- package/dist/recon/capture-filters.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +32 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +647 -404
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -71,10 +71,12 @@ exports.resolveFoldPlan = resolveFoldPlan;
|
|
|
71
71
|
exports.resolveApplicableFoldPlans = resolveApplicableFoldPlans;
|
|
72
72
|
exports.buildContractChecklist = buildContractChecklist;
|
|
73
73
|
exports.emitContractTs = emitContractTs;
|
|
74
|
+
exports.unreferencedRequiredUrlFields = unreferencedRequiredUrlFields;
|
|
74
75
|
exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
|
|
75
76
|
exports.emitConfigManifest = emitConfigManifest;
|
|
76
77
|
exports.emitBrowserFlowTs = emitBrowserFlowTs;
|
|
77
78
|
exports.emitIndexTs = emitIndexTs;
|
|
79
|
+
exports.identifyNoiseCapturesForFields = identifyNoiseCapturesForFields;
|
|
78
80
|
const node_fs_1 = require("node:fs");
|
|
79
81
|
const node_path_1 = require("node:path");
|
|
80
82
|
const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
|
|
@@ -966,6 +968,19 @@ function captureHostname(url) {
|
|
|
966
968
|
return "";
|
|
967
969
|
}
|
|
968
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* Same not-guaranteed-parseable caveat as {@link captureHostname}, for the
|
|
973
|
+
* path instead of the host — used to anchor {@link isStructurallyRelevantCapture}
|
|
974
|
+
* on a capture's URL structure.
|
|
975
|
+
*/
|
|
976
|
+
function capturePathname(url) {
|
|
977
|
+
try {
|
|
978
|
+
return new URL(url).pathname;
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
return "";
|
|
982
|
+
}
|
|
983
|
+
}
|
|
969
984
|
/**
|
|
970
985
|
* `baseUrl` itself is what {@link registrableDomain}'s fallback would be
|
|
971
986
|
* derived FROM, so at this point in generation the registrable-domain gate
|
|
@@ -1507,7 +1522,6 @@ function resolveManifestActionSequence(runRoot, captures) {
|
|
|
1507
1522
|
* chatter and the emitted hot path.
|
|
1508
1523
|
*/
|
|
1509
1524
|
function extractActionSequence(captures, submitPatterns = null, foldReturnSpec = null, ownBackendHostnames = [], fallbackDomain = null) {
|
|
1510
|
-
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
1511
1525
|
const matchesFoldReturn = compileFoldReturnEndpointMatcher(foldReturnSpec);
|
|
1512
1526
|
// Callers with no host-provenance data (the exported function's unit
|
|
1513
1527
|
// tests) pass neither ownBackendHostnames nor fallbackDomain — in that
|
|
@@ -1515,7 +1529,21 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1515
1529
|
// only applies once the caller has actually resolved a notion of "own
|
|
1516
1530
|
// backend" to check against.
|
|
1517
1531
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1518
|
-
|
|
1532
|
+
// With host-provenance data AND a declared submitEndpointPattern, the
|
|
1533
|
+
// endpoint match's job of isolating the submission is taken over by the
|
|
1534
|
+
// structural-relevance narrowing pass below (anchored on that SAME
|
|
1535
|
+
// pattern) instead of the per-capture endpoint regex test: a literal
|
|
1536
|
+
// per-capture match can't admit an EARLIER multi-step chain capture (auth
|
|
1537
|
+
// mint, paged listing, ...) whose URL never matches the terminal submit
|
|
1538
|
+
// endpoint, only a LATER structurally-related one. `submitBodyPattern`
|
|
1539
|
+
// stays enforced either way — it isn't what structural relevance reasons
|
|
1540
|
+
// about. Absent host provenance, or absent a declared endpoint pattern,
|
|
1541
|
+
// the per-capture endpoint match below is the only submit-isolation
|
|
1542
|
+
// mechanism and stays unconditional (matches the "submit patterns isolate
|
|
1543
|
+
// the submission from same-URL chrome" behavior).
|
|
1544
|
+
const hasSubmitEndpointAnchor = hasHostProvenance && submitPatterns !== null && submitPatterns.endpoint !== null;
|
|
1545
|
+
const matchesSubmit = compileSubmitMatcher(hasSubmitEndpointAnchor ? { endpoint: null, body: submitPatterns.body } : submitPatterns);
|
|
1546
|
+
const hostGated = captures
|
|
1519
1547
|
.map((capture, index) => ({ capture, index }))
|
|
1520
1548
|
.filter(({ capture }) => {
|
|
1521
1549
|
if (capture.method === "GET" && !matchesFoldReturn(capture))
|
|
@@ -1531,6 +1559,46 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1531
1559
|
return false;
|
|
1532
1560
|
return true;
|
|
1533
1561
|
});
|
|
1562
|
+
// Host-gating alone can't tell a genuinely related own-backend endpoint
|
|
1563
|
+
// family apart from a same-host capture that shares nothing but the host
|
|
1564
|
+
// (e.g. a marketing/promotions endpoint fired incidentally by a page load,
|
|
1565
|
+
// admitted above because it too is a 2xx own-backend POST). Drop a
|
|
1566
|
+
// structurally-isolated member of the pool first — a compound-path capture
|
|
1567
|
+
// (e.g. `/site-banner`) sharing no token with anything else host-gated in,
|
|
1568
|
+
// regardless of whether a submitEndpointPattern is declared. Guarded to
|
|
1569
|
+
// pools of 3+: a real chain's own steps are usually plain single-word
|
|
1570
|
+
// paths with no tokens of their own (never flagged, see
|
|
1571
|
+
// isStructurallyIsolatedCapture), so this only ever removes a capture that
|
|
1572
|
+
// is BOTH compound-path AND unrelated to every other admitted capture —
|
|
1573
|
+
// but a 1-2 capture pool has no "everything else" to be isolated from, so
|
|
1574
|
+
// skip it there rather than risk flagging a single-endpoint site's own
|
|
1575
|
+
// hyphenated path.
|
|
1576
|
+
const structurallyGated = hasHostProvenance && hostGated.length > 2
|
|
1577
|
+
? hostGated.filter(({ capture }, i) => {
|
|
1578
|
+
const path = safeUrlPathname(capture.url);
|
|
1579
|
+
const otherPaths = hostGated
|
|
1580
|
+
.filter((_, j) => j !== i)
|
|
1581
|
+
.map((h) => safeUrlPathname(h.capture.url));
|
|
1582
|
+
return !(0, capture_filters_1.isStructurallyIsolatedCapture)(path, otherPaths);
|
|
1583
|
+
})
|
|
1584
|
+
: hostGated;
|
|
1585
|
+
// Narrow further using structural relevance, anchored on whichever
|
|
1586
|
+
// captures the flow's own declared `submitEndpointPattern` matches — the
|
|
1587
|
+
// report's own preferred anchor, and the only signal here with no false
|
|
1588
|
+
// positives by construction. No declared endpoint pattern gives no
|
|
1589
|
+
// authoritative reference, so this pass is a no-op — mirroring
|
|
1590
|
+
// compileSubmitMatcher's own null-pattern passthrough — rather than
|
|
1591
|
+
// guessing at a reference set.
|
|
1592
|
+
if (!hasSubmitEndpointAnchor)
|
|
1593
|
+
return structurallyGated;
|
|
1594
|
+
const endpointRx = new RegExp(submitPatterns.endpoint);
|
|
1595
|
+
const referencePaths = structurallyGated
|
|
1596
|
+
.filter(({ capture }) => endpointRx.test(capture.url))
|
|
1597
|
+
.map(({ capture }) => safeUrlPathname(capture.url));
|
|
1598
|
+
if (referencePaths.length === 0)
|
|
1599
|
+
return structurallyGated;
|
|
1600
|
+
return structurallyGated.filter(({ capture }) => endpointRx.test(capture.url) ||
|
|
1601
|
+
(0, capture_filters_1.isStructurallyRelevantCapture)(safeUrlPathname(capture.url), referencePaths));
|
|
1534
1602
|
}
|
|
1535
1603
|
/**
|
|
1536
1604
|
* GraphQL-aware analog of {@link extractActionSequence}: REST's URL/method
|
|
@@ -1555,6 +1623,18 @@ function extractActionSequence(captures, submitPatterns = null, foldReturnSpec =
|
|
|
1555
1623
|
* `resolveFoldPlan` with no primary capture to resolve `resultsPath`
|
|
1556
1624
|
* against. Every other non-mutation capture is still dropped.
|
|
1557
1625
|
*
|
|
1626
|
+
* The fold-return matchers above only look at a capture's URL/response
|
|
1627
|
+
* shape, not at whether it belongs to this flow's own endpoint family — a
|
|
1628
|
+
* same-host marketing query can pass them purely by shape coincidence, the
|
|
1629
|
+
* same gap {@link extractActionSequence} closes with
|
|
1630
|
+
* {@link isStructurallyRelevantCapture}. So once host-provenance data is
|
|
1631
|
+
* available, a non-mutation capture admitted only via the fold-return
|
|
1632
|
+
* matchers is kept only when its path shares structural tokens with at
|
|
1633
|
+
* least one admitted mutation's path — mutations are always genuine flow
|
|
1634
|
+
* steps, so they anchor what "this flow's family" means. When no mutation
|
|
1635
|
+
* was admitted there is nothing to anchor against, so the fold-return
|
|
1636
|
+
* admissions above stand unnarrowed.
|
|
1637
|
+
*
|
|
1558
1638
|
* Exported for tests: this predicate decides what a generated GraphQL plugin
|
|
1559
1639
|
* will send at a live site.
|
|
1560
1640
|
*/
|
|
@@ -1568,7 +1648,8 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
|
|
|
1568
1648
|
// only applies once the caller has actually resolved a notion of "own
|
|
1569
1649
|
// backend" to check against.
|
|
1570
1650
|
const hasHostProvenance = ownBackendHostnames.length > 0 || fallbackDomain !== null;
|
|
1571
|
-
|
|
1651
|
+
const isMutation = (capture) => capture.query !== null && /^\s*mutation\b/.test(capture.query);
|
|
1652
|
+
const admitted = captures
|
|
1572
1653
|
.map((capture, index) => ({ capture, index }))
|
|
1573
1654
|
.filter(({ capture }) => {
|
|
1574
1655
|
if (capture.status < 200 || capture.status >= 300)
|
|
@@ -1580,10 +1661,19 @@ function extractGraphQLActionSequence(captures, submitPatterns = null, foldRetur
|
|
|
1580
1661
|
if (hasHostProvenance &&
|
|
1581
1662
|
!(0, capture_filters_1.isAllowedFixtureHost)(captureHostname(capture.url), ownBackendHostnames, fallbackDomain))
|
|
1582
1663
|
return false;
|
|
1583
|
-
if (
|
|
1664
|
+
if (isMutation(capture))
|
|
1584
1665
|
return true;
|
|
1585
1666
|
return matchesFoldReturn(capture) || matchesFoldReturnResults(capture);
|
|
1586
1667
|
});
|
|
1668
|
+
if (!hasHostProvenance)
|
|
1669
|
+
return admitted;
|
|
1670
|
+
const mutationPaths = admitted
|
|
1671
|
+
.filter(({ capture }) => isMutation(capture))
|
|
1672
|
+
.map(({ capture }) => capturePathname(capture.url));
|
|
1673
|
+
if (mutationPaths.length === 0)
|
|
1674
|
+
return admitted;
|
|
1675
|
+
return admitted.filter(({ capture }) => isMutation(capture) ||
|
|
1676
|
+
(0, capture_filters_1.isStructurallyRelevantCapture)(capturePathname(capture.url), mutationPaths));
|
|
1587
1677
|
}
|
|
1588
1678
|
/**
|
|
1589
1679
|
* Identity of a capture's response shape for dedup purposes: the endpoint it
|
|
@@ -7820,14 +7910,23 @@ export { ${camel}Plugin as plugin };
|
|
|
7820
7910
|
* defect 1) — this generalizes the check past ClickUrl so any future
|
|
7821
7911
|
* required URL field regresses loudly instead of silently.
|
|
7822
7912
|
*/
|
|
7823
|
-
|
|
7913
|
+
/**
|
|
7914
|
+
* The field-name computation {@link assertRequiredUrlFieldsReferenced} guards
|
|
7915
|
+
* with. Exposed separately so a caller catching the guard's failure can
|
|
7916
|
+
* re-derive the exact offending field names to drive a narrowed retry,
|
|
7917
|
+
* instead of parsing them back out of the thrown error's message.
|
|
7918
|
+
*/
|
|
7919
|
+
function unreferencedRequiredUrlFields(contractCode, browserFlowCode) {
|
|
7824
7920
|
const urlFieldLinePattern = /^\s*(\w*Url\w*):\s*z\.\w+\(.*$/gm;
|
|
7825
7921
|
const emittedCode = `${contractCode}\n${browserFlowCode}`;
|
|
7826
|
-
|
|
7922
|
+
return [...contractCode.matchAll(urlFieldLinePattern)]
|
|
7827
7923
|
.filter((match) => !match[0].includes(".optional("))
|
|
7828
7924
|
.map((match) => match[1])
|
|
7829
7925
|
.filter((name) => name !== undefined)
|
|
7830
7926
|
.filter((name) => !emittedCode.includes(`payload.${name}`));
|
|
7927
|
+
}
|
|
7928
|
+
function assertRequiredUrlFieldsReferenced(contractCode, browserFlowCode) {
|
|
7929
|
+
const unreferenced = unreferencedRequiredUrlFields(contractCode, browserFlowCode);
|
|
7831
7930
|
if (unreferenced.length === 0)
|
|
7832
7931
|
return;
|
|
7833
7932
|
throw new Error(`recon-generate: required URL field(s) ${unreferenced.join(", ")} declared on the payload schema ` +
|
|
@@ -8321,6 +8420,124 @@ async function resolveFormSchema(specifier) {
|
|
|
8321
8420
|
logger.info(`form-schema: ${specifier === load_form_schema_1.FORM_SCHEMA_NONE ? "none (no ATS form recovery)" : `custom keys from ${specifier}`}`);
|
|
8322
8421
|
return formSchema;
|
|
8323
8422
|
}
|
|
8423
|
+
/**
|
|
8424
|
+
* The capture whose own response `assertRequiredUrlFieldsReferenced`'s
|
|
8425
|
+
* scan actually describes: a submission flow returns
|
|
8426
|
+
* {@link selectReturnAction}'s pick (the terminal/return call
|
|
8427
|
+
* `executeHttp` hands back); a read flow returns `winningCapture`. This is
|
|
8428
|
+
* the one capture the narrowing pass must never exclude — if an offending
|
|
8429
|
+
* field's sole source turns out to be this capture, the field is genuinely
|
|
8430
|
+
* required and the guard must still hard-fail.
|
|
8431
|
+
*/
|
|
8432
|
+
function resolvedPrimaryResponseCapture(result) {
|
|
8433
|
+
if (!result.isSubmissionFlow)
|
|
8434
|
+
return result.winningCapture;
|
|
8435
|
+
return selectReturnAction(result.actionSteps)?.capture ?? null;
|
|
8436
|
+
}
|
|
8437
|
+
/** True when `capture`'s own top-level response JSON is the field's source —
|
|
8438
|
+
* the same "does this capture's response carry the field" signal
|
|
8439
|
+
* {@link assertRequiredUrlFieldsReferenced} implicitly relies on, applied per
|
|
8440
|
+
* capture instead of to the merged emitted code. */
|
|
8441
|
+
function captureOwnsTopLevelField(capture, fieldName) {
|
|
8442
|
+
const body = capture.responseBody;
|
|
8443
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
8444
|
+
return false;
|
|
8445
|
+
return Object.hasOwn(body, fieldName);
|
|
8446
|
+
}
|
|
8447
|
+
/**
|
|
8448
|
+
* Builds the "field(s) ... capture(s) ..." detail the intent requires a
|
|
8449
|
+
* surviving hard-fail to name — {@link assertRequiredUrlFieldsReferenced}
|
|
8450
|
+
* itself only knows field names (it never sees the resolved pool), so this
|
|
8451
|
+
* is what turns that into an actionable "which capture" answer when the
|
|
8452
|
+
* narrowing pass can't clear the violation.
|
|
8453
|
+
*/
|
|
8454
|
+
function describeOffendingFieldSources(fields, pool) {
|
|
8455
|
+
return fields
|
|
8456
|
+
.map((fieldName) => {
|
|
8457
|
+
const sourceUrls = pool
|
|
8458
|
+
.filter(({ capture }) => captureOwnsTopLevelField(capture, fieldName))
|
|
8459
|
+
.map(({ capture }) => capture.url);
|
|
8460
|
+
return sourceUrls.length > 0
|
|
8461
|
+
? `${fieldName} (from ${sourceUrls.join(", ")})`
|
|
8462
|
+
: `${fieldName} (source capture not found in resolved pool)`;
|
|
8463
|
+
})
|
|
8464
|
+
.join(", ");
|
|
8465
|
+
}
|
|
8466
|
+
/**
|
|
8467
|
+
* The narrowing pass's core relevance decision, isolated for direct
|
|
8468
|
+
* testing: every capture in `resolvedPool` — other than `primaryCapture`,
|
|
8469
|
+
* which must never be dropped — whose own top-level response JSON owns at
|
|
8470
|
+
* least one of `offendingFields`. This is the actual "is this capture
|
|
8471
|
+
* structurally part of the resolved chain or incidental noise" call;
|
|
8472
|
+
* {@link healUnreferencedUrlFieldsOnce} only wires it into the regenerate
|
|
8473
|
+
* retry loop.
|
|
8474
|
+
*
|
|
8475
|
+
* Exported for tests: this predicate decides which captures the required-
|
|
8476
|
+
* URL-field guard's self-heal excludes before regenerating.
|
|
8477
|
+
*/
|
|
8478
|
+
function identifyNoiseCapturesForFields(offendingFields, resolvedPool, primaryCapture) {
|
|
8479
|
+
const noiseCaptures = new Set();
|
|
8480
|
+
for (const fieldName of offendingFields) {
|
|
8481
|
+
for (const { capture } of resolvedPool) {
|
|
8482
|
+
if (capture === primaryCapture)
|
|
8483
|
+
continue;
|
|
8484
|
+
if (captureOwnsTopLevelField(capture, fieldName))
|
|
8485
|
+
noiseCaptures.add(capture);
|
|
8486
|
+
}
|
|
8487
|
+
}
|
|
8488
|
+
return noiseCaptures;
|
|
8489
|
+
}
|
|
8490
|
+
/**
|
|
8491
|
+
* When `assertRequiredUrlFieldsReferenced` would abort on the first pass,
|
|
8492
|
+
* this identifies the capture(s) in the resolved pool that are the SOLE
|
|
8493
|
+
* source of each offending field — excluding the resolved submit/primary
|
|
8494
|
+
* capture, which must never be dropped — removes exactly those captures from
|
|
8495
|
+
* the pool driving generation, and re-runs `generateFromCaptures` exactly
|
|
8496
|
+
* once. Hard-fails (naming the field(s) and, when found, the offending
|
|
8497
|
+
* capture URL(s)) if the violation survives the narrowed pool: either no
|
|
8498
|
+
* noise capture explains it (so nothing was excluded and re-running would be
|
|
8499
|
+
* a no-op), or the field's only source is the resolved primary capture
|
|
8500
|
+
* itself, meaning the field is genuinely required.
|
|
8501
|
+
*/
|
|
8502
|
+
function healUnreferencedUrlFieldsOnce(allCaptures, firstAttempt, regenerate) {
|
|
8503
|
+
const offending = unreferencedRequiredUrlFields(firstAttempt.contractCode, firstAttempt.browserFlow.code);
|
|
8504
|
+
if (offending.length === 0)
|
|
8505
|
+
return firstAttempt;
|
|
8506
|
+
const primaryCapture = resolvedPrimaryResponseCapture(firstAttempt);
|
|
8507
|
+
const noiseCaptures = identifyNoiseCapturesForFields(offending, firstAttempt.resolvedPool, primaryCapture);
|
|
8508
|
+
if (noiseCaptures.size === 0) {
|
|
8509
|
+
throw new Error(`recon-generate: required URL field(s) ${describeOffendingFieldSources(offending, firstAttempt.resolvedPool)} ` +
|
|
8510
|
+
`declared on the payload schema but never referenced by the emitted contract or browser flow, and no ` +
|
|
8511
|
+
`unrelated capture explains them — the field is genuinely required by the resolved submit/primary capture ` +
|
|
8512
|
+
`itself; the flow would enter on the wrong page`);
|
|
8513
|
+
}
|
|
8514
|
+
logger.warn(`required URL field(s) ${offending.join(", ")} traced to ${noiseCaptures.size} unrelated capture(s) ` +
|
|
8515
|
+
`(${[...noiseCaptures].map((c) => c.url).join(", ")}) not referenced or threaded by the resolved action ` +
|
|
8516
|
+
`sequence — excluding them and re-generating once`);
|
|
8517
|
+
// Excluded by INDEX into allCaptures, not by capture reference: a
|
|
8518
|
+
// resolvedPool entry produced by the form-schema-fetch insertion (see
|
|
8519
|
+
// generateFromCaptures' schemaFetchCleaned) wraps a shallow clone of its
|
|
8520
|
+
// source capture (url stripped of cache-buster params), so that entry's
|
|
8521
|
+
// `.capture` can never be `===` anything in allCaptures even though its
|
|
8522
|
+
// `.index` still correctly names the source capture's position — every
|
|
8523
|
+
// ActionCapture-producing extractor in this file threads `index` from the
|
|
8524
|
+
// original captures array, so it survives the clone where the reference
|
|
8525
|
+
// doesn't.
|
|
8526
|
+
const noiseIndices = new Set(firstAttempt.resolvedPool.filter((a) => noiseCaptures.has(a.capture)).map((a) => a.index));
|
|
8527
|
+
const narrowedCaptures = allCaptures.filter((_, i) => !noiseIndices.has(i));
|
|
8528
|
+
const retried = regenerate(narrowedCaptures);
|
|
8529
|
+
if (retried === null) {
|
|
8530
|
+
throw new Error("recon-generate: narrowed retry unexpectedly resolved to the config-manifest branch");
|
|
8531
|
+
}
|
|
8532
|
+
const stillOffending = unreferencedRequiredUrlFields(retried.contractCode, retried.browserFlow.code);
|
|
8533
|
+
if (stillOffending.length > 0) {
|
|
8534
|
+
throw new Error(`recon-generate: required URL field(s) ${describeOffendingFieldSources(stillOffending, retried.resolvedPool)} ` +
|
|
8535
|
+
`declared on the payload schema but never referenced by the emitted contract or browser flow, even after ` +
|
|
8536
|
+
`excluding unrelated capture(s) (${[...noiseCaptures].map((c) => c.url).join(", ")}) — the flow would ` +
|
|
8537
|
+
`enter on the wrong page`);
|
|
8538
|
+
}
|
|
8539
|
+
return retried;
|
|
8540
|
+
}
|
|
8324
8541
|
async function main() {
|
|
8325
8542
|
const args = process.argv.slice(2);
|
|
8326
8543
|
let siteId = "";
|
|
@@ -8491,428 +8708,454 @@ async function main() {
|
|
|
8491
8708
|
// isn't available to deriveBaseUrl yet -- see deriveBaseUrl's own doc
|
|
8492
8709
|
// comment for how it copes when ownBackendHostnames is empty.
|
|
8493
8710
|
const ownBackendHostnames = (0, recon_shared_1.readOwnBackendHostnames)(flowFile);
|
|
8494
|
-
|
|
8495
|
-
|
|
8496
|
-
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
.
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
const unmanifestedFiles = (() => {
|
|
8518
|
-
try {
|
|
8519
|
-
return (0, node_fs_1.readdirSync)(auxDir).filter((f) => f.endsWith(".json") && f !== "aux-manifest.json" && !manifestedFilenames.has(f));
|
|
8520
|
-
}
|
|
8521
|
-
catch {
|
|
8522
|
-
return [];
|
|
8523
|
-
}
|
|
8524
|
-
})();
|
|
8525
|
-
for (const f of unmanifestedFiles) {
|
|
8526
|
-
logger.warn(`excluding aux fixture '${f}' — no aux-manifest.json entry, provenance unverifiable`);
|
|
8527
|
-
}
|
|
8528
|
-
const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain);
|
|
8529
|
-
const minTime = deriveMinTime(rateLimits);
|
|
8530
|
-
const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
|
|
8531
|
-
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
8532
|
-
const gql = isGraphQL(captures);
|
|
8533
|
-
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
8534
|
-
// (further down) read the same computed sequence instead of calling the
|
|
8535
|
-
// extractor twice. Computed unfiltered (submitPatterns: null) — a
|
|
8536
|
-
// flow-declared submit pattern must truncate this sequence, not filter
|
|
8537
|
-
// it, so every gate reading it sees the full host-gated chain.
|
|
8538
|
-
const graphqlActionSequence = gql
|
|
8539
|
-
? extractGraphQLActionSequence(captures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)
|
|
8540
|
-
: [];
|
|
8541
|
-
// A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
|
|
8542
|
-
// doc comment) can put 2+ entries in graphqlActionSequence with none of them
|
|
8543
|
-
// an actual `mutation` — a GraphQL-primary query plus its drill-down, not a
|
|
8544
|
-
// transactional multi-step submission. Only a real mutation makes this a
|
|
8545
|
-
// submission flow; an admitted read/drill capture must not, on its own, null
|
|
8546
|
-
// out primaryGraphQLOperation below or flip isSubmissionFlow further down.
|
|
8547
|
-
const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
|
|
8548
|
-
const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
|
|
8549
|
-
? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
8550
|
-
: null;
|
|
8551
|
-
if (primaryGraphQLOperation && primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
|
|
8552
|
-
const operationLabel = primaryGraphQLOperation.capture.operationName ?? "(anonymous)";
|
|
8553
|
-
logger.warn(`WARN primary GraphQL operation '${operationLabel}' declares filter variable(s) ${primaryGraphQLOperation.unpopulatedDeclaredVariables.join(", ")} that are never populated in any capture — the generated executeHttp's payload field(s) for ${primaryGraphQLOperation.unpopulatedDeclaredVariables.join(", ")} have no wiring target and will replay the captured frozen value instead of the caller's input`);
|
|
8554
|
-
}
|
|
8555
|
-
// When there's no primaryGraphQLOperation winner but the flow is still
|
|
8556
|
-
// GraphQL, the query/endpointPath/responseBody/operationName fallbacks
|
|
8557
|
-
// must all trace back to the SAME capture (firstGraphQLCapture) rather
|
|
8558
|
-
// than resolving independently — a non-GraphQL-shaped own-backend
|
|
8559
|
-
// capture could otherwise win the endpoint/body fallback while an
|
|
8560
|
-
// unrelated capture supplies the query text.
|
|
8561
|
-
const fallbackGraphQLCapture = gql
|
|
8562
|
-
? firstGraphQLCapture(captures, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
8563
|
-
: null;
|
|
8564
|
-
const gqlQuery = primaryGraphQLOperation?.capture.query ?? fallbackGraphQLCapture?.query ?? null;
|
|
8565
|
-
const endpointPath = primaryGraphQLOperation?.endpointPath ??
|
|
8566
|
-
(fallbackGraphQLCapture
|
|
8567
|
-
? safeUrlPathname(fallbackGraphQLCapture.url)
|
|
8568
|
-
: firstEndpointPath(captures, ownBackendHostnames, fallbackDomain, submitPatterns));
|
|
8569
|
-
// Derived from the primary operation's own Phase-1 capture, never from
|
|
8570
|
-
// replay array order -- a replay's body reflects whichever endpoint fired
|
|
8571
|
-
// first, not necessarily the primary operation, and only exists once
|
|
8572
|
-
// recon:http has run.
|
|
8573
|
-
const winningCapture = primaryGraphQLOperation?.capture ??
|
|
8574
|
-
fallbackGraphQLCapture ??
|
|
8575
|
-
firstEndpointCapture(captures, ownBackendHostnames, fallbackDomain, submitPatterns);
|
|
8576
|
-
const responseBody = winningCapture?.responseBody ?? null;
|
|
8577
|
-
// Every 2xx capture sharing the winning capture's operation identity, not
|
|
8578
|
-
// just the one that happened to win selection -- a paginated/re-filtered
|
|
8579
|
-
// re-fire of the same operation can omit a field the winning capture
|
|
8580
|
-
// happened to have (or vice versa), and inferZodSchemaFromSamples needs
|
|
8581
|
-
// that presence evidence across occurrences to mark the field .optional()
|
|
8582
|
-
// instead of required from a single observation.
|
|
8583
|
-
const responseBodySamples = gatherResponseBodySamples(winningCapture, primaryGraphQLOperation !== null || fallbackGraphQLCapture !== null, captures);
|
|
8584
|
-
// Detect a multi-step submission flow (transactional sites like apply forms,
|
|
8585
|
-
// checkout, etc.). When the action sequence has 2+ POSTs, switch the
|
|
8586
|
-
// contract template to emit a state-threaded executeHttp.
|
|
8587
|
-
//
|
|
8588
|
-
// Selection precedence: (A) the authoritative submit-manifest recon-browser
|
|
8589
|
-
// wrote from the verified submission; else (B/C) pattern/heuristic extraction.
|
|
8590
|
-
// The manifest is the only signal that separates a submission POST from a
|
|
8591
|
-
// page-chrome POST sharing its URL, so it normally wins when present — but
|
|
8592
|
-
// a manifest built from a single flow-declared submit step cannot represent
|
|
8593
|
-
// a wizard whose every section saves independently, so it is only trusted
|
|
8594
|
-
// when it isn't a strict undercount of what the same captures' own
|
|
8595
|
-
// heuristic extraction finds.
|
|
8596
|
-
const unfilteredHeuristicActionCaptures = gql
|
|
8597
|
-
? dedupRedundantSameOperationCaptures(graphqlActionSequence, primaryGraphQLOperation)
|
|
8598
|
-
: collapseRedundantPatches(extractActionSequence(captures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain));
|
|
8599
|
-
// A flow-declared submitEndpointPattern is authoritative: it may match only
|
|
8600
|
-
// the final step's URL (the natural way to describe "the button that
|
|
8601
|
-
// finishes the wizard") even though the earlier steps of the same chain
|
|
8602
|
-
// (auth mint, paged listing, ...) are what state-threading depends on.
|
|
8603
|
-
// Truncating at the last match — instead of filtering to matches only —
|
|
8604
|
-
// keeps that whole chain; the gap between this and the unfiltered
|
|
8605
|
-
// sequence is logged below for visibility, but the declared pattern is
|
|
8606
|
-
// never overridden by the richer unfiltered sequence.
|
|
8607
|
-
const patternedHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
|
|
8608
|
-
? unfilteredHeuristicActionCaptures
|
|
8609
|
-
: truncateActionSequenceAtSubmitPattern(unfilteredHeuristicActionCaptures, submitPatterns);
|
|
8610
|
-
const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
|
|
8611
|
-
if (patternUndercounts && requireSubmitEndpointMatch) {
|
|
8612
|
-
// Distinct wording from the non-required case below: this pattern is never
|
|
8613
|
-
// discarded, so a message that says "ignoring"/"undercount" would misstate
|
|
8614
|
-
// what happened. The disagreement is still worth a warn-level surface —
|
|
8615
|
-
// the flow author should know the declared pattern covers fewer captures
|
|
8616
|
-
// than the unfiltered heuristic sequence finds.
|
|
8617
|
-
logger.warn(`submission selection: declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) disagrees with the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s)); using the declared pattern because requireSubmitEndpointMatch is set`);
|
|
8618
|
-
}
|
|
8619
|
-
else if (patternUndercounts) {
|
|
8620
|
-
logger.info(`submission selection: ignoring submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) as an undercount of the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s))`);
|
|
8621
|
-
}
|
|
8622
|
-
const heuristicActionCaptures = patternedHeuristicActionCaptures;
|
|
8623
|
-
const manifestActionCaptures = resolveManifestActionSequence(runRoot, captures);
|
|
8624
|
-
const manifestUndercounts = manifestActionCaptures !== null &&
|
|
8625
|
-
manifestActionCaptures.length < heuristicActionCaptures.length;
|
|
8626
|
-
if (manifestActionCaptures !== null && manifestUndercounts) {
|
|
8627
|
-
logger.info(`submission selection: ignoring submit-manifest.json (${manifestActionCaptures.length} capture(s)) as an undercount of the heuristic action sequence (${heuristicActionCaptures.length} capture(s))`);
|
|
8628
|
-
}
|
|
8629
|
-
else if (manifestActionCaptures !== null) {
|
|
8630
|
-
logger.info(`submission selection: using submit-manifest.json (${manifestActionCaptures.length} authoritative capture(s))`);
|
|
8631
|
-
}
|
|
8632
|
-
const rawActionCaptures = manifestActionCaptures !== null && !manifestUndercounts
|
|
8633
|
-
? manifestActionCaptures
|
|
8634
|
-
: heuristicActionCaptures;
|
|
8635
|
-
// Form-schema detection runs BEFORE state-indexing so the field-id/option-id
|
|
8636
|
-
// UUIDs can be shielded from indexing — those UUIDs are stable schema
|
|
8637
|
-
// anchors that T2/T3 substitution depends on remaining literal in body
|
|
8638
|
-
// templates.
|
|
8639
|
-
const { fieldNameMap, fieldOptionsMap, allSchemaUuids } = detectFormSchemaFieldNames(captures, formSchema);
|
|
8640
|
-
// Shield ALL field-id/option-id UUIDs that appear in any schema response, not
|
|
8641
|
-
// just the ones that detectFormSchemaFieldNames emits a payload name for.
|
|
8642
|
-
// Some fields have names too long for the naming heuristic (>80 chars) and
|
|
8643
|
-
// would be skipped by fieldNameMap; their field-ids still need shielding
|
|
8644
|
-
// because they appear as anchors in the T2-substituted body templates.
|
|
8645
|
-
const shieldedUuids = new Set(allSchemaUuids);
|
|
8646
|
-
// Persona identity bindings + entry-URL job coordinates — the value→payload
|
|
8647
|
-
// reconciliation the body emitter merges into its substitution map so nested
|
|
8648
|
-
// applicant fields and job context reach the caller's data instead of the
|
|
8649
|
-
// recon persona's. Both are site-agnostic: persona mapping comes from the
|
|
8650
|
-
// consumer vocabulary, job coordinates from the entry URL's own query keys.
|
|
8651
|
-
const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
|
|
8652
|
-
const entryUrlParams = extractEntryUrlParams(captures[0]?.url ?? "");
|
|
8653
|
-
// T4 — Phase B+C: detect a form-schema GET capture and insert it into the
|
|
8654
|
-
// action sequence at the position observed during recon, so the existing
|
|
8655
|
-
// state-threading machinery can produce its FormHistoryId / section UUIDs /
|
|
8656
|
-
// etc. as state values for downstream POSTs. Strip cache-buster query
|
|
8657
|
-
// params (recon timestamps) from the captured URL so the emitted runtime
|
|
8658
|
-
// fetch uses a clean template. Sites without a schema-fetch capture
|
|
8659
|
-
// (rawSchemaFetch === null) get unchanged behavior.
|
|
8660
|
-
const rawSchemaFetch = gql || formSchema === null ? null : detectFormSchemaFetchCapture(captures, baseUrl, formSchema);
|
|
8661
|
-
const schemaFetchCleaned = rawSchemaFetch
|
|
8662
|
-
? { ...rawSchemaFetch.capture, url: stripCacheBusterParams(rawSchemaFetch.capture.url) }
|
|
8663
|
-
: null;
|
|
8664
|
-
const actionCaptures = (() => {
|
|
8665
|
-
if (rawActionCaptures.length === 0 || schemaFetchCleaned === null || rawSchemaFetch === null) {
|
|
8666
|
-
return rawActionCaptures;
|
|
8667
|
-
}
|
|
8668
|
-
let insertAt = rawActionCaptures.length;
|
|
8669
|
-
for (let i = 0; i < rawActionCaptures.length; i++) {
|
|
8670
|
-
if (rawActionCaptures[i].index >= rawSchemaFetch.index) {
|
|
8671
|
-
insertAt = i;
|
|
8672
|
-
break;
|
|
8711
|
+
/**
|
|
8712
|
+
* Runs the capture-to-contract pipeline once for a given capture pool.
|
|
8713
|
+
* Nested (not top-level) so `assertRequiredUrlFieldsReferenced`'s
|
|
8714
|
+
* self-healing retry can invoke it a second time against a narrowed
|
|
8715
|
+
* `activeCaptures` without duplicating ~500 lines of deterministic
|
|
8716
|
+
* derivation logic. Returns null once the config-manifest branch has
|
|
8717
|
+
* already written its output and there is nothing left for main() to do.
|
|
8718
|
+
*/
|
|
8719
|
+
function generateFromCaptures(activeCaptures) {
|
|
8720
|
+
const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames);
|
|
8721
|
+
// Gate on the flow's declared own-backend hosts (or the registrable-domain
|
|
8722
|
+
// fallback of baseUrl) via the same predicate recon-http.ts applies at
|
|
8723
|
+
// write time, so a stale aux/ directory from before that filter existed
|
|
8724
|
+
// — or one written by a filter someone bypassed — can never smuggle a
|
|
8725
|
+
// third party's JSON into a generated plugin's fixtures/. A file with no
|
|
8726
|
+
// manifest entry is unverifiable provenance, not proven safe, so it is
|
|
8727
|
+
// excluded rather than assumed to have passed the write-time filter.
|
|
8728
|
+
const fallbackDomain = baseUrl.length > 0 ? (0, capture_filters_1.registrableDomain)(new URL(baseUrl).hostname) : null;
|
|
8729
|
+
const auxFiles = auxManifest
|
|
8730
|
+
.filter((entry) => {
|
|
8731
|
+
const allowed = (0, capture_filters_1.isAllowedFixtureHost)(entry.hostname, ownBackendHostnames, fallbackDomain);
|
|
8732
|
+
if (!allowed) {
|
|
8733
|
+
logger.warn(`excluding aux fixture '${entry.filename}' — host '${entry.hostname}' is not an own-backend host`);
|
|
8673
8734
|
}
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
// each action's capture, so this runs before compileActionSteps/
|
|
8684
|
-
// indexStateValues even exist — so a short numeric join value threaded
|
|
8685
|
-
// through a dependent-drill-down chain hop still gets indexed as
|
|
8686
|
-
// producible state (see collectDependentDrillDownChainValues).
|
|
8687
|
-
const dependentDrillDownChainValues = actionCaptures.length > 1
|
|
8688
|
-
? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
|
|
8689
|
-
: new Set();
|
|
8690
|
-
const stateIndex = actionCaptures.length > 1
|
|
8691
|
-
? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
8692
|
-
: new Map();
|
|
8693
|
-
const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
|
|
8694
|
-
const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
|
|
8695
|
-
// Diagnostic for the FAILURE-3 shape (a flowless recon capture): a "submission flow"
|
|
8696
|
-
// whose every action capture is landing-phase is almost certainly page-chrome
|
|
8697
|
-
// bootstrap (e.g. page-chrome `POST /widgets`) misread as an apply flow, not a walked
|
|
8698
|
-
// wizard. Real wizard steps carry a step-slug phase; single-endpoint search runs
|
|
8699
|
-
// are `length <= 1` and never reach here. We do not filter (that would delete the
|
|
8700
|
-
// sole search POST of legitimate `--url`-only single-endpoint runs, which is also
|
|
8701
|
-
// landing-phase) — we only surface the suspicious shape.
|
|
8702
|
-
if (isSubmissionFlow && actionCaptures.every((a) => a.capture.phase === "home")) {
|
|
8703
|
-
logger.warn(`WARN all ${actionCaptures.length} action captures are landing-phase (phase="home") — this may be page-chrome bootstrap misread as a submission flow, not a walked apply wizard; verify the recon --flow actually advanced the form`);
|
|
8704
|
-
}
|
|
8705
|
-
const inputBody = isSubmissionFlow
|
|
8706
|
-
? (() => {
|
|
8735
|
+
return allowed;
|
|
8736
|
+
})
|
|
8737
|
+
.map((entry) => entry.filename)
|
|
8738
|
+
.sort();
|
|
8739
|
+
// A file on disk with no manifest entry predates the write-time provenance
|
|
8740
|
+
// record (bugfix-002) or otherwise landed outside probeAuxiliaryEndpoints —
|
|
8741
|
+
// its source host is unverifiable, so it is excluded, not assumed safe.
|
|
8742
|
+
const manifestedFilenames = new Set(auxManifest.map((entry) => entry.filename));
|
|
8743
|
+
const unmanifestedFiles = (() => {
|
|
8707
8744
|
try {
|
|
8708
|
-
|
|
8709
|
-
return JSON.parse(payloadAction?.capture.requestPostData ?? "null");
|
|
8745
|
+
return (0, node_fs_1.readdirSync)(auxDir).filter((f) => f.endsWith(".json") && f !== "aux-manifest.json" && !manifestedFilenames.has(f));
|
|
8710
8746
|
}
|
|
8711
8747
|
catch {
|
|
8712
|
-
return
|
|
8748
|
+
return [];
|
|
8749
|
+
}
|
|
8750
|
+
})();
|
|
8751
|
+
for (const f of unmanifestedFiles) {
|
|
8752
|
+
logger.warn(`excluding aux fixture '${f}' — no aux-manifest.json entry, provenance unverifiable`);
|
|
8753
|
+
}
|
|
8754
|
+
const baseHeaders = deriveRequestHeaders(activeCaptures, replays, baseUrl, submitPatterns, ownBackendHostnames, fallbackDomain);
|
|
8755
|
+
const minTime = deriveMinTime(rateLimits);
|
|
8756
|
+
const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
|
|
8757
|
+
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
8758
|
+
const gql = isGraphQL(activeCaptures);
|
|
8759
|
+
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
8760
|
+
// (further down) read the same computed sequence instead of calling the
|
|
8761
|
+
// extractor twice. Computed unfiltered (submitPatterns: null) — a
|
|
8762
|
+
// flow-declared submit pattern must truncate this sequence, not filter
|
|
8763
|
+
// it, so every gate reading it sees the full host-gated chain.
|
|
8764
|
+
const graphqlActionSequence = gql
|
|
8765
|
+
? extractGraphQLActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain)
|
|
8766
|
+
: [];
|
|
8767
|
+
// A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
|
|
8768
|
+
// doc comment) can put 2+ entries in graphqlActionSequence with none of them
|
|
8769
|
+
// an actual `mutation` — a GraphQL-primary query plus its drill-down, not a
|
|
8770
|
+
// transactional multi-step submission. Only a real mutation makes this a
|
|
8771
|
+
// submission flow; an admitted read/drill capture must not, on its own, null
|
|
8772
|
+
// out primaryGraphQLOperation below or flip isSubmissionFlow further down.
|
|
8773
|
+
const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
|
|
8774
|
+
const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
|
|
8775
|
+
? selectPrimaryGraphQLOperation(activeCaptures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
8776
|
+
: null;
|
|
8777
|
+
if (primaryGraphQLOperation &&
|
|
8778
|
+
primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
|
|
8779
|
+
const operationLabel = primaryGraphQLOperation.capture.operationName ?? "(anonymous)";
|
|
8780
|
+
logger.warn(`WARN primary GraphQL operation '${operationLabel}' declares filter variable(s) ${primaryGraphQLOperation.unpopulatedDeclaredVariables.join(", ")} that are never populated in any capture — the generated executeHttp's payload field(s) for ${primaryGraphQLOperation.unpopulatedDeclaredVariables.join(", ")} have no wiring target and will replay the captured frozen value instead of the caller's input`);
|
|
8781
|
+
}
|
|
8782
|
+
// When there's no primaryGraphQLOperation winner but the flow is still
|
|
8783
|
+
// GraphQL, the query/endpointPath/responseBody/operationName fallbacks
|
|
8784
|
+
// must all trace back to the SAME capture (firstGraphQLCapture) rather
|
|
8785
|
+
// than resolving independently — a non-GraphQL-shaped own-backend
|
|
8786
|
+
// capture could otherwise win the endpoint/body fallback while an
|
|
8787
|
+
// unrelated capture supplies the query text.
|
|
8788
|
+
const fallbackGraphQLCapture = gql
|
|
8789
|
+
? firstGraphQLCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns)
|
|
8790
|
+
: null;
|
|
8791
|
+
const gqlQuery = primaryGraphQLOperation?.capture.query ?? fallbackGraphQLCapture?.query ?? null;
|
|
8792
|
+
const endpointPath = primaryGraphQLOperation?.endpointPath ??
|
|
8793
|
+
(fallbackGraphQLCapture
|
|
8794
|
+
? safeUrlPathname(fallbackGraphQLCapture.url)
|
|
8795
|
+
: firstEndpointPath(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns));
|
|
8796
|
+
// Derived from the primary operation's own Phase-1 capture, never from
|
|
8797
|
+
// replay array order -- a replay's body reflects whichever endpoint fired
|
|
8798
|
+
// first, not necessarily the primary operation, and only exists once
|
|
8799
|
+
// recon:http has run.
|
|
8800
|
+
const winningCapture = primaryGraphQLOperation?.capture ??
|
|
8801
|
+
fallbackGraphQLCapture ??
|
|
8802
|
+
firstEndpointCapture(activeCaptures, ownBackendHostnames, fallbackDomain, submitPatterns);
|
|
8803
|
+
const responseBody = winningCapture?.responseBody ?? null;
|
|
8804
|
+
// Every 2xx capture sharing the winning capture's operation identity, not
|
|
8805
|
+
// just the one that happened to win selection -- a paginated/re-filtered
|
|
8806
|
+
// re-fire of the same operation can omit a field the winning capture
|
|
8807
|
+
// happened to have (or vice versa), and inferZodSchemaFromSamples needs
|
|
8808
|
+
// that presence evidence across occurrences to mark the field .optional()
|
|
8809
|
+
// instead of required from a single observation.
|
|
8810
|
+
const responseBodySamples = gatherResponseBodySamples(winningCapture, primaryGraphQLOperation !== null || fallbackGraphQLCapture !== null, activeCaptures);
|
|
8811
|
+
// Detect a multi-step submission flow (transactional sites like apply forms,
|
|
8812
|
+
// checkout, etc.). When the action sequence has 2+ POSTs, switch the
|
|
8813
|
+
// contract template to emit a state-threaded executeHttp.
|
|
8814
|
+
//
|
|
8815
|
+
// Selection precedence: (A) the authoritative submit-manifest recon-browser
|
|
8816
|
+
// wrote from the verified submission; else (B/C) pattern/heuristic extraction.
|
|
8817
|
+
// The manifest is the only signal that separates a submission POST from a
|
|
8818
|
+
// page-chrome POST sharing its URL, so it normally wins when present — but
|
|
8819
|
+
// a manifest built from a single flow-declared submit step cannot represent
|
|
8820
|
+
// a wizard whose every section saves independently, so it is only trusted
|
|
8821
|
+
// when it isn't a strict undercount of what the same activeCaptures' own
|
|
8822
|
+
// heuristic extraction finds.
|
|
8823
|
+
const unfilteredHeuristicActionCaptures = gql
|
|
8824
|
+
? dedupRedundantSameOperationCaptures(graphqlActionSequence, primaryGraphQLOperation)
|
|
8825
|
+
: collapseRedundantPatches(extractActionSequence(activeCaptures, null, foldReturnSpec, ownBackendHostnames, fallbackDomain));
|
|
8826
|
+
// A flow-declared submitEndpointPattern is authoritative: it may match only
|
|
8827
|
+
// the final step's URL (the natural way to describe "the button that
|
|
8828
|
+
// finishes the wizard") even though the earlier steps of the same chain
|
|
8829
|
+
// (auth mint, paged listing, ...) are what state-threading depends on.
|
|
8830
|
+
// Truncating at the last match — instead of filtering to matches only —
|
|
8831
|
+
// keeps that whole chain; the gap between this and the unfiltered
|
|
8832
|
+
// sequence is logged below for visibility, but the declared pattern is
|
|
8833
|
+
// never overridden by the richer unfiltered sequence.
|
|
8834
|
+
const patternedHeuristicActionCaptures = submitPatterns.endpoint === null && submitPatterns.body === null
|
|
8835
|
+
? unfilteredHeuristicActionCaptures
|
|
8836
|
+
: truncateActionSequenceAtSubmitPattern(unfilteredHeuristicActionCaptures, submitPatterns);
|
|
8837
|
+
const patternUndercounts = patternedHeuristicActionCaptures.length < unfilteredHeuristicActionCaptures.length;
|
|
8838
|
+
if (patternUndercounts && requireSubmitEndpointMatch) {
|
|
8839
|
+
// Distinct wording from the non-required case below: this pattern is never
|
|
8840
|
+
// discarded, so a message that says "ignoring"/"undercount" would misstate
|
|
8841
|
+
// what happened. The disagreement is still worth a warn-level surface —
|
|
8842
|
+
// the flow author should know the declared pattern covers fewer activeCaptures
|
|
8843
|
+
// than the unfiltered heuristic sequence finds.
|
|
8844
|
+
logger.warn(`submission selection: declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) disagrees with the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s)); using the declared pattern because requireSubmitEndpointMatch is set`);
|
|
8845
|
+
}
|
|
8846
|
+
else if (patternUndercounts) {
|
|
8847
|
+
logger.info(`submission selection: ignoring submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) as an undercount of the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s))`);
|
|
8848
|
+
}
|
|
8849
|
+
const heuristicActionCaptures = patternedHeuristicActionCaptures;
|
|
8850
|
+
const manifestActionCaptures = resolveManifestActionSequence(runRoot, activeCaptures);
|
|
8851
|
+
const manifestUndercounts = manifestActionCaptures !== null &&
|
|
8852
|
+
manifestActionCaptures.length < heuristicActionCaptures.length;
|
|
8853
|
+
if (manifestActionCaptures !== null && manifestUndercounts) {
|
|
8854
|
+
logger.info(`submission selection: ignoring submit-manifest.json (${manifestActionCaptures.length} capture(s)) as an undercount of the heuristic action sequence (${heuristicActionCaptures.length} capture(s))`);
|
|
8855
|
+
}
|
|
8856
|
+
else if (manifestActionCaptures !== null) {
|
|
8857
|
+
logger.info(`submission selection: using submit-manifest.json (${manifestActionCaptures.length} authoritative capture(s))`);
|
|
8858
|
+
}
|
|
8859
|
+
const rawActionCaptures = manifestActionCaptures !== null && !manifestUndercounts
|
|
8860
|
+
? manifestActionCaptures
|
|
8861
|
+
: heuristicActionCaptures;
|
|
8862
|
+
// Form-schema detection runs BEFORE state-indexing so the field-id/option-id
|
|
8863
|
+
// UUIDs can be shielded from indexing — those UUIDs are stable schema
|
|
8864
|
+
// anchors that T2/T3 substitution depends on remaining literal in body
|
|
8865
|
+
// templates.
|
|
8866
|
+
const { fieldNameMap, fieldOptionsMap, allSchemaUuids } = detectFormSchemaFieldNames(activeCaptures, formSchema);
|
|
8867
|
+
// Shield ALL field-id/option-id UUIDs that appear in any schema response, not
|
|
8868
|
+
// just the ones that detectFormSchemaFieldNames emits a payload name for.
|
|
8869
|
+
// Some fields have names too long for the naming heuristic (>80 chars) and
|
|
8870
|
+
// would be skipped by fieldNameMap; their field-ids still need shielding
|
|
8871
|
+
// because they appear as anchors in the T2-substituted body templates.
|
|
8872
|
+
const shieldedUuids = new Set(allSchemaUuids);
|
|
8873
|
+
// Persona identity bindings + entry-URL job coordinates — the value→payload
|
|
8874
|
+
// reconciliation the body emitter merges into its substitution map so nested
|
|
8875
|
+
// applicant fields and job context reach the caller's data instead of the
|
|
8876
|
+
// recon persona's. Both are site-agnostic: persona mapping comes from the
|
|
8877
|
+
// consumer vocabulary, job coordinates from the entry URL's own query keys.
|
|
8878
|
+
const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
|
|
8879
|
+
const entryUrlParams = extractEntryUrlParams(activeCaptures[0]?.url ?? "");
|
|
8880
|
+
// T4 — Phase B+C: detect a form-schema GET capture and insert it into the
|
|
8881
|
+
// action sequence at the position observed during recon, so the existing
|
|
8882
|
+
// state-threading machinery can produce its FormHistoryId / section UUIDs /
|
|
8883
|
+
// etc. as state values for downstream POSTs. Strip cache-buster query
|
|
8884
|
+
// params (recon timestamps) from the captured URL so the emitted runtime
|
|
8885
|
+
// fetch uses a clean template. Sites without a schema-fetch capture
|
|
8886
|
+
// (rawSchemaFetch === null) get unchanged behavior.
|
|
8887
|
+
const rawSchemaFetch = gql || formSchema === null
|
|
8888
|
+
? null
|
|
8889
|
+
: detectFormSchemaFetchCapture(activeCaptures, baseUrl, formSchema);
|
|
8890
|
+
const schemaFetchCleaned = rawSchemaFetch
|
|
8891
|
+
? { ...rawSchemaFetch.capture, url: stripCacheBusterParams(rawSchemaFetch.capture.url) }
|
|
8892
|
+
: null;
|
|
8893
|
+
const actionCaptures = (() => {
|
|
8894
|
+
if (rawActionCaptures.length === 0 ||
|
|
8895
|
+
schemaFetchCleaned === null ||
|
|
8896
|
+
rawSchemaFetch === null) {
|
|
8897
|
+
return rawActionCaptures;
|
|
8898
|
+
}
|
|
8899
|
+
let insertAt = rawActionCaptures.length;
|
|
8900
|
+
for (let i = 0; i < rawActionCaptures.length; i++) {
|
|
8901
|
+
if (rawActionCaptures[i].index >= rawSchemaFetch.index) {
|
|
8902
|
+
insertAt = i;
|
|
8903
|
+
break;
|
|
8904
|
+
}
|
|
8905
|
+
}
|
|
8906
|
+
return [
|
|
8907
|
+
...rawActionCaptures.slice(0, insertAt),
|
|
8908
|
+
{ capture: schemaFetchCleaned, index: rawSchemaFetch.index },
|
|
8909
|
+
...rawActionCaptures.slice(insertAt),
|
|
8910
|
+
];
|
|
8911
|
+
})();
|
|
8912
|
+
const actionCaptureIndices = new Set(actionCaptures.map((a) => a.index));
|
|
8913
|
+
// Resolved off raw actionCaptures — fold-plan DETECTION depends only on
|
|
8914
|
+
// each action's capture, so this runs before compileActionSteps/
|
|
8915
|
+
// indexStateValues even exist — so a short numeric join value threaded
|
|
8916
|
+
// through a dependent-drill-down chain hop still gets indexed as
|
|
8917
|
+
// producible state (see collectDependentDrillDownChainValues).
|
|
8918
|
+
const dependentDrillDownChainValues = actionCaptures.length > 1
|
|
8919
|
+
? collectDependentDrillDownChainValues(actionCaptures, foldReturnSpec)
|
|
8920
|
+
: new Set();
|
|
8921
|
+
const stateIndex = actionCaptures.length > 1
|
|
8922
|
+
? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
8923
|
+
: new Map();
|
|
8924
|
+
const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
|
|
8925
|
+
const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
|
|
8926
|
+
// Diagnostic for the FAILURE-3 shape (a flowless recon capture): a "submission flow"
|
|
8927
|
+
// whose every action capture is landing-phase is almost certainly page-chrome
|
|
8928
|
+
// bootstrap (e.g. page-chrome `POST /widgets`) misread as an apply flow, not a walked
|
|
8929
|
+
// wizard. Real wizard steps carry a step-slug phase; single-endpoint search runs
|
|
8930
|
+
// are `length <= 1` and never reach here. We do not filter (that would delete the
|
|
8931
|
+
// sole search POST of legitimate `--url`-only single-endpoint runs, which is also
|
|
8932
|
+
// landing-phase) — we only surface the suspicious shape.
|
|
8933
|
+
if (isSubmissionFlow && actionCaptures.every((a) => a.capture.phase === "home")) {
|
|
8934
|
+
logger.warn(`WARN all ${actionCaptures.length} action captures are landing-phase (phase="home") — this may be page-chrome bootstrap misread as a submission flow, not a walked apply wizard; verify the recon --flow actually advanced the form`);
|
|
8935
|
+
}
|
|
8936
|
+
const inputBody = isSubmissionFlow
|
|
8937
|
+
? (() => {
|
|
8938
|
+
try {
|
|
8939
|
+
const payloadAction = selectPayloadAction(actionSteps);
|
|
8940
|
+
return JSON.parse(payloadAction?.capture.requestPostData ?? "null");
|
|
8941
|
+
}
|
|
8942
|
+
catch {
|
|
8943
|
+
return null;
|
|
8944
|
+
}
|
|
8945
|
+
})()
|
|
8946
|
+
: undefined;
|
|
8947
|
+
const errorSignals = detectErrorSignals(actionSteps);
|
|
8948
|
+
const discoveredFormFields = new Set();
|
|
8949
|
+
const discoveredOptionFields = new Set();
|
|
8950
|
+
// Phase E: maps label-derived raw-option payload field name (e.g.
|
|
8951
|
+
// "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
|
|
8952
|
+
// emit `<Name>OptionId: z.string()` payload fields with TSDoc docs.
|
|
8953
|
+
const discoveredRawOptionFields = new Map();
|
|
8954
|
+
// Phase F: keys from additional action POST bodies (beyond inputBody/r0)
|
|
8955
|
+
// that get parameterized. Recorded with their value type so the contract
|
|
8956
|
+
// emitter can add them to the payload schema with appropriate Zod types.
|
|
8957
|
+
const discoveredAdditionalBodyKeys = new Map();
|
|
8958
|
+
// Mechanism A: reconcile flow SELECT steps to submitted option codes. The
|
|
8959
|
+
// resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
|
|
8960
|
+
// i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
|
|
8961
|
+
// existing raw-option channel so their frozen code is still parameterized.
|
|
8962
|
+
const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, activeCaptures, vocabulary, process.env);
|
|
8963
|
+
for (const [semanticName, { code }] of rawCodeFields) {
|
|
8964
|
+
const fieldName = `${semanticName}Code`;
|
|
8965
|
+
if (!discoveredRawOptionFields.has(fieldName))
|
|
8966
|
+
discoveredRawOptionFields.set(fieldName, code);
|
|
8967
|
+
}
|
|
8968
|
+
// Mechanism B: nested caller structures (experienceData/educationData
|
|
8969
|
+
// history, opaque eventData) discovered during the body emit, surfaced to the
|
|
8970
|
+
// contract's payload schema.
|
|
8971
|
+
const discoveredStructuredKeys = new Map();
|
|
8972
|
+
// G1+G2: partition baseHeaders into three buckets:
|
|
8973
|
+
// - static: values that don't reference baseUrl or tenant subdomain
|
|
8974
|
+
// - baseUrl-derived: values containing the recon's baseUrl as substring
|
|
8975
|
+
// (e.g. Origin, Referer) — emit per-call from payload.BaseUrl
|
|
8976
|
+
// - tenant-subdomain: values that EXACTLY equal the first subdomain
|
|
8977
|
+
// (e.g. API-ShortName: "addus") — emit per-call from a payload field
|
|
8978
|
+
const staticBaseHeaders = {};
|
|
8979
|
+
const baseUrlDerivedHeaders = new Map();
|
|
8980
|
+
const tenantSubdomainHeaders = new Map();
|
|
8981
|
+
const firstSubdomain = (() => {
|
|
8982
|
+
try {
|
|
8983
|
+
const host = new URL(baseUrl).hostname;
|
|
8984
|
+
const firstDot = host.indexOf(".");
|
|
8985
|
+
return firstDot === -1 ? host : host.slice(0, firstDot);
|
|
8986
|
+
}
|
|
8987
|
+
catch {
|
|
8988
|
+
return "";
|
|
8989
|
+
}
|
|
8990
|
+
})();
|
|
8991
|
+
for (const [k, v] of Object.entries(baseHeaders)) {
|
|
8992
|
+
if (firstSubdomain.length > 0 && v === firstSubdomain) {
|
|
8993
|
+
tenantSubdomainHeaders.set(k, v);
|
|
8994
|
+
}
|
|
8995
|
+
else if (baseUrl.length > 0 && v.includes(baseUrl)) {
|
|
8996
|
+
baseUrlDerivedHeaders.set(k, v);
|
|
8997
|
+
}
|
|
8998
|
+
else {
|
|
8999
|
+
staticBaseHeaders[k] = v;
|
|
8713
9000
|
}
|
|
8714
|
-
})()
|
|
8715
|
-
: undefined;
|
|
8716
|
-
const errorSignals = detectErrorSignals(actionSteps);
|
|
8717
|
-
const discoveredFormFields = new Set();
|
|
8718
|
-
const discoveredOptionFields = new Set();
|
|
8719
|
-
// Phase E: maps label-derived raw-option payload field name (e.g.
|
|
8720
|
-
// "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
|
|
8721
|
-
// emit `<Name>OptionId: z.string()` payload fields with TSDoc docs.
|
|
8722
|
-
const discoveredRawOptionFields = new Map();
|
|
8723
|
-
// Phase F: keys from additional action POST bodies (beyond inputBody/r0)
|
|
8724
|
-
// that get parameterized. Recorded with their value type so the contract
|
|
8725
|
-
// emitter can add them to the payload schema with appropriate Zod types.
|
|
8726
|
-
const discoveredAdditionalBodyKeys = new Map();
|
|
8727
|
-
// Mechanism A: reconcile flow SELECT steps to submitted option codes. The
|
|
8728
|
-
// resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
|
|
8729
|
-
// i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
|
|
8730
|
-
// existing raw-option channel so their frozen code is still parameterized.
|
|
8731
|
-
const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, captures, vocabulary, process.env);
|
|
8732
|
-
for (const [semanticName, { code }] of rawCodeFields) {
|
|
8733
|
-
const fieldName = `${semanticName}Code`;
|
|
8734
|
-
if (!discoveredRawOptionFields.has(fieldName))
|
|
8735
|
-
discoveredRawOptionFields.set(fieldName, code);
|
|
8736
|
-
}
|
|
8737
|
-
// Mechanism B: nested caller structures (experienceData/educationData
|
|
8738
|
-
// history, opaque eventData) discovered during the body emit, surfaced to the
|
|
8739
|
-
// contract's payload schema.
|
|
8740
|
-
const discoveredStructuredKeys = new Map();
|
|
8741
|
-
// G1+G2: partition baseHeaders into three buckets:
|
|
8742
|
-
// - static: values that don't reference baseUrl or tenant subdomain
|
|
8743
|
-
// - baseUrl-derived: values containing the recon's baseUrl as substring
|
|
8744
|
-
// (e.g. Origin, Referer) — emit per-call from payload.BaseUrl
|
|
8745
|
-
// - tenant-subdomain: values that EXACTLY equal the first subdomain
|
|
8746
|
-
// (e.g. API-ShortName: "addus") — emit per-call from a payload field
|
|
8747
|
-
const staticBaseHeaders = {};
|
|
8748
|
-
const baseUrlDerivedHeaders = new Map();
|
|
8749
|
-
const tenantSubdomainHeaders = new Map();
|
|
8750
|
-
const firstSubdomain = (() => {
|
|
8751
|
-
try {
|
|
8752
|
-
const host = new URL(baseUrl).hostname;
|
|
8753
|
-
const firstDot = host.indexOf(".");
|
|
8754
|
-
return firstDot === -1 ? host : host.slice(0, firstDot);
|
|
8755
|
-
}
|
|
8756
|
-
catch {
|
|
8757
|
-
return "";
|
|
8758
|
-
}
|
|
8759
|
-
})();
|
|
8760
|
-
for (const [k, v] of Object.entries(baseHeaders)) {
|
|
8761
|
-
if (firstSubdomain.length > 0 && v === firstSubdomain) {
|
|
8762
|
-
tenantSubdomainHeaders.set(k, v);
|
|
8763
9001
|
}
|
|
8764
|
-
|
|
8765
|
-
|
|
9002
|
+
// Third branch (browser-flow-only): a multi-action flow (isSubmissionFlow)
|
|
9003
|
+
// that crosses hosts mid-sequence (compileActionSteps' isCrossDomain — a
|
|
9004
|
+
// captured redirect off the original domain, e.g. an auth bounce or a
|
|
9005
|
+
// vendor-hosted submission step). A bare `fetch`-based executeHttp can't
|
|
9006
|
+
// reliably replay that: cookies/CSRF/session state minted for one origin
|
|
9007
|
+
// don't automatically carry to the next the way a real browser's redirect
|
|
9008
|
+
// handling does, so synthesizing a same-shape HTTP sequence would silently
|
|
9009
|
+
// drop the session boundary the recon actually walked. Per-step, this
|
|
9010
|
+
// already surfaces as the "cross-domain redirect detected ... likely needs
|
|
9011
|
+
// browser fallback for this step" TODO (see emitMultiStepExecuteHttp); at
|
|
9012
|
+
// the whole-flow level the honest emit is no executeHttp at all — never a
|
|
9013
|
+
// same-host multi-step body that quietly drops the hop, and never a
|
|
9014
|
+
// downgrade to the single-endpoint `{query}` branch either, since that's a
|
|
9015
|
+
// fabrication of its own kind for a flow that isn't a single-action
|
|
9016
|
+
// query/search to begin with.
|
|
9017
|
+
const browserFlowOnly = isSubmissionFlow && actionSteps.some((s) => s.isCrossDomain);
|
|
9018
|
+
const multiStepBody = browserFlowOnly
|
|
9019
|
+
? undefined
|
|
9020
|
+
: isSubmissionFlow
|
|
9021
|
+
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
|
|
9022
|
+
: undefined;
|
|
9023
|
+
const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
|
|
9024
|
+
const headerBindings = collectHeaderBindings(actionSteps);
|
|
9025
|
+
// Shape inference targets the SAME call executeHttp returns — see
|
|
9026
|
+
// selectEffectiveResponseBody — so the two surfaces can't describe different calls.
|
|
9027
|
+
const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody, foldReturnSpec);
|
|
9028
|
+
// A declared foldReturn that resolves to no plan is a silent no-op otherwise
|
|
9029
|
+
// — the flow author gets the discarding selectReturnAction path with nothing
|
|
9030
|
+
// in the output saying their declaration never applied. A multi-step
|
|
9031
|
+
// (submission) flow applies its fold via emitMultiStepExecuteHttp's own
|
|
9032
|
+
// resolveFoldPlan call, entirely independent of resolveApplicableFoldPlans
|
|
9033
|
+
// (which exists only to gate emitContractTs's single-primary hot path, and
|
|
9034
|
+
// unconditionally reports zero plans once multiStepBody is set) — so this
|
|
9035
|
+
// diagnostic must consult the SAME resolution each path actually applies,
|
|
9036
|
+
// or it falsely reports "no fold plan resolved" for every multi-step flow
|
|
9037
|
+
// with a working foldReturn.
|
|
9038
|
+
const effectiveFoldPlanCount = multiStepBody
|
|
9039
|
+
? resolveFoldPlan(actionSteps, foldReturnSpec).length
|
|
9040
|
+
: resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody).length;
|
|
9041
|
+
if (foldReturnSpec !== null && effectiveFoldPlanCount === 0) {
|
|
9042
|
+
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
|
|
9043
|
+
}
|
|
9044
|
+
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|
|
9045
|
+
if (emit === "config") {
|
|
9046
|
+
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
9047
|
+
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
9048
|
+
siteId,
|
|
9049
|
+
displayName,
|
|
9050
|
+
baseUrl,
|
|
9051
|
+
flowSteps,
|
|
9052
|
+
vocabulary,
|
|
9053
|
+
inputBody,
|
|
9054
|
+
recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
|
|
9055
|
+
isSubmissionFlow,
|
|
9056
|
+
// A submission flow is the case where the `.ts` emit carries an
|
|
9057
|
+
// executeHttp hot path; point the manifest at where the operator drops
|
|
9058
|
+
// the compiled module rather than silently dropping the direct path.
|
|
9059
|
+
// Not set for the browser-flow-only branch — there is no hot path to
|
|
9060
|
+
// compile a module for.
|
|
9061
|
+
httpModulePath: isSubmissionFlow && !browserFlowOnly ? `./${siteId}.http.js` : undefined,
|
|
9062
|
+
}));
|
|
9063
|
+
logger.info(`wrote ${manifestPath}`);
|
|
9064
|
+
logger.info(`done — review ${manifestPath}, fill in response/extract schemas, then load via BARNACLE_PLUGINS or BARNACLE_PLUGINS_CONFIG_DIR (no compile step)`);
|
|
9065
|
+
return null;
|
|
8766
9066
|
}
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
// that crosses hosts mid-sequence (compileActionSteps' isCrossDomain — a
|
|
8773
|
-
// captured redirect off the original domain, e.g. an auth bounce or a
|
|
8774
|
-
// vendor-hosted submission step). A bare `fetch`-based executeHttp can't
|
|
8775
|
-
// reliably replay that: cookies/CSRF/session state minted for one origin
|
|
8776
|
-
// don't automatically carry to the next the way a real browser's redirect
|
|
8777
|
-
// handling does, so synthesizing a same-shape HTTP sequence would silently
|
|
8778
|
-
// drop the session boundary the recon actually walked. Per-step, this
|
|
8779
|
-
// already surfaces as the "cross-domain redirect detected ... likely needs
|
|
8780
|
-
// browser fallback for this step" TODO (see emitMultiStepExecuteHttp); at
|
|
8781
|
-
// the whole-flow level the honest emit is no executeHttp at all — never a
|
|
8782
|
-
// same-host multi-step body that quietly drops the hop, and never a
|
|
8783
|
-
// downgrade to the single-endpoint `{query}` branch either, since that's a
|
|
8784
|
-
// fabrication of its own kind for a flow that isn't a single-action
|
|
8785
|
-
// query/search to begin with.
|
|
8786
|
-
const browserFlowOnly = isSubmissionFlow && actionSteps.some((s) => s.isCrossDomain);
|
|
8787
|
-
const multiStepBody = browserFlowOnly
|
|
8788
|
-
? undefined
|
|
8789
|
-
: isSubmissionFlow
|
|
8790
|
-
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
|
|
8791
|
-
: undefined;
|
|
8792
|
-
const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
|
|
8793
|
-
const headerBindings = collectHeaderBindings(actionSteps);
|
|
8794
|
-
// Shape inference targets the SAME call executeHttp returns — see
|
|
8795
|
-
// selectEffectiveResponseBody — so the two surfaces can't describe different calls.
|
|
8796
|
-
const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody, foldReturnSpec);
|
|
8797
|
-
// A declared foldReturn that resolves to no plan is a silent no-op otherwise
|
|
8798
|
-
// — the flow author gets the discarding selectReturnAction path with nothing
|
|
8799
|
-
// in the output saying their declaration never applied. A multi-step
|
|
8800
|
-
// (submission) flow applies its fold via emitMultiStepExecuteHttp's own
|
|
8801
|
-
// resolveFoldPlan call, entirely independent of resolveApplicableFoldPlans
|
|
8802
|
-
// (which exists only to gate emitContractTs's single-primary hot path, and
|
|
8803
|
-
// unconditionally reports zero plans once multiStepBody is set) — so this
|
|
8804
|
-
// diagnostic must consult the SAME resolution each path actually applies,
|
|
8805
|
-
// or it falsely reports "no fold plan resolved" for every multi-step flow
|
|
8806
|
-
// with a working foldReturn.
|
|
8807
|
-
const effectiveFoldPlanCount = multiStepBody
|
|
8808
|
-
? resolveFoldPlan(actionSteps, foldReturnSpec).length
|
|
8809
|
-
: resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody).length;
|
|
8810
|
-
if (foldReturnSpec !== null && effectiveFoldPlanCount === 0) {
|
|
8811
|
-
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
|
|
8812
|
-
}
|
|
8813
|
-
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|
|
8814
|
-
if (emit === "config") {
|
|
8815
|
-
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
8816
|
-
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
9067
|
+
(0, node_fs_1.mkdirSync)(`${outDir}/flows`, { recursive: true });
|
|
9068
|
+
// Emit the browser flow first so the SAME payloadFieldNames set that drives
|
|
9069
|
+
// its `payload.<field>` splices also extends the contract's payload schema —
|
|
9070
|
+
// the two artifacts can't drift because one accumulator feeds both.
|
|
9071
|
+
const browserFlow = emitBrowserFlowTs({
|
|
8817
9072
|
siteId,
|
|
8818
|
-
|
|
9073
|
+
pascal,
|
|
8819
9074
|
baseUrl,
|
|
8820
9075
|
flowSteps,
|
|
9076
|
+
isSubmissionFlow,
|
|
9077
|
+
hasMultipartStep,
|
|
8821
9078
|
vocabulary,
|
|
9079
|
+
frameSelector,
|
|
9080
|
+
});
|
|
9081
|
+
const contractOpts = {
|
|
9082
|
+
siteId,
|
|
9083
|
+
displayName,
|
|
9084
|
+
pascal,
|
|
9085
|
+
baseUrl,
|
|
9086
|
+
// G1+G2: only the static headers (no baseUrl/tenant-subdomain references)
|
|
9087
|
+
// get baked into BASE_HEADERS. The rest are emitted per-call from payload.
|
|
9088
|
+
baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
|
|
9089
|
+
minTime,
|
|
9090
|
+
safeRps,
|
|
9091
|
+
hasRateLimitProbeData,
|
|
9092
|
+
responseBody: effectiveResponseBody,
|
|
9093
|
+
// selectEffectiveResponseBody already resolves a submission flow's own
|
|
9094
|
+
// single terminal capture -- the multi-sample evidence gathered above
|
|
9095
|
+
// describes the (possibly different) read-flow winning capture and
|
|
9096
|
+
// doesn't apply once a submission flow has picked its own return call.
|
|
9097
|
+
responseBodySamples: isSubmissionFlow ? [effectiveResponseBody] : responseBodySamples,
|
|
9098
|
+
gql,
|
|
9099
|
+
gqlQuery,
|
|
9100
|
+
endpointPath,
|
|
9101
|
+
gqlOperationName: primaryGraphQLOperation
|
|
9102
|
+
? (primaryGraphQLOperation.capture.operationName ??
|
|
9103
|
+
parsedOperationName(primaryGraphQLOperation.capture.query ?? ""))
|
|
9104
|
+
: (fallbackGraphQLCapture?.operationName ??
|
|
9105
|
+
parsedOperationName(fallbackGraphQLCapture?.query ?? "")),
|
|
9106
|
+
gqlVariables: primaryGraphQLOperation?.capture.variables ?? null,
|
|
9107
|
+
allCaptures: activeCaptures,
|
|
9108
|
+
auxFiles,
|
|
9109
|
+
multiStepBody,
|
|
9110
|
+
omitExecuteHttp: browserFlowOnly,
|
|
9111
|
+
isSubmissionFlow,
|
|
8822
9112
|
inputBody,
|
|
8823
|
-
|
|
9113
|
+
hasMultipartStep,
|
|
9114
|
+
actionSteps,
|
|
9115
|
+
foldReturnSpec,
|
|
9116
|
+
discoveredFormFields,
|
|
9117
|
+
fieldOptionsMap,
|
|
9118
|
+
discoveredOptionFields,
|
|
9119
|
+
discoveredRawOptionFields,
|
|
9120
|
+
discoveredAdditionalBodyKeys,
|
|
9121
|
+
discoveredStructuredKeys,
|
|
9122
|
+
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
9123
|
+
optionalPayloadFieldNames: browserFlow.optionalPayloadFieldNames,
|
|
9124
|
+
headerBindings,
|
|
9125
|
+
unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
|
|
9126
|
+
};
|
|
9127
|
+
const contractCode = emitContractTs(contractOpts);
|
|
9128
|
+
return {
|
|
9129
|
+
contractCode,
|
|
9130
|
+
contractOpts,
|
|
9131
|
+
browserFlow,
|
|
9132
|
+
auxFiles,
|
|
9133
|
+
resolvedPool: actionCaptures,
|
|
8824
9134
|
isSubmissionFlow,
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
// Not set for the browser-flow-only branch — there is no hot path to
|
|
8829
|
-
// compile a module for.
|
|
8830
|
-
httpModulePath: isSubmissionFlow && !browserFlowOnly ? `./${siteId}.http.js` : undefined,
|
|
8831
|
-
}));
|
|
8832
|
-
logger.info(`wrote ${manifestPath}`);
|
|
8833
|
-
logger.info(`done — review ${manifestPath}, fill in response/extract schemas, then load via BARNACLE_PLUGINS or BARNACLE_PLUGINS_CONFIG_DIR (no compile step)`);
|
|
8834
|
-
return;
|
|
9135
|
+
actionSteps,
|
|
9136
|
+
winningCapture,
|
|
9137
|
+
};
|
|
8835
9138
|
}
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
siteId,
|
|
8842
|
-
pascal,
|
|
8843
|
-
baseUrl,
|
|
8844
|
-
flowSteps,
|
|
8845
|
-
isSubmissionFlow,
|
|
8846
|
-
hasMultipartStep,
|
|
8847
|
-
vocabulary,
|
|
8848
|
-
frameSelector,
|
|
8849
|
-
});
|
|
8850
|
-
const contractOpts = {
|
|
8851
|
-
siteId,
|
|
8852
|
-
displayName,
|
|
8853
|
-
pascal,
|
|
8854
|
-
baseUrl,
|
|
8855
|
-
// G1+G2: only the static headers (no baseUrl/tenant-subdomain references)
|
|
8856
|
-
// get baked into BASE_HEADERS. The rest are emitted per-call from payload.
|
|
8857
|
-
baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
|
|
8858
|
-
minTime,
|
|
8859
|
-
safeRps,
|
|
8860
|
-
hasRateLimitProbeData,
|
|
8861
|
-
responseBody: effectiveResponseBody,
|
|
8862
|
-
// selectEffectiveResponseBody already resolves a submission flow's own
|
|
8863
|
-
// single terminal capture -- the multi-sample evidence gathered above
|
|
8864
|
-
// describes the (possibly different) read-flow winning capture and
|
|
8865
|
-
// doesn't apply once a submission flow has picked its own return call.
|
|
8866
|
-
responseBodySamples: isSubmissionFlow ? [effectiveResponseBody] : responseBodySamples,
|
|
8867
|
-
gql,
|
|
8868
|
-
gqlQuery,
|
|
8869
|
-
endpointPath,
|
|
8870
|
-
gqlOperationName: primaryGraphQLOperation
|
|
8871
|
-
? (primaryGraphQLOperation.capture.operationName ??
|
|
8872
|
-
parsedOperationName(primaryGraphQLOperation.capture.query ?? ""))
|
|
8873
|
-
: (fallbackGraphQLCapture?.operationName ??
|
|
8874
|
-
parsedOperationName(fallbackGraphQLCapture?.query ?? "")),
|
|
8875
|
-
gqlVariables: primaryGraphQLOperation?.capture.variables ?? null,
|
|
8876
|
-
allCaptures: captures,
|
|
8877
|
-
auxFiles,
|
|
8878
|
-
multiStepBody,
|
|
8879
|
-
omitExecuteHttp: browserFlowOnly,
|
|
8880
|
-
isSubmissionFlow,
|
|
8881
|
-
inputBody,
|
|
8882
|
-
hasMultipartStep,
|
|
8883
|
-
actionSteps,
|
|
8884
|
-
foldReturnSpec,
|
|
8885
|
-
discoveredFormFields,
|
|
8886
|
-
fieldOptionsMap,
|
|
8887
|
-
discoveredOptionFields,
|
|
8888
|
-
discoveredRawOptionFields,
|
|
8889
|
-
discoveredAdditionalBodyKeys,
|
|
8890
|
-
discoveredStructuredKeys,
|
|
8891
|
-
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
8892
|
-
optionalPayloadFieldNames: browserFlow.optionalPayloadFieldNames,
|
|
8893
|
-
headerBindings,
|
|
8894
|
-
unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
|
|
8895
|
-
};
|
|
8896
|
-
const contractCode = emitContractTs(contractOpts);
|
|
8897
|
-
// Fails loudly rather than shipping a flow that requires a URL field it
|
|
8898
|
-
// never reads (see assertRequiredUrlFieldsReferenced doc comment).
|
|
8899
|
-
assertRequiredUrlFieldsReferenced(contractCode, browserFlow.code);
|
|
8900
|
-
(0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, contractCode);
|
|
9139
|
+
const result = generateFromCaptures(captures);
|
|
9140
|
+
if (result === null)
|
|
9141
|
+
return; // emit === "config": already written above.
|
|
9142
|
+
const final = healUnreferencedUrlFieldsOnce(captures, result, generateFromCaptures);
|
|
9143
|
+
(0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, final.contractCode);
|
|
8901
9144
|
logger.info(`wrote ${outDir}/contract.ts`);
|
|
8902
9145
|
// Same opts fed to emitContractTs above, so this can never drift from what
|
|
8903
9146
|
// the header used to embed.
|
|
8904
|
-
const checklist = buildContractChecklist(contractOpts);
|
|
9147
|
+
const checklist = buildContractChecklist(final.contractOpts);
|
|
8905
9148
|
logger.info(`review checklist for ${outDir}/contract.ts:\n${checklist.map((item) => ` [ ] ${item}`).join("\n")}`);
|
|
8906
|
-
(0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);
|
|
9149
|
+
(0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, final.browserFlow.code);
|
|
8907
9150
|
logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
|
|
8908
9151
|
(0, node_fs_1.writeFileSync)(`${outDir}/index.ts`, emitIndexTs({ siteId, pascal }));
|
|
8909
9152
|
logger.info(`wrote ${outDir}/index.ts`);
|
|
8910
|
-
if (auxFiles.length > 0) {
|
|
9153
|
+
if (final.auxFiles.length > 0) {
|
|
8911
9154
|
(0, node_fs_1.mkdirSync)(`${outDir}/fixtures`, { recursive: true });
|
|
8912
|
-
for (const f of auxFiles) {
|
|
9155
|
+
for (const f of final.auxFiles) {
|
|
8913
9156
|
(0, node_fs_1.copyFileSync)((0, node_path_1.join)(auxDir, f), `${outDir}/fixtures/${f}`);
|
|
8914
9157
|
}
|
|
8915
|
-
logger.info(`copied ${auxFiles.length} fixture(s) to ${outDir}/fixtures/`);
|
|
9158
|
+
logger.info(`copied ${final.auxFiles.length} fixture(s) to ${outDir}/fixtures/`);
|
|
8916
9159
|
}
|
|
8917
9160
|
logger.info(`done — review ${outDir}/, build the package, then point BARNACLE_PLUGINS at the compiled module (no core edits required): BARNACLE_PLUGINS=./dist/sites/${siteId}/index.js pnpm start`);
|
|
8918
9161
|
}
|