@enricai/barnacle 1.12.12 → 1.12.13
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/README.md +1 -1
- package/dist/lib/ats-field-vocabulary.d.ts +7 -0
- package/dist/lib/ats-field-vocabulary.d.ts.map +1 -0
- package/dist/lib/ats-field-vocabulary.js +42 -0
- package/dist/lib/ats-field-vocabulary.js.map +1 -0
- package/dist/plugins/config-plugin.d.ts +1 -1
- package/dist/plugins/config-plugin.js +2 -2
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/discover.js +3 -3
- package/dist/plugins/discover.js.map +1 -1
- package/dist/recon/capture-filters.d.ts +10 -0
- package/dist/recon/capture-filters.d.ts.map +1 -1
- package/dist/recon/capture-filters.js +17 -0
- package/dist/recon/capture-filters.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +1 -1
- package/dist/scraper/flow-runner.js +1 -1
- package/dist/scripts/recon-generate.d.ts +67 -1
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +329 -67
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-http.d.ts +19 -1
- package/dist/scripts/recon-http.d.ts.map +1 -1
- package/dist/scripts/recon-http.js +62 -14
- package/dist/scripts/recon-http.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +8 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +23 -0
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/site-plugin.d.ts +6 -2
- package/dist/site-plugin.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -26,12 +26,14 @@ exports.buildKnownFieldValues = buildKnownFieldValues;
|
|
|
26
26
|
exports.extractStepPersonaValue = extractStepPersonaValue;
|
|
27
27
|
exports.deriveFillLabelField = deriveFillLabelField;
|
|
28
28
|
exports.harvestPersonaBindings = harvestPersonaBindings;
|
|
29
|
+
exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
|
|
29
30
|
exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
|
|
30
31
|
exports.selectPayloadAction = selectPayloadAction;
|
|
31
32
|
exports.selectReturnAction = selectReturnAction;
|
|
32
33
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
33
34
|
exports.extractEntryUrlParams = extractEntryUrlParams;
|
|
34
35
|
exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
|
|
36
|
+
exports.firstEndpointCapture = firstEndpointCapture;
|
|
35
37
|
exports.firstEndpointPath = firstEndpointPath;
|
|
36
38
|
exports.resolveManifestActionSequence = resolveManifestActionSequence;
|
|
37
39
|
exports.extractActionSequence = extractActionSequence;
|
|
@@ -42,16 +44,20 @@ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
|
|
|
42
44
|
exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
|
|
43
45
|
exports.walkSetCookiePairs = walkSetCookiePairs;
|
|
44
46
|
exports.indexStateValues = indexStateValues;
|
|
47
|
+
exports.sanitizeFixtureIdentifier = sanitizeFixtureIdentifier;
|
|
45
48
|
exports.compileActionSteps = compileActionSteps;
|
|
46
49
|
exports.collectHeaderBindings = collectHeaderBindings;
|
|
47
50
|
exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
|
|
48
51
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
52
|
+
exports.buildContractChecklist = buildContractChecklist;
|
|
49
53
|
exports.emitContractTs = emitContractTs;
|
|
54
|
+
exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
|
|
50
55
|
exports.emitConfigManifest = emitConfigManifest;
|
|
51
56
|
exports.emitBrowserFlowTs = emitBrowserFlowTs;
|
|
52
57
|
exports.emitIndexTs = emitIndexTs;
|
|
53
58
|
const node_fs_1 = require("node:fs");
|
|
54
59
|
const node_path_1 = require("node:path");
|
|
60
|
+
const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
|
|
55
61
|
const errors_1 = require("../lib/errors");
|
|
56
62
|
const logging_1 = require("../lib/logging");
|
|
57
63
|
const plugin_api_version_1 = require("../plugins/plugin-api-version");
|
|
@@ -348,7 +354,7 @@ function deriveFillLabelField(instruction) {
|
|
|
348
354
|
const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
|
|
349
355
|
if (label === undefined)
|
|
350
356
|
return null;
|
|
351
|
-
return fieldNameToPascalCase(label, null);
|
|
357
|
+
return (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(label) ?? fieldNameToPascalCase(label, null);
|
|
352
358
|
}
|
|
353
359
|
/**
|
|
354
360
|
* Builds the map from a recon persona VALUE (as it appears in the captured
|
|
@@ -389,6 +395,71 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
|
389
395
|
}
|
|
390
396
|
return bindings;
|
|
391
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Detects a flow step whose quoted VALUE is the space-joined concatenation of
|
|
400
|
+
* two already-known field values (either order), e.g. a signature step whose
|
|
401
|
+
* label the vocabulary doesn't recognize but whose value is `${FirstName}
|
|
402
|
+
* ${LastName}`. {@link resolveStepPayloadField} only ever resolves a SINGLE
|
|
403
|
+
* field per step, so a composite value like this falls through its label
|
|
404
|
+
* match entirely and the persona literal would otherwise survive emission.
|
|
405
|
+
*
|
|
406
|
+
* @returns the two known fields (in the order they appear in the value), or
|
|
407
|
+
* null when the step's value is not such a concatenation
|
|
408
|
+
*/
|
|
409
|
+
function resolveCompositePersonaFields(instruction, knownFieldValues) {
|
|
410
|
+
const spans = findQuoteSpans(instruction);
|
|
411
|
+
if (spans.length === 0)
|
|
412
|
+
return null;
|
|
413
|
+
const value = pickValueSpan(instruction, spans).value;
|
|
414
|
+
if (value.length === 0)
|
|
415
|
+
return null;
|
|
416
|
+
for (const [fieldA, valueA] of knownFieldValues) {
|
|
417
|
+
for (const [fieldB, valueB] of knownFieldValues) {
|
|
418
|
+
if (fieldA === fieldB)
|
|
419
|
+
continue;
|
|
420
|
+
if (value === `${valueA} ${valueB}`)
|
|
421
|
+
return { fieldA, fieldB };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Every persona literal a generated flow could leak: each single known field
|
|
428
|
+
* value, plus both concatenation orders of every distinct pair — the same
|
|
429
|
+
* composite shape {@link resolveCompositePersonaFields} splices. Feeds
|
|
430
|
+
* {@link assertNoLeakedPersonaConstant}'s final safety-net scan so a splice
|
|
431
|
+
* miss (single OR composite) is caught regardless of which matching path
|
|
432
|
+
* should have handled it.
|
|
433
|
+
*/
|
|
434
|
+
function allPersonaLiterals(knownFieldValues) {
|
|
435
|
+
const entries = [...knownFieldValues.entries()];
|
|
436
|
+
const literals = entries.map(([, value]) => value);
|
|
437
|
+
for (const [fieldA, valueA] of entries) {
|
|
438
|
+
for (const [fieldB, valueB] of entries) {
|
|
439
|
+
if (fieldA === fieldB)
|
|
440
|
+
continue;
|
|
441
|
+
literals.push(`${valueA} ${valueB}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return literals.filter((value) => value.length > 0);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Final safety net: throws if any known persona constant — single field or
|
|
448
|
+
* composite concatenation — still appears verbatim as a quoted literal in the
|
|
449
|
+
* fully-built flow code. Every splice path above (vocabulary match, derived
|
|
450
|
+
* label, composite match) is a heuristic; this is the loud failure that
|
|
451
|
+
* catches whatever heuristic missed rather than shipping the recon identity's
|
|
452
|
+
* own data as a frozen literal in every caller's submission.
|
|
453
|
+
*
|
|
454
|
+
* @throws when a persona literal survives emission
|
|
455
|
+
*/
|
|
456
|
+
function assertNoLeakedPersonaConstant(code, knownFieldValues) {
|
|
457
|
+
for (const value of allPersonaLiterals(knownFieldValues)) {
|
|
458
|
+
if (code.includes(`'${value}'`)) {
|
|
459
|
+
throw new Error(`recon-generate: persona constant '${value}' survived emission — a flow step failed to splice its known field value to payload.<field>`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
392
463
|
/**
|
|
393
464
|
* How deep to infer before collapsing to z.unknown(). Deep enough to reach the
|
|
394
465
|
* fields that carry meaning on real inventory APIs — a listing's price
|
|
@@ -703,9 +774,6 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null)
|
|
|
703
774
|
function isGraphQL(captures) {
|
|
704
775
|
return captures.some((c) => c.operationName !== null);
|
|
705
776
|
}
|
|
706
|
-
function firstSuccessfulReplayBody(replays) {
|
|
707
|
-
return replays.find((r) => r.success)?.replayBody ?? null;
|
|
708
|
-
}
|
|
709
777
|
function firstGraphQLQuery(captures) {
|
|
710
778
|
return captures.find((c) => c.query)?.query ?? null;
|
|
711
779
|
}
|
|
@@ -789,11 +857,17 @@ function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = pr
|
|
|
789
857
|
})();
|
|
790
858
|
return { capture: winner.capture, endpointPath };
|
|
791
859
|
}
|
|
792
|
-
|
|
860
|
+
/**
|
|
861
|
+
* Resolves the same primary-endpoint capture that {@link firstEndpointPath}
|
|
862
|
+
* derives a path string from, so non-GraphQL flows can also read that
|
|
863
|
+
* capture's own `.responseBody` instead of an array-order-first replay.
|
|
864
|
+
*/
|
|
865
|
+
function firstEndpointCapture(captures) {
|
|
793
866
|
const nonGetCaptures = captures.filter((c) => c.method !== "GET");
|
|
794
867
|
for (const c of nonGetCaptures) {
|
|
795
868
|
try {
|
|
796
|
-
|
|
869
|
+
new URL(c.url);
|
|
870
|
+
return c;
|
|
797
871
|
}
|
|
798
872
|
catch {
|
|
799
873
|
// skip
|
|
@@ -801,13 +875,23 @@ function firstEndpointPath(captures) {
|
|
|
801
875
|
}
|
|
802
876
|
for (const c of captures) {
|
|
803
877
|
try {
|
|
804
|
-
|
|
878
|
+
new URL(c.url);
|
|
879
|
+
return c;
|
|
805
880
|
}
|
|
806
881
|
catch {
|
|
807
882
|
// skip
|
|
808
883
|
}
|
|
809
884
|
}
|
|
810
|
-
return
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
function firstEndpointPath(captures) {
|
|
888
|
+
try {
|
|
889
|
+
const capture = firstEndpointCapture(captures);
|
|
890
|
+
return capture ? new URL(capture.url).pathname : "/api/search";
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
return "/api/search";
|
|
894
|
+
}
|
|
811
895
|
}
|
|
812
896
|
/**
|
|
813
897
|
* Builds the compiled submit-pattern predicate. A flow-declared regex that
|
|
@@ -1344,6 +1428,12 @@ function looksLikeSectionFieldsArray(arr, formSchema) {
|
|
|
1344
1428
|
}
|
|
1345
1429
|
function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchema) {
|
|
1346
1430
|
let currentPrefix = null;
|
|
1431
|
+
// Repeated/indexed headings (e.g. "Reference #1") must keep suppressing
|
|
1432
|
+
// canonicalization to avoid colliding a nested entity's field with the
|
|
1433
|
+
// applicant's own top-level field. Plain grouping headings with no
|
|
1434
|
+
// repetition marker (e.g. "CONTACT INFORMATION") still describe the
|
|
1435
|
+
// applicant, so canonicalization must still apply under them.
|
|
1436
|
+
let currentPrefixIsRepeated = false;
|
|
1347
1437
|
const usedNames = new Set([...fieldNameMap.values()]);
|
|
1348
1438
|
// First field-name key is the machine code (preferred, PascalCased directly);
|
|
1349
1439
|
// any later key is a human label (subject to the section-heading heuristic).
|
|
@@ -1358,8 +1448,9 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
|
|
|
1358
1448
|
const name = labelKey !== undefined ? obj[labelKey] : obj[codeKey ?? ""];
|
|
1359
1449
|
let semantic = null;
|
|
1360
1450
|
if (typeof sourceCode === "string" && sourceCode.trim().length > 0) {
|
|
1361
|
-
semantic = sourceCodeToPascalCase(sourceCode);
|
|
1451
|
+
semantic = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(sourceCode) ?? sourceCodeToPascalCase(sourceCode);
|
|
1362
1452
|
currentPrefix = null;
|
|
1453
|
+
currentPrefixIsRepeated = false;
|
|
1363
1454
|
}
|
|
1364
1455
|
else if (typeof name === "string" && name.trim().length > 0 && name.length < 250) {
|
|
1365
1456
|
const hasNoSourceCode = typeof sourceCode !== "string" || sourceCode.trim().length === 0;
|
|
@@ -1375,10 +1466,23 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
|
|
|
1375
1466
|
const headingPrefix = fieldNameToPascalCase(name, null);
|
|
1376
1467
|
if (headingPrefix !== null) {
|
|
1377
1468
|
currentPrefix = headingPrefix;
|
|
1469
|
+
// Only a repeated/indexed heading (e.g. "Reference #1", "Employer 2")
|
|
1470
|
+
// marks a nested sub-entity whose fields must stay prefixed to avoid
|
|
1471
|
+
// colliding with the applicant's own top-level field name. A trailing
|
|
1472
|
+
// 1-2 digit index, standing on its own word boundary, is a repetition
|
|
1473
|
+
// marker; a longer digit run (e.g. a year in "EMPLOYMENT HISTORY
|
|
1474
|
+
// 2024") is not, and must still canonicalize underneath it.
|
|
1475
|
+
currentPrefixIsRepeated = /\b\d{1,2}\s*$/.test(name) || name.includes("#");
|
|
1378
1476
|
}
|
|
1379
1477
|
continue;
|
|
1380
1478
|
}
|
|
1381
1479
|
semantic = fieldNameToPascalCase(name, currentPrefix);
|
|
1480
|
+
if (!currentPrefixIsRepeated) {
|
|
1481
|
+
const canonical = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(name);
|
|
1482
|
+
if (canonical !== null) {
|
|
1483
|
+
semantic = canonical;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1382
1486
|
}
|
|
1383
1487
|
if (semantic !== null && !fieldNameMap.has(fieldId)) {
|
|
1384
1488
|
let unique = semantic;
|
|
@@ -2124,6 +2228,19 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2124
2228
|
function isValidJsIdentifier(s) {
|
|
2125
2229
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
|
|
2126
2230
|
}
|
|
2231
|
+
/** Derives a valid camelCase identifier from a fixture filename (e.g.
|
|
2232
|
+
* "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
|
|
2233
|
+
* "acmeMetricsConfig") for use in generated `loadFixture` const lines. */
|
|
2234
|
+
function sanitizeFixtureIdentifier(filename) {
|
|
2235
|
+
const camelCased = filename
|
|
2236
|
+
.replace(/\.json$/, "")
|
|
2237
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
2238
|
+
.filter(Boolean)
|
|
2239
|
+
.map((part, i) => (i === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`))
|
|
2240
|
+
.join("");
|
|
2241
|
+
const stripped = camelCased.replace(/[^A-Za-z0-9_$]/g, "");
|
|
2242
|
+
return isValidJsIdentifier(stripped) ? stripped : `fixture${stripped}`;
|
|
2243
|
+
}
|
|
2127
2244
|
/**
|
|
2128
2245
|
* Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
|
|
2129
2246
|
* Identifier segments use dot access; numeric / non-identifier segments use
|
|
@@ -3465,8 +3582,46 @@ function renderGqlVariablesExpr(variables, payloadFieldNames) {
|
|
|
3465
3582
|
});
|
|
3466
3583
|
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
|
|
3467
3584
|
}
|
|
3585
|
+
/**
|
|
3586
|
+
* Same review-checklist items the pre-move contract.ts header used to embed,
|
|
3587
|
+
* now surfaced on recon-generate's own stdout instead of the shipped file —
|
|
3588
|
+
* call with the exact same opts passed to {@link emitContractTs} so the two
|
|
3589
|
+
* can never drift out of sync.
|
|
3590
|
+
*/
|
|
3591
|
+
function buildContractChecklist(opts) {
|
|
3592
|
+
const { pascal, gql, omitExecuteHttp, multiStepBody } = opts;
|
|
3593
|
+
const queryChecklistLine = !omitExecuteHttp && gql
|
|
3594
|
+
? `Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
|
|
3595
|
+
: "";
|
|
3596
|
+
// Multi-step flows validate each call against its own per-call inferred
|
|
3597
|
+
// schema (emitMultiStepExecuteHttp) — narrowing ResponseSchema only changes
|
|
3598
|
+
// what executeHttp promises ITS OWN caller, never a per-call validator, so
|
|
3599
|
+
// the checklist item must say that explicitly. Single-endpoint plugins have
|
|
3600
|
+
// exactly one call, so the client schema and that call's validator are the
|
|
3601
|
+
// same schema and the shorter wording stays accurate. Browser-flow-only
|
|
3602
|
+
// plugins have no executeHttp at all, so ResponseSchema is only ever the
|
|
3603
|
+
// browser flow's own return-value contract.
|
|
3604
|
+
const narrowSchemaChecklistLine = omitExecuteHttp
|
|
3605
|
+
? `Narrow ${pascal}ResponseSchema to match what the browser flow should promise ITS CALLER — this flow could not synthesize a trustworthy executeHttp (a required value from the captured sequence never resolved), so it ships browser-only`
|
|
3606
|
+
: multiStepBody
|
|
3607
|
+
? `Narrow ${pascal}ResponseSchema to match what executeHttp should promise ITS CALLER — this is the plugin's own return-value contract, not a per-call validator (each call in the flow is already checked against its own inferred schema)`
|
|
3608
|
+
: `Narrow ${pascal}ResponseSchema to match the real response shape`;
|
|
3609
|
+
const baseHeadersChecklistLine = omitExecuteHttp
|
|
3610
|
+
? ""
|
|
3611
|
+
: "Verify BASE_HEADERS — remove any that aren't load-bearing";
|
|
3612
|
+
const outOfTreeChecklistLine = omitExecuteHttp
|
|
3613
|
+
? "Out-of-tree: `pnpm add zod` — this file imports it directly, and a strict node_modules layout (pnpm) won't resolve it as a transitive dep of @enricai/barnacle alone"
|
|
3614
|
+
: "Out-of-tree: `pnpm add bottleneck zod` — this file imports both directly, and a strict node_modules layout (pnpm) won't resolve them as transitive deps of @enricai/barnacle alone";
|
|
3615
|
+
return [
|
|
3616
|
+
queryChecklistLine,
|
|
3617
|
+
narrowSchemaChecklistLine,
|
|
3618
|
+
`Adjust ${pascal}PayloadSchema to your actual request parameters`,
|
|
3619
|
+
baseHeadersChecklistLine,
|
|
3620
|
+
outOfTreeChecklistLine,
|
|
3621
|
+
].filter((line) => line !== "");
|
|
3622
|
+
}
|
|
3468
3623
|
function emitContractTs(opts) {
|
|
3469
|
-
const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
|
|
3624
|
+
const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
|
|
3470
3625
|
// This is the CLIENT-level schema — createHttpClient's default, and the
|
|
3471
3626
|
// plugin's caller-facing contract (what executeHttp's return value promises
|
|
3472
3627
|
// its own caller). It does NOT validate any individual call in a multi-step
|
|
@@ -3485,8 +3640,16 @@ function emitContractTs(opts) {
|
|
|
3485
3640
|
// Single-endpoint plugins keep the same inferred-schema treatment, since
|
|
3486
3641
|
// there both roles (client default and sole call) coincide.
|
|
3487
3642
|
// Browser-flow-only plugins have no HTTP call to infer a shape from —
|
|
3488
|
-
// z.unknown() there is the honest gap, not a narrowing shortcut.
|
|
3489
|
-
|
|
3643
|
+
// z.unknown() there is the honest gap, not a narrowing shortcut. A
|
|
3644
|
+
// submission flow is the exception: runHealingFlow's submitStep
|
|
3645
|
+
// verification already throws StepVerificationError on failure, so a
|
|
3646
|
+
// successful return IS a real signal — z.unknown() would be dishonest
|
|
3647
|
+
// in the other direction, hiding a field the flow can actually promise.
|
|
3648
|
+
const responseSchemaExpr = omitExecuteHttp && isSubmissionFlow
|
|
3649
|
+
? `z.object({ verified: z.boolean() })`
|
|
3650
|
+
: omitExecuteHttp
|
|
3651
|
+
? `z.unknown()`
|
|
3652
|
+
: inferZodSchema(responseBody);
|
|
3490
3653
|
// Multi-step flows that include a multipart upload need the binary asset
|
|
3491
3654
|
// on the payload. ApplicantContactSchema (via ApplicantResumeSchema) already
|
|
3492
3655
|
// declares Resume/ResumeContentType/ResumeFilename, so submission flows
|
|
@@ -3632,6 +3795,7 @@ function emitContractTs(opts) {
|
|
|
3632
3795
|
const sortedAdditionalKeys = discoveredAdditionalBodyKeys
|
|
3633
3796
|
? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
3634
3797
|
: [];
|
|
3798
|
+
let usesMultipartBoolean = false;
|
|
3635
3799
|
for (const [name, kind] of sortedAdditionalKeys) {
|
|
3636
3800
|
if (isReservedByApplicantContactSchema(name))
|
|
3637
3801
|
continue;
|
|
@@ -3647,6 +3811,8 @@ function emitContractTs(opts) {
|
|
|
3647
3811
|
: payloadNeedsMultipart
|
|
3648
3812
|
? "multipartBoolean()"
|
|
3649
3813
|
: "z.boolean()";
|
|
3814
|
+
if (zod === "multipartBoolean()")
|
|
3815
|
+
usesMultipartBoolean = true;
|
|
3650
3816
|
addExtendField(name, ` ${name}: ${zod},`);
|
|
3651
3817
|
}
|
|
3652
3818
|
// Mechanism B: nested caller structures become payload fields carrying their
|
|
@@ -3675,6 +3841,9 @@ function emitContractTs(opts) {
|
|
|
3675
3841
|
const internalRequestReferenceExpr = inputBody
|
|
3676
3842
|
? inferZodSchema(inputBody, 0, "", { multipartCoerce: hasMultipartStep })
|
|
3677
3843
|
: null;
|
|
3844
|
+
if (internalRequestReferenceExpr?.includes("multipartBoolean(")) {
|
|
3845
|
+
usesMultipartBoolean = true;
|
|
3846
|
+
}
|
|
3678
3847
|
// All field sources above are merged into a SINGLE `.extend({...})` object
|
|
3679
3848
|
// literal, keyed by field name — a name that recurs across sources (or
|
|
3680
3849
|
// that collides with the base extend's own Email/ClickUrl/Answers) collapses
|
|
@@ -3684,12 +3853,12 @@ function emitContractTs(opts) {
|
|
|
3684
3853
|
const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
|
|
3685
3854
|
// basePayloadSchemaExpr's own Answers field always wraps in
|
|
3686
3855
|
// multipartJsonObject() for submission flows (inputBody set);
|
|
3687
|
-
// multipartBoolean()
|
|
3688
|
-
//
|
|
3689
|
-
//
|
|
3856
|
+
// multipartBoolean() is only imported when a boolean field was actually
|
|
3857
|
+
// wrapped in it above (an additional-body-key or an inputBody field under
|
|
3858
|
+
// multipartCoerce) — payloadNeedsMultipart alone doesn't imply that.
|
|
3690
3859
|
// Named imports from the same module are combined into one import statement.
|
|
3691
3860
|
const zodMultipartNamedImports = [
|
|
3692
|
-
...(
|
|
3861
|
+
...(usesMultipartBoolean ? ["multipartBoolean"] : []),
|
|
3693
3862
|
...(inputBody || (payloadNeedsMultipart && sortedStructuredEntries.length > 0)
|
|
3694
3863
|
? ["multipartJsonObject"]
|
|
3695
3864
|
: []),
|
|
@@ -3703,7 +3872,7 @@ function emitContractTs(opts) {
|
|
|
3703
3872
|
? `import { ApplicantContactSchema } from "${ENGINE_PKG}/lib/applicant-payload";\n`
|
|
3704
3873
|
: "";
|
|
3705
3874
|
// Content-Type must be absent from multipart fetch calls so FormData can inject the boundary.
|
|
3706
|
-
const caseInsensitiveHeadersImport = hasMultipartStep
|
|
3875
|
+
const caseInsensitiveHeadersImport = hasMultipartStep && !omitExecuteHttp
|
|
3707
3876
|
? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
|
|
3708
3877
|
: "";
|
|
3709
3878
|
// Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
|
|
@@ -3768,7 +3937,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
3768
3937
|
const fixtureComments = auxFiles.length > 0
|
|
3769
3938
|
? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
|
|
3770
3939
|
auxFiles
|
|
3771
|
-
.map((f) => `// const ${f
|
|
3940
|
+
.map((f) => `// const ${sanitizeFixtureIdentifier(f)} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
|
|
3772
3941
|
.join("\n") +
|
|
3773
3942
|
"\n"
|
|
3774
3943
|
: "";
|
|
@@ -3791,22 +3960,6 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
3791
3960
|
export const ${pascal}InternalRequestReference = ${internalRequestReferenceExpr};
|
|
3792
3961
|
`
|
|
3793
3962
|
: "";
|
|
3794
|
-
const queryChecklistLine = !omitExecuteHttp && gql
|
|
3795
|
-
? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
|
|
3796
|
-
: "";
|
|
3797
|
-
// Multi-step flows validate each call against its own per-call inferred
|
|
3798
|
-
// schema (emitMultiStepExecuteHttp) — narrowing ResponseSchema only changes
|
|
3799
|
-
// what executeHttp promises ITS OWN caller, never a per-call validator, so
|
|
3800
|
-
// the checklist item must say that explicitly. Single-endpoint plugins have
|
|
3801
|
-
// exactly one call, so the client schema and that call's validator are the
|
|
3802
|
-
// same schema and the shorter wording stays accurate. Browser-flow-only
|
|
3803
|
-
// plugins have no executeHttp at all, so ResponseSchema is only ever the
|
|
3804
|
-
// browser flow's own return-value contract.
|
|
3805
|
-
const narrowSchemaChecklistLine = omitExecuteHttp
|
|
3806
|
-
? `\n * [ ] Narrow ${pascal}ResponseSchema to match what the browser flow should promise ITS CALLER — this flow could not synthesize a trustworthy executeHttp (a required value from the captured sequence never resolved), so it ships browser-only`
|
|
3807
|
-
: multiStepBody
|
|
3808
|
-
? `\n * [ ] Narrow ${pascal}ResponseSchema to match what executeHttp should promise ITS CALLER — this is the plugin's own return-value contract, not a per-call validator (each call in the flow is already checked against its own inferred schema)`
|
|
3809
|
-
: `\n * [ ] Narrow ${pascal}ResponseSchema to match the real response shape`;
|
|
3810
3963
|
const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
3811
3964
|
// Browser-flow-only plugins need neither Bottleneck (no rate-limited HTTP
|
|
3812
3965
|
// client) nor BASE_HEADERS (no per-call headers to bake in) — both would
|
|
@@ -3819,10 +3972,13 @@ const BASE_HEADERS: Record<string, string> = {
|
|
|
3819
3972
|
${headersLiteral},
|
|
3820
3973
|
};
|
|
3821
3974
|
`;
|
|
3975
|
+
const rateLimitComment = hasRateLimitProbeData
|
|
3976
|
+
? `// Safe ceiling: ${safeRps} rps — from recon rate-limit probe.`
|
|
3977
|
+
: `// Safe ceiling: ${safeRps} rps — DEFAULT (no rate-limit probe data; run recon:http).`;
|
|
3822
3978
|
const limiterBlock = omitExecuteHttp
|
|
3823
3979
|
? ""
|
|
3824
3980
|
: `
|
|
3825
|
-
|
|
3981
|
+
${rateLimitComment}
|
|
3826
3982
|
const limiter = new Bottleneck({ minTime: ${minTime} });
|
|
3827
3983
|
`;
|
|
3828
3984
|
const executeHttpMethodBlock = omitExecuteHttp
|
|
@@ -3840,23 +3996,15 @@ ${executeHttpBody}
|
|
|
3840
3996
|
? `/**
|
|
3841
3997
|
* Plugin for ${siteId}. Browser-flow-only: the captured multi-step submission
|
|
3842
3998
|
* sequence could not be synthesized into a trustworthy direct-HTTP hot path
|
|
3843
|
-
* (see
|
|
3999
|
+
* (see recon-generate's review checklist, logged to stdout at generation
|
|
4000
|
+
* time), so this always runs via Stagehand.
|
|
3844
4001
|
*/`
|
|
3845
4002
|
: `/**
|
|
3846
4003
|
* Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
|
|
3847
4004
|
* Stagehand automatically on schema drift or bot challenge.
|
|
3848
4005
|
*/`;
|
|
3849
|
-
const baseHeadersChecklistLine = omitExecuteHttp
|
|
3850
|
-
? ""
|
|
3851
|
-
: `\n * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing`;
|
|
3852
|
-
const outOfTreeChecklistLine = omitExecuteHttp
|
|
3853
|
-
? `\n * [ ] Out-of-tree: \`pnpm add zod\` — this file imports it directly, and a\n * strict node_modules layout (pnpm) won't resolve it as a transitive\n * dep of @enricai/barnacle alone`
|
|
3854
|
-
: `\n * [ ] Out-of-tree: \`pnpm add bottleneck zod\` — this file imports both\n * directly, and a strict node_modules layout (pnpm) won't resolve\n * them as transitive deps of @enricai/barnacle alone`;
|
|
3855
4006
|
return `/**
|
|
3856
4007
|
* Generated by recon-generate.ts — review before shipping.
|
|
3857
|
-
*
|
|
3858
|
-
* Checklist:${queryChecklistLine}${narrowSchemaChecklistLine}
|
|
3859
|
-
* [ ] Adjust ${pascal}PayloadSchema to your actual request parameters${baseHeadersChecklistLine}${outOfTreeChecklistLine}
|
|
3860
4008
|
*/
|
|
3861
4009
|
|
|
3862
4010
|
${bottleneckImport}import { z } from "zod/v4";
|
|
@@ -3880,11 +4028,11 @@ ${pluginDocComment}
|
|
|
3880
4028
|
export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
3881
4029
|
meta: {
|
|
3882
4030
|
siteId: ${JSON.stringify(siteId)},
|
|
3883
|
-
displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
|
|
3884
4031
|
bodySchema: ${pascal}PayloadSchema,
|
|
3885
4032
|
responseSchema: ${pascal}ResponseSchema,
|
|
3886
4033
|
defaultBaseUrl: ${JSON.stringify(baseUrl)},
|
|
3887
|
-
|
|
4034
|
+
${payloadNeedsMultipart || inputBody
|
|
4035
|
+
? `// multipart is required whenever the flow itself uploads a file
|
|
3888
4036
|
// (hasMultipartStep), OR this is a submission flow (inputBody set) since
|
|
3889
4037
|
// basePayloadSchemaExpr always requires a real Resume Buffer via
|
|
3890
4038
|
// ApplicantContactSchema regardless of whether the recorded browser flow
|
|
@@ -3892,7 +4040,8 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
|
3892
4040
|
// discoveredStructuredKeys field (payloadNeedsMultipart), since the
|
|
3893
4041
|
// multipart wire format is what makes that field's JSON-stringified
|
|
3894
4042
|
// encoding parseable.
|
|
3895
|
-
|
|
4043
|
+
`
|
|
4044
|
+
: ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || inputBody ? "\n multipart: true," : ""}
|
|
3896
4045
|
},
|
|
3897
4046
|
${executeHttpMethodBlock}
|
|
3898
4047
|
/** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
|
|
@@ -3901,7 +4050,7 @@ ${executeHttpMethodBlock}
|
|
|
3901
4050
|
session: BrowserSession,
|
|
3902
4051
|
context: SitePluginContext
|
|
3903
4052
|
): Promise<SitePluginResult<${pascal}Response>> {
|
|
3904
|
-
const raw = await run${pascal}BrowserFlow(session.stagehand, context.baseUrl, payload);
|
|
4053
|
+
const raw = await run${pascal}BrowserFlow(session.stagehand, ${inputBody ? "payload.ClickUrl" : "context.baseUrl"}, payload);
|
|
3905
4054
|
return { data: raw as ${pascal}Response };
|
|
3906
4055
|
},
|
|
3907
4056
|
};
|
|
@@ -3912,6 +4061,29 @@ ${executeHttpMethodBlock}
|
|
|
3912
4061
|
export { ${camel}Plugin as plugin };
|
|
3913
4062
|
`;
|
|
3914
4063
|
}
|
|
4064
|
+
/**
|
|
4065
|
+
* Fails generation loudly when the payload schema `emitContractTs` just wrote
|
|
4066
|
+
* declares a required `*Url` field (e.g. `ClickUrl`) that neither the
|
|
4067
|
+
* contract's own `execute()` call site nor the emitted browser flow ever
|
|
4068
|
+
* reads as `payload.<Field>`. A required URL field nothing dereferences means
|
|
4069
|
+
* the flow enters on the wrong page (see
|
|
4070
|
+
* docs/recon-generate-browser-flow-entry-url-pii-and-unverified-submit.md
|
|
4071
|
+
* defect 1) — this generalizes the check past ClickUrl so any future
|
|
4072
|
+
* required URL field regresses loudly instead of silently.
|
|
4073
|
+
*/
|
|
4074
|
+
function assertRequiredUrlFieldsReferenced(contractCode, browserFlowCode) {
|
|
4075
|
+
const urlFieldLinePattern = /^\s*(\w*Url\w*):\s*z\.\w+\(.*$/gm;
|
|
4076
|
+
const emittedCode = `${contractCode}\n${browserFlowCode}`;
|
|
4077
|
+
const unreferenced = [...contractCode.matchAll(urlFieldLinePattern)]
|
|
4078
|
+
.filter((match) => !match[0].includes(".optional("))
|
|
4079
|
+
.map((match) => match[1])
|
|
4080
|
+
.filter((name) => name !== undefined)
|
|
4081
|
+
.filter((name) => !emittedCode.includes(`payload.${name}`));
|
|
4082
|
+
if (unreferenced.length === 0)
|
|
4083
|
+
return;
|
|
4084
|
+
throw new Error(`recon-generate: required URL field(s) ${unreferenced.join(", ")} declared on the payload schema ` +
|
|
4085
|
+
`but never referenced by the emitted contract or browser flow — the flow would enter on the wrong page`);
|
|
4086
|
+
}
|
|
3915
4087
|
/**
|
|
3916
4088
|
* Escape a literal string segment so it is safe INSIDE a JS backtick template
|
|
3917
4089
|
* literal — backslashes, backticks, and `${` interpolation starts must all be
|
|
@@ -3939,6 +4111,19 @@ function buildStepInstructionExpr(instruction, field) {
|
|
|
3939
4111
|
return JSON.stringify(instruction);
|
|
3940
4112
|
return `\`${escapeForTemplateLiteral(site.before)}\${payload.${field}}${escapeForTemplateLiteral(site.after)}\``;
|
|
3941
4113
|
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Build the emitted instruction expression for a step whose value is the
|
|
4116
|
+
* concatenation of two known fields ({@link resolveCompositePersonaFields}):
|
|
4117
|
+
* a backtick template literal with the whole quoted value replaced by
|
|
4118
|
+
* `${payload.<fieldA>} ${payload.<fieldB>}`, preserving the single space the
|
|
4119
|
+
* concatenation was built with.
|
|
4120
|
+
*/
|
|
4121
|
+
function buildCompositeStepInstructionExpr(instruction, fieldA, fieldB) {
|
|
4122
|
+
const site = locateSpliceSite(instruction);
|
|
4123
|
+
if (site === null)
|
|
4124
|
+
return JSON.stringify(instruction);
|
|
4125
|
+
return `\`${escapeForTemplateLiteral(site.before)}\${payload.${fieldA}} \${payload.${fieldB}}${escapeForTemplateLiteral(site.after)}\``;
|
|
4126
|
+
}
|
|
3942
4127
|
/**
|
|
3943
4128
|
* Build the emitted instruction expression for a step whose splice site is the
|
|
3944
4129
|
* reserved `${RECON_PASSWORD}` token: a backtick template literal with the
|
|
@@ -4010,7 +4195,7 @@ function jsonSchemaTypeOf(value) {
|
|
|
4010
4195
|
* browser `flow` is the only execution path, and the field is omitted.
|
|
4011
4196
|
*/
|
|
4012
4197
|
function emitConfigManifest(opts) {
|
|
4013
|
-
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, env = process.env, } = opts;
|
|
4198
|
+
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, env = process.env, } = opts;
|
|
4014
4199
|
const payloadFieldNames = new Set();
|
|
4015
4200
|
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
|
|
4016
4201
|
const steps = flowSteps.map((step) => {
|
|
@@ -4042,6 +4227,23 @@ function emitConfigManifest(opts) {
|
|
|
4042
4227
|
return rewritten;
|
|
4043
4228
|
return { step: rewritten, optional, upload, submitStep };
|
|
4044
4229
|
});
|
|
4230
|
+
// The last step of a submission flow is structurally the submit — force
|
|
4231
|
+
// submitStep:true even when the source recon-flow.json step never declared
|
|
4232
|
+
// it, mirroring emitBrowserFlowTs's forcing (see there) so a config-plugin
|
|
4233
|
+
// load of this manifest also gates on runHealingFlow's submitStep verification.
|
|
4234
|
+
const lastStepIndex = steps.length - 1;
|
|
4235
|
+
const lastStep = steps[lastStepIndex];
|
|
4236
|
+
if (isSubmissionFlow && lastStep !== undefined) {
|
|
4237
|
+
steps[lastStepIndex] =
|
|
4238
|
+
typeof lastStep === "string"
|
|
4239
|
+
? { step: lastStep, optional: false, upload: false, submitStep: true }
|
|
4240
|
+
: {
|
|
4241
|
+
step: lastStep.step,
|
|
4242
|
+
optional: lastStep.optional,
|
|
4243
|
+
upload: lastStep.upload,
|
|
4244
|
+
submitStep: true,
|
|
4245
|
+
};
|
|
4246
|
+
}
|
|
4045
4247
|
// The request surface, widest wins: a flow splice, a recovered form field, or
|
|
4046
4248
|
// a key from the first POST body all name something a caller controls. Splices
|
|
4047
4249
|
// and recovered fields are strings (the browser flow fills them as text); a
|
|
@@ -4062,7 +4264,7 @@ function emitConfigManifest(opts) {
|
|
|
4062
4264
|
const manifest = {
|
|
4063
4265
|
apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
|
|
4064
4266
|
kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
|
|
4065
|
-
metadata: { siteId, displayName },
|
|
4267
|
+
metadata: { siteId, ...(displayName !== undefined && { displayName }) },
|
|
4066
4268
|
spec: {
|
|
4067
4269
|
defaultBaseUrl: baseUrl,
|
|
4068
4270
|
...(httpModulePath ? { httpModule: httpModulePath } : {}),
|
|
@@ -4114,14 +4316,30 @@ function emitBrowserFlowTs(opts) {
|
|
|
4114
4316
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
4115
4317
|
}
|
|
4116
4318
|
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
4319
|
+
const composite = field === null && !(isObj && step.payloadFieldNone)
|
|
4320
|
+
? resolveCompositePersonaFields(instruction, knownFieldValues)
|
|
4321
|
+
: null;
|
|
4117
4322
|
if (field !== null)
|
|
4118
4323
|
payloadFieldNames.add(field);
|
|
4119
|
-
|
|
4324
|
+
if (composite !== null) {
|
|
4325
|
+
payloadFieldNames.add(composite.fieldA);
|
|
4326
|
+
payloadFieldNames.add(composite.fieldB);
|
|
4327
|
+
}
|
|
4328
|
+
const instructionExpr = composite !== null
|
|
4329
|
+
? buildCompositeStepInstructionExpr(instruction, composite.fieldA, composite.fieldB)
|
|
4330
|
+
: buildStepInstructionExpr(instruction, field);
|
|
4120
4331
|
const optional = isObj ? step.optional === true : false;
|
|
4121
4332
|
const upload = isObj ? step.upload === true : false;
|
|
4122
4333
|
const submitStep = isObj ? step.submitStep === true : false;
|
|
4123
4334
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
4124
4335
|
});
|
|
4336
|
+
// The last step of a submission flow is structurally the submit — force
|
|
4337
|
+
// submitStep:true even when the source recon-flow.json step never declared
|
|
4338
|
+
// it, so the engine's pre-submit probe/StepVerificationError actually gates it.
|
|
4339
|
+
const lastStepLiteral = stepLiterals.at(-1);
|
|
4340
|
+
if (isSubmissionFlow && lastStepLiteral !== undefined) {
|
|
4341
|
+
stepLiterals[stepLiterals.length - 1] = lastStepLiteral.replace(/submitStep: (true|false)(?= \},)/, "submitStep: true");
|
|
4342
|
+
}
|
|
4125
4343
|
const flowStepsBlock = stepLiterals.length > 0
|
|
4126
4344
|
? stepLiterals.join("\n")
|
|
4127
4345
|
: " // TODO: no flow steps were parsed. Re-run recon-browser with a --flow\n" +
|
|
@@ -4148,8 +4366,8 @@ function emitBrowserFlowTs(opts) {
|
|
|
4148
4366
|
* Core invokes this automatically when executeHttp throws HttpSchemaError or
|
|
4149
4367
|
* HttpBotChallengeError. Update the flow steps and extract schema as needed.
|
|
4150
4368
|
*
|
|
4151
|
-
* Steps whose instruction named a
|
|
4152
|
-
* constant spliced to \`payload.<field>\` so the caller's real
|
|
4369
|
+
* Steps whose instruction named a labeled payload field have their recon
|
|
4370
|
+
* constant spliced to \`payload.<field>\` so the caller's real value reaches
|
|
4153
4371
|
* the page; operational-default steps stay literal. The steps run through the
|
|
4154
4372
|
* self-heal cascade via runHealingFlow — the same engine the recon CLI uses,
|
|
4155
4373
|
* minus its disk-dump/replan layer.
|
|
@@ -4166,9 +4384,13 @@ import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/cont
|
|
|
4166
4384
|
|
|
4167
4385
|
const logger = getLogger({ name: "${siteId}-browser-flow" });
|
|
4168
4386
|
|
|
4169
|
-
const ${pascal}BrowserSchema = z.object({
|
|
4170
|
-
|
|
4171
|
-
|
|
4387
|
+
const ${pascal}BrowserSchema = z.object({${isSubmissionFlow
|
|
4388
|
+
? `
|
|
4389
|
+
// runHealingFlow throws StepVerificationError on a failed submitStep, so
|
|
4390
|
+
// reaching this point already proves the submission verified.
|
|
4391
|
+
verified: z.boolean(),`
|
|
4392
|
+
: `
|
|
4393
|
+
extraction: z.string(),`}
|
|
4172
4394
|
});
|
|
4173
4395
|
|
|
4174
4396
|
/**
|
|
@@ -4178,13 +4400,13 @@ const ${pascal}BrowserSchema = z.object({
|
|
|
4178
4400
|
*/
|
|
4179
4401
|
export async function run${pascal}BrowserFlow(
|
|
4180
4402
|
stagehand: Stagehand,
|
|
4181
|
-
baseUrl: string,
|
|
4403
|
+
${isSubmissionFlow ? "entryUrl" : "baseUrl"}: string,
|
|
4182
4404
|
payload: ${pascal}Payload
|
|
4183
4405
|
): Promise<${pascal}Response> {
|
|
4184
4406
|
const page = await stagehand.context.awaitActivePage();
|
|
4185
4407
|
|
|
4186
|
-
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
|
4187
|
-
// networkidle can resolve before a
|
|
4408
|
+
await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "networkidle" });
|
|
4409
|
+
// networkidle can resolve before a bot-managed/CDN-fronted SPA hydrates; wait for
|
|
4188
4410
|
// the real DOM so the first steps don't probe an empty shell page and skip.
|
|
4189
4411
|
await waitForSpaReady(page, logger);
|
|
4190
4412
|
${usesThrowawayPassword ? "\n // Minted once per run — the flow needs a credential to authenticate, but\n // there is no caller-supplied Password field on the payload to splice.\n const throwawayPassword = generateThrowawayPassword();\n" : ""}
|
|
@@ -4210,16 +4432,37 @@ ${flowStepsBlock}
|
|
|
4210
4432
|
// both Zod v3 and v4 schemas natively (StagehandZodSchema union since
|
|
4211
4433
|
// 2.4.3 / PR #944), and the caller-side safeParse defends against SDK
|
|
4212
4434
|
// contract drift. Widen ${pascal}BrowserSchema as needed to match the
|
|
4213
|
-
// fields the recon flow actually surfaces
|
|
4435
|
+
// fields the recon flow actually surfaces.${isSubmissionFlow
|
|
4436
|
+
? `
|
|
4437
|
+
// runHealingFlow above already verified the submit (submitStep:true), so the
|
|
4438
|
+
// application is already committed at the ATS. A schema-validation or
|
|
4439
|
+
// watchdog-timeout throw from this trailing confirmation extract must not
|
|
4440
|
+
// propagate and trigger a duplicate re-dispatch — catch it, log it, and
|
|
4441
|
+
// degrade to a not-confirmed result instead.
|
|
4442
|
+
try {
|
|
4443
|
+
const result = await guardedExtract(
|
|
4444
|
+
stagehand,
|
|
4445
|
+
"is a submission confirmation shown, and what is its reference number?",
|
|
4446
|
+
${pascal}BrowserSchema
|
|
4447
|
+
);
|
|
4448
|
+
return { ...result, verified: true } as unknown as ${pascal}Response;
|
|
4449
|
+
} catch (error) {
|
|
4450
|
+
logger.error(\`GuardedExtractError: post-submit confirmation extract failed on an already-verified submit, degrading to not-confirmed: \${error}\`);
|
|
4451
|
+
return { verified: false } as unknown as ${pascal}Response;
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
`
|
|
4455
|
+
: `
|
|
4214
4456
|
const result = await guardedExtract(
|
|
4215
4457
|
stagehand,
|
|
4216
|
-
|
|
4458
|
+
\`extract results matching query: \${payload.query}\`,
|
|
4217
4459
|
${pascal}BrowserSchema
|
|
4218
4460
|
);
|
|
4219
4461
|
|
|
4220
4462
|
return result as unknown as ${pascal}Response;
|
|
4221
4463
|
}
|
|
4222
|
-
`;
|
|
4464
|
+
`}`;
|
|
4465
|
+
assertNoLeakedPersonaConstant(code, knownFieldValues);
|
|
4223
4466
|
return { code, payloadFieldNames };
|
|
4224
4467
|
}
|
|
4225
4468
|
/** Generates the site's index.ts barrel — exported so the out-of-tree e2e
|
|
@@ -4335,6 +4578,9 @@ async function main() {
|
|
|
4335
4578
|
"rate-limit.json",
|
|
4336
4579
|
"introspection-schema.json",
|
|
4337
4580
|
]);
|
|
4581
|
+
if (replays.length === 0) {
|
|
4582
|
+
logger.warn(`recon-generate: run dir ${runRoot} has an empty replays/ directory — recon:http (rate-limit probe) and replay validation were skipped, so this contract's timing and shape are unvalidated`);
|
|
4583
|
+
}
|
|
4338
4584
|
const rateLimits = (() => {
|
|
4339
4585
|
try {
|
|
4340
4586
|
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(replaysDir, "rate-limit.json"), "utf8"));
|
|
@@ -4410,8 +4656,8 @@ async function main() {
|
|
|
4410
4656
|
const baseUrl = deriveBaseUrl(captures);
|
|
4411
4657
|
const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns);
|
|
4412
4658
|
const minTime = deriveMinTime(rateLimits);
|
|
4659
|
+
const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
|
|
4413
4660
|
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
4414
|
-
const responseBody = firstSuccessfulReplayBody(replays);
|
|
4415
4661
|
const gql = isGraphQL(captures);
|
|
4416
4662
|
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
4417
4663
|
// (further down) read the same computed sequence instead of calling the
|
|
@@ -4423,6 +4669,11 @@ async function main() {
|
|
|
4423
4669
|
: null;
|
|
4424
4670
|
const gqlQuery = primaryGraphQLOperation?.capture.query ?? firstGraphQLQuery(captures);
|
|
4425
4671
|
const endpointPath = primaryGraphQLOperation?.endpointPath ?? firstEndpointPath(captures);
|
|
4672
|
+
// Derived from the primary operation's own Phase-1 capture, never from
|
|
4673
|
+
// replay array order -- a replay's body reflects whichever endpoint fired
|
|
4674
|
+
// first, not necessarily the primary operation, and only exists once
|
|
4675
|
+
// recon:http has run.
|
|
4676
|
+
const responseBody = (primaryGraphQLOperation?.capture ?? firstEndpointCapture(captures))?.responseBody ?? null;
|
|
4426
4677
|
// Detect a multi-step submission flow (transactional sites like apply forms,
|
|
4427
4678
|
// checkout, etc.). When the action sequence has 2+ POSTs, switch the
|
|
4428
4679
|
// contract template to emit a state-threaded executeHttp.
|
|
@@ -4656,12 +4907,12 @@ async function main() {
|
|
|
4656
4907
|
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
4657
4908
|
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
4658
4909
|
siteId,
|
|
4659
|
-
displayName: pascal,
|
|
4660
4910
|
baseUrl,
|
|
4661
4911
|
flowSteps,
|
|
4662
4912
|
vocabulary,
|
|
4663
4913
|
inputBody,
|
|
4664
4914
|
recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
|
|
4915
|
+
isSubmissionFlow,
|
|
4665
4916
|
// A submission flow is the case where the `.ts` emit carries an
|
|
4666
4917
|
// executeHttp hot path; point the manifest at where the operator drops
|
|
4667
4918
|
// the compiled module rather than silently dropping the direct path.
|
|
@@ -4687,7 +4938,7 @@ async function main() {
|
|
|
4687
4938
|
vocabulary,
|
|
4688
4939
|
frameSelector,
|
|
4689
4940
|
});
|
|
4690
|
-
|
|
4941
|
+
const contractOpts = {
|
|
4691
4942
|
siteId,
|
|
4692
4943
|
pascal,
|
|
4693
4944
|
baseUrl,
|
|
@@ -4696,6 +4947,7 @@ async function main() {
|
|
|
4696
4947
|
baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
|
|
4697
4948
|
minTime,
|
|
4698
4949
|
safeRps,
|
|
4950
|
+
hasRateLimitProbeData,
|
|
4699
4951
|
responseBody: effectiveResponseBody,
|
|
4700
4952
|
gql,
|
|
4701
4953
|
gqlQuery,
|
|
@@ -4705,6 +4957,7 @@ async function main() {
|
|
|
4705
4957
|
auxFiles,
|
|
4706
4958
|
multiStepBody,
|
|
4707
4959
|
omitExecuteHttp: browserFlowOnly,
|
|
4960
|
+
isSubmissionFlow,
|
|
4708
4961
|
inputBody,
|
|
4709
4962
|
hasMultipartStep,
|
|
4710
4963
|
discoveredFormFields,
|
|
@@ -4715,8 +4968,17 @@ async function main() {
|
|
|
4715
4968
|
discoveredStructuredKeys,
|
|
4716
4969
|
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
4717
4970
|
headerBindings,
|
|
4718
|
-
}
|
|
4971
|
+
};
|
|
4972
|
+
const contractCode = emitContractTs(contractOpts);
|
|
4973
|
+
// Fails loudly rather than shipping a flow that requires a URL field it
|
|
4974
|
+
// never reads (see assertRequiredUrlFieldsReferenced doc comment).
|
|
4975
|
+
assertRequiredUrlFieldsReferenced(contractCode, browserFlow.code);
|
|
4976
|
+
(0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, contractCode);
|
|
4719
4977
|
logger.info(`wrote ${outDir}/contract.ts`);
|
|
4978
|
+
// Same opts fed to emitContractTs above, so this can never drift from what
|
|
4979
|
+
// the header used to embed.
|
|
4980
|
+
const checklist = buildContractChecklist(contractOpts);
|
|
4981
|
+
logger.info(`review checklist for ${outDir}/contract.ts:\n${checklist.map((item) => ` [ ] ${item}`).join("\n")}`);
|
|
4720
4982
|
(0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);
|
|
4721
4983
|
logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
|
|
4722
4984
|
(0, node_fs_1.writeFileSync)(`${outDir}/index.ts`, emitIndexTs({ siteId, pascal }));
|