@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.
@@ -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
- function firstEndpointPath(captures) {
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
- return new URL(c.url).pathname;
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
- return new URL(c.url).pathname;
878
+ new URL(c.url);
879
+ return c;
717
880
  }
718
881
  catch {
719
882
  // skip
720
883
  }
721
884
  }
722
- return "/api/search";
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, otherwise the captured literal value is emitted
3321
- * verbatim via JSON.stringify.
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 valueExpr = matchedField ? `payload.${matchedField}` : JSON.stringify(value);
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
- const responseSchemaExpr = omitExecuteHttp ? `z.unknown()` : inferZodSchema(responseBody);
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() and the structured-keys wrapping above are needed
3554
- // whenever payloadNeedsMultipart is true (an upload step OR a non-scalar
3555
- // discoveredStructuredKeys field).
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
- ...(payloadNeedsMultipart ? ["multipartBoolean"] : []),
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.replace(".json", "")} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
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
- // Safe ceiling: ${safeRps} rps — from recon rate-limit probe.
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 the checklist above), so this always runs via Stagehand.
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
- // multipart is required whenever the flow itself uploads a file
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
- apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || inputBody ? "\n multipart: true," : ""}
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
- const instructionExpr = buildStepInstructionExpr(instruction, field);
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 candidate PII label have their recon
4016
- * constant spliced to \`payload.<field>\` so the caller's real applicant reaches
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
- // TODO: define the fields you need — align with ${pascal}Response
4035
- extraction: z.string(),
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 Cloudflare-fronted SPA hydrates; wait for
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
- ${isSubmissionFlow ? `\`drove the ${siteId} submission flow for payload \${JSON.stringify(payload)}\`` : `\`extract results matching query: \${payload.query}\``},
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
- (0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, emitContractTs({
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 }));