@enricai/barnacle 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,19 +22,24 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.resolveStepPayloadField = resolveStepPayloadField;
25
+ exports.extractStepPersonaValue = extractStepPersonaValue;
26
+ exports.harvestPersonaBindings = harvestPersonaBindings;
25
27
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
26
28
  exports.selectPayloadAction = selectPayloadAction;
27
29
  exports.selectReturnAction = selectReturnAction;
28
30
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
31
+ exports.extractEntryUrlParams = extractEntryUrlParams;
29
32
  exports.resolveManifestActionSequence = resolveManifestActionSequence;
30
33
  exports.extractActionSequence = extractActionSequence;
31
34
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
32
35
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
36
+ exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
37
+ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
38
+ exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
33
39
  exports.walkSetCookiePairs = walkSetCookiePairs;
34
40
  exports.indexStateValues = indexStateValues;
35
41
  exports.compileActionSteps = compileActionSteps;
36
42
  exports.collectHeaderBindings = collectHeaderBindings;
37
- exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
38
43
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
39
44
  exports.emitContractTs = emitContractTs;
40
45
  exports.emitConfigManifest = emitConfigManifest;
@@ -112,6 +117,81 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
112
117
  }
113
118
  return null;
114
119
  }
120
+ /**
121
+ * Extracts the concrete persona VALUE a flow step fills — the recon-supplied
122
+ * constant that appears verbatim in the captured request body — so the body
123
+ * emitter can bind it to `${payload.<field>}`. This is a stricter job than
124
+ * {@link buildStepInstructionExpr}'s browser-flow splice, which only needs *a*
125
+ * span to replace: here the extracted string must equal the wire value exactly,
126
+ * or the value-identity substitution silently misses.
127
+ *
128
+ * Two grammar facts, both verified against real ATS flows, drive the rule:
129
+ * 1. The possessive apostrophe in "the candidate's first name 'Reginald'"
130
+ * opens a false quote — a naive `/'[^']*'/` yields `s first name `, not
131
+ * `Reginald`. Neutralizing `\w's` → `\ws` before matching removes it.
132
+ * 2. A `Select`/`Choose` step names the ANSWER first, then the question
133
+ * ("Select 'No' for the 'sponsorship' question"), so the value is the
134
+ * FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
135
+ * first and the value last, so it is the LAST quoted token.
136
+ * A `${RECON_EMAIL}` token (or the literal email `env` value) short-circuits to
137
+ * the env-resolved address, matching recon-browser's own env substitution.
138
+ *
139
+ * @param instruction the flow step's plain-English instruction
140
+ * @param env process env (or a stub) supplying `${VAR}` token values, e.g. RECON_EMAIL
141
+ * @returns the persona value, or null when the step carries no spliceable constant
142
+ */
143
+ function extractStepPersonaValue(instruction, env) {
144
+ const emailToken = `$${"{RECON_EMAIL}"}`;
145
+ const reconEmail = env.RECON_EMAIL;
146
+ if (reconEmail && (instruction.includes(emailToken) || instruction.includes(reconEmail))) {
147
+ return reconEmail;
148
+ }
149
+ // Resolve any other `${UPPER_SNAKE}` env token the same way recon-browser's
150
+ // substituteFlowEnvVars does, so an env-supplied value (e.g. RECON_PHONE) is
151
+ // matched against the wire body by its runtime form, not the literal token.
152
+ const envToken = /\$\{([A-Z_][A-Z0-9_]*)\}/.exec(instruction);
153
+ if (envToken) {
154
+ const resolved = env[envToken[1]];
155
+ if (resolved)
156
+ return resolved;
157
+ }
158
+ // Neutralize the possessive apostrophe so it isn't read as a quote delimiter.
159
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
160
+ const quotes = [...cleaned.matchAll(/'([^']*)'/g)].map((m) => m[1]);
161
+ if (quotes.length === 0)
162
+ return null;
163
+ const value = /^\s*(select|choose|pick)\b/i.test(instruction)
164
+ ? quotes[0]
165
+ : quotes[quotes.length - 1];
166
+ return value.length > 0 ? value : null;
167
+ }
168
+ /**
169
+ * Builds the map from a recon persona VALUE (as it appears in the captured
170
+ * request body) to the `payload.<Field>` accessor that should replace it, by
171
+ * pairing each flow step's resolved field ({@link resolveStepPayloadField})
172
+ * with its extracted value ({@link extractStepPersonaValue}). This is the
173
+ * value→field reconciliation the browser flow and payload schema already do,
174
+ * finally applied to the request-body templates.
175
+ *
176
+ * Earliest step wins on a duplicate value. Site-agnostic: the field mapping
177
+ * lives entirely in the consumer's `--vocabulary`.
178
+ */
179
+ function harvestPersonaBindings(flowSteps, vocabulary, env) {
180
+ const bindings = new Map();
181
+ for (const step of flowSteps) {
182
+ const isObj = typeof step !== "string";
183
+ const instruction = isObj ? step.step : step;
184
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
185
+ if (field === null)
186
+ continue;
187
+ const value = extractStepPersonaValue(instruction, env);
188
+ if (value === null)
189
+ continue;
190
+ if (!bindings.has(value))
191
+ bindings.set(value, `payload.${field}`);
192
+ }
193
+ return bindings;
194
+ }
115
195
  /**
116
196
  * How deep to infer before collapsing to z.unknown(). Deep enough to reach the
117
197
  * fields that carry meaning on real inventory APIs — a cruise sailing's price
@@ -320,6 +400,40 @@ function deriveBaseUrl(captures) {
320
400
  }
321
401
  return "https://example.com";
322
402
  }
403
+ /**
404
+ * Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
405
+ * query string, mapping each param VALUE to a `payload.<param>` accessor. The
406
+ * first capture is the landing navigation, and a submission flow's job context
407
+ * (`?jobSeqNo=...`, `?jobId=...`) rides its query string — it belongs to the
408
+ * target posting, not the recon run, so it must be caller-supplied. Values
409
+ * below {@link MIN_STATE_VALUE_LENGTH} and cache-buster keys are skipped, since
410
+ * a 1–7 char value collides with arbitrary substrings elsewhere in the body.
411
+ *
412
+ * Site-agnostic: reads only the entry URL's own query keys; no site-specific
413
+ * param knowledge. Downstream, `interpolateStateValues`' length-descending pass
414
+ * composes embedded substrings (a jobId inside a longer jobSeqNo) automatically.
415
+ */
416
+ function extractEntryUrlParams(entryUrl) {
417
+ const params = new Map();
418
+ let u;
419
+ try {
420
+ u = new URL(entryUrl);
421
+ }
422
+ catch {
423
+ return params;
424
+ }
425
+ for (const [key, value] of u.searchParams) {
426
+ if (value.length < MIN_STATE_VALUE_LENGTH)
427
+ continue;
428
+ if (CACHE_BUSTER_QUERY_KEYS.has(key))
429
+ continue;
430
+ if (!isValidJsIdentifier(key))
431
+ continue;
432
+ if (!params.has(value))
433
+ params.set(value, `payload.${key}`);
434
+ }
435
+ return params;
436
+ }
323
437
  const IGNORE_REQUEST_HEADERS = new Set([
324
438
  "host",
325
439
  "content-length",
@@ -1207,6 +1321,408 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
1207
1321
  }
1208
1322
  return result;
1209
1323
  }
