@enricai/barnacle 1.12.79 → 1.12.80

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.
Files changed (135) hide show
  1. package/dist/api/helpers/reply.d.ts.map +1 -1
  2. package/dist/api/helpers/reply.js +5 -2
  3. package/dist/api/helpers/reply.js.map +1 -1
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +2 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/lib/llm/judge.d.ts.map +1 -1
  9. package/dist/lib/llm/judge.js +6 -1
  10. package/dist/lib/llm/judge.js.map +1 -1
  11. package/dist/lib/llm/judges/verify-step-advanced.d.ts +30 -0
  12. package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -0
  13. package/dist/lib/llm/judges/verify-step-advanced.js +62 -0
  14. package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -0
  15. package/dist/lib/llm/judges/verify-submit.d.ts +7 -1
  16. package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -1
  17. package/dist/lib/llm/judges/verify-submit.js +29 -8
  18. package/dist/lib/llm/judges/verify-submit.js.map +1 -1
  19. package/dist/lib/llm/schemas.d.ts +8 -0
  20. package/dist/lib/llm/schemas.d.ts.map +1 -1
  21. package/dist/lib/llm/schemas.js +21 -13
  22. package/dist/lib/llm/schemas.js.map +1 -1
  23. package/dist/lib/logging.d.ts +7 -0
  24. package/dist/lib/logging.d.ts.map +1 -1
  25. package/dist/lib/logging.js +19 -1
  26. package/dist/lib/logging.js.map +1 -1
  27. package/dist/lib/pii-redaction.d.ts +104 -0
  28. package/dist/lib/pii-redaction.d.ts.map +1 -0
  29. package/dist/lib/pii-redaction.js +397 -0
  30. package/dist/lib/pii-redaction.js.map +1 -0
  31. package/dist/lib/random.d.ts +3 -1
  32. package/dist/lib/random.d.ts.map +1 -1
  33. package/dist/lib/random.js +7 -2
  34. package/dist/lib/random.js.map +1 -1
  35. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
  36. package/dist/lib/telemetry/beacon-capture.js +2 -0
  37. package/dist/lib/telemetry/beacon-capture.js.map +1 -1
  38. package/dist/lib/telemetry/call-capture.d.ts.map +1 -1
  39. package/dist/lib/telemetry/call-capture.js +4 -1
  40. package/dist/lib/telemetry/call-capture.js.map +1 -1
  41. package/dist/lib/telemetry/submission-capture.d.ts +11 -1
  42. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  43. package/dist/lib/telemetry/submission-capture.js +32 -3
  44. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  45. package/dist/plugins/config-plugin.d.ts +8 -0
  46. package/dist/plugins/config-plugin.d.ts.map +1 -1
  47. package/dist/plugins/config-plugin.js +4 -0
  48. package/dist/plugins/config-plugin.js.map +1 -1
  49. package/dist/plugins/discover.d.ts.map +1 -1
  50. package/dist/plugins/discover.js +2 -0
  51. package/dist/plugins/discover.js.map +1 -1
  52. package/dist/plugins/loader.d.ts.map +1 -1
  53. package/dist/plugins/loader.js +23 -6
  54. package/dist/plugins/loader.js.map +1 -1
  55. package/dist/scraper/active-page.d.ts +18 -0
  56. package/dist/scraper/active-page.d.ts.map +1 -0
  57. package/dist/scraper/active-page.js +53 -0
  58. package/dist/scraper/active-page.js.map +1 -0
  59. package/dist/scraper/click-settle.d.ts +19 -0
  60. package/dist/scraper/click-settle.d.ts.map +1 -0
  61. package/dist/scraper/click-settle.js +65 -0
  62. package/dist/scraper/click-settle.js.map +1 -0
  63. package/dist/scraper/deep-locator-candidates.d.ts +20 -1
  64. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  65. package/dist/scraper/deep-locator-candidates.js +75 -11
  66. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  67. package/dist/scraper/flow-runner.d.ts +321 -6
  68. package/dist/scraper/flow-runner.d.ts.map +1 -1
  69. package/dist/scraper/flow-runner.js +2087 -341
  70. package/dist/scraper/flow-runner.js.map +1 -1
  71. package/dist/scraper/navigate.d.ts +3 -3
  72. package/dist/scraper/navigate.js +4 -4
  73. package/dist/scraper/navigate.js.map +1 -1
  74. package/dist/scraper/phantom-click.d.ts +34 -2
  75. package/dist/scraper/phantom-click.d.ts.map +1 -1
  76. package/dist/scraper/phantom-click.js +80 -6
  77. package/dist/scraper/phantom-click.js.map +1 -1
  78. package/dist/scraper/quoted-spans.d.ts +35 -0
  79. package/dist/scraper/quoted-spans.d.ts.map +1 -0
  80. package/dist/scraper/quoted-spans.js +83 -0
  81. package/dist/scraper/quoted-spans.js.map +1 -0
  82. package/dist/scraper/session-browserbase.d.ts +4 -2
  83. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  84. package/dist/scraper/session-browserbase.js +36 -5
  85. package/dist/scraper/session-browserbase.js.map +1 -1
  86. package/dist/scraper/session-steel.d.ts.map +1 -1
  87. package/dist/scraper/session-steel.js +16 -2
  88. package/dist/scraper/session-steel.js.map +1 -1
  89. package/dist/scraper/session-teardown.d.ts +1 -0
  90. package/dist/scraper/session-teardown.d.ts.map +1 -1
  91. package/dist/scraper/session-teardown.js +19 -8
  92. package/dist/scraper/session-teardown.js.map +1 -1
  93. package/dist/scraper/spa-readiness.d.ts +13 -1
  94. package/dist/scraper/spa-readiness.d.ts.map +1 -1
  95. package/dist/scraper/spa-readiness.js +31 -1
  96. package/dist/scraper/spa-readiness.js.map +1 -1
  97. package/dist/scraper/stagehand-guard.d.ts +8 -3
  98. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  99. package/dist/scraper/stagehand-guard.js +181 -13
  100. package/dist/scraper/stagehand-guard.js.map +1 -1
  101. package/dist/scraper/transient-provider-error.d.ts +19 -0
  102. package/dist/scraper/transient-provider-error.d.ts.map +1 -0
  103. package/dist/scraper/transient-provider-error.js +59 -0
  104. package/dist/scraper/transient-provider-error.js.map +1 -0
  105. package/dist/scraper/trusted-click.d.ts +122 -0
  106. package/dist/scraper/trusted-click.d.ts.map +1 -0
  107. package/dist/scraper/trusted-click.js +201 -0
  108. package/dist/scraper/trusted-click.js.map +1 -0
  109. package/dist/scraper/upload-affordance-expr.d.ts +33 -0
  110. package/dist/scraper/upload-affordance-expr.d.ts.map +1 -0
  111. package/dist/scraper/upload-affordance-expr.js +91 -0
  112. package/dist/scraper/upload-affordance-expr.js.map +1 -0
  113. package/dist/scripts/recon-browser.d.ts +26 -1
  114. package/dist/scripts/recon-browser.d.ts.map +1 -1
  115. package/dist/scripts/recon-browser.js +108 -21
  116. package/dist/scripts/recon-browser.js.map +1 -1
  117. package/dist/scripts/recon-capture-rebind.d.ts +11 -0
  118. package/dist/scripts/recon-capture-rebind.d.ts.map +1 -0
  119. package/dist/scripts/recon-capture-rebind.js +26 -0
  120. package/dist/scripts/recon-capture-rebind.js.map +1 -0
  121. package/dist/scripts/recon-generate.d.ts +112 -9
  122. package/dist/scripts/recon-generate.d.ts.map +1 -1
  123. package/dist/scripts/recon-generate.js +889 -181
  124. package/dist/scripts/recon-generate.js.map +1 -1
  125. package/dist/scripts/recon-shared.d.ts +7 -0
  126. package/dist/scripts/recon-shared.d.ts.map +1 -1
  127. package/dist/scripts/recon-shared.js +19 -0
  128. package/dist/scripts/recon-shared.js.map +1 -1
  129. package/dist/site-plugin.d.ts +8 -0
  130. package/dist/site-plugin.d.ts.map +1 -1
  131. package/dist/types/pii-redaction.d.ts +39 -0
  132. package/dist/types/pii-redaction.d.ts.map +1 -0
  133. package/dist/types/pii-redaction.js +3 -0
  134. package/dist/types/pii-redaction.js.map +1 -0
  135. package/package.json +14 -1
@@ -22,6 +22,7 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.resolveStepPayloadField = resolveStepPayloadField;
25
+ exports.canonicalPayloadField = canonicalPayloadField;
25
26
  exports.buildKnownFieldValues = buildKnownFieldValues;
26
27
  exports.extractStepPersonaValue = extractStepPersonaValue;
27
28
  exports.extractNavigateToHashFragmentValue = extractNavigateToHashFragmentValue;
@@ -68,6 +69,7 @@ exports.resolveRecurringNavigateToFacets = resolveRecurringNavigateToFacets;
68
69
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
69
70
  exports.countSpliceableFacets = countSpliceableFacets;
70
71
  exports.spliceFacetsIntoStringVariable = spliceFacetsIntoStringVariable;
72
+ exports.matchFacetRecurrence = matchFacetRecurrence;
71
73
  exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
72
74
  exports.renderGqlVariablesExpr = renderGqlVariablesExpr;
73
75
  exports.findAllObjectArrayFields = findAllObjectArrayFields;
@@ -80,6 +82,7 @@ exports.getScanPrimaryCandidateGroupsCallCountForTest = getScanPrimaryCandidateG
80
82
  exports.resetScanPrimaryCandidateGroupsCallCountForTest = resetScanPrimaryCandidateGroupsCallCountForTest;
81
83
  exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
82
84
  exports.parseFoldReturnSpec = parseFoldReturnSpec;
85
+ exports.parseBrowserFlowOnlyOptIn = parseBrowserFlowOnlyOptIn;
83
86
  exports.parseFallbackGateSpec = parseFallbackGateSpec;
84
87
  exports.readComparableBodyLeaf = readComparableBodyLeaf;
85
88
  exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHeaders;
@@ -88,9 +91,12 @@ exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCo
88
91
  exports.resolveFoldPlan = resolveFoldPlan;
89
92
  exports.resolveApplicableFoldPlans = resolveApplicableFoldPlans;
90
93
  exports.buildContractChecklist = buildContractChecklist;
94
+ exports.contractUsesApplicantContactSchema = contractUsesApplicantContactSchema;
91
95
  exports.emitContractTs = emitContractTs;
92
96
  exports.unreferencedRequiredUrlFields = unreferencedRequiredUrlFields;
93
97
  exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
98
+ exports.unreferencedDeclaredPayloadFields = unreferencedDeclaredPayloadFields;
99
+ exports.buildStepInstructionExpr = buildStepInstructionExpr;
94
100
  exports.emitConfigManifest = emitConfigManifest;
95
101
  exports.emitBrowserFlowTs = emitBrowserFlowTs;
96
102
  exports.emitIndexTs = emitIndexTs;
@@ -109,6 +115,8 @@ const load_value_constraints_1 = require("../recon/load-value-constraints");
109
115
  const load_vocabulary_1 = require("../recon/load-vocabulary");
110
116
  const value_constraints_1 = require("../recon/value-constraints");
111
117
  const vocabulary_1 = require("../recon/vocabulary");
118
+ const flow_runner_1 = require("../scraper/flow-runner");
119
+ const quoted_spans_1 = require("../scraper/quoted-spans");
112
120
  const recon_shared_1 = require("./recon-shared");
113
121
  const logger = (0, logging_1.getScriptLogger)("recon-generate");
114
122
  /**
@@ -141,39 +149,54 @@ const RESERVED_ENV_TOKEN = /\$\{RECON_[A-Z0-9_]*\}/;
141
149
  * (never through) vocabulary/payload-field resolution.
142
150
  */
143
151
  const RECON_PASSWORD_TOKEN = `$${"{RECON_PASSWORD}"}`;
144
- /**
145
- * Masks the apostrophe in a possessive `'s` (e.g. "the candidate's name") with
146
- * a non-quote placeholder of the same length, so a naive `'...'` quote scan
147
- * doesn't mistake the possessive apostrophe for an opening quote delimiter.
148
- * Length-preserving so callers that need positions in the ORIGINAL instruction
149
- * can reuse the indices matched against the masked string unchanged.
150
- */
151
- function maskPossessiveApostrophes(instruction) {
152
- return instruction.replace(/(\w)'(s\b)/g, "$1 $2");
153
- }
154
- function findQuoteSpans(instruction) {
155
- const masked = maskPossessiveApostrophes(instruction);
156
- return [...masked.matchAll(/'([^']*)'/g)].map((m) => ({
157
- index: m.index,
158
- length: m[0].length,
159
- value: instruction.slice(m.index + 1, m.index + m[0].length - 1),
152
+ const DOUBLE_QUOTED_SPAN = /"([^"]+)"/g;
153
+ /**
154
+ * Quoted spans of a flow-step instruction. The runtime parsers only understand
155
+ * single quotes, so a double-quoted span is consulted only when the step has no
156
+ * single-quoted one — a mixed instruction keeps its single-quote grammar, and a
157
+ * double-quoted-only step still has a spliceable value instead of being skipped.
158
+ */
159
+ function findInstructionSpans(instruction) {
160
+ const single = (0, quoted_spans_1.findQuotedSpans)(instruction);
161
+ if (single.length > 0)
162
+ return single;
163
+ return Array.from(instruction.matchAll(DOUBLE_QUOTED_SPAN), (match) => ({
164
+ text: match[1] ?? "",
165
+ start: match.index ?? 0,
166
+ end: (match.index ?? 0) + match[0].length,
160
167
  }));
161
168
  }
