@enricai/barnacle 1.9.5 → 1.9.7
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,26 @@
|
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.resolveStepPayloadField = resolveStepPayloadField;
|
|
25
|
+
exports.extractStepPersonaValue = extractStepPersonaValue;
|
|
26
|
+
exports.deriveFillLabelField = deriveFillLabelField;
|
|
27
|
+
exports.harvestPersonaBindings = harvestPersonaBindings;
|
|
25
28
|
exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
|
|
26
29
|
exports.selectPayloadAction = selectPayloadAction;
|
|
27
30
|
exports.selectReturnAction = selectReturnAction;
|
|
28
31
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
32
|
+
exports.extractEntryUrlParams = extractEntryUrlParams;
|
|
29
33
|
exports.resolveManifestActionSequence = resolveManifestActionSequence;
|
|
30
34
|
exports.extractActionSequence = extractActionSequence;
|
|
31
35
|
exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
|
|
32
36
|
exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
|
|
37
|
+
exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
|
|
38
|
+
exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
|
|
39
|
+
exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
|
|
33
40
|
exports.walkSetCookiePairs = walkSetCookiePairs;
|
|
34
41
|
exports.indexStateValues = indexStateValues;
|
|
35
42
|
exports.compileActionSteps = compileActionSteps;
|
|
36
43
|
exports.collectHeaderBindings = collectHeaderBindings;
|
|
37
|
-
exports.
|
|
44
|
+
exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
|
|
38
45
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
39
46
|
exports.emitContractTs = emitContractTs;
|
|
40
47
|
exports.emitConfigManifest = emitConfigManifest;
|
|
@@ -112,6 +119,122 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
|
|
|
112
119
|
}
|
|
113
120
|
return null;
|
|
114
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Extracts the concrete persona VALUE a flow step fills — the recon-supplied
|
|
124
|
+
* constant that appears verbatim in the captured request body — so the body
|
|
125
|
+
* emitter can bind it to `${payload.<field>}`. This is a stricter job than
|
|
126
|
+
* {@link buildStepInstructionExpr}'s browser-flow splice, which only needs *a*
|
|
127
|
+
* span to replace: here the extracted string must equal the wire value exactly,
|
|
128
|
+
* or the value-identity substitution silently misses.
|
|
129
|
+
*
|
|
130
|
+
* Two grammar facts, both verified against real ATS flows, drive the rule:
|
|
131
|
+
* 1. The possessive apostrophe in "the candidate's first name 'Reginald'"
|
|
132
|
+
* opens a false quote — a naive `/'[^']*'/` yields `s first name `, not
|
|
133
|
+
* `Reginald`. Neutralizing `\w's` → `\ws` before matching removes it.
|
|
134
|
+
* 2. A `Select`/`Choose` step names the ANSWER first, then the question
|
|
135
|
+
* ("Select 'No' for the 'sponsorship' question"), so the value is the
|
|
136
|
+
* FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
|
|
137
|
+
* first and the value last, so it is the LAST quoted token.
|
|
138
|
+
* A `${RECON_EMAIL}` token (or the literal email `env` value) short-circuits to
|
|
139
|
+
* the env-resolved address, matching recon-browser's own env substitution.
|
|
140
|
+
*
|
|
141
|
+
* @param instruction the flow step's plain-English instruction
|
|
142
|
+
* @param env process env (or a stub) supplying `${VAR}` token values, e.g. RECON_EMAIL
|
|
143
|
+
* @returns the persona value, or null when the step carries no spliceable constant
|
|
144
|
+
*/
|
|
145
|
+
function extractStepPersonaValue(instruction, env) {
|
|
146
|
+
const emailToken = `$${"{RECON_EMAIL}"}`;
|
|
147
|
+
const reconEmail = env.RECON_EMAIL;
|
|
148
|
+
if (reconEmail && (instruction.includes(emailToken) || instruction.includes(reconEmail))) {
|
|
149
|
+
return reconEmail;
|
|
150
|
+
}
|
|
151
|
+
// Resolve any other `${UPPER_SNAKE}` env token the same way recon-browser's
|
|
152
|
+
// substituteFlowEnvVars does, so an env-supplied value (e.g. RECON_PHONE) is
|
|
153
|
+
// matched against the wire body by its runtime form, not the literal token.
|
|
154
|
+
const envToken = /\$\{([A-Z_][A-Z0-9_]*)\}/.exec(instruction);
|
|
155
|
+
if (envToken) {
|
|
156
|
+
const resolved = env[envToken[1]];
|
|
157
|
+
if (resolved)
|
|
158
|
+
return resolved;
|
|
159
|
+
}
|
|
160
|
+
// Neutralize the possessive apostrophe so it isn't read as a quote delimiter.
|
|
161
|
+
const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
|
|
162
|
+
const quotes = [...cleaned.matchAll(/'([^']*)'/g)].map((m) => m[1]);
|
|
163
|
+
if (quotes.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const value = /^\s*(select|choose|pick)\b/i.test(instruction)
|
|
166
|
+
? quotes[0]
|
|
167
|
+
: quotes[quotes.length - 1];
|
|
168
|
+
return value.length > 0 ? value : null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Derives a payload field name from the field LABEL in a fill/enter/type
|
|
172
|
+
* instruction, for steps the consumer vocabulary does not cover.
|
|
173
|
+
*
|
|
174
|
+
* WHY: `fill in the <LABEL> field with '<VALUE>'` is self-describing — the label
|
|
175
|
+
* names the caller coordinate regardless of domain, so a vocabulary miss on a
|
|
176
|
+
* legitimate identity field (a "middle name" a recruiting vocab forgot to list)
|
|
177
|
+
* need not freeze the recon persona's value into every submission. This reads
|
|
178
|
+
* only the generic instruction grammar; it hardcodes no field or site name.
|
|
179
|
+
*
|
|
180
|
+
* Scoped to Fill/Enter/Type by design. Those name the field label FIRST and a
|
|
181
|
+
* quoted caller VALUE last, so a label→field claim is safe. Select/Choose name
|
|
182
|
+
* the ANSWER first and often only a facet second ("select the departure port
|
|
183
|
+
* from the Country dropdown") — deriving a field from the label there re-opens
|
|
184
|
+
* the exact off-domain false-splice `ReconVocabulary.subject` exists to prevent,
|
|
185
|
+
* so Select/Choose is deliberately excluded and stays vocabulary-gated.
|
|
186
|
+
*
|
|
187
|
+
* @param instruction the flow step's plain-English instruction
|
|
188
|
+
* @returns the PascalCase field name, or null when the shape does not match
|
|
189
|
+
*/
|
|
190
|
+
function deriveFillLabelField(instruction) {
|
|
191
|
+
if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
|
|
192
|
+
return null;
|
|
193
|
+
if (!/'[^']*'/.test(instruction))
|
|
194
|
+
return null;
|
|
195
|
+
const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
|
|
196
|
+
if (label === undefined)
|
|
197
|
+
return null;
|
|
198
|
+
return fieldNameToPascalCase(label, null);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Builds the map from a recon persona VALUE (as it appears in the captured
|
|
202
|
+
* request body) to the `payload.<Field>` accessor that should replace it, by
|
|
203
|
+
* pairing each flow step's resolved field ({@link resolveStepPayloadField})
|
|
204
|
+
* with its extracted value ({@link extractStepPersonaValue}). This is the
|
|
205
|
+
* value→field reconciliation the browser flow and payload schema already do,
|
|
206
|
+
* finally applied to the request-body templates.
|
|
207
|
+
*
|
|
208
|
+
* Earliest step wins on a duplicate value. Site-agnostic: the field mapping
|
|
209
|
+
* lives entirely in the consumer's `--vocabulary`, with a generic
|
|
210
|
+
* label-derivation ({@link deriveFillLabelField}) fallback for fill steps the
|
|
211
|
+
* vocabulary doesn't recognize — the vocabulary always wins when it has a match.
|
|
212
|
+
*/
|
|
213
|
+
function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
214
|
+
const bindings = new Map();
|
|
215
|
+
for (const step of flowSteps) {
|
|
216
|
+
const isObj = typeof step !== "string";
|
|
217
|
+
const instruction = isObj ? step.step : step;
|
|
218
|
+
const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
|
|
219
|
+
// Vocabulary wins outright. Only on a miss do we fall back to deriving the
|
|
220
|
+
// field from the instruction's own label — and never when the author opted
|
|
221
|
+
// the step out (`payloadFieldNone`) or the vocabulary explicitly excluded it.
|
|
222
|
+
const field = vocabField ??
|
|
223
|
+
(isObj && step.payloadFieldNone
|
|
224
|
+
? null
|
|
225
|
+
: vocabulary.exclusions.some((rx) => rx.test(instruction))
|
|
226
|
+
? null
|
|
227
|
+
: deriveFillLabelField(instruction));
|
|
228
|
+
if (field === null)
|
|
229
|
+
continue;
|
|
230
|
+
const value = extractStepPersonaValue(instruction, env);
|
|
231
|
+
if (value === null)
|
|
232
|
+
continue;
|
|
233
|
+
if (!bindings.has(value))
|
|
234
|
+
bindings.set(value, `payload.${field}`);
|
|
235
|
+
}
|
|
236
|
+
return bindings;
|
|
237
|
+
}
|
|
115
238
|
/**
|
|
116
239
|
* How deep to infer before collapsing to z.unknown(). Deep enough to reach the
|
|
117
240
|
* fields that carry meaning on real inventory APIs — a cruise sailing's price
|
|
@@ -320,6 +443,40 @@ function deriveBaseUrl(captures) {
|
|
|
320
443
|
}
|
|
321
444
|
return "https://example.com";
|
|
322
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
|
|
448
|
+
* query string, mapping each param VALUE to a `payload.<param>` accessor. The
|
|
449
|
+
* first capture is the landing navigation, and a submission flow's job context
|
|
450
|
+
* (`?jobSeqNo=...`, `?jobId=...`) rides its query string — it belongs to the
|
|
451
|
+
* target posting, not the recon run, so it must be caller-supplied. Values
|
|
452
|
+
* below {@link MIN_STATE_VALUE_LENGTH} and cache-buster keys are skipped, since
|
|
453
|
+
* a 1–7 char value collides with arbitrary substrings elsewhere in the body.
|
|
454
|
+
*
|
|
455
|
+
* Site-agnostic: reads only the entry URL's own query keys; no site-specific
|
|
456
|
+
* param knowledge. Downstream, `interpolateStateValues`' length-descending pass
|
|
457
|
+
* composes embedded substrings (a jobId inside a longer jobSeqNo) automatically.
|
|
458
|
+
*/
|
|
459
|
+
function extractEntryUrlParams(entryUrl) {
|
|
460
|
+
const params = new Map();
|
|
461
|
+
let u;
|
|
462
|
+
try {
|
|
463
|
+
u = new URL(entryUrl);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return params;
|
|
467
|
+
}
|
|
468
|
+
for (const [key, value] of u.searchParams) {
|
|
469
|
+
if (value.length < MIN_STATE_VALUE_LENGTH)
|
|
470
|
+
continue;
|
|
471
|
+
if (CACHE_BUSTER_QUERY_KEYS.has(key))
|
|
472
|
+
continue;
|
|
473
|
+
if (!isValidJsIdentifier(key))
|
|
474
|
+
continue;
|
|
475
|
+
if (!params.has(value))
|
|
476
|
+
params.set(value, `payload.${key}`);
|
|
477
|
+
}
|
|
478
|
+
return params;
|
|
479
|
+
}
|
|
323
480
|
const IGNORE_REQUEST_HEADERS = new Set([
|
|
324
481
|
"host",
|
|
325
482
|
"content-length",
|
|
@@ -1207,6 +1364,408 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
|
|
|
1207
1364
|
}
|
|
1208
1365
|
return result;
|
|
1209
1366
|
}
|
|
1367
|
+
/** i18n label placeholders (e.g. `{{apply.option.label.gender.a}}`) are not
|
|
1368
|
+
* human-facing answers and can never be matched against a flow step's quoted
|
|
1369
|
+
* label, so a schema whose labels are all templated yields no usable
|
|
1370
|
+
* label→code mapping. */
|
|
1371
|
+
function isI18nLabel(label) {
|
|
1372
|
+
return label.includes("{{");
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Indexes the JSON-Schema `enum`/`enumNames` PARALLEL-ARRAY convention across
|
|
1376
|
+
* every response body: an object carrying a string `name` plus equal-length
|
|
1377
|
+
* `enum` (option codes) and `enumNames` (option labels) declares one dropdown's
|
|
1378
|
+
* label→code mapping, disambiguated per-question by `name`. This is the PRIMARY
|
|
1379
|
+
* label→code source for ATS demographic/eligibility dropdowns whose submitted
|
|
1380
|
+
* value is an opaque code, not the label.
|
|
1381
|
+
*
|
|
1382
|
+
* The same `name` can appear in several captures with progressively fuller
|
|
1383
|
+
* option lists (a later page reveals the "decline to answer" choice), so the
|
|
1384
|
+
* entry with the MOST non-i18n labels wins rather than first-seen — the flow's
|
|
1385
|
+
* answer might be the choice only the fuller list carries.
|
|
1386
|
+
*
|
|
1387
|
+
* Site-agnostic: `enum`/`enumNames` are generic JSON-Schema keys; the field
|
|
1388
|
+
* identities are discovered from the response, never hardcoded.
|
|
1389
|
+
*/
|
|
1390
|
+
function indexEnumEnumNamesSchemas(captures) {
|
|
1391
|
+
const out = new Map();
|
|
1392
|
+
const walk = (value) => {
|
|
1393
|
+
if (Array.isArray(value)) {
|
|
1394
|
+
for (const item of value)
|
|
1395
|
+
walk(item);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (value === null || typeof value !== "object")
|
|
1399
|
+
return;
|
|
1400
|
+
const obj = value;
|
|
1401
|
+
const name = obj.name;
|
|
1402
|
+
const en = obj.enum;
|
|
1403
|
+
const nm = obj.enumNames;
|
|
1404
|
+
if (typeof name === "string" &&
|
|
1405
|
+
Array.isArray(en) &&
|
|
1406
|
+
Array.isArray(nm) &&
|
|
1407
|
+
en.length === nm.length &&
|
|
1408
|
+
en.length > 0 &&
|
|
1409
|
+
en.every((c) => typeof c === "string") &&
|
|
1410
|
+
nm.every((l) => typeof l === "string")) {
|
|
1411
|
+
const codes = en;
|
|
1412
|
+
const labels = nm;
|
|
1413
|
+
const usable = labels.filter((l) => !isI18nLabel(l)).length;
|
|
1414
|
+
const prior = out.get(name);
|
|
1415
|
+
const priorUsable = prior ? prior.labels.filter((l) => !isI18nLabel(l)).length : -1;
|
|
1416
|
+
if (usable > priorUsable)
|
|
1417
|
+
out.set(name, { codes, labels });
|
|
1418
|
+
}
|
|
1419
|
+
for (const v of Object.values(obj))
|
|
1420
|
+
walk(v);
|
|
1421
|
+
};
|
|
1422
|
+
for (const capture of captures)
|
|
1423
|
+
walk(capture.responseBody);
|
|
1424
|
+
return out;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Indexes the `{label,value}`-shaped option-object convention: arrays of
|
|
1428
|
+
* objects each carrying both a `label` and a `value` string (state/country
|
|
1429
|
+
* pickers ship these). Builds a GLOBAL label→code map used as the fallback
|
|
1430
|
+
* source for dropdowns whose flow step carries no `id=` hint (so the
|
|
1431
|
+
* enum/enumNames index can't be keyed) — the answer label is looked up
|
|
1432
|
+
* directly. First non-i18n binding wins on a duplicate label.
|
|
1433
|
+
*
|
|
1434
|
+
* Site-agnostic: `label`/`value` are generic option-object keys; no field name
|
|
1435
|
+
* is assumed.
|
|
1436
|
+
*/
|
|
1437
|
+
function indexLabelValueOptionCodes(captures) {
|
|
1438
|
+
const out = new Map();
|
|
1439
|
+
const walk = (value) => {
|
|
1440
|
+
if (Array.isArray(value)) {
|
|
1441
|
+
for (const item of value) {
|
|
1442
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
1443
|
+
const r = item;
|
|
1444
|
+
const label = r.label;
|
|
1445
|
+
const code = r.value;
|
|
1446
|
+
if (typeof label === "string" &&
|
|
1447
|
+
typeof code === "string" &&
|
|
1448
|
+
label.length > 0 &&
|
|
1449
|
+
code.length > 0 &&
|
|
1450
|
+
!isI18nLabel(label) &&
|
|
1451
|
+
!out.has(label)) {
|
|
1452
|
+
out.set(label, code);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
walk(item);
|
|
1456
|
+
}
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (value === null || typeof value !== "object")
|
|
1460
|
+
return;
|
|
1461
|
+
for (const v of Object.values(value))
|
|
1462
|
+
walk(v);
|
|
1463
|
+
};
|
|
1464
|
+
for (const capture of captures)
|
|
1465
|
+
walk(capture.responseBody);
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Reconciles each flow SELECT step to a submitted option CODE so the body-slot
|
|
1470
|
+
* literal (`"applyHealthCareExclusion":"5395"`) can be rewritten to a
|
|
1471
|
+
* caller-driven `${OPT_<Name>[payload.<Name>]}` lookup. Real ATS bodies carry
|
|
1472
|
+
* codes, not labels; the flow carries labels — this bridges them via the two
|
|
1473
|
+
* generic label→code conventions ({@link indexEnumEnumNamesSchemas} primary,
|
|
1474
|
+
* {@link indexLabelValueOptionCodes} fallback).
|
|
1475
|
+
*
|
|
1476
|
+
* The wire KEY is discovered from the step's `id=<field>` hint when present
|
|
1477
|
+
* (the enum/enumNames schema is keyed by that same field name); steps without
|
|
1478
|
+
* an `id=` (state/country) fall back to the vocabulary-resolved persona field
|
|
1479
|
+
* lowercased, matched against the `{label,value}` map by label. A dropdown
|
|
1480
|
+
* whose labels are all i18n placeholders (e.g. gender) yields no enum and is
|
|
1481
|
+
* reported separately for the raw-code channel.
|
|
1482
|
+
*
|
|
1483
|
+
* Returns structured resolutions plus the set of i18n-only wire keys (raw-code
|
|
1484
|
+
* fallbacks). Site-agnostic: field identities come from the flow's own `id=`
|
|
1485
|
+
* hints and the consumer vocabulary, never a hardcoded key.
|
|
1486
|
+
*/
|
|
1487
|
+
function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
|
|
1488
|
+
const enumSchemas = indexEnumEnumNamesSchemas(captures);
|
|
1489
|
+
const labelValue = indexLabelValueOptionCodes(captures);
|
|
1490
|
+
const resolutions = [];
|
|
1491
|
+
const rawCodeFields = new Map();
|
|
1492
|
+
const seenWireKeys = new Set();
|
|
1493
|
+
for (const step of flowSteps) {
|
|
1494
|
+
const instruction = typeof step === "string" ? step : step.step;
|
|
1495
|
+
if (!/^\s*(select|choose|pick)\b/i.test(instruction) && !/\bselect\b/i.test(instruction)) {
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
const idMatch = /id=(\w+)/.exec(instruction);
|
|
1499
|
+
// A Select step names the ANSWER first, then the question — so the answer is
|
|
1500
|
+
// the FIRST quoted token (apostrophe-aware). extractStepPersonaValue only
|
|
1501
|
+
// returns first-quote when the sentence STARTS with select/choose/pick; a
|
|
1502
|
+
// dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
|
|
1503
|
+
// starts with "On", so read the first quote directly here for id= steps.
|
|
1504
|
+
const firstQuoteLabel = (() => {
|
|
1505
|
+
const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
|
|
1506
|
+
const m = /'([^']*)'/.exec(cleaned);
|
|
1507
|
+
return m && m[1].length > 0 ? m[1] : null;
|
|
1508
|
+
})();
|
|
1509
|
+
const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
|
|
1510
|
+
if (label === null)
|
|
1511
|
+
continue;
|
|
1512
|
+
// Primary: an id= hint names the wire key AND the enum/enumNames schema key.
|
|
1513
|
+
if (idMatch) {
|
|
1514
|
+
const wireKey = idMatch[1];
|
|
1515
|
+
if (seenWireKeys.has(wireKey))
|
|
1516
|
+
continue;
|
|
1517
|
+
const schema = enumSchemas.get(wireKey);
|
|
1518
|
+
if (!schema)
|
|
1519
|
+
continue;
|
|
1520
|
+
const semanticName = fieldNameToPascalCase(wireKey, null);
|
|
1521
|
+
if (semanticName === null)
|
|
1522
|
+
continue;
|
|
1523
|
+
const idx = schema.labels.indexOf(label);
|
|
1524
|
+
const usableOptions = schema.labels
|
|
1525
|
+
.map((l, i) => ({ label: l, code: schema.codes[i] }))
|
|
1526
|
+
.filter((o) => !isI18nLabel(o.label));
|
|
1527
|
+
if (idx >= 0 && !isI18nLabel(schema.labels[idx])) {
|
|
1528
|
+
resolutions.push({
|
|
1529
|
+
wireKey,
|
|
1530
|
+
semanticName,
|
|
1531
|
+
code: schema.codes[idx],
|
|
1532
|
+
label,
|
|
1533
|
+
options: usableOptions,
|
|
1534
|
+
});
|
|
1535
|
+
seenWireKeys.add(wireKey);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
// Label is i18n-only (or the answer maps to a templated label): the field
|
|
1539
|
+
// still must not stay frozen — surface the recon-observed code so the raw
|
|
1540
|
+
// channel emits a caller-supplied default.
|
|
1541
|
+
if (usableOptions.length === 0 && schema.codes.length > 0) {
|
|
1542
|
+
const fallbackIdx = idx >= 0 ? idx : schema.codes.length - 1;
|
|
1543
|
+
rawCodeFields.set(semanticName, { wireKey, code: schema.codes[fallbackIdx] });
|
|
1544
|
+
seenWireKeys.add(wireKey);
|
|
1545
|
+
}
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
// Fallback: no id= — resolve the wire key from the vocabulary persona field
|
|
1549
|
+
// (lowercased) and the code from the global {label,value} map by label.
|
|
1550
|
+
const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary);
|
|
1551
|
+
if (field === null)
|
|
1552
|
+
continue;
|
|
1553
|
+
const code = labelValue.get(label);
|
|
1554
|
+
if (code === undefined)
|
|
1555
|
+
continue;
|
|
1556
|
+
const wireKey = field.toLowerCase();
|
|
1557
|
+
if (seenWireKeys.has(wireKey))
|
|
1558
|
+
continue;
|
|
1559
|
+
const semanticName = fieldNameToPascalCase(wireKey, null);
|
|
1560
|
+
if (semanticName === null)
|
|
1561
|
+
continue;
|
|
1562
|
+
// The {label,value} map only reliably yields the one answered label→code
|
|
1563
|
+
// pair here; emit a single-choice enum so the field still binds (the caller
|
|
1564
|
+
// can widen it). Co-located labels aren't safely attributable to this field.
|
|
1565
|
+
resolutions.push({ wireKey, semanticName, code, label, options: [{ label, code }] });
|
|
1566
|
+
seenWireKeys.add(wireKey);
|
|
1567
|
+
}
|
|
1568
|
+
return { resolutions, rawCodeFields };
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Rewrites plain-JSON dropdown body slots (`"<wireKey>":"<code>"`) to a
|
|
1572
|
+
* caller-driven `${OPT_<Name>[payload.<Name>]}` lookup, anchored on the wire
|
|
1573
|
+
* KEY rather than the UUID field-id marker
|
|
1574
|
+
* {@link applyFormSchemaOptionIdSubstitutions} uses. This is the plain-JSON
|
|
1575
|
+
* ATS case where the submitted body is a flat `{ "field": "code" }` map with no
|
|
1576
|
+
* schema envelope, so the key/value pair — both drawn from recon input — is the
|
|
1577
|
+
* only closed-set anchor available.
|
|
1578
|
+
*
|
|
1579
|
+
* Records each rewritten field's semanticName into `outDiscoveredOptionFields`
|
|
1580
|
+
* so emitContractTs lights up its OPT_<Name> const + z.enum payload entry, and
|
|
1581
|
+
* mutates `fieldOptionsMap` so that emit finds the option mapping. Closed-set:
|
|
1582
|
+
* both the key and the code come from the recon-derived resolutions.
|
|
1583
|
+
*/
|
|
1584
|
+
function applyGenericOptionCodeSubstitutions(rawBody, resolutions, fieldOptionsMap, outDiscoveredOptionFields) {
|
|
1585
|
+
if (resolutions.length === 0)
|
|
1586
|
+
return rawBody;
|
|
1587
|
+
let result = rawBody;
|
|
1588
|
+
for (const res of resolutions) {
|
|
1589
|
+
const slot = `"${res.wireKey}":"${res.code}"`;
|
|
1590
|
+
if (!result.includes(slot))
|
|
1591
|
+
continue;
|
|
1592
|
+
const replacement = `"${res.wireKey}":"$${"{"}OPT_${res.semanticName}[payload.${res.semanticName}]${"}"}"`;
|
|
1593
|
+
result = result.split(slot).join(replacement);
|
|
1594
|
+
// Mutate the option map so emitContractTs emits OPT_<Name> + the z.enum.
|
|
1595
|
+
if (!fieldOptionsMap.has(res.wireKey)) {
|
|
1596
|
+
fieldOptionsMap.set(res.wireKey, {
|
|
1597
|
+
semanticName: res.semanticName,
|
|
1598
|
+
options: res.options.map((o) => ({ value: o.label, optionId: o.code })),
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
outDiscoveredOptionFields.add(res.semanticName);
|
|
1602
|
+
}
|
|
1603
|
+
return result;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Rewrites the body slot of an i18n-only dropdown (one whose labels are all
|
|
1607
|
+
* templated placeholders, so no OPT_<Name> enum is possible) from its frozen
|
|
1608
|
+
* recon code to a caller-supplied `${payload.<Name>Code}`. This is the
|
|
1609
|
+
* plain-JSON, wire-key-anchored twin of {@link applyRawOptionIdPayloadSubstitutions}
|
|
1610
|
+
* (which is UUID/form-schema anchored) — without it a field like gender would
|
|
1611
|
+
* submit the recon persona's frozen choice for every caller.
|
|
1612
|
+
*/
|
|
1613
|
+
function applyGenericRawCodeSubstitutions(rawBody, rawCodeFields) {
|
|
1614
|
+
if (rawCodeFields.size === 0)
|
|
1615
|
+
return rawBody;
|
|
1616
|
+
let result = rawBody;
|
|
1617
|
+
for (const [semanticName, { wireKey, code }] of rawCodeFields) {
|
|
1618
|
+
const slot = `"${wireKey}":"${code}"`;
|
|
1619
|
+
if (!result.includes(slot))
|
|
1620
|
+
continue;
|
|
1621
|
+
const replacement = `"${wireKey}":"$${"{"}payload.${semanticName}Code${"}"}"`;
|
|
1622
|
+
result = result.split(slot).join(replacement);
|
|
1623
|
+
}
|
|
1624
|
+
return result;
|
|
1625
|
+
}
|
|
1626
|
+
/** Counts an object's DIRECT primitive-valued children (string/number/boolean/
|
|
1627
|
+
* null). The form envelope is the object with the most of these — its scalar
|
|
1628
|
+
* children are the fields every other binding pass individually parameterizes,
|
|
1629
|
+
* so it must never be swallowed wholesale. */
|
|
1630
|
+
function directPrimitiveChildCount(obj) {
|
|
1631
|
+
let n = 0;
|
|
1632
|
+
for (const v of Object.values(obj)) {
|
|
1633
|
+
if (v === null || (typeof v !== "object" && typeof v !== "function"))
|
|
1634
|
+
n++;
|
|
1635
|
+
}
|
|
1636
|
+
return n;
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Locates the FORM ENVELOPE inside a submit body — the nested object that
|
|
1640
|
+
* actually holds the scalar form fields (which every other pass binds one by
|
|
1641
|
+
* one) — by descending through wrapper objects and picking the object with the
|
|
1642
|
+
* most direct primitive children. Returns its dotted path from the body root
|
|
1643
|
+
* (empty when the root itself is the envelope). Site-agnostic: no key names are
|
|
1644
|
+
* assumed; the envelope is found by shape.
|
|
1645
|
+
*/
|
|
1646
|
+
function locateFormEnvelopePath(parsedBody) {
|
|
1647
|
+
const candidates = [];
|
|
1648
|
+
const visit = (value, path) => {
|
|
1649
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1650
|
+
return;
|
|
1651
|
+
const obj = value;
|
|
1652
|
+
candidates.push({ path, primitives: directPrimitiveChildCount(obj) });
|
|
1653
|
+
for (const [k, v] of Object.entries(obj))
|
|
1654
|
+
visit(v, [...path, k]);
|
|
1655
|
+
};
|
|
1656
|
+
visit(parsedBody, []);
|
|
1657
|
+
if (candidates.length === 0)
|
|
1658
|
+
return [];
|
|
1659
|
+
const maxP = Math.max(...candidates.map((c) => c.primitives));
|
|
1660
|
+
// The analytics blob (`eventData`) mirrors the form, so the object with the
|
|
1661
|
+
// MOST primitives can be a deep descendant of the true envelope. Pick the
|
|
1662
|
+
// SHALLOWEST primitive-rich object (≥ half the max) instead — that is the
|
|
1663
|
+
// form envelope itself, whose analytics mirror sits below it. Tie-break on a
|
|
1664
|
+
// higher primitive count. Threshold is relative, not a magic key name.
|
|
1665
|
+
const rich = candidates.filter((c) => c.primitives >= Math.max(1, maxP / 2));
|
|
1666
|
+
// No object carries a scalar field (maxP === 0): the body root is the only
|
|
1667
|
+
// sensible envelope — operate at the top level.
|
|
1668
|
+
if (rich.length === 0)
|
|
1669
|
+
return [];
|
|
1670
|
+
rich.sort((a, b) => a.path.length - b.path.length || b.primitives - a.primitives);
|
|
1671
|
+
return rich[0].path;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Parameterizes whole nested caller-supplied structures sitting BESIDE the
|
|
1675
|
+
* scalar form fields — the array-valued work/education history
|
|
1676
|
+
* (`experienceData`/`educationData`/`dqData`) and the opaque `eventData`
|
|
1677
|
+
* analytics object — replacing each `"key":<json>` span with
|
|
1678
|
+
* `"key":${JSON.stringify(payload.<key>)}` and recording the key's inferred Zod
|
|
1679
|
+
* schema so emitContractTs adds it to the payload contract.
|
|
1680
|
+
*
|
|
1681
|
+
* These blocks are caller data the recon merely captured a frozen sample of;
|
|
1682
|
+
* freezing them would submit one applicant's history for every caller. Crucially
|
|
1683
|
+
* the FORM ENVELOPE object itself (the one carrying firstName/state/… that the
|
|
1684
|
+
* persona and dropdown passes bind field-by-field) is NEVER swallowed — that
|
|
1685
|
+
* would collapse the whole form to one opaque `${JSON.stringify(payload.formData)}`
|
|
1686
|
+
* and defeat every other binding. {@link locateFormEnvelopePath} finds it by
|
|
1687
|
+
* shape; only its non-scalar SIBLING children are parameterized. A
|
|
1688
|
+
* brace/bracket-depth scanner finds the exact JSON span (the captured body is
|
|
1689
|
+
* well-formed).
|
|
1690
|
+
*
|
|
1691
|
+
* NOTE on `eventData`: it becomes an opaque `${JSON.stringify(payload.eventData)}`
|
|
1692
|
+
* passthrough. Its nested volatiles (apTxnId, per-step timestamps) therefore
|
|
1693
|
+
* become the CALLER's responsibility to mint fresh — acceptable because the
|
|
1694
|
+
* whole blob is caller-supplied; the generator can't reach inside a value it
|
|
1695
|
+
* has delegated wholesale.
|
|
1696
|
+
*
|
|
1697
|
+
* Site-agnostic: operates only on the recon body's own shape.
|
|
1698
|
+
*/
|
|
1699
|
+
function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
|
|
1700
|
+
if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
|
|
1701
|
+
return template;
|
|
1702
|
+
}
|
|
1703
|
+
// Resolve the envelope object whose non-scalar children are caller structures.
|
|
1704
|
+
const envelopePath = locateFormEnvelopePath(parsedBody);
|
|
1705
|
+
let envelope = parsedBody;
|
|
1706
|
+
for (const seg of envelopePath) {
|
|
1707
|
+
if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
|
|
1708
|
+
envelope = envelope[seg];
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
1712
|
+
return template;
|
|
1713
|
+
}
|
|
1714
|
+
let result = template;
|
|
1715
|
+
for (const [key, value] of Object.entries(envelope)) {
|
|
1716
|
+
const isNonEmptyArray = Array.isArray(value) && value.length > 0;
|
|
1717
|
+
const isNestedObject = value !== null &&
|
|
1718
|
+
typeof value === "object" &&
|
|
1719
|
+
!Array.isArray(value) &&
|
|
1720
|
+
Object.keys(value).length > 0;
|
|
1721
|
+
if (!isNonEmptyArray && !isNestedObject)
|
|
1722
|
+
continue;
|
|
1723
|
+
const keyMarker = `"${key}":`;
|
|
1724
|
+
const markerIdx = result.indexOf(keyMarker);
|
|
1725
|
+
if (markerIdx === -1)
|
|
1726
|
+
continue;
|
|
1727
|
+
const spanStart = markerIdx + keyMarker.length;
|
|
1728
|
+
const open = result[spanStart];
|
|
1729
|
+
if (open !== "[" && open !== "{")
|
|
1730
|
+
continue;
|
|
1731
|
+
const close = open === "[" ? "]" : "}";
|
|
1732
|
+
let depth = 0;
|
|
1733
|
+
let inString = false;
|
|
1734
|
+
let escaped = false;
|
|
1735
|
+
let spanEnd = -1;
|
|
1736
|
+
for (let i = spanStart; i < result.length; i++) {
|
|
1737
|
+
const ch = result[i];
|
|
1738
|
+
if (inString) {
|
|
1739
|
+
if (escaped)
|
|
1740
|
+
escaped = false;
|
|
1741
|
+
else if (ch === "\\")
|
|
1742
|
+
escaped = true;
|
|
1743
|
+
else if (ch === '"')
|
|
1744
|
+
inString = false;
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (ch === '"')
|
|
1748
|
+
inString = true;
|
|
1749
|
+
else if (ch === open)
|
|
1750
|
+
depth++;
|
|
1751
|
+
else if (ch === close) {
|
|
1752
|
+
depth--;
|
|
1753
|
+
if (depth === 0) {
|
|
1754
|
+
spanEnd = i + 1;
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
if (spanEnd === -1)
|
|
1760
|
+
continue;
|
|
1761
|
+
const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
|
|
1762
|
+
result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
|
|
1763
|
+
if (!outStructuredKeys.has(key)) {
|
|
1764
|
+
outStructuredKeys.set(key, inferZodSchema(value));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return result;
|
|
1768
|
+
}
|
|
1210
1769
|
/** Maximum length to guard against indexing massive blobs (HTML fragments,
|
|
1211
1770
|
* embedded base64 images, etc.) that aren't candidates for state threading. */
|
|
1212
1771
|
const MAX_STATE_VALUE_LENGTH = 256;
|
|
@@ -1557,6 +2116,26 @@ function collectHeaderBindings(actionSteps) {
|
|
|
1557
2116
|
}
|
|
1558
2117
|
return [...byKey.values()];
|
|
1559
2118
|
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Reads the concrete string a response-body produce points at, by walking the
|
|
2121
|
+
* capture's response body along the produce path. Returns null when any segment
|
|
2122
|
+
* is absent or the leaf isn't a string. Shared by state-threading and the
|
|
2123
|
+
* producer-boundary binding so both resolve produced values identically.
|
|
2124
|
+
*/
|
|
2125
|
+
function resolveResponsePathValue(responseBody, path) {
|
|
2126
|
+
let cursor = responseBody;
|
|
2127
|
+
for (const segment of path) {
|
|
2128
|
+
if (cursor !== null &&
|
|
2129
|
+
typeof cursor === "object" &&
|
|
2130
|
+
segment in cursor) {
|
|
2131
|
+
cursor = cursor[segment];
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
return null;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
return typeof cursor === "string" ? cursor : null;
|
|
2138
|
+
}
|
|
1560
2139
|
/**
|
|
1561
2140
|
* Replaces occurrences of state values in `template` with `${varName}`
|
|
1562
2141
|
* interpolations. Returns a JS template-literal string fragment (no backticks).
|
|
@@ -1577,20 +2156,9 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
|
|
|
1577
2156
|
// interpolate here.
|
|
1578
2157
|
if (p.kind === "header")
|
|
1579
2158
|
continue;
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
typeof cursor === "object" &&
|
|
1584
|
-
segment in cursor) {
|
|
1585
|
-
cursor = cursor[segment];
|
|
1586
|
-
}
|
|
1587
|
-
else {
|
|
1588
|
-
cursor = null;
|
|
1589
|
-
break;
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
if (typeof cursor === "string")
|
|
1593
|
-
varNameByValue.set(cursor, p.name);
|
|
2159
|
+
const value = resolveResponsePathValue(step.capture.responseBody, p.path);
|
|
2160
|
+
if (value !== null)
|
|
2161
|
+
varNameByValue.set(value, p.name);
|
|
1594
2162
|
}
|
|
1595
2163
|
}
|
|
1596
2164
|
let result = template;
|
|
@@ -1613,6 +2181,144 @@ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = n
|
|
|
1613
2181
|
}
|
|
1614
2182
|
return result;
|
|
1615
2183
|
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Finds the request-body coordinates a PRODUCING step must source from the
|
|
2186
|
+
* caller's payload instead of a frozen capture literal.
|
|
2187
|
+
*
|
|
2188
|
+
* A response-produced state var (see {@link compileActionSteps}' produces[]) is
|
|
2189
|
+
* threaded as `${var}` in every step AFTER its producer. In the producer itself
|
|
2190
|
+
* the value predates its own response, so {@link interpolateStateValues} has no
|
|
2191
|
+
* prior binding for it and the frozen recon literal (the recon persona's
|
|
2192
|
+
* jobId/jobSeqNo/jobTitle/jobLocation) leaks into every caller's submission. The
|
|
2193
|
+
* value's real origin for the producer is the same coordinate the caller
|
|
2194
|
+
* supplies — a payload field.
|
|
2195
|
+
*
|
|
2196
|
+
* WHY it keys off produces[] ∩ the producer's own body, never a field/site name:
|
|
2197
|
+
* the signal is purely structural — "a value this flow threads downstream AND
|
|
2198
|
+
* re-sends in the very step that first emitted it". The field name is the
|
|
2199
|
+
* produced var name verbatim (`pathToVarName`'s output = the wire key), so the
|
|
2200
|
+
* producer's payload field and the downstream `${var}` describe one logical
|
|
2201
|
+
* coordinate and share the same runtime value (the caller passes it, the site
|
|
2202
|
+
* echoes it).
|
|
2203
|
+
*
|
|
2204
|
+
* A coordinate that a HIGHER-priority source already maps to a `payload.<field>`
|
|
2205
|
+
* (an entry-URL param — e.g. a jobSeqNo) is NOT skipped: it is re-emitted here so
|
|
2206
|
+
* the whole-value pass binds it atomically on the producer step, reusing that
|
|
2207
|
+
* source's accessor. Otherwise state threading fragments the composite (a prefix
|
|
2208
|
+
* that a prior step produced) before the length-descending payload pass can match
|
|
2209
|
+
* it, and the collision guard then refuses the embedded remainder — stranding the
|
|
2210
|
+
* middle of the coordinate frozen. A value mapped to a NON-payload target (the
|
|
2211
|
+
* threaded txn id) is left untouched. UUID-shaped values are excluded entirely —
|
|
2212
|
+
* a re-sent UUID is a volatile/threaded id owned by another pass, not a caller
|
|
2213
|
+
* coordinate — and only WHOLE request-body leaves qualify, so every returned
|
|
2214
|
+
* value is one the whole-value pass will bind (and whose field must be declared).
|
|
2215
|
+
*
|
|
2216
|
+
* @param actions the compiled action steps (carry produces[] + request bodies)
|
|
2217
|
+
* @param alreadyBound capture value → existing accessor; a `payload.*` accessor
|
|
2218
|
+
* is reused, a non-payload one (e.g. `txnId`) vetoes the value
|
|
2219
|
+
* @returns capture value → { accessor: "payload.<field>"; field; producerIndex }
|
|
2220
|
+
*/
|
|
2221
|
+
function deriveProducerBoundaryBindings(actions, alreadyBound) {
|
|
2222
|
+
const bindings = new Map();
|
|
2223
|
+
for (let i = 0; i < actions.length; i++) {
|
|
2224
|
+
const step = actions[i];
|
|
2225
|
+
const bodyLeafValues = jsonBodyLeafValues(step.capture.requestPostData);
|
|
2226
|
+
for (const p of step.produces) {
|
|
2227
|
+
if (p.kind === "header")
|
|
2228
|
+
continue;
|
|
2229
|
+
const value = resolveResponsePathValue(step.capture.responseBody, p.path);
|
|
2230
|
+
if (value === null || value.length < MIN_STATE_VALUE_LENGTH)
|
|
2231
|
+
continue;
|
|
2232
|
+
if (bindings.has(value))
|
|
2233
|
+
continue;
|
|
2234
|
+
// A UUID re-sent across steps is never a stable caller coordinate — it's a
|
|
2235
|
+
// per-call volatile id or the threaded transaction id (which the server may
|
|
2236
|
+
// echo, so it looks "produced"). Both are owned by their own passes (the
|
|
2237
|
+
// volatile regen / the hoisted `txnId`); binding one to a payload field
|
|
2238
|
+
// would freeze the recon's single id into every caller's submission.
|
|
2239
|
+
if (UUID_REGEX.test(value))
|
|
2240
|
+
continue;
|
|
2241
|
+
// A value already mapped to a non-payload target (the threaded txn id) must
|
|
2242
|
+
// stay that target; only a `payload.*` accessor is reusable here.
|
|
2243
|
+
const existing = alreadyBound.get(value);
|
|
2244
|
+
if (existing !== undefined && !existing.startsWith("payload."))
|
|
2245
|
+
continue;
|
|
2246
|
+
// Producer-boundary reuse: the value must re-appear as a WHOLE JSON leaf in
|
|
2247
|
+
// THIS step's own request body. Whole-leaf (not substring) keeps "in this
|
|
2248
|
+
// map" ⟺ "the whole-value pass will bind this value's `"<key>":"<value>"`
|
|
2249
|
+
// slot" ⟺ "its field must be declared"; a substring match would declare a
|
|
2250
|
+
// field the pass never references. A composite that embeds a shorter
|
|
2251
|
+
// coordinate (a jobId inside a jobSeqNo) still binds — each is its own whole
|
|
2252
|
+
// leaf, and the longer one's whole-value bind carries the embedded copy. A
|
|
2253
|
+
// non-JSON (multipart) body has no parseable leaves and the whole-value pass
|
|
2254
|
+
// can't rewrite it, so it never qualifies (`bodyLeafValues === null`).
|
|
2255
|
+
if (bodyLeafValues === null || !bodyLeafValues.some((leaf) => leaf === value))
|
|
2256
|
+
continue;
|
|
2257
|
+
// Reuse the higher-priority source's field when present; otherwise the
|
|
2258
|
+
// produced var name IS the wire key (pathToVarName). Use it verbatim so it
|
|
2259
|
+
// stays consistent with the downstream `${<key>N}` var; don't PascalCase it
|
|
2260
|
+
// (that lowercases camelCase, e.g. jobId→Jobid, and diverges from both the
|
|
2261
|
+
// state var and the entry-URL-param raw-key convention).
|
|
2262
|
+
const field = existing !== undefined ? existing.slice("payload.".length) : p.name;
|
|
2263
|
+
if (!isValidJsIdentifier(field))
|
|
2264
|
+
continue;
|
|
2265
|
+
// `pathToVarName` returns the sentinel `"value"` when a produce path has no
|
|
2266
|
+
// identifier segment (all array indices) — a meaningless caller field name.
|
|
2267
|
+
// Freeze such a value (it surfaces via the unbound-literal TODO for the
|
|
2268
|
+
// author to name) rather than shipping `payload.value`. A reused
|
|
2269
|
+
// higher-priority accessor is a real declared field, so only veto the
|
|
2270
|
+
// sentinel when the name came from `p.name`.
|
|
2271
|
+
if (existing === undefined && field === "value")
|
|
2272
|
+
continue;
|
|
2273
|
+
bindings.set(value, { accessor: `payload.${field}`, field, producerIndex: i });
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
return bindings;
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Binds a caller coordinate in a step's body BEFORE state threading runs, via a
|
|
2280
|
+
* JSON-key-anchored WHOLE-value rewrite (`"<key>":"<value>"` →
|
|
2281
|
+
* `"<key>":"${payload.<field>}"`).
|
|
2282
|
+
*
|
|
2283
|
+
* WHY before {@link interpolateStateValues} and not via its payload pass: a
|
|
2284
|
+
* composite coordinate like a jobLocation `"Torrington, Connecticut, United
|
|
2285
|
+
* States"` or a jobSeqNo `"HHKHHEUS26158515EXTERNALENUS"` contains inner tokens a
|
|
2286
|
+
* genuinely-prior step produces as its own state var (a `label`, a `refNum`).
|
|
2287
|
+
* Pass-1 state threading would fragment the string (`"Torrington, ${label}"`,
|
|
2288
|
+
* `"${refNum}26158515EXTERNALENUS"`) before the length-descending payload pass
|
|
2289
|
+
* could match the full literal — and the collision guard then refuses to bind the
|
|
2290
|
+
* embedded remainder, stranding it frozen. Binding the whole coordinate first —
|
|
2291
|
+
* the same "swallow whole before inner passes reach in" discipline as
|
|
2292
|
+
* {@link applyStructuredValuePayloadSubstitutions} — keeps it atomic. Anchored on
|
|
2293
|
+
* the exact `"<key>":` slot, so it only fires on a value's own JSON slot.
|
|
2294
|
+
*
|
|
2295
|
+
* `producerScoped` bindings fire only on their producing step
|
|
2296
|
+
* (`producerIndex === stepIndex`): a later step re-sending the same coordinate
|
|
2297
|
+
* threads the produced state var, the established behavior; only the producer,
|
|
2298
|
+
* which cannot thread its own not-yet-existent response, needs the payload bind.
|
|
2299
|
+
* `entryUrlBindings` (a caller coordinate lifted from the entry URL) fire on
|
|
2300
|
+
* EVERY step — they are the caller's data on every request, never a produced var.
|
|
2301
|
+
*/
|
|
2302
|
+
function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScoped, entryUrlBindings, stepIndex) {
|
|
2303
|
+
if (producerScoped.size === 0 && entryUrlBindings.size === 0)
|
|
2304
|
+
return template;
|
|
2305
|
+
let result = template;
|
|
2306
|
+
for (const { value, path } of walkStringLeaves(parsedBody)) {
|
|
2307
|
+
const scoped = producerScoped.get(value);
|
|
2308
|
+
const accessor = scoped !== undefined && scoped.producerIndex === stepIndex
|
|
2309
|
+
? scoped.accessor
|
|
2310
|
+
: entryUrlBindings.get(value);
|
|
2311
|
+
if (accessor === undefined)
|
|
2312
|
+
continue;
|
|
2313
|
+
const key = path[path.length - 1] ?? "";
|
|
2314
|
+
if (key.length === 0)
|
|
2315
|
+
continue;
|
|
2316
|
+
const target = `"${key}":${JSON.stringify(value)}`;
|
|
2317
|
+
const replacement = `"${key}":"\${${accessor}}"`;
|
|
2318
|
+
result = result.split(target).join(replacement);
|
|
2319
|
+
}
|
|
2320
|
+
return result;
|
|
2321
|
+
}
|
|
1616
2322
|
/**
|
|
1617
2323
|
* Substitutes literal JSON key/value pairs in a body template with payload
|
|
1618
2324
|
* interpolations. Catches short strings (e.g. Culture: "en"), booleans
|
|
@@ -1695,172 +2401,91 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
|
|
|
1695
2401
|
}
|
|
1696
2402
|
return result;
|
|
1697
2403
|
}
|
|
1698
|
-
// ── base64 Content parameterization ──────────────────────────────────────────
|
|
1699
2404
|
/**
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
2405
|
+
* Documented closed set of JSON-key-name fragments (matched case-insensitively)
|
|
2406
|
+
* that mark a value as a per-request TIMESTAMP the plugin must generate fresh at
|
|
2407
|
+
* call time, not replay from the capture. Closed set per the no-regex-on-open-
|
|
2408
|
+
* sets feedback, mirroring {@link CACHE_BUSTER_QUERY_KEYS}'s posture. A frozen
|
|
2409
|
+
* capture timestamp would make every submission claim the recon instant.
|
|
2410
|
+
*/
|
|
2411
|
+
const VOLATILE_TIMESTAMP_KEY_FRAGMENTS = ["timestamp", "esign", "signeddate", "signedat"];
|
|
2412
|
+
/** JSON-key-name suffix marking a value as a per-request time the plugin must
|
|
2413
|
+
* regenerate (e.g. `stepStartTime`, `submissionTime`). Separate from the
|
|
2414
|
+
* fragment set so it anchors on the suffix and doesn't match `runtime`/`downtime`. */
|
|
2415
|
+
const VOLATILE_TIME_KEY_SUFFIX = "time";
|
|
2416
|
+
function isVolatileTimestampKey(key) {
|
|
2417
|
+
const k = key.toLowerCase();
|
|
2418
|
+
if (VOLATILE_TIMESTAMP_KEY_FRAGMENTS.some((frag) => k.includes(frag)))
|
|
2419
|
+
return true;
|
|
2420
|
+
return k.endsWith(VOLATILE_TIME_KEY_SUFFIX) && k !== "time";
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Rewrites per-request VOLATILE values in a body template so the generated
|
|
2424
|
+
* plugin produces them at call time instead of replaying the capture's:
|
|
2425
|
+
* - a UUID-valued leaf → a fresh `crypto.randomUUID()`
|
|
2426
|
+
* - a timestamp/eSign/-time-named leaf → a fresh `new Date().toISOString()`
|
|
1702
2427
|
*
|
|
1703
|
-
*
|
|
1704
|
-
* (JSON)
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
2428
|
+
* Walks the PARSED body (so keys are known) and rewrites JSON-key-anchored
|
|
2429
|
+
* (`"key":JSON.stringify(value)`), the same closed-set idiom as
|
|
2430
|
+
* {@link applyPayloadKeyValueSubstitutions}, recursing to ANY depth so nested
|
|
2431
|
+
* analytics/step blobs (`eventData`, `stepInfo[]`) are neutralized too. Values
|
|
2432
|
+
* in `shieldedUuids` (schema field-id/option-id anchors) or `boundValues` (a
|
|
2433
|
+
* value already substituted to `${payload…}`/`${txnId}`/state) are left alone
|
|
2434
|
+
* — an already-threaded transaction id or a bound email is not volatile.
|
|
2435
|
+
*
|
|
2436
|
+
* `crypto`/`Date` are bare Node/JS globals in the generated file (which already
|
|
2437
|
+
* uses `Buffer` bare); the `${…}` fragments are assembled by concatenation so
|
|
2438
|
+
* Biome's noTemplateCurlyInString doesn't flag THIS file's source.
|
|
2439
|
+
*/
|
|
2440
|
+
function applyVolatileFieldSubstitutions(template, parsedBody, shieldedUuids, boundValues) {
|
|
2441
|
+
const uuidGen = `$${"{"}crypto.randomUUID()${"}"}`;
|
|
2442
|
+
const isoGen = `$${"{"}new Date().toISOString()${"}"}`;
|
|
2443
|
+
let result = template;
|
|
2444
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
2445
|
+
if (typeof value !== "string" || value.length === 0)
|
|
2446
|
+
continue;
|
|
2447
|
+
if (shieldedUuids.has(value) || boundValues.has(value))
|
|
2448
|
+
continue;
|
|
2449
|
+
const key = path[path.length - 1] ?? "";
|
|
2450
|
+
const replacement = UUID_REGEX.test(value)
|
|
2451
|
+
? uuidGen
|
|
2452
|
+
: isVolatileTimestampKey(key)
|
|
2453
|
+
? isoGen
|
|
2454
|
+
: null;
|
|
2455
|
+
if (replacement === null)
|
|
2456
|
+
continue;
|
|
2457
|
+
const target = `"${JSON.stringify(value).slice(1, -1)}"`;
|
|
2458
|
+
result = result.split(target).join(`"${replacement}"`);
|
|
1718
2459
|
}
|
|
2460
|
+
return result;
|
|
1719
2461
|
}
|
|
1720
|
-
const QUESTION_PROMPT_KEYWORDS = loadQuestionPromptKeywords();
|
|
1721
2462
|
/**
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
2463
|
+
* Collects captured string leaves that survived every binding/generation pass as
|
|
2464
|
+
* still-literal — the values a reviewer must look at because they couldn't be
|
|
2465
|
+
* traced to a payload field, a generator, or a schema anchor. Returns the JSON
|
|
2466
|
+
* key names (deduped, in first-seen order) so the emitter can prepend a single
|
|
2467
|
+
* `// TODO: unbound captured literal` marker; it never mutates the body, so the
|
|
2468
|
+
* file still compiles. Short values (< {@link MIN_STATE_VALUE_LENGTH}) are
|
|
2469
|
+
* skipped — they are the legitimately-constant enum-like fields.
|
|
1725
2470
|
*/
|
|
1726
|
-
function
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
? JSON.parse(questionCapture.responseBody)
|
|
1732
|
-
: questionCapture.responseBody;
|
|
1733
|
-
if (!resp || !Array.isArray(resp.items))
|
|
1734
|
-
return null;
|
|
1735
|
-
const mappings = [];
|
|
1736
|
-
const unmapped = [];
|
|
1737
|
-
for (const item of resp.items) {
|
|
1738
|
-
const prompt = String(item.Prompt ?? "").toLowerCase();
|
|
1739
|
-
const qid = item.AttributeName;
|
|
1740
|
-
const uiType = String(item.UIDisplayType ?? "");
|
|
1741
|
-
if (!qid || uiType === "TextBox")
|
|
2471
|
+
function collectUnboundLiterals(finalTemplate, parsedBody, shieldedUuids) {
|
|
2472
|
+
const unbound = [];
|
|
2473
|
+
const seen = new Set();
|
|
2474
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
2475
|
+
if (typeof value !== "string" || value.length < MIN_STATE_VALUE_LENGTH)
|
|
1742
2476
|
continue;
|
|
1743
|
-
|
|
1744
|
-
let bestScore = 0;
|
|
1745
|
-
for (const [field, keywords] of Object.entries(QUESTION_PROMPT_KEYWORDS)) {
|
|
1746
|
-
const score = keywords.filter((kw) => prompt.includes(kw)).length;
|
|
1747
|
-
if (score > bestScore) {
|
|
1748
|
-
bestScore = score;
|
|
1749
|
-
bestField = field;
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
// A question the keyword map cannot place is the interesting case: it is a
|
|
1753
|
-
// question this site asks and the caller has no field for. Report it —
|
|
1754
|
-
// dropping it silently is how a generated plugin ends up submitting nothing
|
|
1755
|
-
// for a required question.
|
|
1756
|
-
if (!bestField || bestScore < 2) {
|
|
1757
|
-
unmapped.push(`${qid}: ${String(item.Prompt ?? "")}`);
|
|
2477
|
+
if (shieldedUuids.has(value))
|
|
1758
2478
|
continue;
|
|
1759
|
-
|
|
1760
|
-
if (
|
|
2479
|
+
const key = path[path.length - 1] ?? "";
|
|
2480
|
+
if (seen.has(key))
|
|
1761
2481
|
continue;
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
if (meaning && code)
|
|
1767
|
-
answers[meaning] = code;
|
|
1768
|
-
}
|
|
1769
|
-
mappings.push({ questionId: qid, payloadField: bestField, answers });
|
|
1770
|
-
}
|
|
1771
|
-
if (unmapped.length > 0) {
|
|
1772
|
-
logger.warn(`${unmapped.length} screening question(s) matched no payload field and will be unanswered — add keywords to RECON_QUESTION_KEYWORDS: ${unmapped.join(" | ")}`);
|
|
1773
|
-
}
|
|
1774
|
-
return mappings.length > 0 ? mappings : null;
|
|
1775
|
-
}
|
|
1776
|
-
/**
|
|
1777
|
-
* Builds the TypeScript source for a `buildBase64Content` function that
|
|
1778
|
-
* constructs the base64-encoded Content JSON from payload values and returns
|
|
1779
|
-
* it as a base64 string. The function replaces persona-specific values
|
|
1780
|
-
* with payload references and maps questionnaire answers via a static
|
|
1781
|
-
* lookup table derived from the recon captures.
|
|
1782
|
-
*/
|
|
1783
|
-
function emitBuildBase64ContentFunction(base64, personaValues, questionMapping, pascal) {
|
|
1784
|
-
const decoded = Buffer.from(base64, "base64").toString("utf8");
|
|
1785
|
-
const content = JSON.parse(decoded);
|
|
1786
|
-
const candidate = content.candidate;
|
|
1787
|
-
const basic = candidate.basicInformation;
|
|
1788
|
-
const phone = basic.phone;
|
|
1789
|
-
const application = content.application;
|
|
1790
|
-
const esig = application.eSignature;
|
|
1791
|
-
basic.firstName = "__PAYLOAD_FirstName__";
|
|
1792
|
-
basic.lastName = "__PAYLOAD_LastName__";
|
|
1793
|
-
basic.email = "__PAYLOAD_Email__";
|
|
1794
|
-
if (esig)
|
|
1795
|
-
esig.fullName = "__PAYLOAD_SignatureFullName__";
|
|
1796
|
-
if (basic.displayName && typeof basic.displayName === "string")
|
|
1797
|
-
basic.displayName = "__PAYLOAD_DisplayName__";
|
|
1798
|
-
if (phone && typeof phone.number === "string" && phone.number)
|
|
1799
|
-
phone.number = "__PAYLOAD_Phone__";
|
|
1800
|
-
for (const [personaVal, _payloadRef] of personaValues) {
|
|
1801
|
-
if (typeof basic.email === "string" && basic.email === personaVal)
|
|
1802
|
-
basic.email = "__PAYLOAD_Email__";
|
|
1803
|
-
}
|
|
1804
|
-
const questionnaires = candidate.questionnaires;
|
|
1805
|
-
if (questionnaires && questionMapping) {
|
|
1806
|
-
for (const q of questionnaires) {
|
|
1807
|
-
q.questionnaireId = -1;
|
|
1808
|
-
for (const question of q.questions) {
|
|
1809
|
-
const mapping = questionMapping.find((m) => m.questionId === question.questionId);
|
|
1810
|
-
if (mapping) {
|
|
1811
|
-
question.answer = `__QMAP_${mapping.payloadField}__`;
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
const attachments = candidate.attachments;
|
|
1817
|
-
if (attachments) {
|
|
1818
|
-
for (const att of attachments) {
|
|
1819
|
-
if (att.id && att.id !== "draft-json-undefined") {
|
|
1820
|
-
att.id = "__PAYLOAD_AttachmentId__";
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
if (attachments[0]) {
|
|
1824
|
-
attachments[0].appDraftId = "__PAYLOAD_DraftId__";
|
|
2482
|
+
// Still a bare literal in the emitted template (no ${…} took its place).
|
|
2483
|
+
if (finalTemplate.includes(JSON.stringify(value))) {
|
|
2484
|
+
seen.add(key);
|
|
2485
|
+
unbound.push(key);
|
|
1825
2486
|
}
|
|
1826
2487
|
}
|
|
1827
|
-
|
|
1828
|
-
const contentObj = JSON.parse(jsonStr);
|
|
1829
|
-
const questionMapEntries = (questionMapping ?? []).map((m) => ` ${JSON.stringify(m.payloadField)}: { answers: ${JSON.stringify(m.answers)} as Record<string, number>, questionId: ${m.questionId} },`);
|
|
1830
|
-
const questionMapConst2 = questionMapEntries.length > 0
|
|
1831
|
-
? `\nconst QUESTIONNAIRE_ANSWER_MAP = {\n${questionMapEntries.join("\n")}\n};\n`
|
|
1832
|
-
: "";
|
|
1833
|
-
const contentTemplate = JSON.stringify(contentObj, null, 2);
|
|
1834
|
-
const parameterized = contentTemplate
|
|
1835
|
-
.replace(/"__PAYLOAD_FirstName__"/g, "payload.FirstName")
|
|
1836
|
-
.replace(/"__PAYLOAD_LastName__"/g, "payload.LastName")
|
|
1837
|
-
.replace(/"__PAYLOAD_Email__"/g, "payload.Email")
|
|
1838
|
-
.replace(/"__PAYLOAD_Phone__"/g, "payload.Phone")
|
|
1839
|
-
.replace(/"__PAYLOAD_SignatureFullName__"/g, "payload.Answers.SignatureFullName")
|
|
1840
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
|
|
1841
|
-
.replace(/"__PAYLOAD_DisplayName__"/g, "`${payload.FirstName} ${payload.LastName}`")
|
|
1842
|
-
.replace(/"__PAYLOAD_AttachmentId__"/g, "attachmentId")
|
|
1843
|
-
.replace(/"__PAYLOAD_DraftId__"/g, "draftId")
|
|
1844
|
-
.replace(/-1(?=,\n\s*"questions")/g, "questionnaireId");
|
|
1845
|
-
for (const m of questionMapping ?? []) {
|
|
1846
|
-
parameterized.replace(`"__QMAP_${m.payloadField}__"`, `QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined"`);
|
|
1847
|
-
}
|
|
1848
|
-
let finalTemplate = parameterized.replace(/"__QMAP_[^"]*__"/g, '"draft-json-undefined"');
|
|
1849
|
-
for (const m of questionMapping ?? []) {
|
|
1850
|
-
finalTemplate = finalTemplate.replace(`"__QMAP_${m.payloadField}__"`, `(QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined")`);
|
|
1851
|
-
}
|
|
1852
|
-
return `${questionMapConst2}
|
|
1853
|
-
/** Builds the ATS Content payload as a base64-encoded JSON string. */
|
|
1854
|
-
function buildBase64Content(
|
|
1855
|
-
payload: ${pascal}Payload,
|
|
1856
|
-
questionnaireId: number,
|
|
1857
|
-
draftId: number,
|
|
1858
|
-
attachmentId: string
|
|
1859
|
-
): string {
|
|
1860
|
-
const content = ${finalTemplate};
|
|
1861
|
-
return Buffer.from(JSON.stringify(content)).toString("base64");
|
|
1862
|
-
}
|
|
1863
|
-
`;
|
|
2488
|
+
return unbound;
|
|
1864
2489
|
}
|
|
1865
2490
|
/** Builds the multi-step `executeHttp` body as a single template-literal string.
|
|
1866
2491
|
*
|
|
@@ -1920,7 +2545,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
|
|
|
1920
2545
|
}
|
|
1921
2546
|
/** Exported for unit testing — lets tests drive the multipart-upload code path directly
|
|
1922
2547
|
* without going through the full emitContractTs pipeline. */
|
|
1923
|
-
function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders,
|
|
2548
|
+
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()) {
|
|
1924
2549
|
// Walk the first action's request body to map each leaf string value to its
|
|
1925
2550
|
// `payload.<accessor>` expression. The emit's second interpolation pass uses
|
|
1926
2551
|
// this to substitute literal occurrences (e.g. "Reginald") with their
|
|
@@ -1958,6 +2583,89 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
1958
2583
|
payloadAccessorByValue.set(baseUrl, "payload.BaseUrl");
|
|
1959
2584
|
outDiscoveredFields.add("BaseUrl");
|
|
1960
2585
|
}
|
|
2586
|
+
// Persona identity bindings (from the flow's quoted literals + RECON_EMAIL,
|
|
2587
|
+
// paired to a payload field by the consumer vocabulary). Merged into the same
|
|
2588
|
+
// value→accessor map so `interpolateStateValues`' length-descending payload
|
|
2589
|
+
// pass substitutes them at ANY nesting depth — the fix for nested ATS bodies
|
|
2590
|
+
// like `formData.firstName` that the top-level-only key pass never reached.
|
|
2591
|
+
//
|
|
2592
|
+
// Collision guard (not a blunt length floor): that pass replaces by UNANCHORED
|
|
2593
|
+
// `String.split(value)`, so a persona value that appears INSIDE a longer token
|
|
2594
|
+
// would corrupt it — e.g. a `Select 'No' …` answer a vocabulary mapped to a
|
|
2595
|
+
// field would rewrite the "No" inside "Nursing"/"Not". A value binds only when
|
|
2596
|
+
// every occurrence across the action bodies sits at a token boundary (the
|
|
2597
|
+
// adjacent character is a non-alphanumeric JSON delimiter like `"`, space, or
|
|
2598
|
+
// punctuation), never flanked by alphanumerics. This keeps legitimately-short
|
|
2599
|
+
// identity values that don't collide (a 5-digit zip `06103`, a first name that
|
|
2600
|
+
// also appears space-delimited inside a signature) while dropping genuinely
|
|
2601
|
+
// dangerous substrings, which then surface via the unbound-literal TODO.
|
|
2602
|
+
// State-threaded produced values still win — they run in Pass 1, before this.
|
|
2603
|
+
const actionBodies = actions
|
|
2604
|
+
.map((a) => a.capture.requestPostData)
|
|
2605
|
+
.filter((b) => typeof b === "string" && b.length > 0);
|
|
2606
|
+
const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
|
|
2607
|
+
const bindsWithoutCollision = (value) => {
|
|
2608
|
+
for (const body of actionBodies) {
|
|
2609
|
+
let from = 0;
|
|
2610
|
+
while (true) {
|
|
2611
|
+
const at = body.indexOf(value, from);
|
|
2612
|
+
if (at === -1)
|
|
2613
|
+
break;
|
|
2614
|
+
// Flanked by an alphanumeric on either side → it's a substring of a
|
|
2615
|
+
// longer token; binding it would mangle that token. Block the value.
|
|
2616
|
+
if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
|
|
2617
|
+
return false;
|
|
2618
|
+
from = at + value.length;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
return true;
|
|
2622
|
+
};
|
|
2623
|
+
for (const [value, accessor] of personaBindings) {
|
|
2624
|
+
if (value.length === 0)
|
|
2625
|
+
continue;
|
|
2626
|
+
if (!bindsWithoutCollision(value))
|
|
2627
|
+
continue;
|
|
2628
|
+
if (!payloadAccessorByValue.has(value))
|
|
2629
|
+
payloadAccessorByValue.set(value, accessor);
|
|
2630
|
+
const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
|
|
2631
|
+
if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
|
|
2632
|
+
outDiscoveredFields.add(field);
|
|
2633
|
+
}
|
|
2634
|
+
// Job coordinates from the recon entry URL's query string (e.g.
|
|
2635
|
+
// `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
|
|
2636
|
+
// occurrence — and, via length-descending order, embedded substrings like a
|
|
2637
|
+
// jobId inside a jobSeqNo — rewrites to the caller-supplied value.
|
|
2638
|
+
for (const [value, accessor] of entryUrlParams) {
|
|
2639
|
+
if (value.length === 0)
|
|
2640
|
+
continue;
|
|
2641
|
+
if (!payloadAccessorByValue.has(value))
|
|
2642
|
+
payloadAccessorByValue.set(value, accessor);
|
|
2643
|
+
const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
|
|
2644
|
+
if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
|
|
2645
|
+
outDiscoveredFields.add(field);
|
|
2646
|
+
}
|
|
2647
|
+
// Producer-boundary job coordinates: values a step's response produces (and
|
|
2648
|
+
// steps 2..N thread as `${var}`) that ALSO appear in that producing step's
|
|
2649
|
+
// own request body. The producer cannot thread its own not-yet-existent
|
|
2650
|
+
// response, so those slots would freeze the recon persona's coordinate; bind
|
|
2651
|
+
// them to the caller's payload instead. Registered here so the length-
|
|
2652
|
+
// descending payload pass rewrites short/embedded coordinates (a jobId inside
|
|
2653
|
+
// a jobSeqNo); the whole-value pass below binds composite coordinates a state
|
|
2654
|
+
// var would otherwise fragment. `bindsWithoutCollision`-guarded like personas.
|
|
2655
|
+
const producerBoundaryBindings = deriveProducerBoundaryBindings(actions, new Map(payloadAccessorByValue));
|
|
2656
|
+
for (const [value, { accessor, field }] of producerBoundaryBindings) {
|
|
2657
|
+
// Always declare: the whole-value pass binds this value's own JSON slot on
|
|
2658
|
+
// its producer step regardless of collision, so the schema MUST carry the
|
|
2659
|
+
// field or the emitted `${payload.<field>}` references an undeclared property.
|
|
2660
|
+
outDiscoveredFields.add(field);
|
|
2661
|
+
// The UNANCHORED length-descending registration stays collision-guarded: it
|
|
2662
|
+
// rewrites embedded substrings globally, so a value that also sits inside a
|
|
2663
|
+
// longer token (a jobId within a jobSeqNo) must not be registered here — the
|
|
2664
|
+
// whole-value pass already binds its standalone slot atomically.
|
|
2665
|
+
if (bindsWithoutCollision(value) && !payloadAccessorByValue.has(value)) {
|
|
2666
|
+
payloadAccessorByValue.set(value, accessor);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
1961
2669
|
// G2: register any tenant-subdomain header values as payload-supplied fields
|
|
1962
2670
|
// (e.g. an `API-ShortName: "addus"` header becomes `payload.ApiShortName`).
|
|
1963
2671
|
for (const [headerName, _value] of tenantSubdomainHeaders) {
|
|
@@ -1979,6 +2687,37 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
1979
2687
|
// skip non-JSON bodies (e.g. multipart raw bytes)
|
|
1980
2688
|
}
|
|
1981
2689
|
}
|
|
2690
|
+
// Detect the flow's THREADED transaction id: a single UUID the site mints
|
|
2691
|
+
// once (on page load) and reuses across every submit body to correlate the
|
|
2692
|
+
// multi-step wizard — observed on real ATS flows where one such id spans
|
|
2693
|
+
// every step. A frozen capture UUID would collide across concurrent/real
|
|
2694
|
+
// submissions, so the plugin must mint ONE at call time and thread it — hence
|
|
2695
|
+
// it maps to a hoisted local, not a payload field. Identified generically:
|
|
2696
|
+
// the same non-shielded UUID present in ≥2 action bodies.
|
|
2697
|
+
const uuidBodyCounts = new Map();
|
|
2698
|
+
for (const { capture } of actions) {
|
|
2699
|
+
const seen = new Set();
|
|
2700
|
+
for (const v of jsonBodyLeafValues(capture.requestPostData) ?? []) {
|
|
2701
|
+
if (UUID_REGEX.test(v) && !shieldedUuids.has(v))
|
|
2702
|
+
seen.add(v);
|
|
2703
|
+
}
|
|
2704
|
+
for (const v of seen)
|
|
2705
|
+
uuidBodyCounts.set(v, (uuidBodyCounts.get(v) ?? 0) + 1);
|
|
2706
|
+
}
|
|
2707
|
+
const threadedTxnId = [...uuidBodyCounts.entries()].find(([, n]) => n >= 2)?.[0] ?? null;
|
|
2708
|
+
// The value→`${txnId}` binding rides the same substitution map as payload
|
|
2709
|
+
// accessors (Pass 2 of interpolateStateValues); the hoisted `const txnId`
|
|
2710
|
+
// declaration is emitted once above the step sequence below. `txnId` is a
|
|
2711
|
+
// generic local name — the wire key it fills is whatever the body used.
|
|
2712
|
+
if (threadedTxnId !== null && !payloadAccessorByValue.has(threadedTxnId)) {
|
|
2713
|
+
payloadAccessorByValue.set(threadedTxnId, "txnId");
|
|
2714
|
+
}
|
|
2715
|
+
// Values already substituted to a `${…}` reference — the volatile pass must
|
|
2716
|
+
// NOT regenerate these (an already-threaded txn id or a bound email is not
|
|
2717
|
+
// volatile). Keyed by the concrete captured value.
|
|
2718
|
+
const boundValues = new Set(payloadAccessorByValue.keys());
|
|
2719
|
+
// Captured literals that survived every pass — surfaced as a review TODO.
|
|
2720
|
+
const unboundLiteralKeys = new Set();
|
|
1982
2721
|
// Pass 1: render every step's emitted strings; collect referenced var names.
|
|
1983
2722
|
const rendered = [];
|
|
1984
2723
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -1997,12 +2736,60 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
1997
2736
|
const rawBodyWithFormSubs = cap.requestPostData && formSchema !== null
|
|
1998
2737
|
? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields, formSchema), fieldOptionsMap, outDiscoveredOptionFields, formSchema), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema)
|
|
1999
2738
|
: (cap.requestPostData ?? "");
|
|
2000
|
-
|
|
2001
|
-
|
|
2739
|
+
// Parsed once and shared by the structured (Mechanism B) and volatile passes
|
|
2740
|
+
// below — both walk the same body JSON, so parsing twice would be redundant.
|
|
2741
|
+
// null for absent/non-JSON bodies (multipart raw bytes), which both passes skip.
|
|
2742
|
+
const parsedBody = (() => {
|
|
2743
|
+
if (!cap.requestPostData)
|
|
2744
|
+
return null;
|
|
2745
|
+
try {
|
|
2746
|
+
return JSON.parse(cap.requestPostData);
|
|
2747
|
+
}
|
|
2748
|
+
catch {
|
|
2749
|
+
return null;
|
|
2750
|
+
}
|
|
2751
|
+
})();
|
|
2752
|
+
// Mechanism B — parameterize whole nested caller structures
|
|
2753
|
+
// (experienceData/educationData history, opaque eventData) BEFORE value
|
|
2754
|
+
// substitution reaches inside them: swallowing the entire array/object first
|
|
2755
|
+
// keeps interpolateStateValues from binding a code buried in the history
|
|
2756
|
+
// sample (e.g. a work entry's state code) to an unrelated field.
|
|
2757
|
+
const rawBodyWithStructuredSubs = parsedBody !== null
|
|
2758
|
+
? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys)
|
|
2759
|
+
: rawBodyWithFormSubs;
|
|
2760
|
+
// Whole-value caller coordinates bind here — after structured subs, BEFORE
|
|
2761
|
+
// state threading — so a composite coordinate (a jobLocation or jobSeqNo
|
|
2762
|
+
// whose inner tokens a prior step produces as a state var) binds as one
|
|
2763
|
+
// atomic `${payload.X}` before Pass 1 can fragment it. Producer-boundary
|
|
2764
|
+
// coordinates fire on their producer only; entry-URL coordinates on every
|
|
2765
|
+
// step. No-op on steps without a match.
|
|
2766
|
+
const rawBodyWithProducerBoundary = parsedBody !== null
|
|
2767
|
+
? applyWholeValuePayloadSubstitutions(rawBodyWithStructuredSubs, parsedBody, producerBoundaryBindings, entryUrlParams, i)
|
|
2768
|
+
: rawBodyWithStructuredSubs;
|
|
2769
|
+
const bodyAfterStateAndKv = rawBodyWithProducerBoundary
|
|
2770
|
+
? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithProducerBoundary, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
|
|
2002
2771
|
: "";
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2772
|
+
// Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
|
|
2773
|
+
// rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
|
|
2774
|
+
// before: the emitted `${OPT_<Name>[…]}` placeholder embeds the PascalCase
|
|
2775
|
+
// field name, and a wizard step-slug value (e.g. stepNum "Disability") that
|
|
2776
|
+
// becomes a global `.split` payload binding would otherwise rewrite the
|
|
2777
|
+
// matching substring INSIDE that placeholder and corrupt it. The closed-set
|
|
2778
|
+
// `"<key>":"<code>"` slot (numeric code, nested key) survives both earlier
|
|
2779
|
+
// passes untouched, so matching it here is still exact.
|
|
2780
|
+
let bodyTemplate = cap.requestPostData
|
|
2781
|
+
? applyGenericRawCodeSubstitutions(applyGenericOptionCodeSubstitutions(bodyAfterStateAndKv, selectResolutions, fieldOptionsMap, outDiscoveredOptionFields), rawCodeFields)
|
|
2782
|
+
: bodyAfterStateAndKv;
|
|
2783
|
+
// Volatile pass: after persona/job/state/kv binding, regenerate any
|
|
2784
|
+
// remaining per-request UUID (fresh crypto.randomUUID()) and timestamp
|
|
2785
|
+
// (fresh new Date().toISOString()) so the plugin never replays the capture
|
|
2786
|
+
// instant. Recurses to any depth; skips schema anchors and already-bound
|
|
2787
|
+
// values (incl. the threaded txn id). Then flag whatever is STILL literal.
|
|
2788
|
+
if (bodyTemplate && parsedBody !== null) {
|
|
2789
|
+
bodyTemplate = applyVolatileFieldSubstitutions(bodyTemplate, parsedBody, shieldedUuids, boundValues);
|
|
2790
|
+
for (const key of collectUnboundLiterals(bodyTemplate, parsedBody, shieldedUuids)) {
|
|
2791
|
+
unboundLiteralKeys.add(key);
|
|
2792
|
+
}
|
|
2006
2793
|
}
|
|
2007
2794
|
const perCallHeaders = {};
|
|
2008
2795
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
@@ -2067,24 +2854,24 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
2067
2854
|
const returnAction = selectReturnAction(actions);
|
|
2068
2855
|
if (returnAction)
|
|
2069
2856
|
referencedNames.add(returnAction.varName);
|
|
2070
|
-
// Base64 Content overrides reference variables inside function calls
|
|
2071
|
-
// (e.g. buildBase64Content(payload, questionnaireId, ...)) that the
|
|
2072
|
-
// ${name} regex above doesn't capture. Add them explicitly.
|
|
2073
|
-
for (const [key, override] of base64PatchOverride.entries()) {
|
|
2074
|
-
if (key === "__EXTRA_VARS__")
|
|
2075
|
-
continue;
|
|
2076
|
-
for (const m of override.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) {
|
|
2077
|
-
const name = m[1];
|
|
2078
|
-
if (/^r\d+$/.test(name))
|
|
2079
|
-
continue;
|
|
2080
|
-
referencedNames.add(name);
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
2857
|
// Pass 2: emit. Skip response bindings that aren't referenced; skip
|
|
2084
2858
|
// produces[] entries whose name isn't referenced. A step's response var
|
|
2085
2859
|
// is still needed when at least one of its produces[] entries IS
|
|
2086
2860
|
// referenced — the produces line dereferences it.
|
|
2087
2861
|
const lines = [];
|
|
2862
|
+
// Mint the threaded transaction id ONCE and reuse across every step — the
|
|
2863
|
+
// `${txnId}` references emitted into the bodies above all resolve to this
|
|
2864
|
+
// single call-time UUID, matching how the site mints one per application.
|
|
2865
|
+
// Emitted only when actually referenced (Biome noUnusedVariables).
|
|
2866
|
+
if (threadedTxnId !== null && referencedNames.has("txnId")) {
|
|
2867
|
+
lines.push(` const txnId = crypto.randomUUID();`);
|
|
2868
|
+
lines.push("");
|
|
2869
|
+
}
|
|
2870
|
+
// Surface any captured literal that no pass could bind, so a reviewer knows
|
|
2871
|
+
// exactly which slots still carry recon data. Comment only — never blocks emit.
|
|
2872
|
+
if (unboundLiteralKeys.size > 0) {
|
|
2873
|
+
lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
|
|
2874
|
+
}
|
|
2088
2875
|
const declaredNames = new Set();
|
|
2089
2876
|
for (let i = 0; i < actions.length; i++) {
|
|
2090
2877
|
const step = actions[i];
|
|
@@ -2113,10 +2900,6 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
2113
2900
|
produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
|
|
2114
2901
|
}
|
|
2115
2902
|
const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
|
|
2116
|
-
if (base64PatchOverride.has(step.varName) && base64PatchOverride.has("__EXTRA_VARS__")) {
|
|
2117
|
-
lines.push(base64PatchOverride.get("__EXTRA_VARS__"));
|
|
2118
|
-
lines.push("");
|
|
2119
|
-
}
|
|
2120
2903
|
if (step.isCrossDomain) {
|
|
2121
2904
|
lines.push(` // TODO: cross-domain redirect detected (${cap.url.split("/")[2]}) — likely needs browser fallback for this step.`);
|
|
2122
2905
|
}
|
|
@@ -2243,7 +3026,7 @@ function bindOptionLiteral(headerBindings) {
|
|
|
2243
3026
|
/** Generates a complete contract.ts source string for a plugin — exported so
|
|
2244
3027
|
* unit tests can drive the emitter directly without spawning the CLI. */
|
|
2245
3028
|
function emitContractTs(opts) {
|
|
2246
|
-
const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys,
|
|
3029
|
+
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;
|
|
2247
3030
|
// This is the CLIENT-level schema — createHttpClient's default, and the
|
|
2248
3031
|
// plugin's caller-facing contract (what executeHttp's return value promises
|
|
2249
3032
|
// its own caller). It does NOT validate any individual call in a multi-step
|
|
@@ -2366,14 +3149,25 @@ function emitContractTs(opts) {
|
|
|
2366
3149
|
})
|
|
2367
3150
|
.join("\n")}\n})`
|
|
2368
3151
|
: "";
|
|
3152
|
+
// Mechanism B: nested caller structures become payload fields carrying their
|
|
3153
|
+
// inferred schema. Emitted as an object body so multi-line z.array(z.object(
|
|
3154
|
+
// …)) expressions indent cleanly; a leading TSDoc flags eventData's opaque
|
|
3155
|
+
// passthrough so callers know its nested volatiles are theirs to mint.
|
|
3156
|
+
const sortedStructuredEntries = discoveredStructuredKeys
|
|
3157
|
+
? [...discoveredStructuredKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
3158
|
+
: [];
|
|
3159
|
+
const structuredKeysExtension = sortedStructuredEntries.length > 0
|
|
3160
|
+
? `.extend({\n${sortedStructuredEntries
|
|
3161
|
+
.map(([name, schema]) => ` ${isValidJsIdentifier(name) ? name : JSON.stringify(name)}: ${schema},`)
|
|
3162
|
+
.join("\n")}\n})`
|
|
3163
|
+
: "";
|
|
2369
3164
|
// optionSchemaExtension is appended LAST so option enums show up at the
|
|
2370
3165
|
// end of the payload type — the section ordering (base, multipart fields,
|
|
2371
3166
|
// form-schema fields, option enums, raw-option fields) mirrors the body
|
|
2372
3167
|
// emit order and keeps the generated payload type readable.
|
|
2373
|
-
const answersExtension = base64ContentHelper ? ".extend({ Answers: AnswersSchema })" : "";
|
|
2374
3168
|
const payloadSchemaExpr = hasMultipartStep
|
|
2375
|
-
? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${
|
|
2376
|
-
: `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${
|
|
3169
|
+
? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`
|
|
3170
|
+
: `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`;
|
|
2377
3171
|
// When the payload schema uses multipartBoolean(), import the shared helper
|
|
2378
3172
|
// so the generated file resolves the reference and doesn't re-inline the
|
|
2379
3173
|
// preprocess expression per boolean field.
|
|
@@ -2480,7 +3274,7 @@ const ${pascal}ResponseSchema = ${responseSchemaExpr};
|
|
|
2480
3274
|
export type ${pascal}Response = z.infer<typeof ${pascal}ResponseSchema>;
|
|
2481
3275
|
|
|
2482
3276
|
export default ${pascal}ResponseSchema;
|
|
2483
|
-
${optionDecls}
|
|
3277
|
+
${optionDecls}
|
|
2484
3278
|
const ${pascal}PayloadSchema = ${payloadSchemaExpr};
|
|
2485
3279
|
|
|
2486
3280
|
export type ${pascal}Payload = z.infer<typeof ${pascal}PayloadSchema>;
|
|
@@ -2990,8 +3784,8 @@ async function main() {
|
|
|
2990
3784
|
body: submitBodyPattern,
|
|
2991
3785
|
};
|
|
2992
3786
|
// Resolved once and threaded down, never captured into a module const: a
|
|
2993
|
-
// module-level const would freeze at import time,
|
|
2994
|
-
//
|
|
3787
|
+
// module-level const would freeze at import time, so an env var set after
|
|
3788
|
+
// module load would be silently inert for anyone reading it that way.
|
|
2995
3789
|
const vocabulary = await resolveVocabulary(vocabularySpecifier);
|
|
2996
3790
|
// Consumer-supplied wire keys for ATS form-schema recovery, or null. When
|
|
2997
3791
|
// null the recovery functions no-op — the engine hardcodes no vendor format.
|
|
@@ -3032,6 +3826,13 @@ async function main() {
|
|
|
3032
3826
|
// would be skipped by fieldNameMap; their field-ids still need shielding
|
|
3033
3827
|
// because they appear as anchors in the T2-substituted body templates.
|
|
3034
3828
|
const shieldedUuids = new Set(allSchemaUuids);
|
|
3829
|
+
// Persona identity bindings + entry-URL job coordinates — the value→payload
|
|
3830
|
+
// reconciliation the body emitter merges into its substitution map so nested
|
|
3831
|
+
// applicant fields and job context reach the caller's data instead of the
|
|
3832
|
+
// recon persona's. Both are site-agnostic: persona mapping comes from the
|
|
3833
|
+
// consumer vocabulary, job coordinates from the entry URL's own query keys.
|
|
3834
|
+
const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
|
|
3835
|
+
const entryUrlParams = extractEntryUrlParams(captures[0]?.url ?? "");
|
|
3035
3836
|
// T4 — Phase B+C: detect a form-schema GET capture and insert it into the
|
|
3036
3837
|
// action sequence at the position observed during recon, so the existing
|
|
3037
3838
|
// state-threading machinery can produce its FormHistoryId / section UUIDs /
|
|
@@ -3098,6 +3899,20 @@ async function main() {
|
|
|
3098
3899
|
// that get parameterized. Recorded with their value type so the contract
|
|
3099
3900
|
// emitter can add them to the payload schema with appropriate Zod types.
|
|
3100
3901
|
const discoveredAdditionalBodyKeys = new Map();
|
|
3902
|
+
// Mechanism A: reconcile flow SELECT steps to submitted option codes. The
|
|
3903
|
+
// resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
|
|
3904
|
+
// i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
|
|
3905
|
+
// existing raw-option channel so their frozen code is still parameterized.
|
|
3906
|
+
const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, captures, vocabulary, process.env);
|
|
3907
|
+
for (const [semanticName, { code }] of rawCodeFields) {
|
|
3908
|
+
const fieldName = `${semanticName}Code`;
|
|
3909
|
+
if (!discoveredRawOptionFields.has(fieldName))
|
|
3910
|
+
discoveredRawOptionFields.set(fieldName, code);
|
|
3911
|
+
}
|
|
3912
|
+
// Mechanism B: nested caller structures (experienceData/educationData
|
|
3913
|
+
// history, opaque eventData) discovered during the body emit, surfaced to the
|
|
3914
|
+
// contract's payload schema.
|
|
3915
|
+
const discoveredStructuredKeys = new Map();
|
|
3101
3916
|
// G1+G2: partition baseHeaders into three buckets:
|
|
3102
3917
|
// - static: values that don't reference baseUrl or tenant subdomain
|
|
3103
3918
|
// - baseUrl-derived: values containing the recon's baseUrl as substring
|
|
@@ -3129,98 +3944,8 @@ async function main() {
|
|
|
3129
3944
|
}
|
|
3130
3945
|
}
|
|
3131
3946
|
const multiStepBody = isSubmissionFlow
|
|
3132
|
-
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders,
|
|
3947
|
+
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
|
|
3133
3948
|
: undefined;
|
|
3134
|
-
let base64ContentHelper = "";
|
|
3135
|
-
const base64PatchOverride = new Map();
|
|
3136
|
-
if (isSubmissionFlow && actionSteps.length > 0) {
|
|
3137
|
-
const lastPatchWithContent = [...actionSteps]
|
|
3138
|
-
.reverse()
|
|
3139
|
-
.find((s) => s.capture.method === "PATCH" &&
|
|
3140
|
-
s.capture.requestPostData &&
|
|
3141
|
-
/"Content":"ey[A-Za-z0-9+/=]{100,}"/.test(s.capture.requestPostData));
|
|
3142
|
-
if (lastPatchWithContent) {
|
|
3143
|
-
const b64Match = lastPatchWithContent.capture.requestPostData.match(/"Content":"(ey[A-Za-z0-9+/=]{100,})"/);
|
|
3144
|
-
if (b64Match) {
|
|
3145
|
-
const b64 = b64Match[1];
|
|
3146
|
-
const qMapping = buildQuestionnaireMapping(captures);
|
|
3147
|
-
const personaValues = new Map();
|
|
3148
|
-
const firstPost = captures.find((c) => c.method === "POST" &&
|
|
3149
|
-
c.url.includes("recruitingCEJobApplicationDrafts") &&
|
|
3150
|
-
c.requestPostData);
|
|
3151
|
-
if (firstPost?.requestPostData) {
|
|
3152
|
-
try {
|
|
3153
|
-
const pb = JSON.parse(firstPost.requestPostData);
|
|
3154
|
-
if (typeof pb.EmailAddress === "string")
|
|
3155
|
-
personaValues.set(pb.EmailAddress, "payload.Email");
|
|
3156
|
-
}
|
|
3157
|
-
catch {
|
|
3158
|
-
/* skip */
|
|
3159
|
-
}
|
|
3160
|
-
}
|
|
3161
|
-
base64ContentHelper = emitBuildBase64ContentFunction(b64, personaValues, qMapping, pascal);
|
|
3162
|
-
base64PatchOverride.set(lastPatchWithContent.varName,
|
|
3163
|
-
// biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
|
|
3164
|
-
'"Content":"${buildBase64Content(payload, questionnaireId, Number(draftId), String(attachmentId))}"');
|
|
3165
|
-
const draftPostStep = actionSteps.find((s) => s.capture.method === "POST" &&
|
|
3166
|
-
s.capture.url.includes("recruitingCEJobApplicationDrafts"));
|
|
3167
|
-
if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
|
|
3168
|
-
draftPostStep.produces.push({
|
|
3169
|
-
kind: "body",
|
|
3170
|
-
name: "draftId",
|
|
3171
|
-
path: ["APPDraftId"],
|
|
3172
|
-
});
|
|
3173
|
-
}
|
|
3174
|
-
const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
|
|
3175
|
-
if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
|
|
3176
|
-
attachPostStep.produces.push({
|
|
3177
|
-
kind: "body",
|
|
3178
|
-
name: "attachmentId",
|
|
3179
|
-
path: ["Id"],
|
|
3180
|
-
});
|
|
3181
|
-
}
|
|
3182
|
-
const questionnaireCapture = captures.find((c) => c.method === "GET" &&
|
|
3183
|
-
c.url.includes("recruitingCEQuestions") &&
|
|
3184
|
-
c.url.includes("expand=answers"));
|
|
3185
|
-
let sampleQid;
|
|
3186
|
-
if (questionnaireCapture) {
|
|
3187
|
-
const qResp = typeof questionnaireCapture.responseBody === "string"
|
|
3188
|
-
? JSON.parse(questionnaireCapture.responseBody)
|
|
3189
|
-
: questionnaireCapture.responseBody;
|
|
3190
|
-
sampleQid = qResp?.items?.[0]?.QuestionnaireId ?? undefined;
|
|
3191
|
-
}
|
|
3192
|
-
const overrideValue = base64PatchOverride.values().next().value;
|
|
3193
|
-
if (overrideValue) {
|
|
3194
|
-
const extraVarLines = [];
|
|
3195
|
-
if (sampleQid) {
|
|
3196
|
-
extraVarLines.push(` const questionnaireId = ${sampleQid};`);
|
|
3197
|
-
}
|
|
3198
|
-
if (!attachPostStep) {
|
|
3199
|
-
extraVarLines.push(` const attachmentId = "";`);
|
|
3200
|
-
}
|
|
3201
|
-
if (extraVarLines.length > 0) {
|
|
3202
|
-
base64PatchOverride.set("__EXTRA_VARS__", extraVarLines.join("\n"));
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
const answersFields = (qMapping ?? []).map((m) => m.payloadField);
|
|
3206
|
-
answersFields.push("SignatureFullName");
|
|
3207
|
-
const answersSchemaFields = answersFields.map((f) => ` ${f}: z.string(),`).join("\n");
|
|
3208
|
-
base64ContentHelper = `\nconst AnswersSchema = z.object({\n${answersSchemaFields}\n});\n${base64ContentHelper}`;
|
|
3209
|
-
const inputKeys = new Set();
|
|
3210
|
-
if (inputBody && typeof inputBody === "object" && !Array.isArray(inputBody)) {
|
|
3211
|
-
for (const k of Object.keys(inputBody))
|
|
3212
|
-
inputKeys.add(k);
|
|
3213
|
-
}
|
|
3214
|
-
for (const fld of ["FirstName", "LastName", "Email", "Phone"]) {
|
|
3215
|
-
if (!inputKeys.has(fld))
|
|
3216
|
-
discoveredAdditionalBodyKeys.set(fld, "string");
|
|
3217
|
-
}
|
|
3218
|
-
}
|
|
3219
|
-
}
|
|
3220
|
-
}
|
|
3221
|
-
const processedMultiStepBody = isSubmissionFlow
|
|
3222
|
-
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride, formSchema)
|
|
3223
|
-
: multiStepBody;
|
|
3224
3949
|
const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
|
|
3225
3950
|
const headerBindings = collectHeaderBindings(actionSteps);
|
|
3226
3951
|
// Shape inference targets the SAME call executeHttp returns — see
|
|
@@ -3274,8 +3999,7 @@ async function main() {
|
|
|
3274
3999
|
gqlQuery,
|
|
3275
4000
|
endpointPath,
|
|
3276
4001
|
auxFiles,
|
|
3277
|
-
multiStepBody
|
|
3278
|
-
base64ContentHelper,
|
|
4002
|
+
multiStepBody,
|
|
3279
4003
|
inputBody,
|
|
3280
4004
|
hasMultipartStep,
|
|
3281
4005
|
discoveredFormFields,
|
|
@@ -3283,6 +4007,7 @@ async function main() {
|
|
|
3283
4007
|
discoveredOptionFields,
|
|
3284
4008
|
discoveredRawOptionFields,
|
|
3285
4009
|
discoveredAdditionalBodyKeys,
|
|
4010
|
+
discoveredStructuredKeys,
|
|
3286
4011
|
payloadFieldNames: browserFlow.payloadFieldNames,
|
|
3287
4012
|
headerBindings,
|
|
3288
4013
|
}));
|