1324
+ /** i18n label placeholders (e.g. `{{apply.option.label.gender.a}}`) are not
1325
+ * human-facing answers and can never be matched against a flow step's quoted
1326
+ * label, so a schema whose labels are all templated yields no usable
1327
+ * label→code mapping. */
1328
+ function isI18nLabel(label) {
1329
+ return label.includes("{{");
1330
+ }
1331
+ /**
1332
+ * Indexes the JSON-Schema `enum`/`enumNames` PARALLEL-ARRAY convention across
1333
+ * every response body: an object carrying a string `name` plus equal-length
1334
+ * `enum` (option codes) and `enumNames` (option labels) declares one dropdown's
1335
+ * label→code mapping, disambiguated per-question by `name`. This is the PRIMARY
1336
+ * label→code source for ATS demographic/eligibility dropdowns whose submitted
1337
+ * value is an opaque code, not the label.
1338
+ *
1339
+ * The same `name` can appear in several captures with progressively fuller
1340
+ * option lists (a later page reveals the "decline to answer" choice), so the
1341
+ * entry with the MOST non-i18n labels wins rather than first-seen — the flow's
1342
+ * answer might be the choice only the fuller list carries.
1343
+ *
1344
+ * Site-agnostic: `enum`/`enumNames` are generic JSON-Schema keys; the field
1345
+ * identities are discovered from the response, never hardcoded.
1346
+ */
1347
+ function indexEnumEnumNamesSchemas(captures) {
1348
+ const out = new Map();
1349
+ const walk = (value) => {
1350
+ if (Array.isArray(value)) {
1351
+ for (const item of value)
1352
+ walk(item);
1353
+ return;
1354
+ }
1355
+ if (value === null || typeof value !== "object")
1356
+ return;
1357
+ const obj = value;
1358
+ const name = obj.name;
1359
+ const en = obj.enum;
1360
+ const nm = obj.enumNames;
1361
+ if (typeof name === "string" &&
1362
+ Array.isArray(en) &&
1363
+ Array.isArray(nm) &&
1364
+ en.length === nm.length &&
1365
+ en.length > 0 &&
1366
+ en.every((c) => typeof c === "string") &&
1367
+ nm.every((l) => typeof l === "string")) {
1368
+ const codes = en;
1369
+ const labels = nm;
1370
+ const usable = labels.filter((l) => !isI18nLabel(l)).length;
1371
+ const prior = out.get(name);
1372
+ const priorUsable = prior ? prior.labels.filter((l) => !isI18nLabel(l)).length : -1;
1373
+ if (usable > priorUsable)
1374
+ out.set(name, { codes, labels });
1375
+ }
1376
+ for (const v of Object.values(obj))
1377
+ walk(v);
1378
+ };
1379
+ for (const capture of captures)
1380
+ walk(capture.responseBody);
1381
+ return out;
1382
+ }
1383
+ /**
1384
+ * Indexes the `{label,value}`-shaped option-object convention: arrays of
1385
+ * objects each carrying both a `label` and a `value` string (state/country
1386
+ * pickers ship these). Builds a GLOBAL label→code map used as the fallback
1387
+ * source for dropdowns whose flow step carries no `id=` hint (so the
1388
+ * enum/enumNames index can't be keyed) — the answer label is looked up
1389
+ * directly. First non-i18n binding wins on a duplicate label.
1390
+ *
1391
+ * Site-agnostic: `label`/`value` are generic option-object keys; no field name
1392
+ * is assumed.
1393
+ */
1394
+ function indexLabelValueOptionCodes(captures) {
1395
+ const out = new Map();
1396
+ const walk = (value) => {
1397
+ if (Array.isArray(value)) {
1398
+ for (const item of value) {
1399
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1400
+ const r = item;
1401
+ const label = r.label;
1402
+ const code = r.value;
1403
+ if (typeof label === "string" &&
1404
+ typeof code === "string" &&
1405
+ label.length > 0 &&
1406
+ code.length > 0 &&
1407
+ !isI18nLabel(label) &&
1408
+ !out.has(label)) {
1409
+ out.set(label, code);
1410
+ }
1411
+ }
1412
+ walk(item);
1413
+ }
1414
+ return;
1415
+ }
1416
+ if (value === null || typeof value !== "object")
1417
+ return;
1418
+ for (const v of Object.values(value))
1419
+ walk(v);
1420
+ };
1421
+ for (const capture of captures)
1422
+ walk(capture.responseBody);
1423
+ return out;
1424
+ }
1425
+ /**
1426
+ * Reconciles each flow SELECT step to a submitted option CODE so the body-slot
1427
+ * literal (`"applyHealthCareExclusion":"5395"`) can be rewritten to a
1428
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup. Real ATS bodies carry
1429
+ * codes, not labels; the flow carries labels — this bridges them via the two
1430
+ * generic label→code conventions ({@link indexEnumEnumNamesSchemas} primary,
1431
+ * {@link indexLabelValueOptionCodes} fallback).
1432
+ *
1433
+ * The wire KEY is discovered from the step's `id=<field>` hint when present
1434
+ * (the enum/enumNames schema is keyed by that same field name); steps without
1435
+ * an `id=` (state/country) fall back to the vocabulary-resolved persona field
1436
+ * lowercased, matched against the `{label,value}` map by label. A dropdown
1437
+ * whose labels are all i18n placeholders (e.g. gender) yields no enum and is
1438
+ * reported separately for the raw-code channel.
1439
+ *
1440
+ * Returns structured resolutions plus the set of i18n-only wire keys (raw-code
1441
+ * fallbacks). Site-agnostic: field identities come from the flow's own `id=`
1442
+ * hints and the consumer vocabulary, never a hardcoded key.
1443
+ */
1444
+ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
1445
+ const enumSchemas = indexEnumEnumNamesSchemas(captures);
1446
+ const labelValue = indexLabelValueOptionCodes(captures);
1447
+ const resolutions = [];
1448
+ const rawCodeFields = new Map();
1449
+ const seenWireKeys = new Set();
1450
+ for (const step of flowSteps) {
1451
+ const instruction = typeof step === "string" ? step : step.step;
1452
+ if (!/^\s*(select|choose|pick)\b/i.test(instruction) && !/\bselect\b/i.test(instruction)) {
1453
+ continue;
1454
+ }
1455
+ const idMatch = /id=(\w+)/.exec(instruction);
1456
+ // A Select step names the ANSWER first, then the question — so the answer is
1457
+ // the FIRST quoted token (apostrophe-aware). extractStepPersonaValue only
1458
+ // returns first-quote when the sentence STARTS with select/choose/pick; a
1459
+ // dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
1460
+ // starts with "On", so read the first quote directly here for id= steps.
1461
+ const firstQuoteLabel = (() => {
1462
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
1463
+ const m = /'([^']*)'/.exec(cleaned);
1464
+ return m && m[1].length > 0 ? m[1] : null;
1465
+ })();
1466
+ const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
1467
+ if (label === null)
1468
+ continue;
1469
+ // Primary: an id= hint names the wire key AND the enum/enumNames schema key.
1470
+ if (idMatch) {
1471
+ const wireKey = idMatch[1];
1472
+ if (seenWireKeys.has(wireKey))
1473
+ continue;
1474
+ const schema = enumSchemas.get(wireKey);
1475
+ if (!schema)
1476
+ continue;
1477
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1478
+ if (semanticName === null)
1479
+ continue;
1480
+ const idx = schema.labels.indexOf(label);
1481
+ const usableOptions = schema.labels
1482
+ .map((l, i) => ({ label: l, code: schema.codes[i] }))
1483
+ .filter((o) => !isI18nLabel(o.label));
1484
+ if (idx >= 0 && !isI18nLabel(schema.labels[idx])) {
1485
+ resolutions.push({
1486
+ wireKey,
1487
+ semanticName,
1488
+ code: schema.codes[idx],
1489
+ label,
1490
+ options: usableOptions,
1491
+ });
1492
+ seenWireKeys.add(wireKey);
1493
+ continue;
1494
+ }
1495
+ // Label is i18n-only (or the answer maps to a templated label): the field
1496
+ // still must not stay frozen — surface the recon-observed code so the raw
1497
+ // channel emits a caller-supplied default.
1498
+ if (usableOptions.length === 0 && schema.codes.length > 0) {
1499
+ const fallbackIdx = idx >= 0 ? idx : schema.codes.length - 1;
1500
+ rawCodeFields.set(semanticName, { wireKey, code: schema.codes[fallbackIdx] });
1501
+ seenWireKeys.add(wireKey);
1502
+ }
1503
+ continue;
1504
+ }
1505
+ // Fallback: no id= — resolve the wire key from the vocabulary persona field
1506
+ // (lowercased) and the code from the global {label,value} map by label.
1507
+ const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary);
1508
+ if (field === null)
1509
+ continue;
1510
+ const code = labelValue.get(label);
1511
+ if (code === undefined)
1512
+ continue;
1513
+ const wireKey = field.toLowerCase();
1514
+ if (seenWireKeys.has(wireKey))
1515
+ continue;
1516
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1517
+ if (semanticName === null)
1518
+ continue;
1519
+ // The {label,value} map only reliably yields the one answered label→code
1520
+ // pair here; emit a single-choice enum so the field still binds (the caller
1521
+ // can widen it). Co-located labels aren't safely attributable to this field.
1522
+ resolutions.push({ wireKey, semanticName, code, label, options: [{ label, code }] });
1523
+ seenWireKeys.add(wireKey);
1524
+ }
1525
+ return { resolutions, rawCodeFields };
1526
+ }
1527
+ /**
1528
+ * Rewrites plain-JSON dropdown body slots (`"<wireKey>":"<code>"`) to a
1529
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup, anchored on the wire
1530
+ * KEY rather than the UUID field-id marker
1531
+ * {@link applyFormSchemaOptionIdSubstitutions} uses. This is the plain-JSON
1532
+ * ATS case where the submitted body is a flat `{ "field": "code" }` map with no
1533
+ * schema envelope, so the key/value pair — both drawn from recon input — is the
1534
+ * only closed-set anchor available.
1535
+ *
1536
+ * Records each rewritten field's semanticName into `outDiscoveredOptionFields`
1537
+ * so emitContractTs lights up its OPT_<Name> const + z.enum payload entry, and
1538
+ * mutates `fieldOptionsMap` so that emit finds the option mapping. Closed-set:
1539
+ * both the key and the code come from the recon-derived resolutions.
1540
+ */
1541
+ function applyGenericOptionCodeSubstitutions(rawBody, resolutions, fieldOptionsMap, outDiscoveredOptionFields) {
1542
+ if (resolutions.length === 0)
1543
+ return rawBody;
1544
+ let result = rawBody;
1545
+ for (const res of resolutions) {
1546
+ const slot = `"${res.wireKey}":"${res.code}"`;
1547
+ if (!result.includes(slot))
1548
+ continue;
1549
+ const replacement = `"${res.wireKey}":"$${"{"}OPT_${res.semanticName}[payload.${res.semanticName}]${"}"}"`;
1550
+ result = result.split(slot).join(replacement);
1551
+ // Mutate the option map so emitContractTs emits OPT_<Name> + the z.enum.
1552
+ if (!fieldOptionsMap.has(res.wireKey)) {
1553
+ fieldOptionsMap.set(res.wireKey, {
1554
+ semanticName: res.semanticName,
1555
+ options: res.options.map((o) => ({ value: o.label, optionId: o.code })),
1556
+ });
1557
+ }
1558
+ outDiscoveredOptionFields.add(res.semanticName);
1559
+ }
1560
+ return result;
1561
+ }
1562
+ /**
1563
+ * Rewrites the body slot of an i18n-only dropdown (one whose labels are all
1564
+ * templated placeholders, so no OPT_<Name> enum is possible) from its frozen
1565
+ * recon code to a caller-supplied `${payload.<Name>Code}`. This is the
1566
+ * plain-JSON, wire-key-anchored twin of {@link applyRawOptionIdPayloadSubstitutions}
1567
+ * (which is UUID/form-schema anchored) — without it a field like gender would
1568
+ * submit the recon persona's frozen choice for every caller.
1569
+ */
1570
+ function applyGenericRawCodeSubstitutions(rawBody, rawCodeFields) {
1571
+ if (rawCodeFields.size === 0)
1572
+ return rawBody;
1573
+ let result = rawBody;
1574
+ for (const [semanticName, { wireKey, code }] of rawCodeFields) {
1575
+ const slot = `"${wireKey}":"${code}"`;
1576
+ if (!result.includes(slot))
1577
+ continue;
1578
+ const replacement = `"${wireKey}":"$${"{"}payload.${semanticName}Code${"}"}"`;
1579
+ result = result.split(slot).join(replacement);
1580
+ }
1581
+ return result;
1582
+ }
1583
+ /** Counts an object's DIRECT primitive-valued children (string/number/boolean/
1584
+ * null). The form envelope is the object with the most of these — its scalar
1585
+ * children are the fields every other binding pass individually parameterizes,
1586
+ * so it must never be swallowed wholesale. */
1587
+ function directPrimitiveChildCount(obj) {
1588
+ let n = 0;
1589
+ for (const v of Object.values(obj)) {
1590
+ if (v === null || (typeof v !== "object" && typeof v !== "function"))
1591
+ n++;
1592
+ }
1593
+ return n;
1594
+ }
1595
+ /**
1596
+ * Locates the FORM ENVELOPE inside a submit body — the nested object that
1597
+ * actually holds the scalar form fields (which every other pass binds one by
1598
+ * one) — by descending through wrapper objects and picking the object with the
1599
+ * most direct primitive children. Returns its dotted path from the body root
1600
+ * (empty when the root itself is the envelope). Site-agnostic: no key names are
1601
+ * assumed; the envelope is found by shape.
1602
+ */
1603
+ function locateFormEnvelopePath(parsedBody) {
1604
+ const candidates = [];
1605
+ const visit = (value, path) => {
1606
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1607
+ return;
1608
+ const obj = value;
1609
+ candidates.push({ path, primitives: directPrimitiveChildCount(obj) });
1610
+ for (const [k, v] of Object.entries(obj))
1611
+ visit(v, [...path, k]);
1612
+ };
1613
+ visit(parsedBody, []);
1614
+ if (candidates.length === 0)
1615
+ return [];
1616
+ const maxP = Math.max(...candidates.map((c) => c.primitives));
1617
+ // The analytics blob (`eventData`) mirrors the form, so the object with the
1618
+ // MOST primitives can be a deep descendant of the true envelope. Pick the
1619
+ // SHALLOWEST primitive-rich object (≥ half the max) instead — that is the
1620
+ // form envelope itself, whose analytics mirror sits below it. Tie-break on a
1621
+ // higher primitive count. Threshold is relative, not a magic key name.
1622
+ const rich = candidates.filter((c) => c.primitives >= Math.max(1, maxP / 2));
1623
+ // No object carries a scalar field (maxP === 0): the body root is the only
1624
+ // sensible envelope — operate at the top level.
1625
+ if (rich.length === 0)
1626
+ return [];
1627
+ rich.sort((a, b) => a.path.length - b.path.length || b.primitives - a.primitives);
1628
+ return rich[0].path;
1629
+ }
1630
+ /**
1631
+ * Parameterizes whole nested caller-supplied structures sitting BESIDE the
1632
+ * scalar form fields — the array-valued work/education history
1633
+ * (`experienceData`/`educationData`/`dqData`) and the opaque `eventData`
1634
+ * analytics object — replacing each `"key":<json>` span with
1635
+ * `"key":${JSON.stringify(payload.<key>)}` and recording the key's inferred Zod
1636
+ * schema so emitContractTs adds it to the payload contract.
1637
+ *
1638
+ * These blocks are caller data the recon merely captured a frozen sample of;
1639
+ * freezing them would submit one applicant's history for every caller. Crucially
1640
+ * the FORM ENVELOPE object itself (the one carrying firstName/state/… that the
1641
+ * persona and dropdown passes bind field-by-field) is NEVER swallowed — that
1642
+ * would collapse the whole form to one opaque `${JSON.stringify(payload.formData)}`
1643
+ * and defeat every other binding. {@link locateFormEnvelopePath} finds it by
1644
+ * shape; only its non-scalar SIBLING children are parameterized. A
1645
+ * brace/bracket-depth scanner finds the exact JSON span (the captured body is
1646
+ * well-formed).
1647
+ *
1648
+ * NOTE on `eventData`: it becomes an opaque `${JSON.stringify(payload.eventData)}`
1649
+ * passthrough. Its nested volatiles (apTxnId, per-step timestamps) therefore
1650
+ * become the CALLER's responsibility to mint fresh — acceptable because the
1651
+ * whole blob is caller-supplied; the generator can't reach inside a value it
1652
+ * has delegated wholesale.
1653
+ *
1654
+ * Site-agnostic: operates only on the recon body's own shape.
1655
+ */
1656
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
1657
+ if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
1658
+ return template;
1659
+ }
1660
+ // Resolve the envelope object whose non-scalar children are caller structures.
1661
+ const envelopePath = locateFormEnvelopePath(parsedBody);
1662
+ let envelope = parsedBody;
1663
+ for (const seg of envelopePath) {
1664
+ if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
1665
+ envelope = envelope[seg];
1666
+ }
1667
+ }
1668
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
1669
+ return template;
1670
+ }
1671
+ let result = template;
1672
+ for (const [key, value] of Object.entries(envelope)) {
1673
+ const isNonEmptyArray = Array.isArray(value) && value.length > 0;
1674
+ const isNestedObject = value !== null &&
1675
+ typeof value === "object" &&
1676
+ !Array.isArray(value) &&
1677
+ Object.keys(value).length > 0;
1678
+ if (!isNonEmptyArray && !isNestedObject)
1679
+ continue;
1680
+ const keyMarker = `"${key}":`;
1681
+ const markerIdx = result.indexOf(keyMarker);
1682
+ if (markerIdx === -1)
1683
+ continue;
1684
+ const spanStart = markerIdx + keyMarker.length;
1685
+ const open = result[spanStart];
1686
+ if (open !== "[" && open !== "{")
1687
+ continue;
1688
+ const close = open === "[" ? "]" : "}";
1689
+ let depth = 0;
1690
+ let inString = false;
1691
+ let escaped = false;
1692
+ let spanEnd = -1;
1693
+ for (let i = spanStart; i < result.length; i++) {
1694
+ const ch = result[i];
1695
+ if (inString) {
1696
+ if (escaped)
1697
+ escaped = false;
1698
+ else if (ch === "\\")
1699
+ escaped = true;
1700
+ else if (ch === '"')
1701
+ inString = false;
1702
+ continue;
1703
+ }
1704
+ if (ch === '"')
1705
+ inString = true;
1706
+ else if (ch === open)
1707
+ depth++;
1708
+ else if (ch === close) {
1709
+ depth--;
1710
+ if (depth === 0) {
1711
+ spanEnd = i + 1;
1712
+ break;
1713
+ }
1714
+ }
1715
+ }
1716
+ if (spanEnd === -1)
1717
+ continue;
1718
+ const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
1719
+ result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
1720
+ if (!outStructuredKeys.has(key)) {
1721
+ outStructuredKeys.set(key, inferZodSchema(value));
1722
+ }
1723
+ }
1724
+ return result;
1725
+ }
1210
1726
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
1211
1727
  * embedded base64 images, etc.) that aren't candidates for state threading. */