169
+ /**
170
+ * A quoted span that names the widget or question a step targets rather than
171
+ * the value it carries: followed by a widget noun ("'State' prompt selector"),
172
+ * or introduced by "for"/"from" ("for the 'State' question").
173
+ */
174
+ function namesAWidgetLabel(instruction, span) {
175
+ const before = instruction.slice(Math.max(0, span.start - 25), span.start);
176
+ const after = instruction.slice(span.end);
177
+ return (/^\s*(?:prompt\s+selector|selector|dropdown|drop-down|multiselect|typeahead|field|question|list|menu|widget)\b/i.test(after) || /\b(?:for|from)(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before));
178
+ }
162
179
  /**
163
180
  * Picks which quoted span in an instruction is the persona VALUE, per the
164
181
  * grammar recon-flow steps use: a `Select`/`Choose`/`Pick` step names the
165
- * ANSWER first, then the question, so the value is the FIRST quoted span; a
166
- * `Fill`/`Enter`/`Type` step names the field label first and the value last,
167
- * so it is the LAST quoted span.
182
+ * ANSWER after the verb (wherever the verb sits, so a leading context phrase
183
+ * does not hide it), so the value is the first span following the verb that is
184
+ * not a widget label — "choose the 'State' field, then choose 'Texas'" must
185
+ * yield Texas, not the label. Any other step (`Fill`/`Enter`/`Type`) names the
186
+ * field label first and the value last, so it is the LAST quoted span.
168
187
  */
169
188
  function pickValueSpan(instruction, spans) {
170
- return /^\s*(select|choose|pick)\b/i.test(instruction) ? spans[0] : spans[spans.length - 1];
189
+ const verb = /\b(select|choose|pick)\b/i.exec(instruction);
190
+ const afterVerb = verb ? spans.filter((span) => span.start >= verb.index + verb[0].length) : [];
191
+ const answer = afterVerb.find((span) => !namesAWidgetLabel(instruction, span)) ?? afterVerb[0];
192
+ return answer ?? spans[spans.length - 1];
171
193
  }
172
194
  /**
173
195
  * Locates the splice site in a flow-step instruction as `{ before, matched,
174
196
  * after }` slices of the ORIGINAL instruction — a reserved `${RECON_*}` env
175
197
  * token when present (preferred, since it names its own site unambiguously),
176
- * otherwise the quoted VALUE span per {@link pickValueSpan}'s verb-class rule.
198
+ * otherwise the quoted VALUE span per {@link pickValueSpan}'s verb-class rule
199
+ * (its quote delimiters stay in `before`/`after`, so a splice remains quoted).
177
200
  * This is the position-aware counterpart to {@link extractStepPersonaValue}:
178
201
  * that function only needs the extracted string, this one needs the actual
179
202
  * before/after slices so a template literal can be rebuilt around the site.
@@ -189,14 +212,14 @@ function locateSpliceSite(instruction) {
189
212
  after: instruction.slice(envToken.index + envToken[0].length),
190
213
  };
191
214
  }
192
- const spans = findQuoteSpans(instruction);
215
+ const spans = findInstructionSpans(instruction);
193
216
  if (spans.length === 0)
194
217
  return null;
195
218
  const span = pickValueSpan(instruction, spans);
196
219
  return {
197
- before: instruction.slice(0, span.index),
198
- matched: instruction.slice(span.index, span.index + span.length),
199
- after: instruction.slice(span.index + span.length),
220
+ before: instruction.slice(0, span.start + 1),
221
+ matched: instruction.slice(span.start + 1, span.end - 1),
222
+ after: instruction.slice(span.end - 1),
200
223
  };
201
224
  }
202
225
  /**
@@ -207,15 +230,15 @@ function locateSpliceSite(instruction) {
207
230
  * can never be mistaken for the data it merely mentions.
208
231
  */
209
232
  function namesAControl(instruction) {
210
- return findQuoteSpans(instruction).some((span) => /^\s*(button|link|tab)\b/i.test(instruction.slice(span.index + span.length)));
233
+ return findInstructionSpans(instruction).some((span) => /^\s*(button|link|tab)\b/i.test(instruction.slice(span.end)));
211
234
  }
212
235
  /** The quoted VALUE a Select/Choose/Fill step carries, per {@link pickValueSpan}'s
213
236
  * grammar rule — used to validate a vocabulary match's ANSWER, not just its label. */
214
237
  function pickedQuotedValue(instruction) {
215
- const spans = findQuoteSpans(instruction);
238
+ const spans = findInstructionSpans(instruction);
216
239
  if (spans.length === 0)
217
240
  return null;
218
- const value = pickValueSpan(instruction, spans).value;
241
+ const value = pickValueSpan(instruction, spans).text;
219
242
  return value.length > 0 ? value : null;
220
243
  }
221
244
  /**
@@ -254,9 +277,12 @@ function toPascalCase(siteId) {
254
277
  * label match names, or it is not that field's datum (e.g. a device-type
255
278
  * dropdown's `'Mobile'` looks like a phone-number field by label alone, but
256
279
  * is nothing like the number the Fill step already bound).
280
+ * @param canonicalise map a vocabulary row's name onto the equivalent
281
+ * `ApplicantContactSchema` key ({@link canonicalPayloadField}); set by the
282
+ * job-application emit paths whose contract extends that base schema
257
283
  * @returns the PascalCase payload field name to splice, or null to keep literal
258
284
  */
259
- function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY, knownFieldValues = EMPTY_KNOWN_FIELD_VALUES) {
285
+ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY, knownFieldValues = EMPTY_KNOWN_FIELD_VALUES, canonicalise = false) {
260
286
  if (forceNone)
261
287
  return null;
262
288
  if (explicit)
@@ -267,7 +293,7 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
267
293
  return null;
268
294
  // A quoted literal or a reserved ${RECON_*} env token IS the recon constant
269
295
  // this step would replace, so it is spliceable on its own.
270
- const hasQuotedConstant = /'[^']*'/.test(instruction) || RESERVED_ENV_TOKEN.test(instruction);
296
+ const hasQuotedConstant = findInstructionSpans(instruction).length > 0 || RESERVED_ENV_TOKEN.test(instruction);
271
297
  // A dropdown step carries no constant to replace, so a label match alone can't
272
298
  // tell "select the test candidate's state" (the caller's data) from "select the
273
299
  // neighborhood from the Country dropdown" (a facet that merely says Country).
@@ -286,18 +312,49 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
286
312
  for (const [rx, field] of vocabulary.table) {
287
313
  if (!rx.test(instruction))
288
314
  continue;
315
+ const resolved = canonicalise ? canonicalPayloadField(field) : field;
289
316
  if (!isSelectStep)
290
- return field;
317
+ return resolved;
291
318
  const answer = pickedQuotedValue(instruction);
292
319
  if (answer !== null && OPERATIONAL_ANSWER.test(answer.trim()))
293
320
  return null;
294
- const known = knownFieldValues.get(field);
321
+ const known = knownFieldValues.get(resolved);
295
322
  if (known !== undefined && answer !== known)
296
323
  return null;
297
- return field;
324
+ return resolved;
298
325
  }
299
326
  return null;
300
327
  }
328
+ /**
329
+ * Maps a vocabulary row's field name onto the base schema's equivalent so a
330
+ * consumer's `MobilePhone` does not become an extra required key beside
331
+ * `Phone`. Names the base schema has no equivalent for pass through.
332
+ */
333
+ function canonicalPayloadField(field) {
334
+ if (applicantContactFieldNames.has(field) || baseContractFieldNames.has(field))
335
+ return field;
336
+ const canonical = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(field);
337
+ return canonical !== null && applicantContactFieldNames.has(canonical) ? canonical : field;
338
+ }
339
+ /**
340
+ * The single decision for which payload field a fill step carries: the
341
+ * vocabulary/explicit field, else the instruction's own label — never when the
342
+ * author opted out (`payloadFieldNone`) or the vocabulary excludes the step.
343
+ * Shared so known-field and persona-binding registration cannot drift apart.
344
+ */
345
+ function fieldForFillStep(step, vocabulary, knownFieldValues, canonicalise = false) {
346
+ const isObj = typeof step !== "string";
347
+ const instruction = isObj ? step.step : step;
348
+ const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
349
+ if (vocabField !== null)
350
+ return vocabField;
351
+ if (isObj && step.payloadFieldNone)
352
+ return null;
353
+ if (vocabulary.exclusions.some((rx) => rx.test(instruction)))
354
+ return null;
355
+ const labelField = deriveFillLabelField(instruction);
356
+ return canonicalise && labelField !== null ? canonicalPayloadField(labelField) : labelField;
357
+ }
301
358
  /**
302
359
  * Field→value pairs the flow establishes unambiguously via a Fill/Enter/Type
303
360
  * step — the applicant's own datum, per {@link deriveFillLabelField}'s same
@@ -307,14 +364,14 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
307
364
  * (a field whose label mentions "phone" but whose select answer is a device
308
365
  * type, not a number).
309
366
  */
