@enricai/barnacle 1.12.11 → 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 +96 -3
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +478 -80
- 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
|
@@ -22,15 +22,18 @@
|
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.resolveStepPayloadField = resolveStepPayloadField;
|
|
25
|
+
exports.buildKnownFieldValues = buildKnownFieldValues;
|
|
25
26
|
exports.extractStepPersonaValue = extractStepPersonaValue;
|
|
26
27
|
exports.deriveFillLabelField = deriveFillLabelField;
|
|
27
28
|
exports.harvestPersonaBindings = harvestPersonaBindings;
|
|
29
|
+
exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
|
|
28
30
|
exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
|
|
29
31
|
exports.selectPayloadAction = selectPayloadAction;
|
|
30
32
|
exports.selectReturnAction = selectReturnAction;
|
|
31
33
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
32
34
|
exports.extractEntryUrlParams = extractEntryUrlParams;
|
|
33
35
|
exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
|
|
36
|
+
exports.firstEndpointCapture = firstEndpointCapture;
|
|
34
37
|
exports.firstEndpointPath = firstEndpointPath;
|
|
35
38
|
exports.resolveManifestActionSequence = resolveManifestActionSequence;
|
|
36
39
|
exports.extractActionSequence = extractActionSequence;
|
|
@@ -41,16 +44,20 @@ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
|
|
|
41
44
|
exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
|
|
42
45
|
exports.walkSetCookiePairs = walkSetCookiePairs;
|
|
43
46
|
exports.indexStateValues = indexStateValues;
|
|
47
|
+
exports.sanitizeFixtureIdentifier = sanitizeFixtureIdentifier;
|
|
44
48
|
exports.compileActionSteps = compileActionSteps;
|
|
45
49
|
exports.collectHeaderBindings = collectHeaderBindings;
|
|
46
50
|
exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
|
|
47
51
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
52
|
+
exports.buildContractChecklist = buildContractChecklist;
|
|
48
53
|
exports.emitContractTs = emitContractTs;
|
|
54
|
+
exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
|
|
49
55
|
exports.emitConfigManifest = emitConfigManifest;
|
|
50
56
|
exports.emitBrowserFlowTs = emitBrowserFlowTs;
|
|
51
57
|
exports.emitIndexTs = emitIndexTs;
|
|
52
58
|
const node_fs_1 = require("node:fs");
|
|
53
59
|
const node_path_1 = require("node:path");
|
|
60
|
+
const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
|
|
54
61
|
const errors_1 = require("../lib/errors");
|
|
55
62
|
const logging_1 = require("../lib/logging");
|
|
56
63
|
const plugin_api_version_1 = require("../plugins/plugin-api-version");
|
|
@@ -149,6 +156,35 @@ function locateSpliceSite(instruction) {
|
|
|
149
156
|
after: instruction.slice(span.index + span.length),
|
|
150
157
|
};
|
|
151
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* A quoted span immediately followed by "button"/"link"/"tab" NAMES a control,
|
|
161
|
+
* not a fill/select VALUE — `click the 'Sign in with email' button` carries no
|
|
162
|
+
* applicant datum even though the quoted text happens to contain a field-label
|
|
163
|
+
* word ("email"). Checked ahead of vocabulary matching so a control's own name
|
|
164
|
+
* can never be mistaken for the data it merely mentions.
|
|
165
|
+
*/
|
|
166
|
+
function namesAControl(instruction) {
|
|
167
|
+
return findQuoteSpans(instruction).some((span) => /^\s*(button|link|tab)\b/i.test(instruction.slice(span.index + span.length)));
|
|
168
|
+
}
|
|
169
|
+
/** The quoted VALUE a Select/Choose/Fill step carries, per {@link pickValueSpan}'s
|
|
170
|
+
* grammar rule — used to validate a vocabulary match's ANSWER, not just its label. */
|
|
171
|
+
function pickedQuotedValue(instruction) {
|
|
172
|
+
const spans = findQuoteSpans(instruction);
|
|
173
|
+
if (spans.length === 0)
|
|
174
|
+
return null;
|
|
175
|
+
const value = pickValueSpan(instruction, spans).value;
|
|
176
|
+
return value.length > 0 ? value : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Closed-enum answers (Yes/No, decline-to-answer, ...) a Select/Choose step
|
|
180
|
+
* commonly carries. None of these is ever an applicant's own datum — they are
|
|
181
|
+
* the RESPONSE to a screening question, not the fact a name/state/phone field
|
|
182
|
+
* asks for — so a vocabulary label match must never bind one to a payload field.
|
|
183
|
+
* Generic English grammar, not a domain vocabulary concern, so it belongs here
|
|
184
|
+
* rather than in any consumer's `--vocabulary`.
|
|
185
|
+
*/
|
|
186
|
+
const OPERATIONAL_ANSWER = /^(yes|no|true|false|n\/a|none|decline(?:\s+to\s+(?:answer|self-identify))?|prefer not to (?:answer|say))$/i;
|
|
187
|
+
const EMPTY_KNOWN_FIELD_VALUES = new Map();
|
|
152
188
|
function toPascalCase(siteId) {
|
|
153
189
|
return siteId
|
|
154
190
|
.split(/[-_]/)
|
|
@@ -169,13 +205,23 @@ function toPascalCase(siteId) {
|
|
|
169
205
|
* @param forceNone when true, force a literal step (the `payloadFieldNone` opt-out)
|
|
170
206
|
* @param vocabulary the consumer's domain vocabulary; defaults to {@link EMPTY_VOCABULARY}
|
|
171
207
|
* (no splicing) when the caller passes none
|
|
208
|
+
* @param knownFieldValues field→value pairs the flow already established
|
|
209
|
+
* unambiguously via a Fill/Enter/Type step ({@link buildKnownFieldValues}). A
|
|
210
|
+
* Select/Choose step's ANSWER must equal the known value for the field a
|
|
211
|
+
* label match names, or it is not that field's datum (e.g. a device-type
|
|
212
|
+
* dropdown's `'Mobile'` looks like a phone-number field by label alone, but
|
|
213
|
+
* is nothing like the number the Fill step already bound).
|
|
172
214
|
* @returns the PascalCase payload field name to splice, or null to keep literal
|
|
173
215
|
*/
|
|
174
|
-
function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY) {
|
|
216
|
+
function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY, knownFieldValues = EMPTY_KNOWN_FIELD_VALUES) {
|
|
175
217
|
if (forceNone)
|
|
176
218
|
return null;
|
|
177
219
|
if (explicit)
|
|
178
220
|
return explicit;
|
|
221
|
+
// A control's own name (a button/link/tab label) is never a fill/select VALUE,
|
|
222
|
+
// even when it happens to contain a word a vocabulary row also matches.
|
|
223
|
+
if (namesAControl(instruction))
|
|
224
|
+
return null;
|
|
179
225
|
// A quoted literal or a reserved ${RECON_*} env token IS the recon constant
|
|
180
226
|
// this step would replace, so it is spliceable on its own.
|
|
181
227
|
const hasQuotedConstant = /'[^']*'/.test(instruction) || RESERVED_ENV_TOKEN.test(instruction);
|
|
@@ -189,12 +235,53 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
|
|
|
189
235
|
return null;
|
|
190
236
|
if (vocabulary.exclusions.some((rx) => rx.test(instruction)))
|
|
191
237
|
return null;
|
|
238
|
+
// A Select/Choose/Pick step names an ANSWER, not a Fill step's self-evident
|
|
239
|
+
// value — a label match alone can't tell the applicant's own datum from an
|
|
240
|
+
// operational choice the widget merely offers, so the answer itself is
|
|
241
|
+
// validated below.
|
|
242
|
+
const isSelectStep = /\b(select|choose|pick)\b/i.test(instruction);
|
|
192
243
|
for (const [rx, field] of vocabulary.table) {
|
|
193
|
-
if (rx.test(instruction))
|
|
244
|
+
if (!rx.test(instruction))
|
|
245
|
+
continue;
|
|
246
|
+
if (!isSelectStep)
|
|
194
247
|
return field;
|
|
248
|
+
const answer = pickedQuotedValue(instruction);
|
|
249
|
+
if (answer !== null && OPERATIONAL_ANSWER.test(answer.trim()))
|
|
250
|
+
return null;
|
|
251
|
+
const known = knownFieldValues.get(field);
|
|
252
|
+
if (known !== undefined && answer !== known)
|
|
253
|
+
return null;
|
|
254
|
+
return field;
|
|
195
255
|
}
|
|
196
256
|
return null;
|
|
197
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Field→value pairs the flow establishes unambiguously via a Fill/Enter/Type
|
|
260
|
+
* step — the applicant's own datum, per {@link deriveFillLabelField}'s same
|
|
261
|
+
* self-describing grammar. {@link resolveStepPayloadField} uses this to verify
|
|
262
|
+
* a Select/Choose step's ANSWER is that SAME datum before binding it to the
|
|
263
|
+
* field a label match names, catching the mismatch a label alone cannot see
|
|
264
|
+
* (a field whose label mentions "phone" but whose select answer is a device
|
|
265
|
+
* type, not a number).
|
|
266
|
+
*/
|
|
267
|
+
function buildKnownFieldValues(flowSteps, vocabulary, env) {
|
|
268
|
+
const known = new Map();
|
|
269
|
+
for (const step of flowSteps) {
|
|
270
|
+
const isObj = typeof step !== "string";
|
|
271
|
+
const instruction = isObj ? step.step : step;
|
|
272
|
+
if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
|
|
273
|
+
continue;
|
|
274
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary) ?? deriveFillLabelField(instruction);
|
|
275
|
+
if (field === null)
|
|
276
|
+
continue;
|
|
277
|
+
const value = extractStepPersonaValue(instruction, env);
|
|
278
|
+
if (value === null)
|
|
279
|
+
continue;
|
|
280
|
+
if (!known.has(field))
|
|
281
|
+
known.set(field, value);
|
|
282
|
+
}
|
|
283
|
+
return known;
|
|
284
|
+
}
|
|
198
285
|
/**
|
|
199
286
|
* Extracts the concrete persona VALUE a flow step fills — the recon-supplied
|
|
200
287
|
* constant that appears verbatim in the captured request body — so the body
|
|
@@ -267,7 +354,7 @@ function deriveFillLabelField(instruction) {
|
|
|
267
354
|
const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
|
|
268
355
|
if (label === undefined)
|
|
269
356
|
return null;
|
|
270
|
-
return fieldNameToPascalCase(label, null);
|
|
357
|
+
return (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(label) ?? fieldNameToPascalCase(label, null);
|
|
271
358
|
}
|
|
272
359
|
/**
|
|
273
360
|
* Builds the map from a recon persona VALUE (as it appears in the captured
|
|
@@ -284,10 +371,11 @@ function deriveFillLabelField(instruction) {
|
|
|
284
371
|
*/
|
|
285
372
|
function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
286
373
|
const bindings = new Map();
|
|
374
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
|
|
287
375
|
for (const step of flowSteps) {
|
|
288
376
|
const isObj = typeof step !== "string";
|
|
289
377
|
const instruction = isObj ? step.step : step;
|
|
290
|
-
const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
|
|
378
|
+
const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
291
379
|
// Vocabulary wins outright. Only on a miss do we fall back to deriving the
|
|
292
380
|
// field from the instruction's own label — and never when the author opted
|
|
293
381
|
// the step out (`payloadFieldNone`) or the vocabulary explicitly excluded it.
|
|
@@ -307,6 +395,71 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
|
307
395
|
}
|
|
308
396
|
return bindings;
|
|
309
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
|
+
}
|
|
310
463
|
/**
|
|
311
464
|
* How deep to infer before collapsing to z.unknown(). Deep enough to reach the
|
|
312
465
|
* fields that carry meaning on real inventory APIs — a listing's price
|
|
@@ -621,9 +774,6 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null)
|
|
|
621
774
|
function isGraphQL(captures) {
|
|
622
775
|
return captures.some((c) => c.operationName !== null);
|
|
623
776
|
}
|
|
624
|
-
function firstSuccessfulReplayBody(replays) {
|
|
625
|
-
return replays.find((r) => r.success)?.replayBody ?? null;
|
|
626
|
-
}
|
|
627
777
|
function firstGraphQLQuery(captures) {
|
|
628
778
|
return captures.find((c) => c.query)?.query ?? null;
|
|
629
779
|
}
|
|
@@ -639,16 +789,22 @@ function firstGraphQLQuery(captures) {
|
|
|
639
789
|
* flow's own `payloadField` facets appearing in the candidate's query/variables,
|
|
640
790
|
* a non-landing capture phase, and how often the same operation re-fires are
|
|
641
791
|
* combined into one composite score.
|
|
792
|
+
*
|
|
793
|
+
* The `payloadField` facets are drawn from {@link resolveStepPayloadField} with
|
|
794
|
+
* {@link buildKnownFieldValues}'s known-value guard applied, so an operational
|
|
795
|
+
* Select answer (e.g. a device-type dropdown) never contributes a spurious
|
|
796
|
+
* facet match to the ranking.
|
|
642
797
|
*/
|
|
643
|
-
function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary) {
|
|
798
|
+
function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = process.env) {
|
|
644
799
|
const candidates = captures.filter((c) => c.status >= 200 && c.status < 300 && c.query !== null && !/^\s*mutation\b/.test(c.query));
|
|
645
800
|
if (candidates.length === 0)
|
|
646
801
|
return null;
|
|
802
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
|
|
647
803
|
const payloadFields = new Set();
|
|
648
804
|
for (const step of flowSteps) {
|
|
649
805
|
const isObj = typeof step !== "string";
|
|
650
806
|
const instruction = isObj ? step.step : step;
|
|
651
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
|
|
807
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
652
808
|
if (field !== null)
|
|
653
809
|
payloadFields.add(field);
|
|
654
810
|
}
|
|
@@ -701,11 +857,17 @@ function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary) {
|
|
|
701
857
|
})();
|
|
702
858
|
return { capture: winner.capture, endpointPath };
|
|
703
859
|
}
|
|
704
|
-
|
|
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) {
|
|
705
866
|
const nonGetCaptures = captures.filter((c) => c.method !== "GET");
|
|
706
867
|
for (const c of nonGetCaptures) {
|
|
707
868
|
try {
|
|
708
|
-
|
|
869
|
+
new URL(c.url);
|
|
870
|
+
return c;
|
|
709
871
|
}
|
|
710
872
|
catch {
|
|
711
873
|
// skip
|
|
@@ -713,13 +875,23 @@ function firstEndpointPath(captures) {
|
|
|
713
875
|
}
|
|
714
876
|
for (const c of captures) {
|
|
715
877
|
try {
|
|
716
|
-
|
|
878
|
+
new URL(c.url);
|
|
879
|
+
return c;
|
|
717
880
|
}
|
|
718
881
|
catch {
|
|
719
882
|
// skip
|
|
720
883
|
}
|
|
721
884
|
}
|
|
722
|
-
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
|
+
}
|
|
723
895
|
}
|
|
724
896
|
/**
|
|
725
897
|
* Builds the compiled submit-pattern predicate. A flow-declared regex that
|
|
@@ -1256,6 +1428,12 @@ function looksLikeSectionFieldsArray(arr, formSchema) {
|
|
|
1256
1428
|
}
|
|
1257
1429
|
function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchema) {
|
|
1258
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;
|
|
1259
1437
|
const usedNames = new Set([...fieldNameMap.values()]);
|
|
1260
1438
|
// First field-name key is the machine code (preferred, PascalCased directly);
|
|
1261
1439
|
// any later key is a human label (subject to the section-heading heuristic).
|
|
@@ -1270,8 +1448,9 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
|
|
|
1270
1448
|
const name = labelKey !== undefined ? obj[labelKey] : obj[codeKey ?? ""];
|
|
1271
1449
|
let semantic = null;
|
|
1272
1450
|
if (typeof sourceCode === "string" && sourceCode.trim().length > 0) {
|
|
1273
|
-
semantic = sourceCodeToPascalCase(sourceCode);
|
|
1451
|
+
semantic = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(sourceCode) ?? sourceCodeToPascalCase(sourceCode);
|
|
1274
1452
|
currentPrefix = null;
|
|
1453
|
+
currentPrefixIsRepeated = false;
|
|
1275
1454
|
}
|
|
1276
1455
|
else if (typeof name === "string" && name.trim().length > 0 && name.length < 250) {
|
|
1277
1456
|
const hasNoSourceCode = typeof sourceCode !== "string" || sourceCode.trim().length === 0;
|
|
@@ -1287,10 +1466,23 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
|
|
|
1287
1466
|
const headingPrefix = fieldNameToPascalCase(name, null);
|
|
1288
1467
|
if (headingPrefix !== null) {
|
|
1289
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("#");
|
|
1290
1476
|
}
|
|
1291
1477
|
continue;
|
|
1292
1478
|
}
|
|
1293
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
|
+
}
|
|
1294
1486
|
}
|
|
1295
1487
|
if (semantic !== null && !fieldNameMap.has(fieldId)) {
|
|
1296
1488
|
let unique = semantic;
|
|
@@ -1617,6 +1809,7 @@ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
|
|
|
1617
1809
|
const resolutions = [];
|
|
1618
1810
|
const rawCodeFields = new Map();
|
|
1619
1811
|
const seenWireKeys = new Set();
|
|
1812
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
|
|
1620
1813
|
for (const step of flowSteps) {
|
|
1621
1814
|
const instruction = typeof step === "string" ? step : step.step;
|
|
1622
1815
|
if (!/^\s*(select|choose|pick)\b/i.test(instruction) && !/\bselect\b/i.test(instruction)) {
|
|
@@ -1674,7 +1867,7 @@ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
|
|
|
1674
1867
|
}
|
|
1675
1868
|
// Fallback: no id= — resolve the wire key from the vocabulary persona field
|
|
1676
1869
|
// (lowercased) and the code from the global {label,value} map by label.
|
|
1677
|
-
const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary);
|
|
1870
|
+
const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary, knownFieldValues);
|
|
1678
1871
|
if (field === null)
|
|
1679
1872
|
continue;
|
|
1680
1873
|
const code = labelValue.get(label);
|
|
@@ -2035,6 +2228,19 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
2035
2228
|
function isValidJsIdentifier(s) {
|
|
2036
2229
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
|
|
2037
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
|
+
}
|
|
2038
2244
|
/**
|
|
2039
2245
|
* Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
|
|
2040
2246
|
* Identifier segments use dot access; numeric / non-identifier segments use
|
|
@@ -3313,12 +3519,54 @@ function bindOptionLiteral(headerBindings) {
|
|
|
3313
3519
|
// ── code emitters ─────────────────────────────────────────────────────────────
|
|
3314
3520
|
/** Generates a complete contract.ts source string for a plugin — exported so
|
|
3315
3521
|
* unit tests can drive the emitter directly without spawning the CLI. */
|
|
3522
|
+
/**
|
|
3523
|
+
* Splices payload fields into a string variable that packs filter facets as
|
|
3524
|
+
* delimited `key:value` segments (e.g. a product-catalog `filters` variable
|
|
3525
|
+
* shaped like `category:widgets|priceRange:10~50`) — the shape GraphQL search
|
|
3526
|
+
* endpoints commonly use instead of exposing each facet as its own top-level
|
|
3527
|
+
* variable, which is otherwise invisible to the key-name-equality strategy in
|
|
3528
|
+
* {@link renderGqlVariablesExpr}. Every facet segment whose key correlates
|
|
3529
|
+
* (case-insensitively) with one of `fields` gets its value slot spliced with
|
|
3530
|
+
* `payload.<Field>`; segments that don't correlate are left as their literal
|
|
3531
|
+
* `key:value` piece. Returns `null` for non-string values, strings with no
|
|
3532
|
+
* delimiter-separated `key:value` segment, and strings whose facet keys
|
|
3533
|
+
* correlate with none of `fields`, so opaque tokens, JSON blobs, and plain
|
|
3534
|
+
* literals fall through to the existing JSON.stringify path unchanged.
|
|
3535
|
+
*/
|
|
3536
|
+
function spliceFacetsIntoStringVariable(value, fields) {
|
|
3537
|
+
if (typeof value !== "string")
|
|
3538
|
+
return null;
|
|
3539
|
+
const segments = value.split(/([|,;])/);
|
|
3540
|
+
const hasFacetShape = segments.some((segment, index) => index % 2 === 0 && segment.includes(":"));
|
|
3541
|
+
if (!hasFacetShape)
|
|
3542
|
+
return null;
|
|
3543
|
+
let hasMatch = false;
|
|
3544
|
+
const body = segments
|
|
3545
|
+
.map((segment, index) => {
|
|
3546
|
+
if (index % 2 !== 0 || !segment.includes(":"))
|
|
3547
|
+
return escapeForTemplateLiteral(segment);
|
|
3548
|
+
const colonIndex = segment.indexOf(":");
|
|
3549
|
+
const facetKey = segment.slice(0, colonIndex);
|
|
3550
|
+
const matchedField = fields.find((field) => field.toLowerCase() === facetKey.toLowerCase());
|
|
3551
|
+
if (!matchedField)
|
|
3552
|
+
return escapeForTemplateLiteral(segment);
|
|
3553
|
+
hasMatch = true;
|
|
3554
|
+
return `${escapeForTemplateLiteral(`${facetKey}:`)}\${payload.${matchedField}}`;
|
|
3555
|
+
})
|
|
3556
|
+
.join("");
|
|
3557
|
+
if (!hasMatch)
|
|
3558
|
+
return null;
|
|
3559
|
+
return `\`${body}\``;
|
|
3560
|
+
}
|
|
3316
3561
|
/**
|
|
3317
3562
|
* Renders the variables literal for the primary-operation getGql() call —
|
|
3318
3563
|
* each key from the selected capture's own recorded variables is bound to
|
|
3319
3564
|
* `payload.<Field>` when it correlates (case-insensitively) with one of the
|
|
3320
|
-
* flow's payloadFieldNames
|
|
3321
|
-
*
|
|
3565
|
+
* flow's payloadFieldNames. When no top-level key correlates and the value is
|
|
3566
|
+
* a string packing facets in a delimited `key:value` grammar (see
|
|
3567
|
+
* {@link spliceFacetsIntoStringVariable}), a correlated facet's value slot is
|
|
3568
|
+
* spliced with `payload.<Field>` instead of freezing the whole string; any
|
|
3569
|
+
* other value is emitted verbatim via JSON.stringify.
|
|
3322
3570
|
*/
|
|
3323
3571
|
function renderGqlVariablesExpr(variables, payloadFieldNames) {
|
|
3324
3572
|
if (variables === null || typeof variables !== "object" || Array.isArray(variables))
|
|
@@ -3326,13 +3574,54 @@ function renderGqlVariablesExpr(variables, payloadFieldNames) {
|
|
|
3326
3574
|
const fields = payloadFieldNames ? [...payloadFieldNames] : [];
|
|
3327
3575
|
const entries = Object.entries(variables).map(([key, value]) => {
|
|
3328
3576
|
const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
|
|
3329
|
-
const
|
|
3577
|
+
const facetSpliceExpr = matchedField ? null : spliceFacetsIntoStringVariable(value, fields);
|
|
3578
|
+
const valueExpr = matchedField
|
|
3579
|
+
? `payload.${matchedField}`
|
|
3580
|
+
: (facetSpliceExpr ?? JSON.stringify(value));
|
|
3330
3581
|
return `${key}: ${valueExpr}`;
|
|
3331
3582
|
});
|
|
3332
3583
|
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
|
|
3333
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
|
+
}
|
|
3334
3623
|
function emitContractTs(opts) {
|
|
3335
|
-
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;
|
|
3336
3625
|
// This is the CLIENT-level schema — createHttpClient's default, and the
|
|
3337
3626
|
// plugin's caller-facing contract (what executeHttp's return value promises
|
|
3338
3627
|
// its own caller). It does NOT validate any individual call in a multi-step
|
|
@@ -3351,8 +3640,16 @@ function emitContractTs(opts) {
|
|
|
3351
3640
|
// Single-endpoint plugins keep the same inferred-schema treatment, since
|
|
3352
3641
|
// there both roles (client default and sole call) coincide.
|
|
3353
3642
|
// Browser-flow-only plugins have no HTTP call to infer a shape from —
|
|
3354
|
-
// z.unknown() there is the honest gap, not a narrowing shortcut.
|
|
3355
|
-
|
|
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);
|
|
3356
3653
|
// Multi-step flows that include a multipart upload need the binary asset
|
|
3357
3654
|
// on the payload. ApplicantContactSchema (via ApplicantResumeSchema) already
|
|
3358
3655
|
// declares Resume/ResumeContentType/ResumeFilename, so submission flows
|
|
@@ -3498,6 +3795,7 @@ function emitContractTs(opts) {
|
|
|
3498
3795
|
const sortedAdditionalKeys = discoveredAdditionalBodyKeys
|
|
3499
3796
|
? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
3500
3797
|
: [];
|
|
3798
|
+
let usesMultipartBoolean = false;
|
|
3501
3799
|
for (const [name, kind] of sortedAdditionalKeys) {
|
|
3502
3800
|
if (isReservedByApplicantContactSchema(name))
|
|
3503
3801
|
continue;
|
|
@@ -3513,6 +3811,8 @@ function emitContractTs(opts) {
|
|
|
3513
3811
|
: payloadNeedsMultipart
|
|
3514
3812
|
? "multipartBoolean()"
|
|
3515
3813
|
: "z.boolean()";
|
|
3814
|
+
if (zod === "multipartBoolean()")
|
|
3815
|
+
usesMultipartBoolean = true;
|
|
3516
3816
|
addExtendField(name, ` ${name}: ${zod},`);
|
|
3517
3817
|
}
|
|
3518
3818
|
// Mechanism B: nested caller structures become payload fields carrying their
|
|
@@ -3541,6 +3841,9 @@ function emitContractTs(opts) {
|
|
|
3541
3841
|
const internalRequestReferenceExpr = inputBody
|
|
3542
3842
|
? inferZodSchema(inputBody, 0, "", { multipartCoerce: hasMultipartStep })
|
|
3543
3843
|
: null;
|
|
3844
|
+
if (internalRequestReferenceExpr?.includes("multipartBoolean(")) {
|
|
3845
|
+
usesMultipartBoolean = true;
|
|
3846
|
+
}
|
|
3544
3847
|
// All field sources above are merged into a SINGLE `.extend({...})` object
|
|
3545
3848
|
// literal, keyed by field name — a name that recurs across sources (or
|
|
3546
3849
|
// that collides with the base extend's own Email/ClickUrl/Answers) collapses
|
|
@@ -3550,12 +3853,12 @@ function emitContractTs(opts) {
|
|
|
3550
3853
|
const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
|
|
3551
3854
|
// basePayloadSchemaExpr's own Answers field always wraps in
|
|
3552
3855
|
// multipartJsonObject() for submission flows (inputBody set);
|
|
3553
|
-
// multipartBoolean()
|
|
3554
|
-
//
|
|
3555
|
-
//
|
|
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.
|
|
3556
3859
|
// Named imports from the same module are combined into one import statement.
|
|
3557
3860
|
const zodMultipartNamedImports = [
|
|
3558
|
-
...(
|
|
3861
|
+
...(usesMultipartBoolean ? ["multipartBoolean"] : []),
|
|
3559
3862
|
...(inputBody || (payloadNeedsMultipart && sortedStructuredEntries.length > 0)
|
|
3560
3863
|
? ["multipartJsonObject"]
|
|
3561
3864
|
: []),
|
|
@@ -3569,7 +3872,7 @@ function emitContractTs(opts) {
|
|
|
3569
3872
|
? `import { ApplicantContactSchema } from "${ENGINE_PKG}/lib/applicant-payload";\n`
|
|
3570
3873
|
: "";
|
|
3571
3874
|
// Content-Type must be absent from multipart fetch calls so FormData can inject the boundary.
|
|
3572
|
-
const caseInsensitiveHeadersImport = hasMultipartStep
|
|
3875
|
+
const caseInsensitiveHeadersImport = hasMultipartStep && !omitExecuteHttp
|
|
3573
3876
|
? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
|
|
3574
3877
|
: "";
|
|
3575
3878
|
// Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
|
|
@@ -3634,7 +3937,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
3634
3937
|
const fixtureComments = auxFiles.length > 0
|
|
3635
3938
|
? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
|
|
3636
3939
|
auxFiles
|
|
3637
|
-
.map((f) => `// const ${f
|
|
3940
|
+
.map((f) => `// const ${sanitizeFixtureIdentifier(f)} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
|
|
3638
3941
|
.join("\n") +
|
|
3639
3942
|
"\n"
|
|
3640
3943
|
: "";
|
|
@@ -3657,22 +3960,6 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
3657
3960
|
export const ${pascal}InternalRequestReference = ${internalRequestReferenceExpr};
|
|
3658
3961
|
`
|
|
3659
3962
|
: "";
|
|
3660
|
-
const queryChecklistLine = !omitExecuteHttp && gql
|
|
3661
|
-
? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
|
|
3662
|
-
: "";
|
|
3663
|
-
// Multi-step flows validate each call against its own per-call inferred
|
|
3664
|
-
// schema (emitMultiStepExecuteHttp) — narrowing ResponseSchema only changes
|
|
3665
|
-
// what executeHttp promises ITS OWN caller, never a per-call validator, so
|
|
3666
|
-
// the checklist item must say that explicitly. Single-endpoint plugins have
|
|
3667
|
-
// exactly one call, so the client schema and that call's validator are the
|
|
3668
|
-
// same schema and the shorter wording stays accurate. Browser-flow-only
|
|
3669
|
-
// plugins have no executeHttp at all, so ResponseSchema is only ever the
|
|
3670
|
-
// browser flow's own return-value contract.
|
|
3671
|
-
const narrowSchemaChecklistLine = omitExecuteHttp
|
|
3672
|
-
? `\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`
|
|
3673
|
-
: multiStepBody
|
|
3674
|
-
? `\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)`
|
|
3675
|
-
: `\n * [ ] Narrow ${pascal}ResponseSchema to match the real response shape`;
|
|
3676
3963
|
const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
3677
3964
|
// Browser-flow-only plugins need neither Bottleneck (no rate-limited HTTP
|
|
3678
3965
|
// client) nor BASE_HEADERS (no per-call headers to bake in) — both would
|
|
@@ -3685,10 +3972,13 @@ const BASE_HEADERS: Record<string, string> = {
|
|
|
3685
3972
|
${headersLiteral},
|
|
3686
3973
|
};
|
|
3687
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).`;
|
|
3688
3978
|
const limiterBlock = omitExecuteHttp
|
|
3689
3979
|
? ""
|
|
3690
3980
|
: `
|
|
3691
|
-
|
|
3981
|
+
${rateLimitComment}
|
|
3692
3982
|
const limiter = new Bottleneck({ minTime: ${minTime} });
|
|
3693
3983
|
`;
|
|
3694
3984
|
const executeHttpMethodBlock = omitExecuteHttp
|
|
@@ -3696,7 +3986,7 @@ const limiter = new Bottleneck({ minTime: ${minTime} });
|
|
|
3696
3986
|
: `
|
|
3697
3987
|
/** Hot path: direct HTTP — no browser, no LLM tokens. */
|
|
3698
3988
|
async executeHttp(
|
|
3699
|
-
payload: ${pascal}Payload,
|
|
3989
|
+
${executeHttpBody.includes("payload.") ? "payload" : "_payload"}: ${pascal}Payload,
|
|
3700
3990
|
${executeHttpBody.includes("context.") ? "context" : "_context"}: SitePluginContext
|
|
3701
3991
|
): Promise<SitePluginResult<${pascal}Response>> {
|
|
3702
3992
|
${executeHttpBody}
|
|
@@ -3706,23 +3996,15 @@ ${executeHttpBody}
|
|
|
3706
3996
|
? `/**
|
|
3707
3997
|
* Plugin for ${siteId}. Browser-flow-only: the captured multi-step submission
|
|
3708
3998
|
* sequence could not be synthesized into a trustworthy direct-HTTP hot path
|
|
3709
|
-
* (see
|
|
3999
|
+
* (see recon-generate's review checklist, logged to stdout at generation
|
|
4000
|
+
* time), so this always runs via Stagehand.
|
|
3710
4001
|
*/`
|
|
3711
4002
|
: `/**
|
|
3712
4003
|
* Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
|
|
3713
4004
|
* Stagehand automatically on schema drift or bot challenge.
|
|
3714
4005
|
*/`;
|
|
3715
|
-
const baseHeadersChecklistLine = omitExecuteHttp
|
|
3716
|
-
? ""
|
|
3717
|
-
: `\n * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing`;
|
|
3718
|
-
const outOfTreeChecklistLine = omitExecuteHttp
|
|
3719
|
-
? `\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`
|
|
3720
|
-
: `\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`;
|
|
3721
4006
|
return `/**
|
|
3722
4007
|
* Generated by recon-generate.ts — review before shipping.
|
|
3723
|
-
*
|
|
3724
|
-
* Checklist:${queryChecklistLine}${narrowSchemaChecklistLine}
|
|
3725
|
-
* [ ] Adjust ${pascal}PayloadSchema to your actual request parameters${baseHeadersChecklistLine}${outOfTreeChecklistLine}
|
|
3726
4008
|
*/
|
|
3727
4009
|
|
|
3728
4010
|
${bottleneckImport}import { z } from "zod/v4";
|
|
@@ -3746,11 +4028,11 @@ ${pluginDocComment}
|
|
|
3746
4028
|
export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
3747
4029
|
meta: {
|
|
3748
4030
|
siteId: ${JSON.stringify(siteId)},
|
|
3749
|
-
displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
|
|
3750
4031
|
bodySchema: ${pascal}PayloadSchema,
|
|
3751
4032
|
responseSchema: ${pascal}ResponseSchema,
|
|
3752
4033
|
defaultBaseUrl: ${JSON.stringify(baseUrl)},
|
|
3753
|
-
|
|
4034
|
+
${payloadNeedsMultipart || inputBody
|
|
4035
|
+
? `// multipart is required whenever the flow itself uploads a file
|
|
3754
4036
|
// (hasMultipartStep), OR this is a submission flow (inputBody set) since
|
|
3755
4037
|
// basePayloadSchemaExpr always requires a real Resume Buffer via
|
|
3756
4038
|
// ApplicantContactSchema regardless of whether the recorded browser flow
|
|
@@ -3758,7 +4040,8 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
|
3758
4040
|
// discoveredStructuredKeys field (payloadNeedsMultipart), since the
|
|
3759
4041
|
// multipart wire format is what makes that field's JSON-stringified
|
|
3760
4042
|
// encoding parseable.
|
|
3761
|
-
|
|
4043
|
+
`
|
|
4044
|
+
: ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || inputBody ? "\n multipart: true," : ""}
|
|
3762
4045
|
},
|
|
3763
4046
|
${executeHttpMethodBlock}
|
|
3764
4047
|
/** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
|
|
@@ -3767,7 +4050,7 @@ ${executeHttpMethodBlock}
|
|
|
3767
4050
|
session: BrowserSession,
|
|
3768
4051
|
context: SitePluginContext
|
|
3769
4052
|
): Promise<SitePluginResult<${pascal}Response>> {
|
|
3770
|
-
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);
|
|
3771
4054
|
return { data: raw as ${pascal}Response };
|
|
3772
4055
|
},
|
|
3773
4056
|
};
|
|
@@ -3778,6 +4061,29 @@ ${executeHttpMethodBlock}
|
|
|
3778
4061
|
export { ${camel}Plugin as plugin };
|
|
3779
4062
|
`;
|
|
3780
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
|
+
}
|
|
3781
4087
|
/**
|
|
3782
4088
|
* Escape a literal string segment so it is safe INSIDE a JS backtick template
|
|
3783
4089
|
* literal — backslashes, backticks, and `${` interpolation starts must all be
|
|
@@ -3805,6 +4111,19 @@ function buildStepInstructionExpr(instruction, field) {
|
|
|
3805
4111
|
return JSON.stringify(instruction);
|
|
3806
4112
|
return `\`${escapeForTemplateLiteral(site.before)}\${payload.${field}}${escapeForTemplateLiteral(site.after)}\``;
|
|
3807
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
|
+
}
|
|
3808
4127
|
/**
|
|
3809
4128
|
* Build the emitted instruction expression for a step whose splice site is the
|
|
3810
4129
|
* reserved `${RECON_PASSWORD}` token: a backtick template literal with the
|
|
@@ -3876,8 +4195,9 @@ function jsonSchemaTypeOf(value) {
|
|
|
3876
4195
|
* browser `flow` is the only execution path, and the field is omitted.
|
|
3877
4196
|
*/
|
|
3878
4197
|
function emitConfigManifest(opts) {
|
|
3879
|
-
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, } = opts;
|
|
4198
|
+
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, env = process.env, } = opts;
|
|
3880
4199
|
const payloadFieldNames = new Set();
|
|
4200
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
|
|
3881
4201
|
const steps = flowSteps.map((step) => {
|
|
3882
4202
|
const isObj = typeof step !== "string";
|
|
3883
4203
|
const instruction = isObj ? step.step : step;
|
|
@@ -3896,7 +4216,7 @@ function emitConfigManifest(opts) {
|
|
|
3896
4216
|
return rewritten;
|
|
3897
4217
|
return { step: rewritten, optional, upload, submitStep };
|
|
3898
4218
|
}
|
|
3899
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
|
|
4219
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
3900
4220
|
if (field !== null)
|
|
3901
4221
|
payloadFieldNames.add(field);
|
|
3902
4222
|
const rewritten = buildManifestInstruction(instruction, field);
|
|
@@ -3907,6 +4227,23 @@ function emitConfigManifest(opts) {
|
|
|
3907
4227
|
return rewritten;
|
|
3908
4228
|
return { step: rewritten, optional, upload, submitStep };
|
|
3909
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
|
+
}
|
|
3910
4247
|
// The request surface, widest wins: a flow splice, a recovered form field, or
|
|
3911
4248
|
// a key from the first POST body all name something a caller controls. Splices
|
|
3912
4249
|
// and recovered fields are strings (the browser flow fills them as text); a
|
|
@@ -3927,7 +4264,7 @@ function emitConfigManifest(opts) {
|
|
|
3927
4264
|
const manifest = {
|
|
3928
4265
|
apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
|
|
3929
4266
|
kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
|
|
3930
|
-
metadata: { siteId, displayName },
|
|
4267
|
+
metadata: { siteId, ...(displayName !== undefined && { displayName }) },
|
|
3931
4268
|
spec: {
|
|
3932
4269
|
defaultBaseUrl: baseUrl,
|
|
3933
4270
|
...(httpModulePath ? { httpModule: httpModulePath } : {}),
|
|
@@ -3957,9 +4294,10 @@ function emitConfigManifest(opts) {
|
|
|
3957
4294
|
* payload schema (both are driven by this same set).
|
|
3958
4295
|
*/
|
|
3959
4296
|
function emitBrowserFlowTs(opts) {
|
|
3960
|
-
const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, } = opts;
|
|
4297
|
+
const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, env = process.env, } = opts;
|
|
3961
4298
|
const payloadFieldNames = new Set();
|
|
3962
4299
|
const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
|
|
4300
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
|
|
3963
4301
|
let usesThrowawayPassword = false;
|
|
3964
4302
|
const stepLiterals = flowSteps.map((step) => {
|
|
3965
4303
|
const isObj = typeof step !== "string";
|
|
@@ -3977,15 +4315,31 @@ function emitBrowserFlowTs(opts) {
|
|
|
3977
4315
|
const submitStep = isObj ? step.submitStep === true : false;
|
|
3978
4316
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
3979
4317
|
}
|
|
3980
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
|
|
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;
|
|
3981
4322
|
if (field !== null)
|
|
3982
4323
|
payloadFieldNames.add(field);
|
|
3983
|
-
|
|
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);
|
|
3984
4331
|
const optional = isObj ? step.optional === true : false;
|
|
3985
4332
|
const upload = isObj ? step.upload === true : false;
|
|
3986
4333
|
const submitStep = isObj ? step.submitStep === true : false;
|
|
3987
4334
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
3988
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
|
+
}
|
|
3989
4343
|
const flowStepsBlock = stepLiterals.length > 0
|
|
3990
4344
|
? stepLiterals.join("\n")
|
|
3991
4345
|
: " // TODO: no flow steps were parsed. Re-run recon-browser with a --flow\n" +
|
|
@@ -4012,8 +4366,8 @@ function emitBrowserFlowTs(opts) {
|
|
|
4012
4366
|
* Core invokes this automatically when executeHttp throws HttpSchemaError or
|
|
4013
4367
|
* HttpBotChallengeError. Update the flow steps and extract schema as needed.
|
|
4014
4368
|
*
|
|
4015
|
-
* Steps whose instruction named a
|
|
4016
|
-
* 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
|
|
4017
4371
|
* the page; operational-default steps stay literal. The steps run through the
|
|
4018
4372
|
* self-heal cascade via runHealingFlow — the same engine the recon CLI uses,
|
|
4019
4373
|
* minus its disk-dump/replan layer.
|
|
@@ -4030,9 +4384,13 @@ import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/cont
|
|
|
4030
4384
|
|
|
4031
4385
|
const logger = getLogger({ name: "${siteId}-browser-flow" });
|
|
4032
4386
|
|
|
4033
|
-
const ${pascal}BrowserSchema = z.object({
|
|
4034
|
-
|
|
4035
|
-
|
|
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(),`}
|
|
4036
4394
|
});
|
|
4037
4395
|
|
|
4038
4396
|
/**
|
|
@@ -4042,13 +4400,13 @@ const ${pascal}BrowserSchema = z.object({
|
|
|
4042
4400
|
*/
|
|
4043
4401
|
export async function run${pascal}BrowserFlow(
|
|
4044
4402
|
stagehand: Stagehand,
|
|
4045
|
-
baseUrl: string,
|
|
4403
|
+
${isSubmissionFlow ? "entryUrl" : "baseUrl"}: string,
|
|
4046
4404
|
payload: ${pascal}Payload
|
|
4047
4405
|
): Promise<${pascal}Response> {
|
|
4048
4406
|
const page = await stagehand.context.awaitActivePage();
|
|
4049
4407
|
|
|
4050
|
-
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
|
4051
|
-
// 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
|
|
4052
4410
|
// the real DOM so the first steps don't probe an empty shell page and skip.
|
|
4053
4411
|
await waitForSpaReady(page, logger);
|
|
4054
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" : ""}
|
|
@@ -4074,16 +4432,37 @@ ${flowStepsBlock}
|
|
|
4074
4432
|
// both Zod v3 and v4 schemas natively (StagehandZodSchema union since
|
|
4075
4433
|
// 2.4.3 / PR #944), and the caller-side safeParse defends against SDK
|
|
4076
4434
|
// contract drift. Widen ${pascal}BrowserSchema as needed to match the
|
|
4077
|
-
// 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
|
+
: `
|
|
4078
4456
|
const result = await guardedExtract(
|
|
4079
4457
|
stagehand,
|
|
4080
|
-
|
|
4458
|
+
\`extract results matching query: \${payload.query}\`,
|
|
4081
4459
|
${pascal}BrowserSchema
|
|
4082
4460
|
);
|
|
4083
4461
|
|
|
4084
4462
|
return result as unknown as ${pascal}Response;
|
|
4085
4463
|
}
|
|
4086
|
-
`;
|
|
4464
|
+
`}`;
|
|
4465
|
+
assertNoLeakedPersonaConstant(code, knownFieldValues);
|
|
4087
4466
|
return { code, payloadFieldNames };
|
|
4088
4467
|
}
|
|
4089
4468
|
/** Generates the site's index.ts barrel — exported so the out-of-tree e2e
|
|
@@ -4199,6 +4578,9 @@ async function main() {
|
|
|
4199
4578
|
"rate-limit.json",
|
|
4200
4579
|
"introspection-schema.json",
|
|
4201
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
|
+
}
|
|
4202
4584
|
const rateLimits = (() => {
|
|
4203
4585
|
try {
|
|
4204
4586
|
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(replaysDir, "rate-limit.json"), "utf8"));
|
|
@@ -4274,8 +4656,8 @@ async function main() {
|
|
|
4274
4656
|
const baseUrl = deriveBaseUrl(captures);
|
|
4275
4657
|
const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns);
|
|
4276
4658
|
const minTime = deriveMinTime(rateLimits);
|
|
4659
|
+
const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
|
|
4277
4660
|
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
4278
|
-
const responseBody = firstSuccessfulReplayBody(replays);
|
|
4279
4661
|
const gql = isGraphQL(captures);
|
|
4280
4662
|
// Hoisted so both the primary-operation gate below and rawActionCaptures
|
|
4281
4663
|
// (further down) read the same computed sequence instead of calling the
|
|
@@ -4287,6 +4669,11 @@ async function main() {
|
|
|
4287
4669
|
: null;
|
|
4288
4670
|
const gqlQuery = primaryGraphQLOperation?.capture.query ?? firstGraphQLQuery(captures);
|
|
4289
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;
|
|
4290
4677
|
// Detect a multi-step submission flow (transactional sites like apply forms,
|
|
4291
4678
|
// checkout, etc.). When the action sequence has 2+ POSTs, switch the
|
|
4292
4679
|
// contract template to emit a state-threaded executeHttp.
|
|
@@ -4520,12 +4907,12 @@ async function main() {
|
|
|
4520
4907
|
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
4521
4908
|
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
4522
4909
|
siteId,
|
|
4523
|
-
displayName: pascal,
|
|
4524
4910
|
baseUrl,
|
|
4525
4911
|
flowSteps,
|
|
4526
4912
|
vocabulary,
|
|
4527
4913
|
inputBody,
|
|
4528
4914
|
recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
|
|
4915
|
+
isSubmissionFlow,
|
|
4529
4916
|
// A submission flow is the case where the `.ts` emit carries an
|
|
4530
4917
|
// executeHttp hot path; point the manifest at where the operator drops
|
|
4531
4918
|
// the compiled module rather than silently dropping the direct path.
|
|
@@ -4551,7 +4938,7 @@ async function main() {
|
|
|
4551
4938
|
vocabulary,
|
|
4552
4939
|
frameSelector,
|
|
4553
4940
|
});
|
|
4554
|
-
|
|
4941
|
+
const contractOpts = {
|
|
4555
4942
|
siteId,
|
|
4556
4943
|
pascal,
|
|
4557
4944
|
baseUrl,
|
|
@@ -4560,6 +4947,7 @@ async function main() {
|
|
|
4560
4947
|
baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
|
|
4561
4948
|
minTime,
|
|
4562
4949
|
safeRps,
|
|
4950
|
+
hasRateLimitProbeData,
|
|
4563
4951
|
responseBody: effectiveResponseBody,
|
|
4564
4952
|
gql,
|
|
4565
4953
|
gqlQuery,
|
|
@@ -4569,6 +4957,7 @@ async function main() {
|
|
|
4569
4957
|
auxFiles,
|
|
4570
4958
|
multiStepBody,
|
|
4571
4959
|
omitExecuteHttp: browserFlowOnly,
|
|
4960
|
+
isSubmissionFlow,
|
|
4572
4961
|
inputBody,
|
|
4573
4962
|
hasMultipartStep,
|
|
4574
4963
|
discoveredFormFields,
|
|
@@ -4579,8 +4968,17 @@ async function main() {
|
|
|
4579
4968
|
discoveredStructuredKeys,
|
|
4580
4969
|
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
4581
4970
|
headerBindings,
|
|
4582
|
-
}
|
|
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);
|
|
4583
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")}`);
|
|
4584
4982
|
(0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);
|
|
4585
4983
|
logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
|
|
4586
4984
|
(0, node_fs_1.writeFileSync)(`${outDir}/index.ts`, emitIndexTs({ siteId, pascal }));
|