1212
1728
  const MAX_STATE_VALUE_LENGTH = 256;
@@ -1351,16 +1867,23 @@ function isValidJsIdentifier(s) {
1351
1867
  }
1352
1868
  /**
1353
1869
  * Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
1354
- * Bracket segments (numeric array indices, non-identifier keys) get a trailing
1355
- * `!` under `noUncheckedIndexedAccess` an array/index-signature access types
1356
- * as `T | undefined`, and this accessor is only ever used against a real Zod-
1357
- * inferred array/object type (payload fields, captured response bodies), never
1358
- * against the object-literal assertion types `pathToAssertionType` builds (those
1359
- * use known string-literal keys, which `noUncheckedIndexedAccess` does not
1360
- * widen). Dot segments stay bare since object property access isn't affected.
1870
+ * Identifier segments use dot access; numeric / non-identifier segments use
1871
+ * JSON-quoted bracket access. A trailing `!` is emitted on bracket segments ONLY
1872
+ * when `assertNonNull` is set the two call sites differ:
1873
+ * - Payload accessors (`payload…`) target the real Zod-inferred payload type,
1874
+ * where an array/index segment types as `T | undefined` under
1875
+ * `noUncheckedIndexedAccess`; an intermediate index followed by more path
1876
+ * fails to compile without the `!`, so it is required there.
1877
+ * - Produce extractions (`(rN as <assertionType>)…`) target the object-literal
1878
+ * type `pathToAssertionType` builds from known string-literal keys, which
1879
+ * `noUncheckedIndexedAccess` does NOT widen — so the `!` is unnecessary AND
1880
+ * is a Biome `noNonNullAssertion` error. That site passes `assertNonNull:
1881
+ * false`.
1361
1882
  */