310
- function buildKnownFieldValues(flowSteps, vocabulary, env) {
367
+ function buildKnownFieldValues(flowSteps, vocabulary, env, canonicalise = false) {
311
368
  const known = new Map();
312
369
  for (const step of flowSteps) {
313
370
  const isObj = typeof step !== "string";
314
371
  const instruction = isObj ? step.step : step;
315
372
  if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
316
373
  continue;
317
- const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary) ?? deriveFillLabelField(instruction);
374
+ const field = fieldForFillStep(step, vocabulary, undefined, canonicalise);
318
375
  if (field === null)
319
376
  continue;
320
377
  const value = extractStepPersonaValue(instruction, env);
@@ -334,9 +391,9 @@ function buildKnownFieldValues(flowSteps, vocabulary, env) {
334
391
  * or the value-identity substitution silently misses.
335
392
  *
336
393
  * Two grammar facts, both verified against real ATS flows, drive the rule:
337
- * 1. The possessive apostrophe in "the candidate's first name 'Reginald'"
338
- * opens a false quote — a naive `/'[^']*'/` yields `s first name `, not
339
- * `Reginald`. Neutralizing `\w's` → `\ws` before matching removes it.
394
+ * 1. A letter-flanked apostrophe ("the candidate's first name 'Reginald'",
395
+ * "Don't") is not a quote delimiter; the shared tokenizer skips it, so the
396
+ * span is `Reginald`, not `s first name `.
340
397
  * 2. A `Select`/`Choose` step names the ANSWER first, then the question
341
398
  * ("Select 'No' for the 'sponsorship' question"), so the value is the
342
399
  * FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
@@ -363,10 +420,10 @@ function extractStepPersonaValue(instruction, env) {
363
420
  if (resolved)
364
421
  return resolved;
365
422
  }
366
- const spans = findQuoteSpans(instruction);
423
+ const spans = findInstructionSpans(instruction);
367
424
  if (spans.length === 0)
368
425
  return null;
369
- const value = pickValueSpan(instruction, spans).value;
426
+ const value = pickValueSpan(instruction, spans).text;
370
427
  return value.length > 0 ? value : null;
371
428
  }
372
429
  /**
@@ -409,12 +466,26 @@ function extractNavigateToHashFragmentValue(url, previousHash) {
409
466
  * hash-bearing navigateTo; a hashless reset navigation must not erase it, or
410
467
  * the next cumulative hash would be taken whole and never recur in a request.
411
468
  */
412
- function walkNavigateToFacets(flowSteps) {
469
+ function walkNavigateToFacets(flowSteps, env) {
413
470
  const facets = [];
414
471
  let baselineHash;
415
472
  for (const step of flowSteps) {
416
- if (typeof step === "string" || step.navigateTo === undefined)
473
+ if (typeof step === "string")
474
+ continue;
475
+ if (step.navigateTo === undefined) {
476
+ if (step.payloadField && !step.payloadFieldNone) {
477
+ facets.push({
478
+ value: extractStepPersonaValue(step.step, env),
479
+ field: step.payloadField,
480
+ optional: step.optional === true,
481
+ origin: "step",
482
+ literals: collectStepFacetLiterals(step.step, env),
483
+ kind: deriveFacetKind(step.payloadField),
484
+ kindHints: [],
485
+ });
486
+ }
417
487
  continue;
488
+ }
418
489
  const hashIndex = step.navigateTo.indexOf("#");
419
490
  const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
420
491
  if (step.payloadField) {
@@ -422,6 +493,10 @@ function walkNavigateToFacets(flowSteps) {
422
493
  value: extractNavigateToHashFragmentValue(step.navigateTo, baselineHash),
423
494
  field: step.payloadField,
424
495
  optional: step.optional === true,
496
+ origin: "navigateTo",
497
+ literals: collectHashFacetLiterals(step.navigateTo, baselineHash),
498
+ kind: deriveFacetKind(step.payloadField),
499
+ kindHints: collectHashFacetKindHints(step.navigateTo, baselineHash),
425
500
  });
426
501
  }
427
502
  if (currentHash !== undefined)
@@ -429,6 +504,60 @@ function walkNavigateToFacets(flowSteps) {
429
504
  }
430
505
  return facets;
431
506
  }
507
+ /**
508
+ * Derives a facet's element kind from its declared field name (`tripLength`
509
+ * and `TripLength` both yield `trip length`), so a matcher can tell which
510
+ * captured element a literal belongs to without any site vocabulary.
511
+ */
512
+ function deriveFacetKind(field) {
513
+ return field
514
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
515
+ .replace(/[_\-\s]+/g, " ")
516
+ .trim()
517
+ .toLowerCase();
518
+ }
519
+ function uniqueLiterals(primary, others) {
520
+ return [...new Set([...(primary === null ? [] : [primary]), ...others])].filter((literal) => literal.length > 0);
521
+ }
522
+ function collectStepFacetLiterals(instruction, env) {
523
+ const primary = extractStepPersonaValue(instruction, env);
524
+ return uniqueLiterals(primary, findInstructionSpans(instruction).map((span) => span.text));
525
+ }
526
+ function collectHashFacetLiterals(url, previousHash) {
527
+ const primary = extractNavigateToHashFragmentValue(url, previousHash);
528
+ const hashIndex = url.indexOf("#");
529
+ const rawTokens = hashIndex === -1 || previousHash === undefined
530
+ ? []
531
+ : splitHashTokens(url.slice(hashIndex + 1)).filter((token) => !splitHashTokens(previousHash).includes(token));
532
+ return uniqueLiterals(primary, rawTokens);
533
+ }
534
+ /**
535
+ * Further element kinds a facet's own hash names (`ship` in `#/ship/<slug>` or
536
+ * `ship=<slug>`, `ports` in `ports_<slug>`), for a field name that does not
537
+ * itself name the element kind. Kept beside the binding rather than on it so
538
+ * the binding's declared shape stays the contract callers build and compare.
539
+ */
540
+ const FACET_KIND_HINTS = new WeakMap();
541
+ /**
542
+ * Element kinds a hash facet's own tokens name: the key of a `key=value` pair,
543
+ * the path segment ahead of the value, and the `<kind>_` prefix of a slug.
544
+ */
545
+ function collectHashFacetKindHints(url, previousHash) {
546
+ const hashIndex = url.indexOf("#");
547
+ if (hashIndex === -1)
548
+ return [];
549
+ const previousTokens = new Set(previousHash === undefined ? [] : splitHashTokens(previousHash));
550
+ const tokens = splitHashTokens(url.slice(hashIndex + 1));
551
+ const fresh = tokens.filter((token) => !previousTokens.has(token));
552
+ const valueToken = fresh[fresh.length - 1] ?? tokens[tokens.length - 1];
553
+ if (valueToken === undefined)
554
+ return [];
555
+ const value = stripHashKeyPrefix(valueToken);
556
+ const keyOfPair = valueToken.includes("=") ? valueToken.slice(0, valueToken.indexOf("=")) : "";
557
+ const ahead = tokens[tokens.indexOf(valueToken) - 1] ?? "";
558
+ const slugPrefix = value.includes("_") ? value.slice(0, value.indexOf("_")) : "";
559
+ return [keyOfPair, ahead, slugPrefix].filter((hint) => /^[A-Za-z][A-Za-z0-9 _-]*$/.test(hint));
560
+ }
432
561
  function splitHashTokens(hash) {
433
562
  return hash.split(/[,/;&]/).filter((token) => token.length > 0);
434
563
  }
@@ -459,7 +588,7 @@ function stripHashKeyPrefix(token) {
459
588
  function deriveFillLabelField(instruction) {
460
589
  if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
461
590
  return null;
462
- if (!/'[^']*'/.test(instruction))
591
+ if (findInstructionSpans(instruction).length === 0)
463
592
  return null;
464
593
  const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
465
594
  if (label === undefined)
@@ -482,7 +611,7 @@ function deriveFillLabelField(instruction) {
482
611
  function harvestPersonaBindings(flowSteps, vocabulary, env) {
483
612
  const bindings = new Map();
484
613
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
485
- for (const facet of walkNavigateToFacets(flowSteps)) {
614
+ for (const facet of walkNavigateToFacets(flowSteps, env)) {
486
615
  if (facet.value !== null && !bindings.has(facet.value)) {
487
616
  bindings.set(facet.value, `payload.${facet.field}`);
488
617
  }
@@ -499,16 +628,7 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
499
628
  // fragment, not anything extractStepPersonaValue could find in `step.step`.
500
629
  if (isObj && step.navigateTo !== undefined)
501
630
  continue;
502
- const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
503
- // Vocabulary wins outright. Only on a miss do we fall back to deriving the
504
- // field from the instruction's own label — and never when the author opted
505
- // the step out (`payloadFieldNone`) or the vocabulary explicitly excluded it.
506
- const field = vocabField ??
507
- (isObj && step.payloadFieldNone
508
- ? null
509
- : vocabulary.exclusions.some((rx) => rx.test(instruction))
510
- ? null
511
- : deriveFillLabelField(instruction));
631
+ const field = fieldForFillStep(step, vocabulary, knownFieldValues);
512
632
  if (field === null)
513
633
  continue;
514
634
  const value = extractStepPersonaValue(instruction, env);
@@ -534,12 +654,22 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
534
654
  * aligned with the declared facets; {@link resolveRecurringNavigateToFacets}
535
655
  * drops the empty literal itself and keeps only a correlated value.
536
656
  */
537
- function extractNavigateToFacetOrder(flowSteps) {
538
- return walkNavigateToFacets(flowSteps).map((facet) => ({
539
- value: facet.value ?? "",
540
- field: facet.field,
541
- optional: facet.optional,
542
- }));
657
+ function extractNavigateToFacetOrder(flowSteps, env = process.env) {
658
+ return walkNavigateToFacets(flowSteps, env)
659
+ .filter((facet) => facet.origin === "navigateTo" || facet.value !== null)
660
+ .map((facet) => {
661
+ const binding = {
662
+ value: facet.value ?? "",
663
+ field: facet.field,
664
+ optional: facet.optional,
665
+ literals: facet.literals,
666
+ kind: facet.kind,
667
+ ...(facet.origin === "step" ? { origin: facet.origin } : {}),
668
+ };
669
+ if (facet.kindHints.length > 0)
670
+ FACET_KIND_HINTS.set(binding, facet.kindHints);
671
+ return binding;
672
+ });
543
673
  }
544
674
  /**
545
675
  * Detects a flow step whose quoted VALUE is the space-joined concatenation of
@@ -553,10 +683,10 @@ function extractNavigateToFacetOrder(flowSteps) {
553
683
  * null when the step's value is not such a concatenation
554
684
  */
555
685
  function resolveCompositePersonaFields(instruction, knownFieldValues) {
556
- const spans = findQuoteSpans(instruction);
686
+ const spans = findInstructionSpans(instruction);
557
687
  if (spans.length === 0)
558
688
  return null;
559
- const value = pickValueSpan(instruction, spans).value;
689
+ const value = pickValueSpan(instruction, spans).text;
560
690
  if (value.length === 0)
561
691
  return null;
562
692
  for (const [fieldA, valueA] of knownFieldValues) {
@@ -1299,6 +1429,10 @@ function deriveBaseUrl(captures, ownBackendHostnames, submitPatterns = null) {
1299
1429
  return dominantHostOrigin(sameDomainCandidates);
1300
1430
  }
1301
1431
  const candidates = captures.filter((c) => (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, null));
1432
+ if (candidates.length === 0) {
1433
+ const captured = [...new Set(captures.map((c) => captureHostname(c.url)))].join(", ");
1434
+ throw new Error(`ownBackendHostnames matched no capture: declared [${ownBackendHostnames.join(", ")}], captured hosts [${captured}]`);
1435
+ }
1302
1436
  return dominantHostOrigin(candidates);
1303
1437
  }
1304
1438
  function firstCaptureOrigin(captures) {
@@ -1311,7 +1445,7 @@ function firstCaptureOrigin(captures) {
1311
1445
  // try next
1312
1446
  }
1313
1447
  }
1314
- return "https://example.com";
1448
+ throw new Error(`no capture has a parseable URL to derive a base URL from (${captures.length} candidate captures)`);
1315
1449
  }
1316
1450
  /**
1317
1451
  * Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
@@ -3804,11 +3938,8 @@ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
3804
3938
  // returns first-quote when the sentence STARTS with select/choose/pick; a
3805
3939
  // dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
3806
3940
  // starts with "On", so read the first quote directly here for id= steps.
3807
- const firstQuoteLabel = (() => {
3808
- const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
3809
- const m = /'([^']*)'/.exec(cleaned);
3810
- return m && m[1].length > 0 ? m[1] : null;
3811
- })();
3941
+ const firstQuoteText = findInstructionSpans(instruction)[0]?.text;
3942
+ const firstQuoteLabel = firstQuoteText ? firstQuoteText : null;
3812
3943
  const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
3813
3944
  if (label === null)
3814
3945
  continue;
@@ -5998,9 +6129,14 @@ function applyScalarFacetSplicePayloadSubstitutions(template, inputBody, navigat
5998
6129
  continue;
5999
6130
  const literal = JSON.stringify(value);
6000
6131
  const fallback = facet.optional ? ` ?? ${literal}` : "";
6001
- const replacement = typeof value === "number" || facet.optional
6002
- ? `\${JSON.stringify(payload.${facet.field}${fallback})}`
6003
- : `"\${payload.${facet.field}}"`;
6132
+ const casing = facetCaseMethod(String(value), facet);
6133
+ const expression = facetPayloadExpression(facet, fallback);
6134
+ const cased = casing ? `String(${expression}).${casing}()` : undefined;
6135
+ const replacement = cased
6136
+ ? `\${JSON.stringify(${cased})}`
6137
+ : typeof value === "number" || facet.optional
6138
+ ? `\${JSON.stringify(${expression})}`
6139
+ : `"\${${expression}}"`;
6004
6140
  for (const sep of [":", ": "]) {
6005
6141
  if (typeof value === "number") {
6006
6142
  const re = new RegExp(`${sep}${literal}(?![\\w.])`, "g");
@@ -6062,11 +6198,36 @@ function applyArrayFacetSplicePayloadSubstitutions(template, inputBody, navigate
6062
6198
  }
6063
6199
  const absentFacets = collectSiblingArrayFacets(value, path, siblingBodies, navigateToFacets);
6064
6200
  const absentFields = new Set(absentFacets.map(({ facet }) => facet.field));
6065
- const expression = renderFacetValueExpression([...value, ...absentFacets.map(({ element }) => element)], navigateToFacets.map((facet) => absentFields.has(facet.field) ? { ...facet, optional: true } : facet));
6201
+ const expression = renderFacetValueExpression(insertAbsentFacetElements(value, absentFacets), navigateToFacets.map((facet) => absentFields.has(facet.field) ? { ...facet, optional: true } : facet));
6066
6202
  result = replaceJsonArrayOccurrences(result, value, `\${JSON.stringify(${expression})}`);
6067
6203
  }
6068
6204
  return result;
6069
6205
  }
6206
+ /**
6207
+ * Inserts each sibling-only element next to its nearest neighbour that `own`
6208
+ * (or an earlier insertion) also carries, so the emitted order follows the
6209
+ * capture it came from. With no shared neighbour it is appended.
6210
+ */
6211
+ function insertAbsentFacetElements(own, absent) {
6212
+ const merged = [...own];
6213
+ const indexOfElement = (target) => merged.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(target));
6214
+ for (const { element, before, after } of absent) {
6215
+ const previous = [...before]
6216
+ .reverse()
6217
+ .map(indexOfElement)
6218
+ .find((index) => index >= 0);
6219
+ if (previous !== undefined) {
6220
+ merged.splice(previous + 1, 0, element);
6221
+ continue;
6222
+ }
6223
+ const next = after.map(indexOfElement).find((index) => index >= 0);
6224
+ if (next === undefined)
6225
+ merged.push(element);
6226
+ else
6227
+ merged.splice(next, 0, element);
6228
+ }
6229
+ return merged;
6230
+ }
6070
6231
  /**
6071
6232
  * Facet-bearing elements that same-endpoint sibling captures carry at the
6072
6233
  * array `path` but `own` does not. A fold drill re-issues ONE representative
@@ -6084,14 +6245,19 @@ function collectSiblingArrayFacets(own, path, siblingBodies, facets) {
6084
6245
  for (const { value, path: siblingPath } of walkArrayLeaves(body)) {
6085
6246
  if (arrayPathKey(siblingPath) !== key)
6086
6247
  continue;
6087
- for (const element of value) {
6248
+ for (const [index, element] of value.entries()) {
6088
6249
  if (typeof element !== "string")
6089
6250
  continue;
6090
6251
  const facet = matchArrayElementFacet(element, facets);
6091
6252
  if (facet === undefined || present.has(facet.field))
6092
6253
  continue;
6093
6254
  present.add(facet.field);
6094
- absent.push({ element, facet });
6255
+ absent.push({
6256
+ element,
6257
+ facet,
6258
+ before: value.slice(0, index),
6259
+ after: value.slice(index + 1),
6260
+ });
6095
6261
  }
6096
6262
  }
6097
6263
  }
@@ -6495,6 +6661,16 @@ function parseUrlQueryParams(url) {
6495
6661
  }
6496
6662
  return params;
6497
6663
  }
6664
+ /** Every query value of a URL, repeated keys included (`?f=a&f=b`), where
6665
+ * {@link parseUrlQueryParams} keeps only the last per key. */
6666
+ function parseUrlQueryValues(url) {
6667
+ try {
6668
+ return [...new URL(url).searchParams.values()];
6669
+ }
6670
+ catch {
6671
+ return [];
6672
+ }
6673
+ }
6498
6674
  /** Parses a captured JSON request body into its string leaf values. Returns
6499
6675
  * an empty array when the body is absent or not valid JSON. */
6500
6676
  function parseBodyLeafValues(requestPostData) {
@@ -6587,9 +6763,11 @@ function bindsWithoutCollisionIn(bodies, value) {
6587
6763
  * so facet reachability agrees with the token-boundary {@link matchFacetRecurrence}
6588
6764
  * the splice passes use instead of counting a mere substring of a longer token.
6589
6765
  */
6590
- function occursAtTokenBoundary(text, value) {
6591
- if (value.length === 0)
6766
+ function occursAtTokenBoundary(rawText, rawValue, ignoreCase = false) {
6767
+ if (rawValue.length === 0)
6592
6768
  return false;
6769
+ const text = ignoreCase ? rawText.toLowerCase() : rawText;
6770
+ const value = ignoreCase ? rawValue.toLowerCase() : rawValue;
6593
6771
  const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
6594
6772
  let from = 0;
6595
6773
  while (true) {
@@ -6610,22 +6788,45 @@ function occursAtTokenBoundary(text, value) {
6610
6788
  * deciding from the hash-derived literal alone.
6611
6789
  */
6612
6790
  function resolveRecurringNavigateToFacets(declared, actions) {
6613
- const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value) ||
6791
+ const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value, true) ||
6614
6792
  (capture.requestPostData != null &&
6615
- occursAtTokenBoundary(capture.requestPostData, value)) ||
6616
- Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value)));
6793
+ occursAtTokenBoundary(capture.requestPostData, value, true)) ||
6794
+ Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value, true)));
6617
6795
  const reachable = declared.filter(({ value }) => value.length > 0 && appearsAnywhere(value));
6618
- const resolvedDeclared = declared.filter(({ value }) => value.length > 0);
6619
- const unreachable = declared.filter((facet) => !reachable.includes(facet));
6620
- if (unreachable.length === 0)
6796
+ const resolvedDeclared = declared.filter((facet) => facet.value.length > 0 && (facet.origin !== "step" || reachable.includes(facet)));
6797
+ const unreachable = declared.filter((facet) => facet.origin !== "step" && !reachable.includes(facet));
6798
+ // Every declared facet also owns the elements of its own kind in the code
6799
+ // vocabulary, whether or not its declared literal recurs: a literal that
6800
+ // recurs in one vocabulary (a hash slug, a quoted label) says nothing about
6801
+ // the same facet's element in another (`WW;filterType=ship`).
6802
+ const claimedLiterals = new Set();
6803
+ const aliased = declared.flatMap((facet) => {
6804
+ const literal = findKindKeyedLiteral(facet, actions);
6805
+ if (literal === undefined || declared.some(({ value }) => value === literal))
6806
+ return [];
6807
+ if (claimedLiterals.has(literal))
6808
+ return [];
6809
+ claimedLiterals.add(literal);
6810
+ const known = [facet.value, ...(facet.literals ?? [])];
6811
+ if (known.some((candidate) => candidate.toLowerCase() === literal.toLowerCase()))
6812
+ return [];
6813
+ const valueMap = new Map(facet.value.length > 0 ? [[facet.value, literal]] : []);
6814
+ return [{ ...facet, value: literal, literals: [literal], keyedByKind: true, valueMap }];
6815
+ });
6816
+ if (unreachable.length === 0 && aliased.length === 0)
6621
6817
  return resolvedDeclared;
6622
- const correlated = correlateUnreachableNavigateToFacets(unreachable, actions, reachable.map(({ value }) => value));
6818
+ const aliasedFields = new Set(aliased.map(({ field }) => field));
6819
+ const awaiting = unreachable.filter(({ field }) => !aliasedFields.has(field));
6820
+ const withAliases = [...resolvedDeclared, ...aliased];
6821
+ if (awaiting.length === 0)
6822
+ return withAliases;
6823
+ const correlated = correlateUnreachableNavigateToFacets(awaiting, actions, [...reachable, ...aliased].map(({ value }) => value));
6623
6824
  if (correlated === null)
6624
- return resolvedDeclared;
6825
+ return withAliases;
6625
6826
  const bodies = actions
6626
6827
  .map(({ capture }) => capture.requestPostData)
6627
6828
  .filter((body) => typeof body === "string" && body.length > 0);
6628
- const extras = unreachable.flatMap((facet) => {
6829
+ const extras = awaiting.flatMap((facet) => {
6629
6830
  const diffed = correlated.get(facet.field);
6630
6831
  if (diffed === undefined || diffed.length === 0)
6631
6832
  return [];
@@ -6633,7 +6834,50 @@ function resolveRecurringNavigateToFacets(declared, actions) {
6633
6834
  return [];
6634
6835
  return bindsWithoutCollisionIn(bodies, diffed) ? [{ ...facet, value: diffed }] : [];
6635
6836
  });
6636
- return [...resolvedDeclared, ...extras];
6837
+ return [...withAliases, ...extras];
6838
+ }
6839
+ /**
6840
+ * The literal a facet takes in the code vocabulary: the captured request
6841
+ * element (query value or body string leaf) whose `<literal><delim>key=<kind>`
6842
+ * suffix names the facet's own kind. Derived from the traffic itself, so it
6843
+ * never depends on how many other facets or transitions the flow declared.
6844
+ * Returns `undefined` unless exactly one distinct literal carries the kind.
6845
+ */
6846
+ function findKindKeyedLiteral(facet, actions) {
6847
+ const byTier = new Map();
6848
+ const sameMonth = new Set();
6849
+ const facetMonths = new Set([facet.value, ...(facet.literals ?? [])]
6850
+ .map((literal) => monthCodeOf(literal))
6851
+ .filter((code) => code !== undefined));
6852
+ const yearlessMonths = new Set([facet.value, ...(facet.literals ?? [])]
6853
+ .map((literal) => bareMonthNumberOf(literal))
6854
+ .filter((month) => month !== undefined));
6855
+ for (const { capture } of actions) {
6856
+ const values = [
6857
+ ...parseUrlQueryValues(capture.url),
6858
+ ...parseBodyLeafValues(capture.requestPostData),
6859
+ ];
6860
+ for (const value of values) {
6861
+ const parsed = parseElementKind(value);
6862
+ if (parsed === undefined)
6863
+ continue;
6864
+ const tier = kindMatchTier(facet, parsed.kind);
6865
+ if (tier > 0)
6866
+ byTier.set(tier, (byTier.get(tier) ?? new Set()).add(parsed.literal));
6867
+ const month = monthCodeOf(parsed.literal);
6868
+ const isOwnLiteral = [facet.value, ...(facet.literals ?? [])].some((known) => known.toLowerCase() === parsed.literal.toLowerCase());
6869
+ if (month !== undefined &&
6870
+ !isOwnLiteral &&
6871
+ (facetMonths.has(month) || yearlessMonths.has(month.slice("YYYY-".length)))) {
6872
+ sameMonth.add(parsed.literal);
6873
+ }
6874
+ }
6875
+ }
6876
+ const closest = [...byTier.entries()].sort(([a], [b]) => a - b)[0]?.[1];
6877
+ if (closest !== undefined && closest.size === 1)
6878
+ return [...closest][0];
6879
+ const sameMonthInKind = [...(closest ?? sameMonth)].filter((literal) => sameMonth.has(literal));
6880
+ return sameMonthInKind.length === 1 ? sameMonthInKind[0] : undefined;
6637
6881
  }
6638
6882
  /**
6639
6883
  * Resolves each zero-recurrence navigateTo facet (in flow order) to the real
@@ -6718,7 +6962,7 @@ flowSteps = [], vocabulary = undefined) {
6718
6962
  // below can correlate a REST body's facet-packed string leaves against it.
6719
6963
  const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
6720
6964
  const payloadAccessorByValue = new Map();
6721
- const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps);
6965
+ const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps, process.env);
6722
6966
  const navigateToFacetOrder = resolveRecurringNavigateToFacets(declaredNavigateToFacets, actions);
6723
6967
  const facetArrayPathKeys = collectFacetArrayPathKeys(actions.map((action) => parseJsonBodyOrNull(action.capture.requestPostData)), navigateToFacetOrder);
6724
6968
  if (inputBody !== undefined && inputBody !== null) {
@@ -8081,17 +8325,157 @@ function matchArrayElementFacet(element, facets) {
8081
8325
  */
8082
8326
  function matchFacetRecurrence(text, facets, options) {
8083
8327
  const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
8084
- return facets.reduce((best, facet) => {
8085
- const literal = facet.value;
8086
- const matches = text === literal ||
8087
- (options.allowDelimiterSuffix &&
8088
- literal.length > 0 &&
8089
- text.startsWith(literal) &&
8090
- !isAlnum(text[literal.length]));
8091
- if (!matches)
8328
+ const parsedElement = parseElementKind(text);
8329
+ const elementKind = parsedElement?.kind;
8330
+ // A kind no declared facet names (an identifier format such as
8331
+ // `<slug>;filterId=urlFriendlyId`) cannot belong to another facet, so the
8332
+ // kind gate only protects elements some facet claims; a whole-literal
8333
+ // match there still binds the facet whose literal the element carries.
8334
+ const kindUnclaimed = elementKind !== undefined && !facets.some((candidate) => kindsMatch(candidate, elementKind));
8335
+ const pick = (ignoreCase) => facets.reduce((best, facet) => {
8336
+ const readFromElement = facet.keyedByKind === true && parsedElement?.literal === facet.value;
8337
+ const ownsUnclaimedKind = kindUnclaimed &&
8338
+ [facet.value, ...(facet.literals ?? [])].some((known) => known.toLowerCase() === parsedElement?.literal.toLowerCase());
8339
+ if (elementKind !== undefined &&
8340
+ isKindKeyed(facet) &&
8341
+ !readFromElement &&
8342
+ !ownsUnclaimedKind &&
8343
+ !kindsMatch(facet, elementKind)) {
8092
8344
  return best;
8093
- return best === undefined || literal.length > best.value.length ? facet : best;
8345
+ }
8346
+ const literal = [facet.value, ...(facet.literals ?? [])]
8347
+ .filter((candidate) => {
8348
+ const same = (value) => ignoreCase ? value.toLowerCase() === candidate.toLowerCase() : value === candidate;
8349
+ return (candidate.length > 0 &&
8350
+ (same(text) ||
8351
+ (options.allowDelimiterSuffix &&
8352
+ same(text.slice(0, candidate.length)) &&
8353
+ !isAlnum(text[candidate.length]))));
8354
+ })
8355
+ .reduce((longest, candidate) => longest === undefined || candidate.length > longest.length ? candidate : longest, undefined);
8356
+ const matchedLiteral = literal;
8357
+ if (matchedLiteral === undefined)
8358
+ return best;
8359
+ const matched = matchedLiteral === facet.value ? facet : { ...facet, value: matchedLiteral };
8360
+ return best === undefined || matchedLiteral.length > best.value.length ? matched : best;
8094
8361
  }, undefined);
8362
+ return pick(false) ?? pick(true);
8363
+ }
8364
+ /**
8365
+ * Splits an element of the grammar `<literal><delim><key>=<kind>` into its
8366
+ * literal and kind, or returns `undefined` when the element carries no kind
8367
+ * suffix. Structural only — no key or kind vocabulary is assumed.
8368
+ */
8369
+ function parseElementKind(element) {
8370
+ const parsed = /^(.+)[;&|,]([^;&|,=]+)=([^;&|,=]+)$/.exec(element);
8371
+ return parsed ? { literal: parsed[1], kind: parsed[3] } : undefined;
8372
+ }
8373
+ function normalizeKind(kind) {
8374
+ return deriveFacetKind(kind).replace(/\s+/g, "").replace(/s$/, "");
8375
+ }
8376
+ const KIND_NOISE_WORDS = new Set(["facet", "filter", "field", "value", "option", "selection"]);
8377
+ /**
8378
+ * How closely a facet names an element's kind, 0 when it does not: 1 for the
8379
+ * declared field (qualifier words such as `facet` dropped) or a hash-named
8380
+ * hint equal to the kind, 2 when the field's trailing word is the kind
8381
+ * (`sailDate` names a `date` element), 3 when any other word of the field is.
8382
+ * A caller picking an element for the facet takes the closest tier, so a field
8383
+ * naming two kinds still resolves to the one it ends with.
8384
+ */
8385
+ function kindMatchTier(facet, elementKind) {
8386
+ const element = normalizeKind(elementKind);
8387
+ if (element.length === 0 || facet.kind === undefined)
8388
+ return 0;
8389
+ const words = deriveFacetKind(facet.kind).split(" ");
8390
+ const meaningful = words.filter((word) => !KIND_NOISE_WORDS.has(word));
8391
+ const named = meaningful.length > 0 ? meaningful : words;
8392
+ if (normalizeKind(facet.kind) === element || normalizeKind(named.join(" ")) === element)
8393
+ return 1;
8394
+ if ((FACET_KIND_HINTS.get(facet) ?? []).some((hint) => normalizeKind(hint) === element))
8395
+ return 1;
8396
+ if (normalizeKind(named[named.length - 1] ?? "") === element)
8397
+ return 2;
8398
+ return named.some((word) => normalizeKind(word) === element) ? 3 : 0;
8399
+ }
8400
+ function kindsMatch(facet, elementKind) {
8401
+ return kindMatchTier(facet, elementKind) > 0;
8402
+ }
8403
+ const MONTH_NAMES = [
8404
+ "january",
8405
+ "february",
8406
+ "march",
8407
+ "april",
8408
+ "may",
8409
+ "june",
8410
+ "july",
8411
+ "august",
8412
+ "september",
8413
+ "october",
8414
+ "november",
8415
+ "december",
8416
+ ];
8417
+ /**
8418
+ * Normalizes a month written as a slug or label (`december-2026`, `Dec 2026`,
8419
+ * `2026-12`) to `YYYY-MM`, so the same month matches across vocabularies.
8420
+ */
8421
+ function monthCodeOf(literal) {
8422
+ const text = literal.trim();
8423
+ const iso = /^(\d{4})[-/](0[1-9]|1[0-2])$/.exec(text);
8424
+ if (iso)
8425
+ return `${iso[1]}-${iso[2]}`;
8426
+ const named = /^([A-Za-z]{3,9})[\s_/-]+(\d{4})$/.exec(text) ?? /^(\d{4})[\s_/-]+([A-Za-z]{3,9})$/.exec(text);
8427
+ if (!named)
8428
+ return undefined;
8429
+ const yearFirst = /^\d{4}$/.test(named[1]);
8430
+ const name = (yearFirst ? named[2] : named[1]).toLowerCase();
8431
+ const year = yearFirst ? named[1] : named[2];
8432
+ const index = MONTH_NAMES.findIndex((month) => month === name || (name.length === 3 && month.startsWith(name)));
8433
+ return index === -1 ? undefined : `${year}-${String(index + 1).padStart(2, "0")}`;
8434
+ }
8435
+ /** Month number (`12`) of a month written without a year (`december`, `Dec`),
8436
+ * so a facet whose literal names only the month still matches a dated element. */
8437
+ function bareMonthNumberOf(literal) {
8438
+ const name = literal.trim().toLowerCase();
8439
+ const index = MONTH_NAMES.findIndex((month) => month === name || (name.length === 3 && month.startsWith(name)));
8440
+ return index === -1 ? undefined : String(index + 1).padStart(2, "0");
8441
+ }
8442
+ function isKindKeyed(facet) {
8443
+ return (facet.kind !== undefined &&
8444
+ facet.kind.length > 0 &&
8445
+ (facet.origin === "step" || facet.keyedByKind === true));
8446
+ }
8447
+ /**
8448
+ * Renders the payload value for a matched facet in the casing the captured
8449
+ * element carried, so a caller sending the declared literal reproduces the
8450
+ * captured request exactly. An exact-case or mixed-case occurrence is emitted
8451
+ * untouched.
8452
+ */
8453
+ function facetCaseMethod(captured, facet) {
8454
+ const prefix = captured.slice(0, facet.value.length);
8455
+ if (prefix === facet.value)
8456
+ return undefined;
8457
+ if (prefix === facet.value.toUpperCase())
8458
+ return "toUpperCase";
8459
+ if (prefix === facet.value.toLowerCase())
8460
+ return "toLowerCase";
8461
+ return undefined;
8462
+ }
8463
+ /**
8464
+ * JS expression for the value a facet's payload field carries in the code
8465
+ * vocabulary: a kind-keyed alias translates the caller's declared literal
8466
+ * through its `valueMap`, passing an already-coded value through untouched.
8467
+ */
8468
+ function facetPayloadExpression(facet, fallback = "") {
8469
+ const raw = `payload.${facet.field}${fallback}`;
8470
+ if (!facet.valueMap || facet.valueMap.size === 0)
8471
+ return raw;
8472
+ const entries = JSON.stringify([...facet.valueMap.entries()]);
8473
+ return `(new Map(${entries}).get(String(payload.${facet.field})) ?? ${raw})`;
8474
+ }
8475
+ function renderFacetPayloadValue(captured, facet) {
8476
+ const method = facetCaseMethod(captured, facet);
8477
+ const expression = facetPayloadExpression(facet);
8478
+ return method ? `\${String(${expression}).${method}()}` : `\${${expression}}`;
8095
8479
  }
8096
8480
  /**
8097
8481
  * Binds a top-level GraphQL variable whose scalar value (or, for a string, any
@@ -8104,8 +8488,12 @@ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
8104
8488
  return null;
8105
8489
  if (typeof value === "string" || typeof value === "number") {
8106
8490
  const facet = matchFacetRecurrence(String(value), facets, { allowDelimiterSuffix: false });
8107
- if (facet !== undefined)
8108
- return `payload.${facet.field}`;
8491
+ if (facet !== undefined) {
8492
+ const rendered = renderFacetPayloadValue(String(value), facet);
8493
+ return rendered === `\${${facetPayloadExpression(facet)}}`
8494
+ ? facetPayloadExpression(facet)
8495
+ : `\`${rendered}\``;
8496
+ }
8109
8497
  }
8110
8498
  if (typeof value !== "string")
8111
8499
  return null;
@@ -8127,7 +8515,7 @@ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
8127
8515
  continue;
8128
8516
  }
8129
8517
  bound = true;
8130
- const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}\${payload.${facet.field}}${escapeForTemplateLiteral(trailingDelimiter)}\``;
8518
+ const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}${renderFacetPayloadValue(segment.slice(colonIndex + 1), facet)}${escapeForTemplateLiteral(trailingDelimiter)}\``;
8131
8519
  // An absent optional facet drops its own segment (and delimiter) rather
8132
8520
  // than rendering the string `undefined` into the packed value.
8133
8521
  if (facet.optional) {
@@ -8170,7 +8558,7 @@ function spliceFacetsIntoArrayVariable(value, facets, renderElement = (element)
8170
8558
  return renderElement(element);
8171
8559
  matchCount++;
8172
8560
  const suffix = element.slice(matched.value.length);
8173
- const elementLiteral = `\`\${payload.${matched.field}}${escapeForTemplateLiteral(suffix)}\``;
8561
+ const elementLiteral = `\`${renderFacetPayloadValue(element, matched)}${escapeForTemplateLiteral(suffix)}\``;
8174
8562
  return matched.optional
8175
8563
  ? `...(payload.${matched.field} ? [${elementLiteral}] : [])`
8176
8564
  : elementLiteral;
@@ -8602,29 +8990,36 @@ function collectRequestStringValues(capture, allCaptures) {
8602
8990
  */
8603
8991
  function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, siblingPaths) {
8604
8992
  const allBodies = [parsedBody, ...otherBodies];
8605
- for (const siblingPath of siblingPaths) {
8606
- const pathSegments = siblingPath.split(".");
8993
+ const siblingReaders = [
8994
+ ...[...siblingPaths].map((siblingPath) => {
8995
+ const pathSegments = siblingPath.split(".");
8996
+ return (body) => {
8997
+ const siblingRaw = readComparableBodyLeaf(parsedBody, body, pathSegments);
8998
+ return siblingRaw === undefined ? undefined : String(siblingRaw);
8999
+ };
9000
+ }),
9001
+ ...selfDescribingArrayPaths(parsedBody).map((arrayPath) => (body) => {
9002
+ const array = readValueAtPath(body, arrayPath);
9003
+ return Array.isArray(array) ? identityShape(array) : ABSENT_ARRAY_SHAPE;
9004
+ }),
9005
+ ];
9006
+ return siblingReaders.some((readSibling) => {
8607
9007
  const pairs = [];
8608
- let sawUnexplainableField = false;
8609
9008
  for (const body of allBodies) {
8610
9009
  const fieldRaw = readComparableBodyLeaf(parsedBody, body, path);
8611
9010
  if (fieldRaw === undefined)
8612
9011
  continue;
8613
- const siblingRaw = readComparableBodyLeaf(parsedBody, body, pathSegments);
8614
- if (siblingRaw === undefined) {
8615
- // This body has a value for the field we're trying to explain but no
8616
- // value for the candidate sibling — the sibling can't vouch for it,
8617
- // so don't let a function fitted over the OTHER bodies paper over it.
8618
- sawUnexplainableField = true;
8619
- break;
8620
- }
8621
- pairs.push({ siblingValue: String(siblingRaw), fieldValue: String(fieldRaw) });
9012
+ const siblingValue = readSibling(body);
9013
+ // This body has a value for the field we're trying to explain but no
9014
+ // value for the candidate sibling — the sibling can't vouch for it, so
9015
+ // don't let a function fitted over the OTHER bodies paper over it.
9016
+ if (siblingValue === undefined)
9017
+ return false;
9018
+ pairs.push({ siblingValue, fieldValue: String(fieldRaw) });
8622
9019
  }
8623
- if (sawUnexplainableField)
8624
- continue;
8625
9020
  const distinctSiblingValues = new Set(pairs.map((p) => p.siblingValue));
8626
9021
  if (distinctSiblingValues.size < 2)
8627
- continue;
9022
+ return false;
8628
9023
  const siblingToField = new Map();
8629
9024
  const isFunction = pairs.every(({ siblingValue, fieldValue }) => {
8630
9025
  const existing = siblingToField.get(siblingValue);
@@ -8634,10 +9029,8 @@ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, sibl
8634
9029
  }
8635
9030
  return existing === fieldValue;
8636
9031
  });
8637
- if (isFunction && siblingToField.size === distinctSiblingValues.size)
8638
- return true;
8639
- }
8640
- return false;
9032
+ return isFunction && siblingToField.size === distinctSiblingValues.size;
9033
+ });
8641
9034
  }
8642
9035
  /**
8643
9036
  * Finds every query param or JSON body leaf on `capture`'s own request that
@@ -9620,6 +10013,23 @@ function parseFoldReturnSpec(flowFileContents) {
9620
10013
  return null;
9621
10014
  }
9622
10015
  }
10016
+ /**
10017
+ * Reads the flow file's `browserFlowOnly: true` opt-in, which forces the
10018
+ * browser-flow-only emit for a submission flow whose HTTP analysis would
10019
+ * otherwise produce an executeHttp path. Anything but a literal `true` (or an
10020
+ * unreadable/legacy bare-array file) is "not opted in".
10021
+ */
10022
+ function parseBrowserFlowOnlyOptIn(flowFileContents) {
10023
+ try {
10024
+ const raw = JSON.parse(flowFileContents);
10025
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
10026
+ return false;
10027
+ return raw.browserFlowOnly === true;
10028
+ }
10029
+ catch {
10030
+ return false;
10031
+ }
10032
+ }
9623
10033
  /**
9624
10034
  * Parses `browserFallbackGate`/`httpTimeoutMs` out of an object-form
9625
10035
  * recon-flow.json, mirroring {@link parseFoldReturnSpec}'s null-safe pattern:
@@ -9709,6 +10119,32 @@ function arrayElementIdentity(element) {
9709
10119
  return undefined;
9710
10120
  return ARRAY_ELEMENT_IDENTITY.exec(element)?.[1];
9711
10121
  }
10122
+ /** The `<delimiter><name>` head of an {@link arrayElementIdentity} (its tag
10123
+ * dropped), the part that says what ROLE an element plays in its array. */
10124
+ function identityName(identity) {
10125
+ return identity?.slice(0, identity.indexOf("="));
10126
+ }
10127
+ /** Order-insensitive fingerprint of the identity suffixes an array carries —
10128
+ * two arrays with equal fingerprints are the same shape (reordered, or with
10129
+ * only values changed), anything else is a different shape or length. */
10130
+ function identityShape(elements) {
10131
+ return elements
10132
+ .map((element) => arrayElementIdentity(element) ?? "")
10133
+ .sort()
10134
+ .join("\u0000");
10135
+ }
10136
+ const ABSENT_ARRAY_SHAPE = "\u0001absent";
10137
+ /** Paths of every array in `body` that lists self-describing string elements
10138
+ * (see {@link arrayElementIdentity}). Which identity suffixes such an array
10139
+ * carries is itself a property of the request — a sibling field that differs
10140
+ * only because the array took a different shape is explained by that shape. */
10141
+ function selfDescribingArrayPaths(body, path = []) {
10142
+ if (body === null || typeof body !== "object")
10143
+ return [];
10144
+ const children = Object.entries(body).flatMap(([key, child]) => selfDescribingArrayPaths(child, [...path, key]));
10145
+ const isSelfDescribing = Array.isArray(body) && body.some((element) => arrayElementIdentity(element) !== undefined);
10146
+ return isSelfDescribing ? [[...path], ...children] : children;
10147
+ }
9712
10148
  /**
9713
10149
  * Index of the innermost path segment that indexes into an array (the array
9714
10150
  * holding the leaf, or one holding an object element that carries it), or -1.
@@ -9728,7 +10164,13 @@ function innermostArrayIndexSegment(body, path) {
9728
10164
  * field of an object element) is matched by its identity suffix within the same
9729
10165
  * array, never by index, so differently shaped arrays are not mistaken for one
9730
10166
  * array with a varying slot; an identity the other body lacks yields
9731
- * `undefined`. A plain element of such an array is matched by value, since its
10167
+ * `undefined`. An identity shared by several elements of either array (e.g.
10168
+ * repeated `;tag=x` suffixes) is ambiguous and is paired only by exact value.
10169
+ * When the arrays differ in shape or length, an identity is trusted only if
10170
+ * its `;name=` role is also unique in both: a tag repeated by position
10171
+ * (`;tagId=t0`, `;tagId=t1`) in two unrelated arrays says nothing about the
10172
+ * elements being the same one.
10173
+ * A plain element of such an array is matched by value, since its
9732
10174
  * slot says nothing about it once the arrays' shapes differ. Every other path
9733
10175
  * defers to {@link readValueAtPath}.
9734
10176
  */
@@ -9755,7 +10197,19 @@ function readComparableBodyLeaf(body, otherBody, path) {
9755
10197
  return leaf;
9756
10198
  if (identity === undefined)
9757
10199
  return undefined;
9758
- return counterparts.find((candidate) => arrayElementIdentity(candidate) === identity);
10200
+ const ownCounterparts = ownArray.map((element) => readValueAtPath(element, elementSubpath));
10201
+ const sharesIdentity = (candidate) => arrayElementIdentity(candidate) === identity;
10202
+ const ownSharing = ownCounterparts.filter(sharesIdentity);
10203
+ const otherSharing = counterparts.filter(sharesIdentity);
10204
+ if (ownSharing.length !== 1 || otherSharing.length !== 1)
10205
+ return undefined;
10206
+ const name = identityName(identity);
10207
+ const nameIsUnique = (values) => values.filter((value) => identityName(arrayElementIdentity(value)) === name).length === 1;
10208
+ const isSameShape = identityShape(ownCounterparts) === identityShape(counterparts);
10209
+ if (!isSameShape && !(nameIsUnique(ownCounterparts) && nameIsUnique(counterparts))) {
10210
+ return undefined;
10211
+ }
10212
+ return otherSharing[0];
9759
10213
  }
9760
10214
  /**
9761
10215
  * Turns a flow author's plain dot-separated {@link FoldReturnSpec.resultsPath}
@@ -11339,10 +11793,48 @@ function declaredSchemaCoversAccessorShape(declared, shape) {
11339
11793
  const childShapes = [...(shape.element ? [shape.element] : []), ...shape.keys.values()];
11340
11794
  return ([...shape.keys.keys()].every((key) => new RegExp(`(?:^|[\\s{,])"?${key.replace(/[$]/g, "\\$&")}"?\\s*:`).test(declared)) && childShapes.every((child) => declaredSchemaCoversAccessorShape(declared, child)));
11341
11795
  }
11796
+ const applicantContactFieldNames = new Set([
11797
+ "FirstName",
11798
+ "LastName",
11799
+ "Phone",
11800
+ "AddressLine",
11801
+ "City",
11802
+ "State",
11803
+ "PostalCode",
11804
+ "Country",
11805
+ "County",
11806
+ "Resume",
11807
+ "ResumeContentType",
11808
+ "ResumeFilename",
11809
+ "ResumeBase64",
11810
+ ]);
11811
+ const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
11812
+ const applicantContactEvidenceFieldNamesLower = new Set([...applicantContactFieldNames, ...baseContractFieldNames].map((name) => name.toLowerCase()));
11813
+ /**
11814
+ * Single source of truth for "the contract extends ApplicantContactSchema", so
11815
+ * the contract and browser-flow emitters can't disagree about whether the
11816
+ * payload carries Resume fields.
11817
+ */
11818
+ function contractUsesApplicantContactSchema(opts) {
11819
+ const { inputBody, isSubmissionFlow, browserFlowOnly, payloadFieldNames } = opts;
11820
+ // A browser-flow-only submission flow has no HTTP body of its own: whatever
11821
+ // inputBody HTTP analysis captured describes a path the plugin won't emit,
11822
+ // so it is not evidence about the real caller's shape.
11823
+ if (isSubmissionFlow === true && browserFlowOnly === true)
11824
+ return true;
11825
+ // A body-less submission flow has no inputBody to inspect; the payload fields
11826
+ // its steps splice are the only evidence, whether or not it also has an HTTP
11827
+ // path (the browser-flow emitter can't know which the contract will take).
11828
+ if (inputBody != null) {
11829
+ return [...walkAllPrimitiveLeaves(inputBody)].some(({ path }) => path.some((segment) => applicantContactEvidenceFieldNamesLower.has(segment.toLowerCase())));
11830
+ }
11831
+ return (isSubmissionFlow === true &&
11832
+ [...(payloadFieldNames ?? [])].some((name) => applicantContactEvidenceFieldNamesLower.has(name.toLowerCase())));
11833
+ }
11342
11834
  /** Generates a complete contract.ts source string for a plugin — exported so
11343
11835
  * unit tests can drive the emitter directly without spawning the CLI. */
11344
11836
  function emitContractTs(opts) {
11345
- const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), flowSteps: contractFlowSteps = [], headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
11837
+ const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, createsAccount = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), flowSteps: contractFlowSteps = [], headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
11346
11838
  const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
11347
11839
  /** Single source of truth for every GraphQL-emission decision below
11348
11840
  * (clientImport, gqlCacheBlock, queryConst, and both executeHttpBody
@@ -11419,25 +11911,12 @@ function emitContractTs(opts) {
11419
11911
  // one of ApplicantContactSchema's own field names (case-insensitively,
11420
11912
  // checked at any depth via walkAllPrimitiveLeaves) before the template
11421
11913
  // applies (see recon-generate-payload-schema-mismatch.md).
11422
- const applicantContactFieldNames = new Set([
11423
- "FirstName",
11424
- "LastName",
11425
- "Phone",
11426
- "AddressLine",
11427
- "City",
11428
- "State",
11429
- "PostalCode",
11430
- "Country",
11431
- "County",
11432
- "Resume",
11433
- "ResumeContentType",
11434
- "ResumeFilename",
11435
- "ResumeBase64",
11436
- ]);
11437
- const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
11438
- const applicantContactEvidenceFieldNamesLower = new Set([...applicantContactFieldNames, ...baseContractFieldNames].map((name) => name.toLowerCase()));
11439
- const usesApplicantContactSchema = inputBody != null &&
11440
- [...walkAllPrimitiveLeaves(inputBody)].some(({ path }) => path.some((segment) => applicantContactEvidenceFieldNamesLower.has(segment.toLowerCase())));
11914
+ const usesApplicantContactSchema = contractUsesApplicantContactSchema({
11915
+ inputBody,
11916
+ isSubmissionFlow,
11917
+ browserFlowOnly: omitExecuteHttp,
11918
+ payloadFieldNames,
11919
+ });
11441
11920
  const isReservedByApplicantContactSchema = (name) => usesApplicantContactSchema && applicantContactFieldNames.has(name);
11442
11921
  // A missing inputBody, or one with no ApplicantContactSchema evidence,
11443
11922
  // means this is not a job-application submission flow. The former keeps
@@ -11447,7 +11926,7 @@ function emitContractTs(opts) {
11447
11926
  // the other sources unchanged.
11448
11927
  const basePayloadSchemaExpr = usesApplicantContactSchema
11449
11928
  ? `ApplicantContactSchema`
11450
- : inputBody
11929
+ : inputBody || (omitExecuteHttp && isSubmissionFlow)
11451
11930
  ? `z.object({})`
11452
11931
  : `z.object({\n query: z.string().min(1),\n})`;
11453
11932
  // Only the single-endpoint read path (a real primary operation, no
@@ -11563,11 +12042,16 @@ function emitContractTs(opts) {
11563
12042
  // as required strings (z.email() for Email per the repo's z.string().email()→
11564
12043
  // z.email() migration) so those references typecheck in the generated flow.
11565
12044
  if (payloadFieldNames) {
11566
- for (const name of [...payloadFieldNames].sort()) {
12045
+ // The browser-flow-only emit canonicalises its own splices already; this
12046
+ // covers a caller handing over vocabulary-row names directly, so one never
12047
+ // becomes a required key beside the base schema's own equivalent.
12048
+ const canonicaliseNames = usesApplicantContactSchema && omitExecuteHttp && isSubmissionFlow;
12049
+ for (const rawName of [...payloadFieldNames].sort()) {
12050
+ const name = canonicaliseNames ? canonicalPayloadField(rawName) : rawName;
11567
12051
  if (isReservedByApplicantContactSchema(name))
11568
12052
  continue;
11569
12053
  const zod = name === "Email" ? "z.email()" : "z.string()";
11570
- const suffix = optionalPayloadFieldNames.has(name) ? ".optional()" : "";
12054
+ const suffix = optionalPayloadFieldNames.has(rawName) ? ".optional()" : "";
11571
12055
  addExtendField(name, ` ${name}: ${zod}${suffix},`);
11572
12056
  }
11573
12057
  }
@@ -11778,7 +12262,12 @@ function emitContractTs(opts) {
11778
12262
  extendFields.set(fieldName, ` ${key}: ${zod}${optional ? ".optional()" : ""},`);
11779
12263
  }
11780
12264
  const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
11781
- const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
12265
+ // zod strips unknown keys, so without composing it a job-application
12266
+ // contract drops the TrackingUrl the engine's fireTrackingClick reads. merge
12267
+ // rather than a second extend keeps the schema to a single `.extend()`.
12268
+ const composesJobTracking = usesApplicantContactSchema && !extendFields.has("TrackingUrl");
12269
+ const trackingMerge = composesJobTracking ? ".merge(JobTrackingSchema)" : "";
12270
+ const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}${trackingMerge}`;
11782
12271
  // basePayloadSchemaExpr's own Answers field always wraps in
11783
12272
  // multipartJsonObject() for job-application submission flows
11784
12273
  // (usesApplicantContactSchema); multipartBoolean() is only imported when a
@@ -11798,6 +12287,9 @@ function emitContractTs(opts) {
11798
12287
  // ApplicantContactSchema backs the job-application submission-flow payload
11799
12288
  // schema (see basePayloadSchemaExpr above); only referenced when
11800
12289
  // usesApplicantContactSchema is true.
12290
+ const jobTrackingImport = composesJobTracking
12291
+ ? `import { JobTrackingSchema } from "${ENGINE_PKG}/lib/job-tracking";\n`
12292
+ : "";
11801
12293
  const applicantContactImport = usesApplicantContactSchema
11802
12294
  ? `import { ApplicantContactSchema } from "${ENGINE_PKG}/lib/applicant-payload";\n`
11803
12295
  : "";
@@ -11878,7 +12370,9 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
11878
12370
  // the same captured request-variables object the signal was itself
11879
12371
  // detected from (see paginationOperationIdentity above) — the default
11880
12372
  // `{ q: payload.query }` REST body has no skip/count container to advance.
11881
- const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps), actionSteps);
12373
+ // A single-primary flow compiles no action steps, so its captured traffic
12374
+ // is the only place a declared facet's kind-suffixed element can be found.
12375
+ const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps, process.env), actionSteps.length > 0 ? actionSteps : allCaptures.map((capture) => ({ capture })));
11882
12376
  const gqlVariablesExpr = gqlOperationName
11883
12377
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
11884
12378
  : paginationSignal
@@ -12266,6 +12760,19 @@ const limiter = new Bottleneck({ minTime: ${minTime} });
12266
12760
  ${executeHttpBody}
12267
12761
  },
12268
12762
  `;
12763
+ // Applicant contracts enter on payload.ClickUrl, so a browser-flow-only
12764
+ // execute has nothing left to read from the context.
12765
+ const executeOmitsContext = omitExecuteHttp && usesApplicantContactSchema;
12766
+ const executeContextParam = executeOmitsContext ? "" : `,\n context: SitePluginContext`;
12767
+ const sitePluginImports = executeOmitsContext
12768
+ ? "SitePlugin, SitePluginResult"
12769
+ : "SitePlugin, SitePluginContext, SitePluginResult";
12770
+ const executeDocComment = omitExecuteHttp
12771
+ ? ""
12772
+ : ` /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */\n`;
12773
+ const maxAttemptsLiteral = createsAccount
12774
+ ? `\n // A retry would re-register the same account the first attempt created.\n maxAttempts: 1,`
12775
+ : "";
12269
12776
  const pluginDocComment = omitExecuteHttp
12270
12777
  ? `/**
12271
12778
  * Plugin for ${siteId}. Browser-flow-only: the captured multi-step submission
@@ -12283,9 +12790,9 @@ ${executeHttpBody}
12283
12790
 
12284
12791
  ${bottleneckImport}import { z } from "zod/v4";
12285
12792
 
12286
- ${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
12793
+ ${fixtureImport}${applicantContactImport}${jobTrackingImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
12287
12794
  import type { BrowserSession } from "${ENGINE_PKG}/scraper/session";
12288
- import type { SitePlugin, SitePluginContext, SitePluginResult } from "${ENGINE_PKG}/site-plugin";
12795
+ import type { ${sitePluginImports} } from "${ENGINE_PKG}/site-plugin";
12289
12796
  import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
12290
12797
  ${baseHeadersBlock}${limiterBlock}
12291
12798
  export const ${pascal}ResponseSchema = ${responseSchemaExpr};
@@ -12315,14 +12822,12 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
12315
12822
  // since the multipart wire format is what makes that field's
12316
12823
  // JSON-stringified encoding parseable.
12317
12824
  `
12318
- : ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}${browserFallbackGateLiteral}
12825
+ : ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}${browserFallbackGateLiteral}${maxAttemptsLiteral}
12319
12826
  },
12320
12827
  ${executeHttpMethodBlock}
12321
- /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
12322
- async execute(
12828
+ ${executeDocComment} async execute(
12323
12829
  payload: ${pascal}Payload,
12324
- session: BrowserSession,
12325
- context: SitePluginContext
12830
+ session: BrowserSession${executeContextParam}
12326
12831
  ): Promise<SitePluginResult<${pascal}Response>> {
12327
12832
  const raw = await run${pascal}BrowserFlow(session.stagehand, ${usesApplicantContactSchema ? "payload.ClickUrl" : "context.baseUrl"}, payload, session.sessionProxy ?? null);
12328
12833
  return { data: raw as ${pascal}Response };
@@ -12367,6 +12872,101 @@ function assertRequiredUrlFieldsReferenced(contractCode, browserFlowCode) {
12367
12872
  throw new Error(`recon-generate: required URL field(s) ${unreferenced.join(", ")} declared on the payload schema ` +
12368
12873
  `but never referenced by the emitted contract or browser flow — the flow would enter on the wrong page`);
12369
12874
  }
12875
+ /**
12876
+ * Blank comments and plain string literals so a `payload.<name>` mention inside
12877
+ * prose or an unrelated string is not mistaken for a read. Template-literal
12878
+ * text is blanked but its `${…}` expressions are kept, because the browser flow
12879
+ * splices fields as `${payload.X}`.
12880
+ */
12881
+ function stripNonCodeText(code) {
12882
+ const out = [];
12883
+ // Non-negative: brace depth at which a template literal's text resumes; negative: -(depth+1) of an open `${`.
12884
+ const templateDepths = [];
12885
+ let depth = 0;
12886
+ let i = 0;
12887
+ while (i < code.length) {
12888
+ const ch = code[i];
12889
+ const next = code[i + 1];
12890
+ const inTemplateText = templateDepths.length > 0 && templateDepths[templateDepths.length - 1] === depth;
12891
+ if (inTemplateText) {
12892
+ if (ch === "\\")
12893
+ i += 2;
12894
+ else if (ch === "`") {
12895
+ templateDepths.pop();
12896
+ i++;
12897
+ }
12898
+ else if (ch === "$" && next === "{") {
12899
+ templateDepths.push(-(depth + 1));
12900
+ depth++;
12901
+ out.push("{");
12902
+ i += 2;
12903
+ }
12904
+ else
12905
+ i++;
12906
+ continue;
12907
+ }
12908
+ if (ch === "/" && next === "/") {
12909
+ while (i < code.length && code[i] !== "\n")
12910
+ i++;
12911
+ }
12912
+ else if (ch === "/" && next === "*") {
12913
+ const end = code.indexOf("*/", i + 2);
12914
+ i = end === -1 ? code.length : end + 2;
12915
+ }
12916
+ else if (ch === '"' || ch === "'") {
12917
+ i++;
12918
+ while (i < code.length && code[i] !== ch && code[i] !== "\n")
12919
+ i += code[i] === "\\" ? 2 : 1;
12920
+ i++;
12921
+ out.push('""');
12922
+ }
12923
+ else if (ch === "`") {
12924
+ templateDepths.push(depth);
12925
+ i++;
12926
+ }
12927
+ else {
12928
+ if (ch === "{")
12929
+ depth++;
12930
+ if (ch === "}") {
12931
+ depth--;
12932
+ if (templateDepths[templateDepths.length - 1] === -(depth + 1))
12933
+ templateDepths.pop();
12934
+ }
12935
+ out.push(ch ?? "");
12936
+ i++;
12937
+ }
12938
+ }
12939
+ return out.join("");
12940
+ }
12941
+ /**
12942
+ * Names of the payload fields a flow explicitly declared via `payloadField`
12943
+ * that no emitted request surface reads as `payload.<field>`. The surface is
12944
+ * the emitted contract plus the browser flow: a field only the browser flow
12945
+ * dereferences still changes browser-executed requests, so it counts as
12946
+ * referenced. The schema declaration (`Field: z.…`) is not a `payload.` read,
12947
+ * so it never counts. Vocabulary-derived fields are out of scope. The generator emits the
12948
+ * fields no emitted request reads as optional schema fields, since a required field nothing
12949
+ * sends only burdens callers.
12950
+ */
12951
+ function unreferencedDeclaredPayloadFields(contractCode, browserFlowCode, flowSteps) {
12952
+ const emittedCode = `${stripNonCodeText(contractCode)}\n${stripNonCodeText(browserFlowCode)}`;
12953
+ const declared = new Set();
12954
+ for (const step of flowSteps) {
12955
+ if (typeof step !== "string" && step.payloadField)
12956
+ declared.add(step.payloadField);
12957
+ }
12958
+ return [...declared].filter((name) => !new RegExp(`payload\\.${name}(?![\\w$])`).test(emittedCode));
12959
+ }
12960
+ /**
12961
+ * Declared payload fields the emitted requests never read. When the contract
12962
+ * replays HTTP requests only those count (the browser flow just changes
12963
+ * instruction text); a browser-only contract counts the browser flow too.
12964
+ * These are emitted optional so no caller has to supply a value that nothing
12965
+ * sends.
12966
+ */
12967
+ function unreadDeclaredPayloadFields(contractCode, browserFlowCode, flowSteps) {
12968
+ return new Set(unreferencedDeclaredPayloadFields(contractCode, contractCode.includes("httpClient(") ? "" : browserFlowCode, flowSteps));
12969
+ }
12370
12970
  /**
12371
12971
  * Escape a literal string segment so it is safe INSIDE a JS backtick template
12372
12972
  * literal — backslashes, backticks, and `${` interpolation starts must all be
@@ -12386,13 +12986,17 @@ function escapeForTemplateLiteral(segment) {
12386
12986
  * own choice so the browser-flow and HTTP-body emitters never disagree on
12387
12987
  * which quoted span is the persona value.
12388
12988
  */
12389
- function buildStepInstructionExpr(instruction, field) {
12989
+ function buildStepInstructionExpr(instruction, field, capturedFallback = false) {
12390
12990
  if (field === null)
12391
12991
  return JSON.stringify(instruction);
12392
12992
  const site = locateSpliceSite(instruction);
12393
12993
  if (site === null)
12394
12994
  return JSON.stringify(instruction);
12395
- return `\`${escapeForTemplateLiteral(site.before)}\${payload.${field}}${escapeForTemplateLiteral(site.after)}\``;
12995
+ const captured = pickedQuotedValue(instruction);
12996
+ const read = capturedFallback && captured !== null
12997
+ ? `payload.${field} ?? ${JSON.stringify(captured)}`
12998
+ : `payload.${field}`;
12999
+ return `\`${escapeForTemplateLiteral(site.before)}\${${read}}${escapeForTemplateLiteral(site.after)}\``;
12396
13000
  }
12397
13001
  /**
12398
13002
  * Build the emitted instruction expression for a step whose value is the
@@ -12459,6 +13063,22 @@ function jsonSchemaTypeOf(value) {
12459
13063
  return "object";
12460
13064
  return "string";
12461
13065
  }
13066
+ /**
13067
+ * Whether the flow's last step declares itself non-submit in its own wording
13068
+ * (and carries no explicit `submitStep` flag). The submission-flow forcing must
13069
+ * skip it: an explicit flag overrides declared intent at runtime, so forcing it
13070
+ * onto a trailing guardrail step would route that step through the submit judge.
13071
+ */
13072
+ function lastFlowStepDeclaresNonSubmit(flowSteps) {
13073
+ const last = flowSteps.at(-1);
13074
+ if (last === undefined)
13075
+ return false;
13076
+ const isObj = typeof last !== "string";
13077
+ return (0, flow_runner_1.isStepDeclaredNonSubmit)({
13078
+ step: isObj ? last.step : last,
13079
+ submitStep: isObj ? last.submitStep === true : false,
13080
+ });
13081
+ }
12462
13082
  /**
12463
13083
  * Emits a config-only plugin manifest (`<siteId>.plugin.json`) from the recon
12464
13084
  * flow, as an alternative to the `.ts` trio for browser-only sites. Reuses the
@@ -12478,9 +13098,10 @@ function jsonSchemaTypeOf(value) {
12478
13098
  * browser `flow` is the only execution path, and the field is omitted.
12479
13099
  */
12480
13100
  function emitConfigManifest(opts) {
12481
- const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, env = process.env, } = opts;
13101
+ const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, hasMultipartStep = false, browserFlowOnly = false, env = process.env, } = opts;
13102
+ const canonicalise = isSubmissionFlow && browserFlowOnly;
12482
13103
  const payloadFieldNames = new Set();
12483
- const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
13104
+ const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
12484
13105
  const steps = flowSteps.map((step) => {
12485
13106
  const isObj = typeof step !== "string";
12486
13107
  const instruction = isObj ? step.step : step;
@@ -12507,7 +13128,7 @@ function emitConfigManifest(opts) {
12507
13128
  ...(emailStep ? { emailStep, emailStepConfig: emailStepConfig ?? {} } : {}),
12508
13129
  };
12509
13130
  }
12510
- const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
13131
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
12511
13132
  if (field !== null)
12512
13133
  payloadFieldNames.add(field);
12513
13134
  const rewritten = buildManifestInstruction(instruction, field);
@@ -12532,7 +13153,7 @@ function emitConfigManifest(opts) {
12532
13153
  // load of this manifest also gates on runHealingFlow's submitStep verification.
12533
13154
  const lastStepIndex = steps.length - 1;
12534
13155
  const lastStep = steps[lastStepIndex];
12535
- if (isSubmissionFlow && lastStep !== undefined) {
13156
+ if (isSubmissionFlow && lastStep !== undefined && !lastFlowStepDeclaresNonSubmit(flowSteps)) {
12536
13157
  steps[lastStepIndex] =
12537
13158
  typeof lastStep === "string"
12538
13159
  ? { step: lastStep, optional: false, upload: false, submitStep: true }
@@ -12557,9 +13178,24 @@ function emitConfigManifest(opts) {
12557
13178
  requestProperties[name] = { type: jsonSchemaTypeOf(value) };
12558
13179
  }
12559
13180
  }
13181
+ const usesApplicantContactSchema = contractUsesApplicantContactSchema({
13182
+ inputBody,
13183
+ isSubmissionFlow,
13184
+ browserFlowOnly,
13185
+ payloadFieldNames,
13186
+ });
13187
+ // Mirrors the .ts contract's JobTrackingSchema composition: a request schema
13188
+ // without it would drop the TrackingUrl the engine reads after submission.
13189
+ if (usesApplicantContactSchema)
13190
+ requestProperties.TrackingUrl ??= { type: "string" };
12560
13191
  const sortedRequestProperties = Object.fromEntries(Object.keys(requestProperties)
12561
13192
  .sort()
12562
13193
  .map((name) => [name, requestProperties[name]]));
13194
+ // Same decision emitBrowserFlowTs makes: only a flow that uploads AND whose
13195
+ // contract carries Resume fields is multipart, so buildUploadFixture agrees.
13196
+ const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
13197
+ const contractCarriesResume = hasMultipartStep || usesApplicantContactSchema;
13198
+ const multipart = hasUploadStep && contractCarriesResume;
12563
13199
  const manifest = {
12564
13200
  apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
12565
13201
  kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
@@ -12567,6 +13203,7 @@ function emitConfigManifest(opts) {
12567
13203
  spec: {
12568
13204
  defaultBaseUrl: baseUrl,
12569
13205
  ...(httpModulePath ? { httpModule: httpModulePath } : {}),
13206
+ ...(multipart ? { multipart: true } : {}),
12570
13207
  request: { type: "object", properties: sortedRequestProperties },
12571
13208
  response: {
12572
13209
  type: "object",
@@ -12601,7 +13238,7 @@ function emitConfigManifest(opts) {
12601
13238
  * facet-splice pass can correlate against the exact same declared field set
12602
13239
  * instead of re-deriving a second, potentially-drifting one.
12603
13240
  */
12604
- function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
13241
+ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env, canonicalise = false) {
12605
13242
  const payloadFieldNames = new Set();
12606
13243
  // Widest wins: a field is only optional if EVERY step that registers it
12607
13244
  // does so as optional.
@@ -12615,7 +13252,7 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
12615
13252
  optionalPayloadFieldNames.delete(field);
12616
13253
  }
12617
13254
  };
12618
- const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
13255
+ const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
12619
13256
  for (const step of flowSteps) {
12620
13257
  const isObj = typeof step !== "string";
12621
13258
  const instruction = isObj ? step.step : step;
@@ -12642,7 +13279,7 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
12642
13279
  continue;
12643
13280
  if (instruction.includes(RECON_PASSWORD_TOKEN))
12644
13281
  continue;
12645
- const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
13282
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
12646
13283
  const composite = field === null && !(isObj && step.payloadFieldNone)
12647
13284
  ? resolveCompositePersonaFields(instruction, knownFieldValues)
12648
13285
  : null;
@@ -12665,10 +13302,22 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
12665
13302
  return { payloadFieldNames, optionalPayloadFieldNames };
12666
13303
  }
12667
13304
  function emitBrowserFlowTs(opts) {
12668
- const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, env = process.env, } = opts;
12669
- const { payloadFieldNames, optionalPayloadFieldNames } = computeFlowPayloadFieldNames(flowSteps, vocabulary, env);
13305
+ const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, browserFlowOnly = false, inputBody, vocabulary, frameSelector, capturedFallbackFields = new Set(), env = process.env, } = opts;
13306
+ const canonicalise = isSubmissionFlow && browserFlowOnly;
13307
+ const { payloadFieldNames, optionalPayloadFieldNames } = computeFlowPayloadFieldNames(flowSteps, vocabulary, env, canonicalise);
13308
+ for (const field of capturedFallbackFields) {
13309
+ if (payloadFieldNames.has(field))
13310
+ optionalPayloadFieldNames.add(field);
13311
+ }
12670
13312
  const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
12671
- const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
13313
+ const contractCarriesResume = hasMultipartStep ||
13314
+ contractUsesApplicantContactSchema({
13315
+ inputBody,
13316
+ isSubmissionFlow,
13317
+ browserFlowOnly,
13318
+ payloadFieldNames,
13319
+ });
13320
+ const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
12672
13321
  let usesThrowawayPassword = false;
12673
13322
  let usesEmailStep = false;
12674
13323
  const stepLiterals = flowSteps.map((step) => {
@@ -12710,14 +13359,14 @@ function emitBrowserFlowTs(opts) {
12710
13359
  const submitStep = isObj ? step.submitStep === true : false;
12711
13360
  return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
12712
13361
  }
12713
- const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
13362
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
12714
13363
  const composite = field === null && !(isObj && step.payloadFieldNone)
12715
13364
  ? resolveCompositePersonaFields(instruction, knownFieldValues)
12716
13365
  : null;
12717
13366
  const optional = isObj ? step.optional === true : false;
12718
13367
  const instructionExpr = composite !== null
12719
13368
  ? buildCompositeStepInstructionExpr(instruction, composite.fieldA, composite.fieldB)
12720
- : buildStepInstructionExpr(instruction, field);
13369
+ : buildStepInstructionExpr(instruction, field, field !== null && capturedFallbackFields.has(field));
12721
13370
  const upload = isObj ? step.upload === true : false;
12722
13371
  const submitStep = isObj ? step.submitStep === true : false;
12723
13372
  return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
@@ -12726,7 +13375,9 @@ function emitBrowserFlowTs(opts) {
12726
13375
  // submitStep:true even when the source recon-flow.json step never declared
12727
13376
  // it, so the engine's pre-submit probe/StepVerificationError actually gates it.
12728
13377
  const lastStepLiteral = stepLiterals.at(-1);
12729
- if (isSubmissionFlow && lastStepLiteral !== undefined) {
13378
+ if (isSubmissionFlow &&
13379
+ lastStepLiteral !== undefined &&
13380
+ !lastFlowStepDeclaresNonSubmit(flowSteps)) {
12730
13381
  stepLiterals[stepLiterals.length - 1] = lastStepLiteral.replace(/submitStep: (true|false)(?= \},)/, "submitStep: true");
12731
13382
  }
12732
13383
  const flowStepsBlock = stepLiterals.length > 0
@@ -12741,7 +13392,7 @@ function emitBrowserFlowTs(opts) {
12741
13392
  // multipart detection is dropped), those payload fields don't exist yet — emit
12742
13393
  // a null + TODO so the generated module still typechecks; the operator adds
12743
13394
  // the multipart contract fields and wires the fixture during hand-finish.
12744
- const uploadFixtureExpr = hasUploadStep && hasMultipartStep
13395
+ const uploadFixtureExpr = hasUploadStep && contractCarriesResume
12745
13396
  ? `{
12746
13397
  buffer: Buffer.from(payload.Resume),
12747
13398
  name: payload.ResumeFilename ?? "resume.pdf",
@@ -12797,8 +13448,8 @@ export async function run${pascal}BrowserFlow(
12797
13448
  ): Promise<${pascal}Response> {
12798
13449
  const page = await stagehand.context.awaitActivePage();
12799
13450
 
12800
- await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "networkidle" });
12801
- // networkidle can resolve before a bot-managed/CDN-fronted SPA hydrates; wait for
13451
+ await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "domcontentloaded" });
13452
+ // domcontentloaded fires before a bot-managed/CDN-fronted SPA hydrates; wait for
12802
13453
  // the real DOM so the first steps don't probe an empty shell page and skip.
12803
13454
  await waitForSpaReady(page, logger);
12804
13455
  ${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" : ""}${usesEmailStep ? "\n // Resolved once per run from the caller-supplied Email — the emailStep hook\n // polls this inbox rather than a freshly allocated one, since the applicant\n // (not this run) owns the address the ATS actually emails.\n const allocatedInbox = testmailInboxFromAddress(payload.Email);\n" : ""}
@@ -12855,7 +13506,7 @@ ${flowStepsBlock}
12855
13506
  }
12856
13507
  `}`;
12857
13508
  assertNoLeakedPersonaConstant(code, knownFieldValues);
12858
- return { code, payloadFieldNames, optionalPayloadFieldNames };
13509
+ return { code, payloadFieldNames, optionalPayloadFieldNames, usesThrowawayPassword };
12859
13510
  }
12860
13511
  /** Generates the site's index.ts barrel — exported so the out-of-tree e2e
12861
13512
  * test can drive the emitter directly without spawning the CLI. */
@@ -13235,6 +13886,14 @@ async function main() {
13235
13886
  };
13236
13887
  }
13237
13888
  })();
13889
+ const browserFlowOnlyOptIn = (() => {
13890
+ try {
13891
+ return parseBrowserFlowOnlyOptIn((0, node_fs_1.readFileSync)(flowFile, "utf8"));
13892
+ }
13893
+ catch {
13894
+ return false;
13895
+ }
13896
+ })();
13238
13897
  // Flow-declared signals that isolate the submission POSTs from same-origin
13239
13898
  // page chrome. Threaded into action-sequence extraction and header derivation
13240
13899
  // so both draw from the real submission, not incidental widget/chatbot POSTs.
@@ -13271,7 +13930,9 @@ async function main() {
13271
13930
  * already written its output and there is nothing left for main() to do.
13272
13931
  */
13273
13932
  function generateFromCaptures(activeCaptures) {
13274
- const baseUrl = deriveBaseUrl(activeCaptures, ownBackendHostnames, submitPatterns);
13933
+ const baseUrl = activeCaptures.length === 0
13934
+ ? "https://example.com"
13935
+ : deriveBaseUrl(activeCaptures, ownBackendHostnames, submitPatterns);
13275
13936
  // Gate on the flow's declared own-backend hosts (or the registrable-domain
13276
13937
  // fallback of baseUrl) via the same predicate recon-http.ts applies at
13277
13938
  // write time, so a stale aux/ directory from before that filter existed
@@ -13417,7 +14078,7 @@ async function main() {
13417
14078
  logger.warn(`submission selection: declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) disagrees with the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s)); using the declared pattern because requireSubmitEndpointMatch is set`);
13418
14079
  }
13419
14080
  else if (patternUndercounts) {
13420
- logger.info(`submission selection: ignoring submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) as an undercount of the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s))`);
14081
+ logger.info(`submission selection: using declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) although the unfiltered heuristic action sequence finds ${unfilteredHeuristicActionCaptures.length} capture(s); the unfiltered set would make the generator attempt an HTTP replay that aborts on frozen-varying fields`);
13421
14082
  }
13422
14083
  const heuristicActionCaptures = patternedHeuristicActionCaptures;
13423
14084
  const manifestActionCaptures = resolveManifestActionSequence(runRoot, activeCaptures);
@@ -13508,7 +14169,12 @@ async function main() {
13508
14169
  ? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
13509
14170
  : new Map();
13510
14171
  const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
13511
- const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
14172
+ // A multi-step flow whose declared submit pattern selects no captures has no
14173
+ // replayable HTTP sequence, yet is still not a search: treat it as a
14174
+ // browser-flow-only submission flow rather than the single-endpoint `{query}` branch.
14175
+ const noReplayableActionFlow = !gql && actionSteps.length === 0 && flowSteps.length > 1;
14176
+ const isSubmissionFlow = noReplayableActionFlow ||
14177
+ (actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation));
13512
14178
  // Diagnostic for the FAILURE-3 shape (a flowless recon capture): a "submission flow"
13513
14179
  // whose every action capture is landing-phase is almost certainly page-chrome
13514
14180
  // bootstrap (e.g. page-chrome `POST /widgets`) misread as an apply flow, not a walked
@@ -13610,7 +14276,8 @@ async function main() {
13610
14276
  // downgrade to the single-endpoint `{query}` branch either, since that's a
13611
14277
  // fabrication of its own kind for a flow that isn't a single-action
13612
14278
  // query/search to begin with.
13613
- const browserFlowOnly = isSubmissionFlow && actionSteps.some((s) => s.isCrossDomain);
14279
+ const browserFlowOnly = isSubmissionFlow &&
14280
+ (browserFlowOnlyOptIn || noReplayableActionFlow || actionSteps.some((s) => s.isCrossDomain));
13614
14281
  const multiStepBody = browserFlowOnly
13615
14282
  ? undefined
13616
14283
  : isSubmissionFlow
@@ -13678,7 +14345,7 @@ async function main() {
13678
14345
  if (foldReturnSpec !== null && effectiveFoldPlanCount > 0 && !declaredJoinFieldsApplied) {
13679
14346
  logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but the declared spec resolved no fold plan — only structurally-detected fold plans (with their own guessed join fields) were emitted, so the declared joinFields were not applied; check that the endpointPattern names a capture in the action sequence and resultsPath resolves on the primary response`);
13680
14347
  }
13681
- logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
14348
+ logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (${browserFlowOnlyOptIn ? "forced by flow file" : noReplayableActionFlow ? "no replayable action sequence" : "cross-domain hop detected"})` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
13682
14349
  if (emit === "config") {
13683
14350
  (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
13684
14351
  (0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
@@ -13690,6 +14357,8 @@ async function main() {
13690
14357
  inputBody,
13691
14358
  recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
13692
14359
  isSubmissionFlow,
14360
+ hasMultipartStep,
14361
+ browserFlowOnly,
13693
14362
  // A submission flow is the case where the `.ts` emit carries an
13694
14363
  // executeHttp hot path; point the manifest at where the operator drops
13695
14364
  // the compiled module rather than silently dropping the direct path.
@@ -13712,6 +14381,8 @@ async function main() {
13712
14381
  flowSteps,
13713
14382
  isSubmissionFlow,
13714
14383
  hasMultipartStep,
14384
+ browserFlowOnly,
14385
+ inputBody,
13715
14386
  vocabulary,
13716
14387
  frameSelector,
13717
14388
  });
@@ -13750,6 +14421,7 @@ async function main() {
13750
14421
  auxFiles,
13751
14422
  multiStepBody,
13752
14423
  omitExecuteHttp: browserFlowOnly,
14424
+ createsAccount: browserFlow.usesThrowawayPassword,
13753
14425
  isSubmissionFlow,
13754
14426
  inputBody,
13755
14427
  hasMultipartStep,
@@ -13769,11 +14441,43 @@ async function main() {
13769
14441
  unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
13770
14442
  fallbackGateSpec,
13771
14443
  };
13772
- const contractCode = emitContractTs(contractOpts);
14444
+ const firstContractCode = emitContractTs(contractOpts);
14445
+ const unread = unreadDeclaredPayloadFields(firstContractCode, browserFlow.code, flowSteps);
14446
+ // A field no emitted request reads must not be required of callers. The
14447
+ // browser fallback keeps splicing it, from the captured value when the
14448
+ // caller omits it, so the field still takes effect when supplied.
14449
+ const settled = unread.size === 0
14450
+ ? { browserFlow, contractOpts, contractCode: firstContractCode }
14451
+ : (() => {
14452
+ const fallbackBrowserFlow = emitBrowserFlowTs({
14453
+ siteId,
14454
+ pascal,
14455
+ baseUrl,
14456
+ flowSteps,
14457
+ isSubmissionFlow,
14458
+ hasMultipartStep,
14459
+ browserFlowOnly,
14460
+ vocabulary,
14461
+ frameSelector,
14462
+ capturedFallbackFields: unread,
14463
+ });
14464
+ const optionalized = {
14465
+ ...contractOpts,
14466
+ optionalPayloadFieldNames: new Set([
14467
+ ...fallbackBrowserFlow.optionalPayloadFieldNames,
14468
+ ...unread,
14469
+ ]),
14470
+ };
14471
+ return {
14472
+ browserFlow: fallbackBrowserFlow,
14473
+ contractOpts: optionalized,
14474
+ contractCode: emitContractTs(optionalized),
14475
+ };
14476
+ })();
13773
14477
  return {
13774
- contractCode,
13775
- contractOpts,
13776
- browserFlow,
14478
+ contractCode: settled.contractCode,
14479
+ contractOpts: settled.contractOpts,
14480
+ browserFlow: settled.browserFlow,
13777
14481
  auxFiles,
13778
14482
  resolvedPool: actionCaptures,
13779
14483
  isSubmissionFlow,
@@ -13785,6 +14489,10 @@ async function main() {
13785
14489
  if (result === null)
13786
14490
  return; // emit === "config": already written above.
13787
14491
  const final = healUnreferencedUrlFieldsOnce(captures, result, generateFromCaptures);
14492
+ const unreferencedEverywhere = new Set(unreferencedDeclaredPayloadFields(final.contractCode, final.browserFlow.code, flowSteps));
14493
+ for (const field of unreadDeclaredPayloadFields(final.contractCode, final.browserFlow.code, flowSteps)) {
14494
+ logger.warn(`declared payloadField ${field} is read by no emitted request; emitted as optional schema field${unreferencedEverywhere.has(field) ? "" : " (only the browser flow splices it)"}`);
14495
+ }
13788
14496
  (0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, final.contractCode);
13789
14497
  logger.info(`wrote ${outDir}/contract.ts`);
13790
14498
  // Same opts fed to emitContractTs above, so this can never drift from what