1362
- function pathToAccessor(path) {
1363
- return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]!`)).join("");
1883
+ function pathToAccessor(path, opts = { assertNonNull: true }) {
1884
+ return path
1885
+ .map((p) => isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]${opts.assertNonNull ? "!" : ""}`)
1886
+ .join("");
1364
1887
  }
1365
1888
  /**
1366
1889
  * Builds a nested TypeScript assertion type matching a JSON path. e.g.
@@ -1497,7 +2020,7 @@ function compileActionSteps(actions, stateIndex) {
1497
2020
  name = `${pathToVarName(path)}${suffix}`;
1498
2021
  }
1499
2022
  seenNames.add(name);
1500
- produces.push({ kind: "body", name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
2023
+ produces.push({ kind: "body", name, path });
1501
2024
  }
1502
2025
  }
1503
2026
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -1688,172 +2211,91 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
1688
2211
  }
1689
2212
  return result;
1690
2213
  }
1691
- // ── base64 Content parameterization ──────────────────────────────────────────
1692
2214
  /**
1693
- * Maps a site's screening-question prompts to the payload field that answers
1694
- * them, as `{ payloadField: [keyword, …] }`.
2215
+ * Documented closed set of JSON-key-name fragments (matched case-insensitively)
2216
+ * that mark a value as a per-request TIMESTAMP the plugin must generate fresh at
2217
+ * call time, not replay from the capture. Closed set per the no-regex-on-open-
2218
+ * sets feedback, mirroring {@link CACHE_BUSTER_QUERY_KEYS}'s posture. A frozen
2219
+ * capture timestamp would make every submission claim the recon instant.
2220
+ */
2221
+ const VOLATILE_TIMESTAMP_KEY_FRAGMENTS = ["timestamp", "esign", "signeddate", "signedat"];
2222
+ /** JSON-key-name suffix marking a value as a per-request time the plugin must
2223
+ * regenerate (e.g. `stepStartTime`, `submissionTime`). Separate from the
2224
+ * fragment set so it anchors on the suffix and doesn't match `runtime`/`downtime`. */
2225
+ const VOLATILE_TIME_KEY_SUFFIX = "time";
2226
+ function isVolatileTimestampKey(key) {
2227
+ const k = key.toLowerCase();
2228
+ if (VOLATILE_TIMESTAMP_KEY_FRAGMENTS.some((frag) => k.includes(frag)))
2229
+ return true;
2230
+ return k.endsWith(VOLATILE_TIME_KEY_SUFFIX) && k !== "time";
2231
+ }
2232
+ /**
2233
+ * Rewrites per-request VOLATILE values in a body template so the generated
2234
+ * plugin produces them at call time instead of replaying the capture's:
2235
+ * - a UUID-valued leaf → a fresh `crypto.randomUUID()`
2236
+ * - a timestamp/eSign/-time-named leaf → a fresh `new Date().toISOString()`
1695
2237
  *
1696
- * Empty by default and supplied by the operator via `RECON_QUESTION_KEYWORDS`
1697
- * (JSON) the engine cannot know what any site asks or what a caller's payload
1698
- * calls things. It previously hardcoded one product's field names, which capped
1699
- * discovery at those questions and silently dropped every other site's.
1700
- */
1701
- function loadQuestionPromptKeywords() {
1702
- const raw = process.env.RECON_QUESTION_KEYWORDS;
1703
- if (!raw)
1704
- return {};
1705
- try {
1706
- return JSON.parse(raw);
1707
- }
1708
- catch (err) {
1709
- logger.warn(`RECON_QUESTION_KEYWORDS is not valid JSON, ignoring: ${(0, errors_1.toErrorMessage)(err)}`);
1710
- return {};
2238
+ * Walks the PARSED body (so keys are known) and rewrites JSON-key-anchored
2239
+ * (`"key":JSON.stringify(value)`), the same closed-set idiom as
2240
+ * {@link applyPayloadKeyValueSubstitutions}, recursing to ANY depth so nested
2241
+ * analytics/step blobs (`eventData`, `stepInfo[]`) are neutralized too. Values
2242
+ * in `shieldedUuids` (schema field-id/option-id anchors) or `boundValues` (a
2243
+ * value already substituted to `${payload…}`/`${txnId}`/state) are left alone
2244
+ * an already-threaded transaction id or a bound email is not volatile.
2245
+ *
2246
+ * `crypto`/`Date` are bare Node/JS globals in the generated file (which already
2247
+ * uses `Buffer` bare); the `${…}` fragments are assembled by concatenation so
2248
+ * Biome's noTemplateCurlyInString doesn't flag THIS file's source.
2249
+ */
2250
+ function applyVolatileFieldSubstitutions(template, parsedBody, shieldedUuids, boundValues) {
2251
+ const uuidGen = `$${"{"}crypto.randomUUID()${"}"}`;
2252
+ const isoGen = `$${"{"}new Date().toISOString()${"}"}`;
2253
+ let result = template;
2254
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2255
+ if (typeof value !== "string" || value.length === 0)
2256
+ continue;
2257
+ if (shieldedUuids.has(value) || boundValues.has(value))
2258
+ continue;
2259
+ const key = path[path.length - 1] ?? "";
2260
+ const replacement = UUID_REGEX.test(value)
2261
+ ? uuidGen
2262
+ : isVolatileTimestampKey(key)
2263
+ ? isoGen
2264
+ : null;
2265
+ if (replacement === null)
2266
+ continue;
2267
+ const target = `"${JSON.stringify(value).slice(1, -1)}"`;
2268
+ result = result.split(target).join(`"${replacement}"`);
1711
2269
  }
2270
+ return result;
1712
2271
  }
1713
- const QUESTION_PROMPT_KEYWORDS = loadQuestionPromptKeywords();
1714
2272
  /**
1715
- * Scans captures for a `recruitingCEQuestions` GET response and builds a
1716
- * mapping from question prompts to payload.Answers field names using keyword
1717
- * overlap scoring. Returns null if no questions capture is found.
2273
+ * Collects captured string leaves that survived every binding/generation pass as
2274
+ * still-literal the values a reviewer must look at because they couldn't be
2275
+ * traced to a payload field, a generator, or a schema anchor. Returns the JSON
2276
+ * key names (deduped, in first-seen order) so the emitter can prepend a single
2277
+ * `// TODO: unbound captured literal` marker; it never mutates the body, so the
2278
+ * file still compiles. Short values (< {@link MIN_STATE_VALUE_LENGTH}) are
2279
+ * skipped — they are the legitimately-constant enum-like fields.
1718
2280
  */
1719
- function buildQuestionnaireMapping(captures) {
1720
- const questionCapture = captures.find((c) => c.method === "GET" && c.url.includes("recruitingCEQuestions"));
1721
- if (!questionCapture)
1722
- return null;
1723
- const resp = typeof questionCapture.responseBody === "string"
1724
- ? JSON.parse(questionCapture.responseBody)
1725
- : questionCapture.responseBody;
1726
- if (!resp || !Array.isArray(resp.items))
1727
- return null;
1728
- const mappings = [];
1729
- const unmapped = [];
1730
- for (const item of resp.items) {
1731
- const prompt = String(item.Prompt ?? "").toLowerCase();
1732
- const qid = item.AttributeName;
1733
- const uiType = String(item.UIDisplayType ?? "");
1734
- if (!qid || uiType === "TextBox")
2281
+ function collectUnboundLiterals(finalTemplate, parsedBody, shieldedUuids) {
2282
+ const unbound = [];
2283
+ const seen = new Set();
2284
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2285
+ if (typeof value !== "string" || value.length < MIN_STATE_VALUE_LENGTH)
1735
2286
  continue;
1736
- let bestField = null;
1737
- let bestScore = 0;
1738
- for (const [field, keywords] of Object.entries(QUESTION_PROMPT_KEYWORDS)) {
1739
- const score = keywords.filter((kw) => prompt.includes(kw)).length;
1740
- if (score > bestScore) {
1741
- bestScore = score;
1742
- bestField = field;
1743
- }
1744
- }
1745
- // A question the keyword map cannot place is the interesting case: it is a
1746
- // question this site asks and the caller has no field for. Report it —
1747
- // dropping it silently is how a generated plugin ends up submitting nothing
1748
- // for a required question.
1749
- if (!bestField || bestScore < 2) {
1750
- unmapped.push(`${qid}: ${String(item.Prompt ?? "")}`);
2287
+ if (shieldedUuids.has(value))
1751
2288
  continue;
1752
- }
1753
- if (mappings.some((m) => m.payloadField === bestField))
2289
+ const key = path[path.length - 1] ?? "";
2290
+ if (seen.has(key))
1754
2291
  continue;
1755
- const answers = {};
1756
- for (const a of (item.answers ?? [])) {
1757
- const meaning = String(a.Meaning ?? "");
1758
- const code = a.LookupCode;
1759
- if (meaning && code)
1760
- answers[meaning] = code;
1761
- }
1762
- mappings.push({ questionId: qid, payloadField: bestField, answers });
1763
- }
1764
- if (unmapped.length > 0) {
1765
- logger.warn(`${unmapped.length} screening question(s) matched no payload field and will be unanswered — add keywords to RECON_QUESTION_KEYWORDS: ${unmapped.join(" | ")}`);
1766
- }
1767
- return mappings.length > 0 ? mappings : null;
1768
- }
1769
- /**
1770
- * Builds the TypeScript source for a `buildBase64Content` function that
1771
- * constructs the base64-encoded Content JSON from payload values and returns
1772
- * it as a base64 string. The function replaces persona-specific values
1773
- * with payload references and maps questionnaire answers via a static
1774
- * lookup table derived from the recon captures.
1775
- */
1776
- function emitBuildBase64ContentFunction(base64, personaValues, questionMapping, pascal) {
1777
- const decoded = Buffer.from(base64, "base64").toString("utf8");
1778
- const content = JSON.parse(decoded);
1779
- const candidate = content.candidate;
1780
- const basic = candidate.basicInformation;
1781
- const phone = basic.phone;
1782
- const application = content.application;
1783
- const esig = application.eSignature;
1784
- basic.firstName = "__PAYLOAD_FirstName__";
1785
- basic.lastName = "__PAYLOAD_LastName__";
1786
- basic.email = "__PAYLOAD_Email__";
1787
- if (esig)
1788
- esig.fullName = "__PAYLOAD_SignatureFullName__";
1789
- if (basic.displayName && typeof basic.displayName === "string")
1790
- basic.displayName = "__PAYLOAD_DisplayName__";
1791
- if (phone && typeof phone.number === "string" && phone.number)
1792
- phone.number = "__PAYLOAD_Phone__";
1793
- for (const [personaVal, _payloadRef] of personaValues) {
1794
- if (typeof basic.email === "string" && basic.email === personaVal)
1795
- basic.email = "__PAYLOAD_Email__";
1796
- }
1797
- const questionnaires = candidate.questionnaires;
1798
- if (questionnaires && questionMapping) {
1799
- for (const q of questionnaires) {
1800
- q.questionnaireId = -1;
1801
- for (const question of q.questions) {
1802
- const mapping = questionMapping.find((m) => m.questionId === question.questionId);
1803
- if (mapping) {
1804
- question.answer = `__QMAP_${mapping.payloadField}__`;
1805
- }
1806
- }
2292
+ // Still a bare literal in the emitted template (no ${} took its place).
2293
+ if (finalTemplate.includes(JSON.stringify(value))) {
2294
+ seen.add(key);
2295
+ unbound.push(key);
1807
2296
  }
1808
2297
  }
1809
- const attachments = candidate.attachments;
1810
- if (attachments) {
1811
- for (const att of attachments) {
1812
- if (att.id && att.id !== "draft-json-undefined") {
1813
- att.id = "__PAYLOAD_AttachmentId__";
1814
- }
1815
- }
1816
- if (attachments[0]) {
1817
- attachments[0].appDraftId = "__PAYLOAD_DraftId__";
1818
- }
1819
- }
1820
- const jsonStr = JSON.stringify(content, null, 0);
1821
- const contentObj = JSON.parse(jsonStr);
1822
- const questionMapEntries = (questionMapping ?? []).map((m) => ` ${JSON.stringify(m.payloadField)}: { answers: ${JSON.stringify(m.answers)} as Record<string, number>, questionId: ${m.questionId} },`);
1823
- const questionMapConst2 = questionMapEntries.length > 0
1824
- ? `\nconst QUESTIONNAIRE_ANSWER_MAP = {\n${questionMapEntries.join("\n")}\n};\n`
1825
- : "";
1826
- const contentTemplate = JSON.stringify(contentObj, null, 2);
1827
- const parameterized = contentTemplate
1828
- .replace(/"__PAYLOAD_FirstName__"/g, "payload.FirstName")
1829
- .replace(/"__PAYLOAD_LastName__"/g, "payload.LastName")
1830
- .replace(/"__PAYLOAD_Email__"/g, "payload.Email")
1831
- .replace(/"__PAYLOAD_Phone__"/g, "payload.Phone")
1832
- .replace(/"__PAYLOAD_SignatureFullName__"/g, "payload.Answers.SignatureFullName")
1833
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
1834
- .replace(/"__PAYLOAD_DisplayName__"/g, "`${payload.FirstName} ${payload.LastName}`")
1835
- .replace(/"__PAYLOAD_AttachmentId__"/g, "attachmentId")
1836
- .replace(/"__PAYLOAD_DraftId__"/g, "draftId")
1837
- .replace(/-1(?=,\n\s*"questions")/g, "questionnaireId");
1838
- for (const m of questionMapping ?? []) {
1839
- parameterized.replace(`"__QMAP_${m.payloadField}__"`, `QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined"`);
1840
- }
1841
- let finalTemplate = parameterized.replace(/"__QMAP_[^"]*__"/g, '"draft-json-undefined"');
1842
- for (const m of questionMapping ?? []) {
1843
- finalTemplate = finalTemplate.replace(`"__QMAP_${m.payloadField}__"`, `(QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined")`);
1844
- }
1845
- return `${questionMapConst2}
1846
- /** Builds the ATS Content payload as a base64-encoded JSON string. */
1847
- function buildBase64Content(
1848
- payload: ${pascal}Payload,
1849
- questionnaireId: number,
1850
- draftId: number,
1851
- attachmentId: string
1852
- ): string {
1853
- const content = ${finalTemplate};
1854
- return Buffer.from(JSON.stringify(content)).toString("base64");
1855
- }
1856
- `;
2298
+ return unbound;
1857
2299
  }
1858
2300
  /** Builds the multi-step `executeHttp` body as a single template-literal string.
1859
2301
  *
@@ -1913,7 +2355,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
1913
2355
  }
1914
2356
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
1915
2357
  * without going through the full emitContractTs pipeline. */
1916
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map(), formSchema = null) {
2358
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map()) {
1917
2359
  // Walk the first action's request body to map each leaf string value to its
1918
2360
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
1919
2361
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -1951,6 +2393,67 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1951
2393
  payloadAccessorByValue.set(baseUrl, "payload.BaseUrl");
1952
2394
  outDiscoveredFields.add("BaseUrl");
1953
2395
  }
2396
+ // Persona identity bindings (from the flow's quoted literals + RECON_EMAIL,
2397
+ // paired to a payload field by the consumer vocabulary). Merged into the same
2398
+ // value→accessor map so `interpolateStateValues`' length-descending payload
2399
+ // pass substitutes them at ANY nesting depth — the fix for nested ATS bodies
2400
+ // like `formData.firstName` that the top-level-only key pass never reached.
2401
+ //
2402
+ // Collision guard (not a blunt length floor): that pass replaces by UNANCHORED
2403
+ // `String.split(value)`, so a persona value that appears INSIDE a longer token
2404
+ // would corrupt it — e.g. a `Select 'No' …` answer a vocabulary mapped to a
2405
+ // field would rewrite the "No" inside "Nursing"/"Not". A value binds only when
2406
+ // every occurrence across the action bodies sits at a token boundary (the
2407
+ // adjacent character is a non-alphanumeric JSON delimiter like `"`, space, or
2408
+ // punctuation), never flanked by alphanumerics. This keeps legitimately-short
2409
+ // identity values that don't collide (a 5-digit zip `06103`, a first name that
2410
+ // also appears space-delimited inside a signature) while dropping genuinely
2411
+ // dangerous substrings, which then surface via the unbound-literal TODO.
2412
+ // State-threaded produced values still win — they run in Pass 1, before this.
2413
+ const actionBodies = actions
2414
+ .map((a) => a.capture.requestPostData)
2415
+ .filter((b) => typeof b === "string" && b.length > 0);
2416
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
2417
+ const bindsWithoutCollision = (value) => {
2418
+ for (const body of actionBodies) {
2419
+ let from = 0;
2420
+ while (true) {
2421
+ const at = body.indexOf(value, from);
2422
+ if (at === -1)
2423
+ break;
2424
+ // Flanked by an alphanumeric on either side → it's a substring of a
2425
+ // longer token; binding it would mangle that token. Block the value.
2426
+ if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
2427
+ return false;
2428
+ from = at + value.length;
2429
+ }
2430
+ }
2431
+ return true;
2432
+ };
2433
+ for (const [value, accessor] of personaBindings) {
2434
+ if (value.length === 0)
2435
+ continue;
2436
+ if (!bindsWithoutCollision(value))
2437
+ continue;
2438
+ if (!payloadAccessorByValue.has(value))
2439
+ payloadAccessorByValue.set(value, accessor);
2440
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2441
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2442
+ outDiscoveredFields.add(field);
2443
+ }
2444
+ // Job coordinates from the recon entry URL's query string (e.g.
2445
+ // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
2446
+ // occurrence — and, via length-descending order, embedded substrings like a
2447
+ // jobId inside a jobSeqNo — rewrites to the caller-supplied value.
2448
+ for (const [value, accessor] of entryUrlParams) {
2449
+ if (value.length === 0)
2450
+ continue;
2451
+ if (!payloadAccessorByValue.has(value))
2452
+ payloadAccessorByValue.set(value, accessor);
2453
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2454
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2455
+ outDiscoveredFields.add(field);
2456
+ }
1954
2457
  // G2: register any tenant-subdomain header values as payload-supplied fields
1955
2458
  // (e.g. an `API-ShortName: "addus"` header becomes `payload.ApiShortName`).
1956
2459
  for (const [headerName, _value] of tenantSubdomainHeaders) {
@@ -1972,6 +2475,37 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1972
2475
  // skip non-JSON bodies (e.g. multipart raw bytes)
1973
2476
  }
1974
2477
  }
2478
+ // Detect the flow's THREADED transaction id: a single UUID the site mints
2479
+ // once (on page load) and reuses across every submit body to correlate the
2480
+ // multi-step wizard — observed on real ATS flows where one such id spans
2481
+ // every step. A frozen capture UUID would collide across concurrent/real
2482
+ // submissions, so the plugin must mint ONE at call time and thread it — hence
2483
+ // it maps to a hoisted local, not a payload field. Identified generically:
2484
+ // the same non-shielded UUID present in ≥2 action bodies.
2485
+ const uuidBodyCounts = new Map();
2486
+ for (const { capture } of actions) {
2487
+ const seen = new Set();
2488
+ for (const v of jsonBodyLeafValues(capture.requestPostData) ?? []) {
2489
+ if (UUID_REGEX.test(v) && !shieldedUuids.has(v))
2490
+ seen.add(v);
2491
+ }
2492
+ for (const v of seen)
2493
+ uuidBodyCounts.set(v, (uuidBodyCounts.get(v) ?? 0) + 1);
2494
+ }
2495
+ const threadedTxnId = [...uuidBodyCounts.entries()].find(([, n]) => n >= 2)?.[0] ?? null;
2496
+ // The value→`${txnId}` binding rides the same substitution map as payload
2497
+ // accessors (Pass 2 of interpolateStateValues); the hoisted `const txnId`
2498
+ // declaration is emitted once above the step sequence below. `txnId` is a
2499
+ // generic local name — the wire key it fills is whatever the body used.
2500
+ if (threadedTxnId !== null && !payloadAccessorByValue.has(threadedTxnId)) {
2501
+ payloadAccessorByValue.set(threadedTxnId, "txnId");
2502
+ }
2503
+ // Values already substituted to a `${…}` reference — the volatile pass must
2504
+ // NOT regenerate these (an already-threaded txn id or a bound email is not
2505
+ // volatile). Keyed by the concrete captured value.
2506
+ const boundValues = new Set(payloadAccessorByValue.keys());
2507
+ // Captured literals that survived every pass — surfaced as a review TODO.
2508
+ const unboundLiteralKeys = new Set();
1975
2509
  // Pass 1: render every step's emitted strings; collect referenced var names.
1976
2510
  const rendered = [];
1977
2511
  for (let i = 0; i < actions.length; i++) {
@@ -1990,12 +2524,51 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1990
2524
  const rawBodyWithFormSubs = cap.requestPostData && formSchema !== null
1991
2525
  ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields, formSchema), fieldOptionsMap, outDiscoveredOptionFields, formSchema), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema)
1992
2526
  : (cap.requestPostData ?? "");
1993
- let bodyTemplate = rawBodyWithFormSubs
1994
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithFormSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
2527
+ // Parsed once and shared by the structured (Mechanism B) and volatile passes
2528
+ // below both walk the same body JSON, so parsing twice would be redundant.
2529
+ // null for absent/non-JSON bodies (multipart raw bytes), which both passes skip.
2530
+ const parsedBody = (() => {
2531
+ if (!cap.requestPostData)
2532
+ return null;
2533
+ try {
2534
+ return JSON.parse(cap.requestPostData);
2535
+ }
2536
+ catch {
2537
+ return null;
2538
+ }
2539
+ })();
2540
+ // Mechanism B — parameterize whole nested caller structures
2541
+ // (experienceData/educationData history, opaque eventData) BEFORE value
2542
+ // substitution reaches inside them: swallowing the entire array/object first
2543
+ // keeps interpolateStateValues from binding a code buried in the history
2544
+ // sample (e.g. a work entry's state code) to an unrelated field.
2545
+ const rawBodyWithStructuredSubs = parsedBody !== null
2546
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys)
2547
+ : rawBodyWithFormSubs;
2548
+ const bodyAfterStateAndKv = rawBodyWithStructuredSubs
2549
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithStructuredSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
1995
2550
  : "";
1996
- const contentOverride = base64PatchOverride.get(step.varName);
1997
- if (contentOverride && bodyTemplate) {
1998
- bodyTemplate = bodyTemplate.replace(/"Content":"ey[A-Za-z0-9+/=]{100,}"/, contentOverride);
2551
+ // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
2552
+ // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
2553
+ // before: the emitted `${OPT_<Name>[]}` placeholder embeds the PascalCase
2554
+ // field name, and a wizard step-slug value (e.g. stepNum "Disability") that
2555
+ // becomes a global `.split` payload binding would otherwise rewrite the
2556
+ // matching substring INSIDE that placeholder and corrupt it. The closed-set
2557
+ // `"<key>":"<code>"` slot (numeric code, nested key) survives both earlier
2558
+ // passes untouched, so matching it here is still exact.
2559
+ let bodyTemplate = cap.requestPostData
2560
+ ? applyGenericRawCodeSubstitutions(applyGenericOptionCodeSubstitutions(bodyAfterStateAndKv, selectResolutions, fieldOptionsMap, outDiscoveredOptionFields), rawCodeFields)
2561
+ : bodyAfterStateAndKv;
2562
+ // Volatile pass: after persona/job/state/kv binding, regenerate any
2563
+ // remaining per-request UUID (fresh crypto.randomUUID()) and timestamp
2564
+ // (fresh new Date().toISOString()) so the plugin never replays the capture
2565
+ // instant. Recurses to any depth; skips schema anchors and already-bound
2566
+ // values (incl. the threaded txn id). Then flag whatever is STILL literal.
2567
+ if (bodyTemplate && parsedBody !== null) {
2568
+ bodyTemplate = applyVolatileFieldSubstitutions(bodyTemplate, parsedBody, shieldedUuids, boundValues);
2569
+ for (const key of collectUnboundLiterals(bodyTemplate, parsedBody, shieldedUuids)) {
2570
+ unboundLiteralKeys.add(key);
2571
+ }
1999
2572
  }
2000
2573
  const perCallHeaders = {};
2001
2574
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
@@ -2060,35 +2633,52 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2060
2633
  const returnAction = selectReturnAction(actions);
2061
2634
  if (returnAction)
2062
2635
  referencedNames.add(returnAction.varName);
2063
- // Base64 Content overrides reference variables inside function calls
2064
- // (e.g. buildBase64Content(payload, questionnaireId, ...)) that the
2065
- // ${name} regex above doesn't capture. Add them explicitly.
2066
- for (const [key, override] of base64PatchOverride.entries()) {
2067
- if (key === "__EXTRA_VARS__")
2068
- continue;
2069
- for (const m of override.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) {
2070
- const name = m[1];
2071
- if (/^r\d+$/.test(name))
2072
- continue;
2073
- referencedNames.add(name);
2074
- }
2075
- }
2076
2636
  // Pass 2: emit. Skip response bindings that aren't referenced; skip
2077
2637
  // produces[] entries whose name isn't referenced. A step's response var
2078
2638
  // is still needed when at least one of its produces[] entries IS
2079
2639
  // referenced — the produces line dereferences it.
2080
2640
  const lines = [];
2641
+ // Mint the threaded transaction id ONCE and reuse across every step — the
2642
+ // `${txnId}` references emitted into the bodies above all resolve to this
2643
+ // single call-time UUID, matching how the site mints one per application.
2644
+ // Emitted only when actually referenced (Biome noUnusedVariables).
2645
+ if (threadedTxnId !== null && referencedNames.has("txnId")) {
2646
+ lines.push(` const txnId = crypto.randomUUID();`);
2647
+ lines.push("");
2648
+ }
2649
+ // Surface any captured literal that no pass could bind, so a reviewer knows
2650
+ // exactly which slots still carry recon data. Comment only — never blocks emit.
2651
+ if (unboundLiteralKeys.size > 0) {
2652
+ lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
2653
+ }
2081
2654
  const declaredNames = new Set();
2082
2655
  for (let i = 0; i < actions.length; i++) {
2083
2656
  const step = actions[i];
2084
2657
  const cap = step.capture;
2085
2658
  const r = rendered[i];
2086
- const hasReferencedProduce = step.produces.some((p) => referencedNames.has(p.name));
2087
- const bindResponse = referencedNames.has(step.varName) || hasReferencedProduce;
2088
- if (base64PatchOverride.has(step.varName) && base64PatchOverride.has("__EXTRA_VARS__")) {
2089
- lines.push(base64PatchOverride.get("__EXTRA_VARS__"));
2090
- lines.push("");
2659
+ // Build the produce-extraction lines FIRST so the binding decision reflects
2660
+ // what is actually emitted, not a pre-scan predicate. A produce whose name
2661
+ // was already declared by an earlier step is de-dup-skipped here — and must
2662
+ // NOT keep this step's response bound, or `rN` is bound but never read
2663
+ // (Biome `noUnusedVariables`). `assertNonNull: false`: the `pathToAssertionType`
2664
+ // cast uses string-literal keys, so the accessor needs no `!` (and a `!`
2665
+ // would trip Biome `noNonNullAssertion`).
2666
+ const produceLines = [];
2667
+ for (const p of step.produces) {
2668
+ // Header/cookie-origin produces never surface as a JS accessor —
2669
+ // createHttpClient's `bind` option (rendered once, above the steps)
2670
+ // captures and forwards the value internally.
2671
+ if (p.kind === "header")
2672
+ continue;
2673
+ if (declaredNames.has(p.name))
2674
+ continue;
2675
+ if (!referencedNames.has(p.name))
2676
+ continue;
2677
+ declaredNames.add(p.name);
2678
+ const assertion = pathToAssertionType(p.path);
2679
+ produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
2091
2680
  }
2681
+ const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
2092
2682
  if (step.isCrossDomain) {
2093
2683
  lines.push(` // TODO: cross-domain redirect detected (${cap.url.split("/")[2]}) — likely needs browser fallback for this step.`);
2094
2684
  }
@@ -2173,20 +2763,8 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2173
2763
  lines.push(` }`);
2174
2764
  }
2175
2765
  }
2176
- for (const p of step.produces) {
2177
- // Header/cookie-origin produces never surface as a JS accessor —
2178
- // createHttpClient's `bind` option (rendered once, above the steps)
2179
- // captures and forwards the value internally.
2180
- if (p.kind === "header")
2181
- continue;
2182
- if (declaredNames.has(p.name))
2183
- continue;
2184
- if (!referencedNames.has(p.name))
2185
- continue;
2186
- declaredNames.add(p.name);
2187
- const assertion = pathToAssertionType(p.path);
2188
- lines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path)};`);
2189
- }
2766
+ for (const line of produceLines)
2767
+ lines.push(line);
2190
2768
  lines.push("");
2191
2769
  }
2192
2770
  const returnVar = returnAction ? returnAction.varName : "undefined";
@@ -2227,7 +2805,7 @@ function bindOptionLiteral(headerBindings) {
2227
2805
  /** Generates a complete contract.ts source string for a plugin — exported so
2228
2806
  * unit tests can drive the emitter directly without spawning the CLI. */
2229
2807
  function emitContractTs(opts) {
2230
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", headerBindings = [], } = opts;
2808
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
2231
2809
  // This is the CLIENT-level schema — createHttpClient's default, and the
2232
2810
  // plugin's caller-facing contract (what executeHttp's return value promises
2233
2811
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -2350,14 +2928,25 @@ function emitContractTs(opts) {
2350
2928
  })
2351
2929
  .join("\n")}\n})`
2352
2930
  : "";
2931
+ // Mechanism B: nested caller structures become payload fields carrying their
2932
+ // inferred schema. Emitted as an object body so multi-line z.array(z.object(
2933
+ // …)) expressions indent cleanly; a leading TSDoc flags eventData's opaque
2934
+ // passthrough so callers know its nested volatiles are theirs to mint.
2935
+ const sortedStructuredEntries = discoveredStructuredKeys
2936
+ ? [...discoveredStructuredKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
2937
+ : [];
2938
+ const structuredKeysExtension = sortedStructuredEntries.length > 0
2939
+ ? `.extend({\n${sortedStructuredEntries
2940
+ .map(([name, schema]) => ` ${isValidJsIdentifier(name) ? name : JSON.stringify(name)}: ${schema},`)
2941
+ .join("\n")}\n})`
2942
+ : "";
2353
2943
  // optionSchemaExtension is appended LAST so option enums show up at the
2354
2944
  // end of the payload type — the section ordering (base, multipart fields,
2355
2945
  // form-schema fields, option enums, raw-option fields) mirrors the body
2356
2946
  // emit order and keeps the generated payload type readable.
2357
- const answersExtension = base64ContentHelper ? ".extend({ Answers: AnswersSchema })" : "";
2358
2947
  const payloadSchemaExpr = hasMultipartStep
2359
- ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`
2360
- : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`;
2948
+ ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`
2949
+ : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`;
2361
2950
  // When the payload schema uses multipartBoolean(), import the shared helper
2362
2951
  // so the generated file resolves the reference and doesn't re-inline the
2363
2952
  // preprocess expression per boolean field.
@@ -2464,7 +3053,7 @@ const ${pascal}ResponseSchema = ${responseSchemaExpr};
2464
3053
  export type ${pascal}Response = z.infer<typeof ${pascal}ResponseSchema>;
2465
3054
 
2466
3055
  export default ${pascal}ResponseSchema;
2467
- ${optionDecls}${base64ContentHelper}
3056
+ ${optionDecls}
2468
3057
  const ${pascal}PayloadSchema = ${payloadSchemaExpr};
2469
3058
 
2470
3059
  export type ${pascal}Payload = z.infer<typeof ${pascal}PayloadSchema>;
@@ -2974,8 +3563,8 @@ async function main() {
2974
3563
  body: submitBodyPattern,
2975
3564
  };
2976
3565
  // Resolved once and threaded down, never captured into a module const: a
2977
- // module-level const would freeze at import time, which is the bug that makes
2978
- // RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
3566
+ // module-level const would freeze at import time, so an env var set after
3567
+ // module load would be silently inert for anyone reading it that way.
2979
3568
  const vocabulary = await resolveVocabulary(vocabularySpecifier);
2980
3569
  // Consumer-supplied wire keys for ATS form-schema recovery, or null. When
2981
3570
  // null the recovery functions no-op — the engine hardcodes no vendor format.
@@ -3016,6 +3605,13 @@ async function main() {
3016
3605
  // would be skipped by fieldNameMap; their field-ids still need shielding
3017
3606
  // because they appear as anchors in the T2-substituted body templates.
3018
3607
  const shieldedUuids = new Set(allSchemaUuids);
3608
+ // Persona identity bindings + entry-URL job coordinates — the value→payload
3609
+ // reconciliation the body emitter merges into its substitution map so nested
3610
+ // applicant fields and job context reach the caller's data instead of the
3611
+ // recon persona's. Both are site-agnostic: persona mapping comes from the
3612
+ // consumer vocabulary, job coordinates from the entry URL's own query keys.
3613
+ const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
3614
+ const entryUrlParams = extractEntryUrlParams(captures[0]?.url ?? "");
3019
3615
  // T4 — Phase B+C: detect a form-schema GET capture and insert it into the
3020
3616
  // action sequence at the position observed during recon, so the existing
3021
3617
  // state-threading machinery can produce its FormHistoryId / section UUIDs /
@@ -3082,6 +3678,20 @@ async function main() {
3082
3678
  // that get parameterized. Recorded with their value type so the contract
3083
3679
  // emitter can add them to the payload schema with appropriate Zod types.
3084
3680
  const discoveredAdditionalBodyKeys = new Map();
3681
+ // Mechanism A: reconcile flow SELECT steps to submitted option codes. The
3682
+ // resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
3683
+ // i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
3684
+ // existing raw-option channel so their frozen code is still parameterized.
3685
+ const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, captures, vocabulary, process.env);
3686
+ for (const [semanticName, { code }] of rawCodeFields) {
3687
+ const fieldName = `${semanticName}Code`;
3688
+ if (!discoveredRawOptionFields.has(fieldName))
3689
+ discoveredRawOptionFields.set(fieldName, code);
3690
+ }
3691
+ // Mechanism B: nested caller structures (experienceData/educationData
3692
+ // history, opaque eventData) discovered during the body emit, surfaced to the
3693
+ // contract's payload schema.
3694
+ const discoveredStructuredKeys = new Map();
3085
3695
  // G1+G2: partition baseHeaders into three buckets:
3086
3696
  // - static: values that don't reference baseUrl or tenant subdomain
3087
3697
  // - baseUrl-derived: values containing the recon's baseUrl as substring
@@ -3113,100 +3723,8 @@ async function main() {
3113
3723
  }
3114
3724
  }
3115
3725
  const multiStepBody = isSubmissionFlow
3116
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, new Map(), formSchema)
3726
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
3117
3727
  : undefined;
3118
- let base64ContentHelper = "";
3119
- const base64PatchOverride = new Map();
3120
- if (isSubmissionFlow && actionSteps.length > 0) {
3121
- const lastPatchWithContent = [...actionSteps]
3122
- .reverse()
3123
- .find((s) => s.capture.method === "PATCH" &&
3124
- s.capture.requestPostData &&
3125
- /"Content":"ey[A-Za-z0-9+/=]{100,}"/.test(s.capture.requestPostData));
3126
- if (lastPatchWithContent) {
3127
- const b64Match = lastPatchWithContent.capture.requestPostData.match(/"Content":"(ey[A-Za-z0-9+/=]{100,})"/);
3128
- if (b64Match) {
3129
- const b64 = b64Match[1];
3130
- const qMapping = buildQuestionnaireMapping(captures);
3131
- const personaValues = new Map();
3132
- const firstPost = captures.find((c) => c.method === "POST" &&
3133
- c.url.includes("recruitingCEJobApplicationDrafts") &&
3134
- c.requestPostData);
3135
- if (firstPost?.requestPostData) {
3136
- try {
3137
- const pb = JSON.parse(firstPost.requestPostData);
3138
- if (typeof pb.EmailAddress === "string")
3139
- personaValues.set(pb.EmailAddress, "payload.Email");
3140
- }
3141
- catch {
3142
- /* skip */
3143
- }
3144
- }
3145
- base64ContentHelper = emitBuildBase64ContentFunction(b64, personaValues, qMapping, pascal);
3146
- base64PatchOverride.set(lastPatchWithContent.varName,
3147
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
3148
- '"Content":"${buildBase64Content(payload, questionnaireId, Number(draftId), String(attachmentId))}"');
3149
- const draftPostStep = actionSteps.find((s) => s.capture.method === "POST" &&
3150
- s.capture.url.includes("recruitingCEJobApplicationDrafts"));
3151
- if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
3152
- draftPostStep.produces.push({
3153
- kind: "body",
3154
- name: "draftId",
3155
- pathExpr: `${draftPostStep.varName}.APPDraftId`,
3156
- path: ["APPDraftId"],
3157
- });
3158
- }
3159
- const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
3160
- if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
3161
- attachPostStep.produces.push({
3162
- kind: "body",
3163
- name: "attachmentId",
3164
- pathExpr: `${attachPostStep.varName}.Id`,
3165
- path: ["Id"],
3166
- });
3167
- }
3168
- const questionnaireCapture = captures.find((c) => c.method === "GET" &&
3169
- c.url.includes("recruitingCEQuestions") &&
3170
- c.url.includes("expand=answers"));
3171
- let sampleQid;
3172
- if (questionnaireCapture) {
3173
- const qResp = typeof questionnaireCapture.responseBody === "string"
3174
- ? JSON.parse(questionnaireCapture.responseBody)
3175
- : questionnaireCapture.responseBody;
3176
- sampleQid = qResp?.items?.[0]?.QuestionnaireId ?? undefined;
3177
- }
3178
- const overrideValue = base64PatchOverride.values().next().value;
3179
- if (overrideValue) {
3180
- const extraVarLines = [];
3181
- if (sampleQid) {
3182
- extraVarLines.push(` const questionnaireId = ${sampleQid};`);
3183
- }
3184
- if (!attachPostStep) {
3185
- extraVarLines.push(` const attachmentId = "";`);
3186
- }
3187
- if (extraVarLines.length > 0) {
3188
- base64PatchOverride.set("__EXTRA_VARS__", extraVarLines.join("\n"));
3189
- }
3190
- }
3191
- const answersFields = (qMapping ?? []).map((m) => m.payloadField);
3192
- answersFields.push("SignatureFullName");
3193
- const answersSchemaFields = answersFields.map((f) => ` ${f}: z.string(),`).join("\n");
3194
- base64ContentHelper = `\nconst AnswersSchema = z.object({\n${answersSchemaFields}\n});\n${base64ContentHelper}`;
3195
- const inputKeys = new Set();
3196
- if (inputBody && typeof inputBody === "object" && !Array.isArray(inputBody)) {
3197
- for (const k of Object.keys(inputBody))
3198
- inputKeys.add(k);
3199
- }
3200
- for (const fld of ["FirstName", "LastName", "Email", "Phone"]) {
3201
- if (!inputKeys.has(fld))
3202
- discoveredAdditionalBodyKeys.set(fld, "string");
3203
- }
3204
- }
3205
- }
3206
- }
3207
- const processedMultiStepBody = isSubmissionFlow
3208
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride, formSchema)
3209
- : multiStepBody;
3210
3728
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
3211
3729
  const headerBindings = collectHeaderBindings(actionSteps);
3212
3730
  // Shape inference targets the SAME call executeHttp returns — see
@@ -3260,8 +3778,7 @@ async function main() {
3260
3778
  gqlQuery,
3261
3779
  endpointPath,
3262
3780
  auxFiles,
3263
- multiStepBody: processedMultiStepBody,
3264
- base64ContentHelper,
3781
+ multiStepBody,
3265
3782
  inputBody,
3266
3783
  hasMultipartStep,
3267
3784
  discoveredFormFields,
@@ -3269,6 +3786,7 @@ async function main() {
3269
3786
  discoveredOptionFields,
3270
3787
  discoveredRawOptionFields,
3271
3788
  discoveredAdditionalBodyKeys,
3789
+ discoveredStructuredKeys,
3272
3790
  payloadFieldNames: browserFlow.payloadFieldNames,
3273
3791
  headerBindings,
3274
3792
  }));