@enricai/barnacle 1.12.79 → 1.12.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/helpers/reply.d.ts.map +1 -1
- package/dist/api/helpers/reply.js +5 -2
- package/dist/api/helpers/reply.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/llm/judge.d.ts.map +1 -1
- package/dist/lib/llm/judge.js +6 -1
- package/dist/lib/llm/judge.js.map +1 -1
- package/dist/lib/llm/judges/verify-step-advanced.d.ts +30 -0
- package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -0
- package/dist/lib/llm/judges/verify-step-advanced.js +62 -0
- package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -0
- package/dist/lib/llm/judges/verify-submit.d.ts +7 -1
- package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -1
- package/dist/lib/llm/judges/verify-submit.js +29 -8
- package/dist/lib/llm/judges/verify-submit.js.map +1 -1
- package/dist/lib/llm/schemas.d.ts +8 -0
- package/dist/lib/llm/schemas.d.ts.map +1 -1
- package/dist/lib/llm/schemas.js +21 -13
- package/dist/lib/llm/schemas.js.map +1 -1
- package/dist/lib/logging.d.ts +7 -0
- package/dist/lib/logging.d.ts.map +1 -1
- package/dist/lib/logging.js +19 -1
- package/dist/lib/logging.js.map +1 -1
- package/dist/lib/pii-redaction.d.ts +104 -0
- package/dist/lib/pii-redaction.d.ts.map +1 -0
- package/dist/lib/pii-redaction.js +397 -0
- package/dist/lib/pii-redaction.js.map +1 -0
- package/dist/lib/random.d.ts +3 -1
- package/dist/lib/random.d.ts.map +1 -1
- package/dist/lib/random.js +7 -2
- package/dist/lib/random.js.map +1 -1
- package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/beacon-capture.js +2 -0
- package/dist/lib/telemetry/beacon-capture.js.map +1 -1
- package/dist/lib/telemetry/call-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/call-capture.js +4 -1
- package/dist/lib/telemetry/call-capture.js.map +1 -1
- package/dist/lib/telemetry/submission-capture.d.ts +11 -1
- package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/submission-capture.js +32 -3
- package/dist/lib/telemetry/submission-capture.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +8 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +4 -0
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/discover.d.ts.map +1 -1
- package/dist/plugins/discover.js +2 -0
- package/dist/plugins/discover.js.map +1 -1
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +23 -6
- package/dist/plugins/loader.js.map +1 -1
- package/dist/scraper/active-page.d.ts +18 -0
- package/dist/scraper/active-page.d.ts.map +1 -0
- package/dist/scraper/active-page.js +53 -0
- package/dist/scraper/active-page.js.map +1 -0
- package/dist/scraper/click-settle.d.ts +53 -0
- package/dist/scraper/click-settle.d.ts.map +1 -0
- package/dist/scraper/click-settle.js +135 -0
- package/dist/scraper/click-settle.js.map +1 -0
- package/dist/scraper/deep-locator-candidates.d.ts +20 -1
- package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
- package/dist/scraper/deep-locator-candidates.js +75 -11
- package/dist/scraper/deep-locator-candidates.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +347 -6
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +2457 -357
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/navigate.d.ts +3 -3
- package/dist/scraper/navigate.js +4 -4
- package/dist/scraper/navigate.js.map +1 -1
- package/dist/scraper/phantom-click.d.ts +34 -2
- package/dist/scraper/phantom-click.d.ts.map +1 -1
- package/dist/scraper/phantom-click.js +80 -6
- package/dist/scraper/phantom-click.js.map +1 -1
- package/dist/scraper/quoted-spans.d.ts +35 -0
- package/dist/scraper/quoted-spans.d.ts.map +1 -0
- package/dist/scraper/quoted-spans.js +83 -0
- package/dist/scraper/quoted-spans.js.map +1 -0
- package/dist/scraper/session-browserbase.d.ts +4 -2
- package/dist/scraper/session-browserbase.d.ts.map +1 -1
- package/dist/scraper/session-browserbase.js +36 -5
- package/dist/scraper/session-browserbase.js.map +1 -1
- package/dist/scraper/session-steel.d.ts.map +1 -1
- package/dist/scraper/session-steel.js +16 -2
- package/dist/scraper/session-steel.js.map +1 -1
- package/dist/scraper/session-teardown.d.ts +1 -0
- package/dist/scraper/session-teardown.d.ts.map +1 -1
- package/dist/scraper/session-teardown.js +19 -8
- package/dist/scraper/session-teardown.js.map +1 -1
- package/dist/scraper/spa-readiness.d.ts +13 -1
- package/dist/scraper/spa-readiness.d.ts.map +1 -1
- package/dist/scraper/spa-readiness.js +31 -1
- package/dist/scraper/spa-readiness.js.map +1 -1
- package/dist/scraper/stagehand-guard.d.ts +18 -3
- package/dist/scraper/stagehand-guard.d.ts.map +1 -1
- package/dist/scraper/stagehand-guard.js +198 -12
- package/dist/scraper/stagehand-guard.js.map +1 -1
- package/dist/scraper/transient-provider-error.d.ts +19 -0
- package/dist/scraper/transient-provider-error.d.ts.map +1 -0
- package/dist/scraper/transient-provider-error.js +59 -0
- package/dist/scraper/transient-provider-error.js.map +1 -0
- package/dist/scraper/trusted-click.d.ts +133 -0
- package/dist/scraper/trusted-click.d.ts.map +1 -0
- package/dist/scraper/trusted-click.js +205 -0
- package/dist/scraper/trusted-click.js.map +1 -0
- package/dist/scraper/upload-affordance-expr.d.ts +33 -0
- package/dist/scraper/upload-affordance-expr.d.ts.map +1 -0
- package/dist/scraper/upload-affordance-expr.js +91 -0
- package/dist/scraper/upload-affordance-expr.js.map +1 -0
- package/dist/scripts/recon-browser.d.ts +26 -1
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +108 -21
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-capture-rebind.d.ts +11 -0
- package/dist/scripts/recon-capture-rebind.d.ts.map +1 -0
- package/dist/scripts/recon-capture-rebind.js +26 -0
- package/dist/scripts/recon-capture-rebind.js.map +1 -0
- package/dist/scripts/recon-generate.d.ts +110 -9
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +962 -183
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +8 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +14 -0
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/site-plugin.d.ts +8 -0
- package/dist/site-plugin.d.ts.map +1 -1
- package/dist/types/pii-redaction.d.ts +39 -0
- package/dist/types/pii-redaction.d.ts.map +1 -0
- package/dist/types/pii-redaction.js +3 -0
- package/dist/types/pii-redaction.js.map +1 -0
- package/package.json +14 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.resolveStepPayloadField = resolveStepPayloadField;
|
|
25
|
+
exports.canonicalPayloadField = canonicalPayloadField;
|
|
25
26
|
exports.buildKnownFieldValues = buildKnownFieldValues;
|
|
26
27
|
exports.extractStepPersonaValue = extractStepPersonaValue;
|
|
27
28
|
exports.extractNavigateToHashFragmentValue = extractNavigateToHashFragmentValue;
|
|
@@ -68,6 +69,7 @@ exports.resolveRecurringNavigateToFacets = resolveRecurringNavigateToFacets;
|
|
|
68
69
|
exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
|
|
69
70
|
exports.countSpliceableFacets = countSpliceableFacets;
|
|
70
71
|
exports.spliceFacetsIntoStringVariable = spliceFacetsIntoStringVariable;
|
|
72
|
+
exports.matchFacetRecurrence = matchFacetRecurrence;
|
|
71
73
|
exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
|
|
72
74
|
exports.renderGqlVariablesExpr = renderGqlVariablesExpr;
|
|
73
75
|
exports.findAllObjectArrayFields = findAllObjectArrayFields;
|
|
@@ -88,9 +90,13 @@ exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCo
|
|
|
88
90
|
exports.resolveFoldPlan = resolveFoldPlan;
|
|
89
91
|
exports.resolveApplicableFoldPlans = resolveApplicableFoldPlans;
|
|
90
92
|
exports.buildContractChecklist = buildContractChecklist;
|
|
93
|
+
exports.contractUsesApplicantContactSchema = contractUsesApplicantContactSchema;
|
|
91
94
|
exports.emitContractTs = emitContractTs;
|
|
92
95
|
exports.unreferencedRequiredUrlFields = unreferencedRequiredUrlFields;
|
|
93
96
|
exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
|
|
97
|
+
exports.unreferencedDeclaredPayloadFields = unreferencedDeclaredPayloadFields;
|
|
98
|
+
exports.unreadPayloadFieldChecklistLines = unreadPayloadFieldChecklistLines;
|
|
99
|
+
exports.buildStepInstructionExpr = buildStepInstructionExpr;
|
|
94
100
|
exports.emitConfigManifest = emitConfigManifest;
|
|
95
101
|
exports.emitBrowserFlowTs = emitBrowserFlowTs;
|
|
96
102
|
exports.emitIndexTs = emitIndexTs;
|
|
@@ -109,6 +115,8 @@ const load_value_constraints_1 = require("../recon/load-value-constraints");
|
|
|
109
115
|
const load_vocabulary_1 = require("../recon/load-vocabulary");
|
|
110
116
|
const value_constraints_1 = require("../recon/value-constraints");
|
|
111
117
|
const vocabulary_1 = require("../recon/vocabulary");
|
|
118
|
+
const flow_runner_1 = require("../scraper/flow-runner");
|
|
119
|
+
const quoted_spans_1 = require("../scraper/quoted-spans");
|
|
112
120
|
const recon_shared_1 = require("./recon-shared");
|
|
113
121
|
const logger = (0, logging_1.getScriptLogger)("recon-generate");
|
|
114
122
|
/**
|
|
@@ -141,39 +149,54 @@ const RESERVED_ENV_TOKEN = /\$\{RECON_[A-Z0-9_]*\}/;
|
|
|
141
149
|
* (never through) vocabulary/payload-field resolution.
|
|
142
150
|
*/
|
|
143
151
|
const RECON_PASSWORD_TOKEN = `$${"{RECON_PASSWORD}"}`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*/
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
value: instruction.slice(m.index + 1, m.index + m[0].length - 1),
|
|
152
|
+
const DOUBLE_QUOTED_SPAN = /"([^"]+)"/g;
|
|
153
|
+
/**
|
|
154
|
+
* Quoted spans of a flow-step instruction. The runtime parsers only understand
|
|
155
|
+
* single quotes, so a double-quoted span is consulted only when the step has no
|
|
156
|
+
* single-quoted one — a mixed instruction keeps its single-quote grammar, and a
|
|
157
|
+
* double-quoted-only step still has a spliceable value instead of being skipped.
|
|
158
|
+
*/
|
|
159
|
+
function findInstructionSpans(instruction) {
|
|
160
|
+
const single = (0, quoted_spans_1.findQuotedSpans)(instruction);
|
|
161
|
+
if (single.length > 0)
|
|
162
|
+
return single;
|
|
163
|
+
return Array.from(instruction.matchAll(DOUBLE_QUOTED_SPAN), (match) => ({
|
|
164
|
+
text: match[1] ?? "",
|
|
165
|
+
start: match.index ?? 0,
|
|
166
|
+
end: (match.index ?? 0) + match[0].length,
|
|
160
167
|
}));
|
|
161
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* A quoted span that names the widget or question a step targets rather than
|
|
171
|
+
* the value it carries: followed by a widget noun ("'State' prompt selector"),
|
|
172
|
+
* or introduced by "for"/"from" ("for the 'State' question").
|
|
173
|
+
*/
|
|
174
|
+
function namesAWidgetLabel(instruction, span) {
|
|
175
|
+
const before = instruction.slice(Math.max(0, span.start - 25), span.start);
|
|
176
|
+
const after = instruction.slice(span.end);
|
|
177
|
+
return (/^\s*(?:prompt\s+selector|selector|dropdown|drop-down|multiselect|typeahead|field|question|list|menu|widget)\b/i.test(after) || /\b(?:for|from)(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before));
|
|
178
|
+
}
|
|
162
179
|
/**
|
|
163
180
|
* Picks which quoted span in an instruction is the persona VALUE, per the
|
|
164
181
|
* grammar recon-flow steps use: a `Select`/`Choose`/`Pick` step names the
|
|
165
|
-
* ANSWER
|
|
166
|
-
*
|
|
167
|
-
*
|
|
182
|
+
* ANSWER after the verb (wherever the verb sits, so a leading context phrase
|
|
183
|
+
* does not hide it), so the value is the first span following the verb that is
|
|
184
|
+
* not a widget label — "choose the 'State' field, then choose 'Texas'" must
|
|
185
|
+
* yield Texas, not the label. Any other step (`Fill`/`Enter`/`Type`) names the
|
|
186
|
+
* field label first and the value last, so it is the LAST quoted span.
|
|
168
187
|
*/
|
|
169
188
|
function pickValueSpan(instruction, spans) {
|
|
170
|
-
|
|
189
|
+
const verb = /\b(select|choose|pick)\b/i.exec(instruction);
|
|
190
|
+
const afterVerb = verb ? spans.filter((span) => span.start >= verb.index + verb[0].length) : [];
|
|
191
|
+
const answer = afterVerb.find((span) => !namesAWidgetLabel(instruction, span)) ?? afterVerb[0];
|
|
192
|
+
return answer ?? spans[spans.length - 1];
|
|
171
193
|
}
|
|
172
194
|
/**
|
|
173
195
|
* Locates the splice site in a flow-step instruction as `{ before, matched,
|
|
174
196
|
* after }` slices of the ORIGINAL instruction — a reserved `${RECON_*}` env
|
|
175
197
|
* token when present (preferred, since it names its own site unambiguously),
|
|
176
|
-
* otherwise the quoted VALUE span per {@link pickValueSpan}'s verb-class rule
|
|
198
|
+
* otherwise the quoted VALUE span per {@link pickValueSpan}'s verb-class rule
|
|
199
|
+
* (its quote delimiters stay in `before`/`after`, so a splice remains quoted).
|
|
177
200
|
* This is the position-aware counterpart to {@link extractStepPersonaValue}:
|
|
178
201
|
* that function only needs the extracted string, this one needs the actual
|
|
179
202
|
* before/after slices so a template literal can be rebuilt around the site.
|
|
@@ -189,14 +212,14 @@ function locateSpliceSite(instruction) {
|
|
|
189
212
|
after: instruction.slice(envToken.index + envToken[0].length),
|
|
190
213
|
};
|
|
191
214
|
}
|
|
192
|
-
const spans =
|
|
215
|
+
const spans = findInstructionSpans(instruction);
|
|
193
216
|
if (spans.length === 0)
|
|
194
217
|
return null;
|
|
195
218
|
const span = pickValueSpan(instruction, spans);
|
|
196
219
|
return {
|
|
197
|
-
before: instruction.slice(0, span.
|
|
198
|
-
matched: instruction.slice(span.
|
|
199
|
-
after: instruction.slice(span.
|
|
220
|
+
before: instruction.slice(0, span.start + 1),
|
|
221
|
+
matched: instruction.slice(span.start + 1, span.end - 1),
|
|
222
|
+
after: instruction.slice(span.end - 1),
|
|
200
223
|
};
|
|
201
224
|
}
|
|
202
225
|
/**
|
|
@@ -207,15 +230,15 @@ function locateSpliceSite(instruction) {
|
|
|
207
230
|
* can never be mistaken for the data it merely mentions.
|
|
208
231
|
*/
|
|
209
232
|
function namesAControl(instruction) {
|
|
210
|
-
return
|
|
233
|
+
return findInstructionSpans(instruction).some((span) => /^\s*(button|link|tab)\b/i.test(instruction.slice(span.end)));
|
|
211
234
|
}
|
|
212
235
|
/** The quoted VALUE a Select/Choose/Fill step carries, per {@link pickValueSpan}'s
|
|
213
236
|
* grammar rule — used to validate a vocabulary match's ANSWER, not just its label. */
|
|
214
237
|
function pickedQuotedValue(instruction) {
|
|
215
|
-
const spans =
|
|
238
|
+
const spans = findInstructionSpans(instruction);
|
|
216
239
|
if (spans.length === 0)
|
|
217
240
|
return null;
|
|
218
|
-
const value = pickValueSpan(instruction, spans).
|
|
241
|
+
const value = pickValueSpan(instruction, spans).text;
|
|
219
242
|
return value.length > 0 ? value : null;
|
|
220
243
|
}
|
|
221
244
|
/**
|
|
@@ -254,9 +277,12 @@ function toPascalCase(siteId) {
|
|
|
254
277
|
* label match names, or it is not that field's datum (e.g. a device-type
|
|
255
278
|
* dropdown's `'Mobile'` looks like a phone-number field by label alone, but
|
|
256
279
|
* is nothing like the number the Fill step already bound).
|
|
280
|
+
* @param canonicalise map a vocabulary row's name onto the equivalent
|
|
281
|
+
* `ApplicantContactSchema` key ({@link canonicalPayloadField}); set by the
|
|
282
|
+
* job-application emit paths whose contract extends that base schema
|
|
257
283
|
* @returns the PascalCase payload field name to splice, or null to keep literal
|
|
258
284
|
*/
|
|
259
|
-
function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY, knownFieldValues = EMPTY_KNOWN_FIELD_VALUES) {
|
|
285
|
+
function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary = vocabulary_1.EMPTY_VOCABULARY, knownFieldValues = EMPTY_KNOWN_FIELD_VALUES, canonicalise = false) {
|
|
260
286
|
if (forceNone)
|
|
261
287
|
return null;
|
|
262
288
|
if (explicit)
|
|
@@ -267,7 +293,7 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
|
|
|
267
293
|
return null;
|
|
268
294
|
// A quoted literal or a reserved ${RECON_*} env token IS the recon constant
|
|
269
295
|
// this step would replace, so it is spliceable on its own.
|
|
270
|
-
const hasQuotedConstant =
|
|
296
|
+
const hasQuotedConstant = findInstructionSpans(instruction).length > 0 || RESERVED_ENV_TOKEN.test(instruction);
|
|
271
297
|
// A dropdown step carries no constant to replace, so a label match alone can't
|
|
272
298
|
// tell "select the test candidate's state" (the caller's data) from "select the
|
|
273
299
|
// neighborhood from the Country dropdown" (a facet that merely says Country).
|
|
@@ -286,18 +312,49 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
|
|
|
286
312
|
for (const [rx, field] of vocabulary.table) {
|
|
287
313
|
if (!rx.test(instruction))
|
|
288
314
|
continue;
|
|
315
|
+
const resolved = canonicalise ? canonicalPayloadField(field) : field;
|
|
289
316
|
if (!isSelectStep)
|
|
290
|
-
return
|
|
317
|
+
return resolved;
|
|
291
318
|
const answer = pickedQuotedValue(instruction);
|
|
292
319
|
if (answer !== null && OPERATIONAL_ANSWER.test(answer.trim()))
|
|
293
320
|
return null;
|
|
294
|
-
const known = knownFieldValues.get(
|
|
321
|
+
const known = knownFieldValues.get(resolved);
|
|
295
322
|
if (known !== undefined && answer !== known)
|
|
296
323
|
return null;
|
|
297
|
-
return
|
|
324
|
+
return resolved;
|
|
298
325
|
}
|
|
299
326
|
return null;
|
|
300
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Maps a vocabulary row's field name onto the base schema's equivalent so a
|
|
330
|
+
* consumer's `MobilePhone` does not become an extra required key beside
|
|
331
|
+
* `Phone`. Names the base schema has no equivalent for pass through.
|
|
332
|
+
*/
|
|
333
|
+
function canonicalPayloadField(field) {
|
|
334
|
+
if (applicantContactFieldNames.has(field) || baseContractFieldNames.has(field))
|
|
335
|
+
return field;
|
|
336
|
+
const canonical = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(field);
|
|
337
|
+
return canonical !== null && applicantContactFieldNames.has(canonical) ? canonical : field;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The single decision for which payload field a fill step carries: the
|
|
341
|
+
* vocabulary/explicit field, else the instruction's own label — never when the
|
|
342
|
+
* author opted out (`payloadFieldNone`) or the vocabulary excludes the step.
|
|
343
|
+
* Shared so known-field and persona-binding registration cannot drift apart.
|
|
344
|
+
*/
|
|
345
|
+
function fieldForFillStep(step, vocabulary, knownFieldValues, canonicalise = false) {
|
|
346
|
+
const isObj = typeof step !== "string";
|
|
347
|
+
const instruction = isObj ? step.step : step;
|
|
348
|
+
const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
|
|
349
|
+
if (vocabField !== null)
|
|
350
|
+
return vocabField;
|
|
351
|
+
if (isObj && step.payloadFieldNone)
|
|
352
|
+
return null;
|
|
353
|
+
if (vocabulary.exclusions.some((rx) => rx.test(instruction)))
|
|
354
|
+
return null;
|
|
355
|
+
const labelField = deriveFillLabelField(instruction);
|
|
356
|
+
return canonicalise && labelField !== null ? canonicalPayloadField(labelField) : labelField;
|
|
357
|
+
}
|
|
301
358
|
/**
|
|
302
359
|
* Field→value pairs the flow establishes unambiguously via a Fill/Enter/Type
|
|
303
360
|
* step — the applicant's own datum, per {@link deriveFillLabelField}'s same
|
|
@@ -307,14 +364,14 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
|
|
|
307
364
|
* (a field whose label mentions "phone" but whose select answer is a device
|
|
308
365
|
* type, not a number).
|
|
309
366
|
*/
|
|
310
|
-
function buildKnownFieldValues(flowSteps, vocabulary, env) {
|
|
367
|
+
function buildKnownFieldValues(flowSteps, vocabulary, env, canonicalise = false) {
|
|
311
368
|
const known = new Map();
|
|
312
369
|
for (const step of flowSteps) {
|
|
313
370
|
const isObj = typeof step !== "string";
|
|
314
371
|
const instruction = isObj ? step.step : step;
|
|
315
372
|
if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
|
|
316
373
|
continue;
|
|
317
|
-
const field =
|
|
374
|
+
const field = fieldForFillStep(step, vocabulary, undefined, canonicalise);
|
|
318
375
|
if (field === null)
|
|
319
376
|
continue;
|
|
320
377
|
const value = extractStepPersonaValue(instruction, env);
|
|
@@ -334,9 +391,9 @@ function buildKnownFieldValues(flowSteps, vocabulary, env) {
|
|
|
334
391
|
* or the value-identity substitution silently misses.
|
|
335
392
|
*
|
|
336
393
|
* Two grammar facts, both verified against real ATS flows, drive the rule:
|
|
337
|
-
* 1.
|
|
338
|
-
*
|
|
339
|
-
*
|
|
394
|
+
* 1. A letter-flanked apostrophe ("the candidate's first name 'Reginald'",
|
|
395
|
+
* "Don't") is not a quote delimiter; the shared tokenizer skips it, so the
|
|
396
|
+
* span is `Reginald`, not `s first name `.
|
|
340
397
|
* 2. A `Select`/`Choose` step names the ANSWER first, then the question
|
|
341
398
|
* ("Select 'No' for the 'sponsorship' question"), so the value is the
|
|
342
399
|
* FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
|
|
@@ -363,10 +420,10 @@ function extractStepPersonaValue(instruction, env) {
|
|
|
363
420
|
if (resolved)
|
|
364
421
|
return resolved;
|
|
365
422
|
}
|
|
366
|
-
const spans =
|
|
423
|
+
const spans = findInstructionSpans(instruction);
|
|
367
424
|
if (spans.length === 0)
|
|
368
425
|
return null;
|
|
369
|
-
const value = pickValueSpan(instruction, spans).
|
|
426
|
+
const value = pickValueSpan(instruction, spans).text;
|
|
370
427
|
return value.length > 0 ? value : null;
|
|
371
428
|
}
|
|
372
429
|
/**
|
|
@@ -409,12 +466,26 @@ function extractNavigateToHashFragmentValue(url, previousHash) {
|
|
|
409
466
|
* hash-bearing navigateTo; a hashless reset navigation must not erase it, or
|
|
410
467
|
* the next cumulative hash would be taken whole and never recur in a request.
|
|
411
468
|
*/
|
|
412
|
-
function walkNavigateToFacets(flowSteps) {
|
|
469
|
+
function walkNavigateToFacets(flowSteps, env) {
|
|
413
470
|
const facets = [];
|
|
414
471
|
let baselineHash;
|
|
415
472
|
for (const step of flowSteps) {
|
|
416
|
-
if (typeof step === "string"
|
|
473
|
+
if (typeof step === "string")
|
|
474
|
+
continue;
|
|
475
|
+
if (step.navigateTo === undefined) {
|
|
476
|
+
if (step.payloadField && !step.payloadFieldNone) {
|
|
477
|
+
facets.push({
|
|
478
|
+
value: extractStepPersonaValue(step.step, env),
|
|
479
|
+
field: step.payloadField,
|
|
480
|
+
optional: step.optional === true,
|
|
481
|
+
origin: "step",
|
|
482
|
+
literals: collectStepFacetLiterals(step.step, env),
|
|
483
|
+
kind: deriveFacetKind(step.payloadField),
|
|
484
|
+
kindHints: collectStepFacetKindHints(step.step, env),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
417
487
|
continue;
|
|
488
|
+
}
|
|
418
489
|
const hashIndex = step.navigateTo.indexOf("#");
|
|
419
490
|
const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
|
|
420
491
|
if (step.payloadField) {
|
|
@@ -422,6 +493,10 @@ function walkNavigateToFacets(flowSteps) {
|
|
|
422
493
|
value: extractNavigateToHashFragmentValue(step.navigateTo, baselineHash),
|
|
423
494
|
field: step.payloadField,
|
|
424
495
|
optional: step.optional === true,
|
|
496
|
+
origin: "navigateTo",
|
|
497
|
+
literals: collectHashFacetLiterals(step.navigateTo, baselineHash),
|
|
498
|
+
kind: deriveFacetKind(step.payloadField),
|
|
499
|
+
kindHints: collectHashFacetKindHints(step.navigateTo, baselineHash),
|
|
425
500
|
});
|
|
426
501
|
}
|
|
427
502
|
if (currentHash !== undefined)
|
|
@@ -429,6 +504,99 @@ function walkNavigateToFacets(flowSteps) {
|
|
|
429
504
|
}
|
|
430
505
|
return facets;
|
|
431
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* Derives a facet's element kind from its declared field name (`tripLength`
|
|
509
|
+
* and `TripLength` both yield `trip length`), so a matcher can tell which
|
|
510
|
+
* captured element a literal belongs to without any site vocabulary.
|
|
511
|
+
*/
|
|
512
|
+
function deriveFacetKind(field) {
|
|
513
|
+
return field
|
|
514
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
515
|
+
.replace(/[_\-\s]+/g, " ")
|
|
516
|
+
.trim()
|
|
517
|
+
.toLowerCase();
|
|
518
|
+
}
|
|
519
|
+
function uniqueLiterals(primary, others) {
|
|
520
|
+
return [...new Set([...(primary === null ? [] : [primary]), ...others])].filter((literal) => literal.length > 0);
|
|
521
|
+
}
|
|
522
|
+
function collectStepFacetLiterals(instruction, env) {
|
|
523
|
+
const primary = extractStepPersonaValue(instruction, env);
|
|
524
|
+
return uniqueLiterals(primary, findInstructionSpans(instruction).map((span) => span.text));
|
|
525
|
+
}
|
|
526
|
+
const STEP_KIND_STOP_WORDS = new Set([
|
|
527
|
+
"facet",
|
|
528
|
+
"filter",
|
|
529
|
+
"field",
|
|
530
|
+
"value",
|
|
531
|
+
"option",
|
|
532
|
+
"selection",
|
|
533
|
+
"the",
|
|
534
|
+
"a",
|
|
535
|
+
"an",
|
|
536
|
+
"dropdown",
|
|
537
|
+
"drop-down",
|
|
538
|
+
"selector",
|
|
539
|
+
"menu",
|
|
540
|
+
"list",
|
|
541
|
+
"widget",
|
|
542
|
+
"question",
|
|
543
|
+
]);
|
|
544
|
+
/**
|
|
545
|
+
* Element kinds a click step's own prose names: the noun phrase trailing the
|
|
546
|
+
* value span ("select the 'Lisbon' city option" names `city`), so a facet whose
|
|
547
|
+
* field name differs from the page's vocabulary can still own the element of
|
|
548
|
+
* that kind. Reads only the generic instruction grammar, no site vocabulary.
|
|
549
|
+
*/
|
|
550
|
+
function collectStepFacetKindHints(instruction, env) {
|
|
551
|
+
const value = extractStepPersonaValue(instruction, env);
|
|
552
|
+
const span = findInstructionSpans(instruction).find((candidate) => candidate.text === value);
|
|
553
|
+
if (span === undefined)
|
|
554
|
+
return [];
|
|
555
|
+
const phrase = instruction
|
|
556
|
+
.slice(span.end)
|
|
557
|
+
.split(/[.,;:]|\b(?:from|in|on|for|with|and|then|to)\b/i)[0];
|
|
558
|
+
const words = phrase
|
|
559
|
+
.split(/\s+/)
|
|
560
|
+
.map((word) => word.replace(/^['"]+|['"]+$/g, ""))
|
|
561
|
+
.filter((word) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(word));
|
|
562
|
+
const kindWords = words.filter((word) => !STEP_KIND_STOP_WORDS.has(word.toLowerCase()));
|
|
563
|
+
return [...new Set([...(kindWords.length > 1 ? [kindWords.join(" ")] : []), ...kindWords])];
|
|
564
|
+
}
|
|
565
|
+
function collectHashFacetLiterals(url, previousHash) {
|
|
566
|
+
const primary = extractNavigateToHashFragmentValue(url, previousHash);
|
|
567
|
+
const hashIndex = url.indexOf("#");
|
|
568
|
+
const rawTokens = hashIndex === -1 || previousHash === undefined
|
|
569
|
+
? []
|
|
570
|
+
: splitHashTokens(url.slice(hashIndex + 1)).filter((token) => !splitHashTokens(previousHash).includes(token));
|
|
571
|
+
return uniqueLiterals(primary, rawTokens);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Further element kinds a facet's own hash names (`ship` in `#/ship/<slug>` or
|
|
575
|
+
* `ship=<slug>`, `ports` in `ports_<slug>`), for a field name that does not
|
|
576
|
+
* itself name the element kind. Kept beside the binding rather than on it so
|
|
577
|
+
* the binding's declared shape stays the contract callers build and compare.
|
|
578
|
+
*/
|
|
579
|
+
const FACET_KIND_HINTS = new WeakMap();
|
|
580
|
+
/**
|
|
581
|
+
* Element kinds a hash facet's own tokens name: the key of a `key=value` pair,
|
|
582
|
+
* the path segment ahead of the value, and the `<kind>_` prefix of a slug.
|
|
583
|
+
*/
|
|
584
|
+
function collectHashFacetKindHints(url, previousHash) {
|
|
585
|
+
const hashIndex = url.indexOf("#");
|
|
586
|
+
if (hashIndex === -1)
|
|
587
|
+
return [];
|
|
588
|
+
const previousTokens = new Set(previousHash === undefined ? [] : splitHashTokens(previousHash));
|
|
589
|
+
const tokens = splitHashTokens(url.slice(hashIndex + 1));
|
|
590
|
+
const fresh = tokens.filter((token) => !previousTokens.has(token));
|
|
591
|
+
const valueToken = fresh[fresh.length - 1] ?? tokens[tokens.length - 1];
|
|
592
|
+
if (valueToken === undefined)
|
|
593
|
+
return [];
|
|
594
|
+
const value = stripHashKeyPrefix(valueToken);
|
|
595
|
+
const keyOfPair = valueToken.includes("=") ? valueToken.slice(0, valueToken.indexOf("=")) : "";
|
|
596
|
+
const ahead = tokens[tokens.indexOf(valueToken) - 1] ?? "";
|
|
597
|
+
const slugPrefix = value.includes("_") ? value.slice(0, value.indexOf("_")) : "";
|
|
598
|
+
return [keyOfPair, ahead, slugPrefix].filter((hint) => /^[A-Za-z][A-Za-z0-9 _-]*$/.test(hint));
|
|
599
|
+
}
|
|
432
600
|
function splitHashTokens(hash) {
|
|
433
601
|
return hash.split(/[,/;&]/).filter((token) => token.length > 0);
|
|
434
602
|
}
|
|
@@ -459,7 +627,7 @@ function stripHashKeyPrefix(token) {
|
|
|
459
627
|
function deriveFillLabelField(instruction) {
|
|
460
628
|
if (!/^\s*(?:fill(?:\s+in)?|enter|type)\b/i.test(instruction))
|
|
461
629
|
return null;
|
|
462
|
-
if (
|
|
630
|
+
if (findInstructionSpans(instruction).length === 0)
|
|
463
631
|
return null;
|
|
464
632
|
const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
|
|
465
633
|
if (label === undefined)
|
|
@@ -482,7 +650,7 @@ function deriveFillLabelField(instruction) {
|
|
|
482
650
|
function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
483
651
|
const bindings = new Map();
|
|
484
652
|
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
|
|
485
|
-
for (const facet of walkNavigateToFacets(flowSteps)) {
|
|
653
|
+
for (const facet of walkNavigateToFacets(flowSteps, env)) {
|
|
486
654
|
if (facet.value !== null && !bindings.has(facet.value)) {
|
|
487
655
|
bindings.set(facet.value, `payload.${facet.field}`);
|
|
488
656
|
}
|
|
@@ -499,16 +667,7 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
|
499
667
|
// fragment, not anything extractStepPersonaValue could find in `step.step`.
|
|
500
668
|
if (isObj && step.navigateTo !== undefined)
|
|
501
669
|
continue;
|
|
502
|
-
const
|
|
503
|
-
// Vocabulary wins outright. Only on a miss do we fall back to deriving the
|
|
504
|
-
// field from the instruction's own label — and never when the author opted
|
|
505
|
-
// the step out (`payloadFieldNone`) or the vocabulary explicitly excluded it.
|
|
506
|
-
const field = vocabField ??
|
|
507
|
-
(isObj && step.payloadFieldNone
|
|
508
|
-
? null
|
|
509
|
-
: vocabulary.exclusions.some((rx) => rx.test(instruction))
|
|
510
|
-
? null
|
|
511
|
-
: deriveFillLabelField(instruction));
|
|
670
|
+
const field = fieldForFillStep(step, vocabulary, knownFieldValues);
|
|
512
671
|
if (field === null)
|
|
513
672
|
continue;
|
|
514
673
|
const value = extractStepPersonaValue(instruction, env);
|
|
@@ -534,12 +693,22 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
|
|
|
534
693
|
* aligned with the declared facets; {@link resolveRecurringNavigateToFacets}
|
|
535
694
|
* drops the empty literal itself and keeps only a correlated value.
|
|
536
695
|
*/
|
|
537
|
-
function extractNavigateToFacetOrder(flowSteps) {
|
|
538
|
-
return walkNavigateToFacets(flowSteps)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
696
|
+
function extractNavigateToFacetOrder(flowSteps, env = process.env) {
|
|
697
|
+
return walkNavigateToFacets(flowSteps, env)
|
|
698
|
+
.filter((facet) => facet.origin === "navigateTo" || facet.value !== null)
|
|
699
|
+
.map((facet) => {
|
|
700
|
+
const binding = {
|
|
701
|
+
value: facet.value ?? "",
|
|
702
|
+
field: facet.field,
|
|
703
|
+
optional: facet.optional,
|
|
704
|
+
literals: facet.literals,
|
|
705
|
+
kind: facet.kind,
|
|
706
|
+
...(facet.origin === "step" ? { origin: facet.origin } : {}),
|
|
707
|
+
};
|
|
708
|
+
if (facet.kindHints.length > 0)
|
|
709
|
+
FACET_KIND_HINTS.set(binding, facet.kindHints);
|
|
710
|
+
return binding;
|
|
711
|
+
});
|
|
543
712
|
}
|
|
544
713
|
/**
|
|
545
714
|
* Detects a flow step whose quoted VALUE is the space-joined concatenation of
|
|
@@ -553,10 +722,10 @@ function extractNavigateToFacetOrder(flowSteps) {
|
|
|
553
722
|
* null when the step's value is not such a concatenation
|
|
554
723
|
*/
|
|
555
724
|
function resolveCompositePersonaFields(instruction, knownFieldValues) {
|
|
556
|
-
const spans =
|
|
725
|
+
const spans = findInstructionSpans(instruction);
|
|
557
726
|
if (spans.length === 0)
|
|
558
727
|
return null;
|
|
559
|
-
const value = pickValueSpan(instruction, spans).
|
|
728
|
+
const value = pickValueSpan(instruction, spans).text;
|
|
560
729
|
if (value.length === 0)
|
|
561
730
|
return null;
|
|
562
731
|
for (const [fieldA, valueA] of knownFieldValues) {
|
|
@@ -1299,6 +1468,10 @@ function deriveBaseUrl(captures, ownBackendHostnames, submitPatterns = null) {
|
|
|
1299
1468
|
return dominantHostOrigin(sameDomainCandidates);
|
|
1300
1469
|
}
|
|
1301
1470
|
const candidates = captures.filter((c) => (0, capture_filters_1.isAllowedFixtureHost)(captureHostname(c.url), ownBackendHostnames, null));
|
|
1471
|
+
if (candidates.length === 0) {
|
|
1472
|
+
const captured = [...new Set(captures.map((c) => captureHostname(c.url)))].join(", ");
|
|
1473
|
+
throw new Error(`ownBackendHostnames matched no capture: declared [${ownBackendHostnames.join(", ")}], captured hosts [${captured}]`);
|
|
1474
|
+
}
|
|
1302
1475
|
return dominantHostOrigin(candidates);
|
|
1303
1476
|
}
|
|
1304
1477
|
function firstCaptureOrigin(captures) {
|
|
@@ -1311,7 +1484,7 @@ function firstCaptureOrigin(captures) {
|
|
|
1311
1484
|
// try next
|
|
1312
1485
|
}
|
|
1313
1486
|
}
|
|
1314
|
-
|
|
1487
|
+
throw new Error(`no capture has a parseable URL to derive a base URL from (${captures.length} candidate captures)`);
|
|
1315
1488
|
}
|
|
1316
1489
|
/**
|
|
1317
1490
|
* Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
|
|
@@ -3804,11 +3977,8 @@ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
|
|
|
3804
3977
|
// returns first-quote when the sentence STARTS with select/choose/pick; a
|
|
3805
3978
|
// dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
|
|
3806
3979
|
// starts with "On", so read the first quote directly here for id= steps.
|
|
3807
|
-
const
|
|
3808
|
-
|
|
3809
|
-
const m = /'([^']*)'/.exec(cleaned);
|
|
3810
|
-
return m && m[1].length > 0 ? m[1] : null;
|
|
3811
|
-
})();
|
|
3980
|
+
const firstQuoteText = findInstructionSpans(instruction)[0]?.text;
|
|
3981
|
+
const firstQuoteLabel = firstQuoteText ? firstQuoteText : null;
|
|
3812
3982
|
const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
|
|
3813
3983
|
if (label === null)
|
|
3814
3984
|
continue;
|
|
@@ -5998,9 +6168,14 @@ function applyScalarFacetSplicePayloadSubstitutions(template, inputBody, navigat
|
|
|
5998
6168
|
continue;
|
|
5999
6169
|
const literal = JSON.stringify(value);
|
|
6000
6170
|
const fallback = facet.optional ? ` ?? ${literal}` : "";
|
|
6001
|
-
const
|
|
6002
|
-
|
|
6003
|
-
|
|
6171
|
+
const casing = facetCaseMethod(String(value), facet);
|
|
6172
|
+
const expression = facetPayloadExpression(facet, fallback);
|
|
6173
|
+
const cased = casing ? `String(${expression}).${casing}()` : undefined;
|
|
6174
|
+
const replacement = cased
|
|
6175
|
+
? `\${JSON.stringify(${cased})}`
|
|
6176
|
+
: typeof value === "number" || facet.optional
|
|
6177
|
+
? `\${JSON.stringify(${expression})}`
|
|
6178
|
+
: `"\${${expression}}"`;
|
|
6004
6179
|
for (const sep of [":", ": "]) {
|
|
6005
6180
|
if (typeof value === "number") {
|
|
6006
6181
|
const re = new RegExp(`${sep}${literal}(?![\\w.])`, "g");
|
|
@@ -6062,11 +6237,36 @@ function applyArrayFacetSplicePayloadSubstitutions(template, inputBody, navigate
|
|
|
6062
6237
|
}
|
|
6063
6238
|
const absentFacets = collectSiblingArrayFacets(value, path, siblingBodies, navigateToFacets);
|
|
6064
6239
|
const absentFields = new Set(absentFacets.map(({ facet }) => facet.field));
|
|
6065
|
-
const expression = renderFacetValueExpression(
|
|
6240
|
+
const expression = renderFacetValueExpression(insertAbsentFacetElements(value, absentFacets), navigateToFacets.map((facet) => absentFields.has(facet.field) ? { ...facet, optional: true } : facet));
|
|
6066
6241
|
result = replaceJsonArrayOccurrences(result, value, `\${JSON.stringify(${expression})}`);
|
|
6067
6242
|
}
|
|
6068
6243
|
return result;
|
|
6069
6244
|
}
|
|
6245
|
+
/**
|
|
6246
|
+
* Inserts each sibling-only element next to its nearest neighbour that `own`
|
|
6247
|
+
* (or an earlier insertion) also carries, so the emitted order follows the
|
|
6248
|
+
* capture it came from. With no shared neighbour it is appended.
|
|
6249
|
+
*/
|
|
6250
|
+
function insertAbsentFacetElements(own, absent) {
|
|
6251
|
+
const merged = [...own];
|
|
6252
|
+
const indexOfElement = (target) => merged.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(target));
|
|
6253
|
+
for (const { element, before, after } of absent) {
|
|
6254
|
+
const previous = [...before]
|
|
6255
|
+
.reverse()
|
|
6256
|
+
.map(indexOfElement)
|
|
6257
|
+
.find((index) => index >= 0);
|
|
6258
|
+
if (previous !== undefined) {
|
|
6259
|
+
merged.splice(previous + 1, 0, element);
|
|
6260
|
+
continue;
|
|
6261
|
+
}
|
|
6262
|
+
const next = after.map(indexOfElement).find((index) => index >= 0);
|
|
6263
|
+
if (next === undefined)
|
|
6264
|
+
merged.push(element);
|
|
6265
|
+
else
|
|
6266
|
+
merged.splice(next, 0, element);
|
|
6267
|
+
}
|
|
6268
|
+
return merged;
|
|
6269
|
+
}
|
|
6070
6270
|
/**
|
|
6071
6271
|
* Facet-bearing elements that same-endpoint sibling captures carry at the
|
|
6072
6272
|
* array `path` but `own` does not. A fold drill re-issues ONE representative
|
|
@@ -6084,14 +6284,19 @@ function collectSiblingArrayFacets(own, path, siblingBodies, facets) {
|
|
|
6084
6284
|
for (const { value, path: siblingPath } of walkArrayLeaves(body)) {
|
|
6085
6285
|
if (arrayPathKey(siblingPath) !== key)
|
|
6086
6286
|
continue;
|
|
6087
|
-
for (const element of value) {
|
|
6287
|
+
for (const [index, element] of value.entries()) {
|
|
6088
6288
|
if (typeof element !== "string")
|
|
6089
6289
|
continue;
|
|
6090
6290
|
const facet = matchArrayElementFacet(element, facets);
|
|
6091
6291
|
if (facet === undefined || present.has(facet.field))
|
|
6092
6292
|
continue;
|
|
6093
6293
|
present.add(facet.field);
|
|
6094
|
-
absent.push({
|
|
6294
|
+
absent.push({
|
|
6295
|
+
element,
|
|
6296
|
+
facet,
|
|
6297
|
+
before: value.slice(0, index),
|
|
6298
|
+
after: value.slice(index + 1),
|
|
6299
|
+
});
|
|
6095
6300
|
}
|
|
6096
6301
|
}
|
|
6097
6302
|
}
|
|
@@ -6495,6 +6700,16 @@ function parseUrlQueryParams(url) {
|
|
|
6495
6700
|
}
|
|
6496
6701
|
return params;
|
|
6497
6702
|
}
|
|
6703
|
+
/** Every query value of a URL, repeated keys included (`?f=a&f=b`), where
|
|
6704
|
+
* {@link parseUrlQueryParams} keeps only the last per key. */
|
|
6705
|
+
function parseUrlQueryValues(url) {
|
|
6706
|
+
try {
|
|
6707
|
+
return [...new URL(url).searchParams.values()];
|
|
6708
|
+
}
|
|
6709
|
+
catch {
|
|
6710
|
+
return [];
|
|
6711
|
+
}
|
|
6712
|
+
}
|
|
6498
6713
|
/** Parses a captured JSON request body into its string leaf values. Returns
|
|
6499
6714
|
* an empty array when the body is absent or not valid JSON. */
|
|
6500
6715
|
function parseBodyLeafValues(requestPostData) {
|
|
@@ -6587,9 +6802,11 @@ function bindsWithoutCollisionIn(bodies, value) {
|
|
|
6587
6802
|
* so facet reachability agrees with the token-boundary {@link matchFacetRecurrence}
|
|
6588
6803
|
* the splice passes use instead of counting a mere substring of a longer token.
|
|
6589
6804
|
*/
|
|
6590
|
-
function occursAtTokenBoundary(
|
|
6591
|
-
if (
|
|
6805
|
+
function occursAtTokenBoundary(rawText, rawValue, ignoreCase = false) {
|
|
6806
|
+
if (rawValue.length === 0)
|
|
6592
6807
|
return false;
|
|
6808
|
+
const text = ignoreCase ? rawText.toLowerCase() : rawText;
|
|
6809
|
+
const value = ignoreCase ? rawValue.toLowerCase() : rawValue;
|
|
6593
6810
|
const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
|
|
6594
6811
|
let from = 0;
|
|
6595
6812
|
while (true) {
|
|
@@ -6610,22 +6827,61 @@ function occursAtTokenBoundary(text, value) {
|
|
|
6610
6827
|
* deciding from the hash-derived literal alone.
|
|
6611
6828
|
*/
|
|
6612
6829
|
function resolveRecurringNavigateToFacets(declared, actions) {
|
|
6613
|
-
const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value) ||
|
|
6830
|
+
const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value, true) ||
|
|
6614
6831
|
(capture.requestPostData != null &&
|
|
6615
|
-
occursAtTokenBoundary(capture.requestPostData, value)) ||
|
|
6616
|
-
Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value)));
|
|
6832
|
+
occursAtTokenBoundary(capture.requestPostData, value, true)) ||
|
|
6833
|
+
Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value, true)));
|
|
6617
6834
|
const reachable = declared.filter(({ value }) => value.length > 0 && appearsAnywhere(value));
|
|
6618
|
-
const resolvedDeclared = declared.filter((
|
|
6619
|
-
const unreachable = declared.filter((facet) => !reachable.includes(facet));
|
|
6620
|
-
|
|
6835
|
+
const resolvedDeclared = declared.filter((facet) => facet.value.length > 0 && (facet.origin !== "step" || reachable.includes(facet)));
|
|
6836
|
+
const unreachable = declared.filter((facet) => facet.origin !== "step" && !reachable.includes(facet));
|
|
6837
|
+
// Every declared facet also owns the elements of its own kind in the code
|
|
6838
|
+
// vocabulary, whether or not its declared literal recurs: a literal that
|
|
6839
|
+
// recurs in one vocabulary (a hash slug, a quoted label) says nothing about
|
|
6840
|
+
// the same facet's element in another (`WW;filterType=ship`).
|
|
6841
|
+
const claimedLiterals = new Set();
|
|
6842
|
+
const pairsByField = new Map();
|
|
6843
|
+
const aliased = declared
|
|
6844
|
+
.flatMap((facet) => findKindKeyedLiterals(facet, actions).flatMap((literal) => {
|
|
6845
|
+
if (declared.some(({ value }) => value === literal))
|
|
6846
|
+
return [];
|
|
6847
|
+
if (claimedLiterals.has(literal))
|
|
6848
|
+
return [];
|
|
6849
|
+
claimedLiterals.add(literal);
|
|
6850
|
+
const known = [facet.value, ...(facet.literals ?? [])];
|
|
6851
|
+
// A case-only variant is already owned by the declared facet, whose
|
|
6852
|
+
// renderer re-cases any caller value to the captured element's casing.
|
|
6853
|
+
if (known.some((candidate) => candidate.toLowerCase() === literal.toLowerCase()))
|
|
6854
|
+
return [];
|
|
6855
|
+
// The traffic carries codes, not which declared value each belongs to;
|
|
6856
|
+
// pair only where the code abbreviates the value, never by position.
|
|
6857
|
+
const sameField = declared.filter(({ field, value }) => field === facet.field && value.length > 0);
|
|
6858
|
+
const abbreviated = sameField.filter(({ value }) => abbreviates(literal, value));
|
|
6859
|
+
const owners = abbreviated.length > 0 || sameField.length !== 1 ? abbreviated : sameField;
|
|
6860
|
+
if (owners.length > 0)
|
|
6861
|
+
pairsByField.set(facet.field, [
|
|
6862
|
+
...(pairsByField.get(facet.field) ?? []),
|
|
6863
|
+
...owners.map((owner) => [owner.value, literal]),
|
|
6864
|
+
]);
|
|
6865
|
+
return [{ ...facet, value: literal, literals: [literal], keyedByKind: true }];
|
|
6866
|
+
}))
|
|
6867
|
+
.map((alias) => {
|
|
6868
|
+
const pairs = pairsByField.get(alias.field) ?? [];
|
|
6869
|
+
return pairs.length === 0 ? alias : { ...alias, valueMap: new Map(pairs) };
|
|
6870
|
+
});
|
|
6871
|
+
if (unreachable.length === 0 && aliased.length === 0)
|
|
6621
6872
|
return resolvedDeclared;
|
|
6622
|
-
const
|
|
6873
|
+
const aliasedFields = new Set(aliased.map(({ field }) => field));
|
|
6874
|
+
const awaiting = unreachable.filter(({ field }) => !aliasedFields.has(field));
|
|
6875
|
+
const withAliases = [...resolvedDeclared, ...aliased];
|
|
6876
|
+
if (awaiting.length === 0)
|
|
6877
|
+
return withAliases;
|
|
6878
|
+
const correlated = correlateUnreachableNavigateToFacets(awaiting, actions, [...reachable, ...aliased].map(({ value }) => value));
|
|
6623
6879
|
if (correlated === null)
|
|
6624
|
-
return
|
|
6880
|
+
return withAliases;
|
|
6625
6881
|
const bodies = actions
|
|
6626
6882
|
.map(({ capture }) => capture.requestPostData)
|
|
6627
6883
|
.filter((body) => typeof body === "string" && body.length > 0);
|
|
6628
|
-
const extras =
|
|
6884
|
+
const extras = awaiting.flatMap((facet) => {
|
|
6629
6885
|
const diffed = correlated.get(facet.field);
|
|
6630
6886
|
if (diffed === undefined || diffed.length === 0)
|
|
6631
6887
|
return [];
|
|
@@ -6633,7 +6889,52 @@ function resolveRecurringNavigateToFacets(declared, actions) {
|
|
|
6633
6889
|
return [];
|
|
6634
6890
|
return bindsWithoutCollisionIn(bodies, diffed) ? [{ ...facet, value: diffed }] : [];
|
|
6635
6891
|
});
|
|
6636
|
-
return [...
|
|
6892
|
+
return [...withAliases, ...extras];
|
|
6893
|
+
}
|
|
6894
|
+
/**
|
|
6895
|
+
* The literals a facet takes in the code vocabulary: the captured request
|
|
6896
|
+
* element (query value or body string leaf) whose `<literal><delim>key=<kind>`
|
|
6897
|
+
* suffix names the facet's own kind. Derived from the traffic itself, so it
|
|
6898
|
+
* never depends on how many other facets or transitions the flow declared.
|
|
6899
|
+
* A kind carrying several distinct literals across captures yields each of
|
|
6900
|
+
* them, so every element of the kind is owned instead of none; the month
|
|
6901
|
+
* evidence narrows them only when it singles out some of them.
|
|
6902
|
+
*/
|
|
6903
|
+
function findKindKeyedLiterals(facet, actions) {
|
|
6904
|
+
const byTier = new Map();
|
|
6905
|
+
const sameMonth = new Set();
|
|
6906
|
+
const facetMonths = new Set([facet.value, ...(facet.literals ?? [])]
|
|
6907
|
+
.map((literal) => monthCodeOf(literal))
|
|
6908
|
+
.filter((code) => code !== undefined));
|
|
6909
|
+
const yearlessMonths = new Set([facet.value, ...(facet.literals ?? [])]
|
|
6910
|
+
.map((literal) => bareMonthNumberOf(literal))
|
|
6911
|
+
.filter((month) => month !== undefined));
|
|
6912
|
+
for (const { capture } of actions) {
|
|
6913
|
+
const values = [
|
|
6914
|
+
...parseUrlQueryValues(capture.url),
|
|
6915
|
+
...parseBodyLeafValues(capture.requestPostData),
|
|
6916
|
+
];
|
|
6917
|
+
for (const value of values) {
|
|
6918
|
+
const parsed = parseElementKind(value);
|
|
6919
|
+
if (parsed === undefined)
|
|
6920
|
+
continue;
|
|
6921
|
+
const tier = kindMatchTier(facet, parsed.kind);
|
|
6922
|
+
if (tier > 0)
|
|
6923
|
+
byTier.set(tier, (byTier.get(tier) ?? new Set()).add(parsed.literal));
|
|
6924
|
+
const month = monthCodeOf(parsed.literal);
|
|
6925
|
+
const isOwnLiteral = [facet.value, ...(facet.literals ?? [])].some((known) => known.toLowerCase() === parsed.literal.toLowerCase());
|
|
6926
|
+
if (month !== undefined &&
|
|
6927
|
+
!isOwnLiteral &&
|
|
6928
|
+
(facetMonths.has(month) || yearlessMonths.has(month.slice("YYYY-".length)))) {
|
|
6929
|
+
sameMonth.add(parsed.literal);
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6932
|
+
}
|
|
6933
|
+
const closest = [...byTier.entries()].sort(([a], [b]) => a - b)[0]?.[1];
|
|
6934
|
+
if (closest === undefined)
|
|
6935
|
+
return sameMonth.size === 1 ? [...sameMonth] : [];
|
|
6936
|
+
const sameMonthInKind = [...closest].filter((literal) => sameMonth.has(literal));
|
|
6937
|
+
return sameMonthInKind.length > 0 && closest.size > 1 ? sameMonthInKind : [...closest];
|
|
6637
6938
|
}
|
|
6638
6939
|
/**
|
|
6639
6940
|
* Resolves each zero-recurrence navigateTo facet (in flow order) to the real
|
|
@@ -6718,7 +7019,7 @@ flowSteps = [], vocabulary = undefined) {
|
|
|
6718
7019
|
// below can correlate a REST body's facet-packed string leaves against it.
|
|
6719
7020
|
const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
|
|
6720
7021
|
const payloadAccessorByValue = new Map();
|
|
6721
|
-
const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps);
|
|
7022
|
+
const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps, process.env);
|
|
6722
7023
|
const navigateToFacetOrder = resolveRecurringNavigateToFacets(declaredNavigateToFacets, actions);
|
|
6723
7024
|
const facetArrayPathKeys = collectFacetArrayPathKeys(actions.map((action) => parseJsonBodyOrNull(action.capture.requestPostData)), navigateToFacetOrder);
|
|
6724
7025
|
if (inputBody !== undefined && inputBody !== null) {
|
|
@@ -8081,17 +8382,171 @@ function matchArrayElementFacet(element, facets) {
|
|
|
8081
8382
|
*/
|
|
8082
8383
|
function matchFacetRecurrence(text, facets, options) {
|
|
8083
8384
|
const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8385
|
+
const parsedElement = parseElementKind(text);
|
|
8386
|
+
const elementKind = parsedElement?.kind;
|
|
8387
|
+
// A kind no declared facet names (an identifier format such as
|
|
8388
|
+
// `<slug>;filterId=urlFriendlyId`) cannot belong to another facet, so the
|
|
8389
|
+
// kind gate only protects elements some facet claims; a whole-literal
|
|
8390
|
+
// match there still binds the facet whose literal the element carries.
|
|
8391
|
+
const kindUnclaimed = elementKind !== undefined && !facets.some((candidate) => kindsMatch(candidate, elementKind));
|
|
8392
|
+
const pick = (ignoreCase) => facets.reduce((best, facet) => {
|
|
8393
|
+
const readFromElement = facet.keyedByKind === true && parsedElement?.literal === facet.value;
|
|
8394
|
+
const ownsUnclaimedKind = kindUnclaimed &&
|
|
8395
|
+
[facet.value, ...(facet.literals ?? [])].some((known) => known.toLowerCase() === parsedElement?.literal.toLowerCase());
|
|
8396
|
+
if (elementKind !== undefined &&
|
|
8397
|
+
isKindKeyed(facet) &&
|
|
8398
|
+
!readFromElement &&
|
|
8399
|
+
!ownsUnclaimedKind &&
|
|
8400
|
+
!kindsMatch(facet, elementKind)) {
|
|
8401
|
+
return best;
|
|
8402
|
+
}
|
|
8403
|
+
const literal = [facet.value, ...(facet.literals ?? [])]
|
|
8404
|
+
.filter((candidate) => {
|
|
8405
|
+
const same = (value) => ignoreCase ? value.toLowerCase() === candidate.toLowerCase() : value === candidate;
|
|
8406
|
+
return (candidate.length > 0 &&
|
|
8407
|
+
(same(text) ||
|
|
8408
|
+
(options.allowDelimiterSuffix &&
|
|
8409
|
+
same(text.slice(0, candidate.length)) &&
|
|
8410
|
+
!isAlnum(text[candidate.length]))));
|
|
8411
|
+
})
|
|
8412
|
+
.reduce((longest, candidate) => longest === undefined || candidate.length > longest.length ? candidate : longest, undefined);
|
|
8413
|
+
const matchedLiteral = literal;
|
|
8414
|
+
if (matchedLiteral === undefined)
|
|
8092
8415
|
return best;
|
|
8093
|
-
|
|
8416
|
+
const matched = matchedLiteral === facet.value ? facet : { ...facet, value: matchedLiteral };
|
|
8417
|
+
return best === undefined || matchedLiteral.length > best.value.length ? matched : best;
|
|
8094
8418
|
}, undefined);
|
|
8419
|
+
return pick(false) ?? pick(true);
|
|
8420
|
+
}
|
|
8421
|
+
/**
|
|
8422
|
+
* Splits an element of the grammar `<literal><delim><key>=<kind>` into its
|
|
8423
|
+
* literal and kind, or returns `undefined` when the element carries no kind
|
|
8424
|
+
* suffix. Structural only — no key or kind vocabulary is assumed.
|
|
8425
|
+
*/
|
|
8426
|
+
function parseElementKind(element) {
|
|
8427
|
+
const parsed = /^(.+)[;&|,]([^;&|,=]+)=([^;&|,=]+)$/.exec(element);
|
|
8428
|
+
return parsed ? { literal: parsed[1], kind: parsed[3] } : undefined;
|
|
8429
|
+
}
|
|
8430
|
+
function normalizeKind(kind) {
|
|
8431
|
+
return deriveFacetKind(kind).replace(/\s+/g, "").replace(/s$/, "");
|
|
8432
|
+
}
|
|
8433
|
+
const KIND_NOISE_WORDS = new Set(["facet", "filter", "field", "value", "option", "selection"]);
|
|
8434
|
+
/**
|
|
8435
|
+
* How closely a facet names an element's kind, 0 when it does not: 1 for the
|
|
8436
|
+
* declared field (qualifier words such as `facet` dropped) or a hash-named
|
|
8437
|
+
* hint equal to the kind, 2 when the field's trailing word is the kind
|
|
8438
|
+
* (`sailDate` names a `date` element), 3 when any other word of the field is.
|
|
8439
|
+
* A caller picking an element for the facet takes the closest tier, so a field
|
|
8440
|
+
* naming two kinds still resolves to the one it ends with.
|
|
8441
|
+
*/
|
|
8442
|
+
function kindMatchTier(facet, elementKind) {
|
|
8443
|
+
const element = normalizeKind(elementKind);
|
|
8444
|
+
if (element.length === 0 || facet.kind === undefined)
|
|
8445
|
+
return 0;
|
|
8446
|
+
const words = deriveFacetKind(facet.kind).split(" ");
|
|
8447
|
+
const meaningful = words.filter((word) => !KIND_NOISE_WORDS.has(word));
|
|
8448
|
+
const named = meaningful.length > 0 ? meaningful : words;
|
|
8449
|
+
if (normalizeKind(facet.kind) === element || normalizeKind(named.join(" ")) === element)
|
|
8450
|
+
return 1;
|
|
8451
|
+
if ((FACET_KIND_HINTS.get(facet) ?? []).some((hint) => normalizeKind(hint) === element))
|
|
8452
|
+
return 1;
|
|
8453
|
+
if (normalizeKind(named[named.length - 1] ?? "") === element)
|
|
8454
|
+
return 2;
|
|
8455
|
+
return named.some((word) => normalizeKind(word) === element) ? 3 : 0;
|
|
8456
|
+
}
|
|
8457
|
+
function kindsMatch(facet, elementKind) {
|
|
8458
|
+
return kindMatchTier(facet, elementKind) > 0;
|
|
8459
|
+
}
|
|
8460
|
+
const MONTH_NAMES = [
|
|
8461
|
+
"january",
|
|
8462
|
+
"february",
|
|
8463
|
+
"march",
|
|
8464
|
+
"april",
|
|
8465
|
+
"may",
|
|
8466
|
+
"june",
|
|
8467
|
+
"july",
|
|
8468
|
+
"august",
|
|
8469
|
+
"september",
|
|
8470
|
+
"october",
|
|
8471
|
+
"november",
|
|
8472
|
+
"december",
|
|
8473
|
+
];
|
|
8474
|
+
/**
|
|
8475
|
+
* Normalizes a month written as a slug or label (`december-2026`, `Dec 2026`,
|
|
8476
|
+
* `2026-12`) to `YYYY-MM`, so the same month matches across vocabularies.
|
|
8477
|
+
*/
|
|
8478
|
+
function monthCodeOf(literal) {
|
|
8479
|
+
const text = literal.trim();
|
|
8480
|
+
const iso = /^(\d{4})[-/](0[1-9]|1[0-2])$/.exec(text);
|
|
8481
|
+
if (iso)
|
|
8482
|
+
return `${iso[1]}-${iso[2]}`;
|
|
8483
|
+
const named = /^([A-Za-z]{3,9})[\s_/-]+(\d{4})$/.exec(text) ?? /^(\d{4})[\s_/-]+([A-Za-z]{3,9})$/.exec(text);
|
|
8484
|
+
if (!named)
|
|
8485
|
+
return undefined;
|
|
8486
|
+
const yearFirst = /^\d{4}$/.test(named[1]);
|
|
8487
|
+
const name = (yearFirst ? named[2] : named[1]).toLowerCase();
|
|
8488
|
+
const year = yearFirst ? named[1] : named[2];
|
|
8489
|
+
const index = MONTH_NAMES.findIndex((month) => month === name || (name.length === 3 && month.startsWith(name)));
|
|
8490
|
+
return index === -1 ? undefined : `${year}-${String(index + 1).padStart(2, "0")}`;
|
|
8491
|
+
}
|
|
8492
|
+
/** Month number (`12`) of a month written without a year (`december`, `Dec`),
|
|
8493
|
+
* so a facet whose literal names only the month still matches a dated element. */
|
|
8494
|
+
function bareMonthNumberOf(literal) {
|
|
8495
|
+
const name = literal.trim().toLowerCase();
|
|
8496
|
+
const index = MONTH_NAMES.findIndex((month) => month === name || (name.length === 3 && month.startsWith(name)));
|
|
8497
|
+
return index === -1 ? undefined : String(index + 1).padStart(2, "0");
|
|
8498
|
+
}
|
|
8499
|
+
function isKindKeyed(facet) {
|
|
8500
|
+
return (facet.kind !== undefined &&
|
|
8501
|
+
facet.kind.length > 0 &&
|
|
8502
|
+
(facet.origin === "step" || facet.keyedByKind === true));
|
|
8503
|
+
}
|
|
8504
|
+
/**
|
|
8505
|
+
* Renders the payload value for a matched facet in the casing the captured
|
|
8506
|
+
* element carried, so a caller sending the declared literal reproduces the
|
|
8507
|
+
* captured request exactly. An exact-case or mixed-case occurrence is emitted
|
|
8508
|
+
* untouched.
|
|
8509
|
+
*/
|
|
8510
|
+
function facetCaseMethod(captured, facet) {
|
|
8511
|
+
const prefix = captured.slice(0, facet.value.length);
|
|
8512
|
+
if (prefix === facet.value)
|
|
8513
|
+
return undefined;
|
|
8514
|
+
if (prefix === facet.value.toUpperCase())
|
|
8515
|
+
return "toUpperCase";
|
|
8516
|
+
if (prefix === facet.value.toLowerCase())
|
|
8517
|
+
return "toLowerCase";
|
|
8518
|
+
return undefined;
|
|
8519
|
+
}
|
|
8520
|
+
/** True when `code`'s letters occur in order within `value`, starting at its first letter (`LIS` in `Lisbon`). */
|
|
8521
|
+
function abbreviates(code, value) {
|
|
8522
|
+
const letters = code.toLowerCase().replace(/[^a-z]/g, "");
|
|
8523
|
+
const target = value.toLowerCase();
|
|
8524
|
+
if (letters.length === 0 || letters[0] !== target[0])
|
|
8525
|
+
return false;
|
|
8526
|
+
let from = 0;
|
|
8527
|
+
for (const letter of letters) {
|
|
8528
|
+
from = target.indexOf(letter, from) + 1;
|
|
8529
|
+
if (from === 0)
|
|
8530
|
+
return false;
|
|
8531
|
+
}
|
|
8532
|
+
return true;
|
|
8533
|
+
}
|
|
8534
|
+
/**
|
|
8535
|
+
* JS expression for the value a facet's payload field carries in the code
|
|
8536
|
+
* vocabulary: a kind-keyed alias translates the caller's declared literal
|
|
8537
|
+
* through its `valueMap`, passing an already-coded value through untouched.
|
|
8538
|
+
*/
|
|
8539
|
+
function facetPayloadExpression(facet, fallback = "") {
|
|
8540
|
+
const raw = `payload.${facet.field}${fallback}`;
|
|
8541
|
+
if (!facet.valueMap || facet.valueMap.size === 0)
|
|
8542
|
+
return raw;
|
|
8543
|
+
const entries = JSON.stringify([...facet.valueMap.entries()]);
|
|
8544
|
+
return `(new Map(${entries}).get(String(payload.${facet.field})) ?? ${raw})`;
|
|
8545
|
+
}
|
|
8546
|
+
function renderFacetPayloadValue(captured, facet) {
|
|
8547
|
+
const method = facetCaseMethod(captured, facet);
|
|
8548
|
+
const expression = facetPayloadExpression(facet);
|
|
8549
|
+
return method ? `\${String(${expression}).${method}()}` : `\${${expression}}`;
|
|
8095
8550
|
}
|
|
8096
8551
|
/**
|
|
8097
8552
|
* Binds a top-level GraphQL variable whose scalar value (or, for a string, any
|
|
@@ -8104,8 +8559,12 @@ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
|
|
|
8104
8559
|
return null;
|
|
8105
8560
|
if (typeof value === "string" || typeof value === "number") {
|
|
8106
8561
|
const facet = matchFacetRecurrence(String(value), facets, { allowDelimiterSuffix: false });
|
|
8107
|
-
if (facet !== undefined)
|
|
8108
|
-
|
|
8562
|
+
if (facet !== undefined) {
|
|
8563
|
+
const rendered = renderFacetPayloadValue(String(value), facet);
|
|
8564
|
+
return rendered === `\${${facetPayloadExpression(facet)}}`
|
|
8565
|
+
? facetPayloadExpression(facet)
|
|
8566
|
+
: `\`${rendered}\``;
|
|
8567
|
+
}
|
|
8109
8568
|
}
|
|
8110
8569
|
if (typeof value !== "string")
|
|
8111
8570
|
return null;
|
|
@@ -8127,7 +8586,7 @@ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
|
|
|
8127
8586
|
continue;
|
|
8128
8587
|
}
|
|
8129
8588
|
bound = true;
|
|
8130
|
-
const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}
|
|
8589
|
+
const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}${renderFacetPayloadValue(segment.slice(colonIndex + 1), facet)}${escapeForTemplateLiteral(trailingDelimiter)}\``;
|
|
8131
8590
|
// An absent optional facet drops its own segment (and delimiter) rather
|
|
8132
8591
|
// than rendering the string `undefined` into the packed value.
|
|
8133
8592
|
if (facet.optional) {
|
|
@@ -8170,7 +8629,7 @@ function spliceFacetsIntoArrayVariable(value, facets, renderElement = (element)
|
|
|
8170
8629
|
return renderElement(element);
|
|
8171
8630
|
matchCount++;
|
|
8172
8631
|
const suffix = element.slice(matched.value.length);
|
|
8173
|
-
const elementLiteral =
|
|
8632
|
+
const elementLiteral = `\`${renderFacetPayloadValue(element, matched)}${escapeForTemplateLiteral(suffix)}\``;
|
|
8174
8633
|
return matched.optional
|
|
8175
8634
|
? `...(payload.${matched.field} ? [${elementLiteral}] : [])`
|
|
8176
8635
|
: elementLiteral;
|
|
@@ -8602,29 +9061,36 @@ function collectRequestStringValues(capture, allCaptures) {
|
|
|
8602
9061
|
*/
|
|
8603
9062
|
function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, siblingPaths) {
|
|
8604
9063
|
const allBodies = [parsedBody, ...otherBodies];
|
|
8605
|
-
|
|
8606
|
-
|
|
9064
|
+
const siblingReaders = [
|
|
9065
|
+
...[...siblingPaths].map((siblingPath) => {
|
|
9066
|
+
const pathSegments = siblingPath.split(".");
|
|
9067
|
+
return (body) => {
|
|
9068
|
+
const siblingRaw = readComparableBodyLeaf(parsedBody, body, pathSegments);
|
|
9069
|
+
return siblingRaw === undefined ? undefined : String(siblingRaw);
|
|
9070
|
+
};
|
|
9071
|
+
}),
|
|
9072
|
+
...selfDescribingArrayPaths(parsedBody).map((arrayPath) => (body) => {
|
|
9073
|
+
const array = readValueAtPath(body, arrayPath);
|
|
9074
|
+
return Array.isArray(array) ? identityShape(array) : ABSENT_ARRAY_SHAPE;
|
|
9075
|
+
}),
|
|
9076
|
+
];
|
|
9077
|
+
return siblingReaders.some((readSibling) => {
|
|
8607
9078
|
const pairs = [];
|
|
8608
|
-
let sawUnexplainableField = false;
|
|
8609
9079
|
for (const body of allBodies) {
|
|
8610
9080
|
const fieldRaw = readComparableBodyLeaf(parsedBody, body, path);
|
|
8611
9081
|
if (fieldRaw === undefined)
|
|
8612
9082
|
continue;
|
|
8613
|
-
const
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
}
|
|
8621
|
-
pairs.push({ siblingValue: String(siblingRaw), fieldValue: String(fieldRaw) });
|
|
9083
|
+
const siblingValue = readSibling(body);
|
|
9084
|
+
// This body has a value for the field we're trying to explain but no
|
|
9085
|
+
// value for the candidate sibling — the sibling can't vouch for it, so
|
|
9086
|
+
// don't let a function fitted over the OTHER bodies paper over it.
|
|
9087
|
+
if (siblingValue === undefined)
|
|
9088
|
+
return false;
|
|
9089
|
+
pairs.push({ siblingValue, fieldValue: String(fieldRaw) });
|
|
8622
9090
|
}
|
|
8623
|
-
if (sawUnexplainableField)
|
|
8624
|
-
continue;
|
|
8625
9091
|
const distinctSiblingValues = new Set(pairs.map((p) => p.siblingValue));
|
|
8626
9092
|
if (distinctSiblingValues.size < 2)
|
|
8627
|
-
|
|
9093
|
+
return false;
|
|
8628
9094
|
const siblingToField = new Map();
|
|
8629
9095
|
const isFunction = pairs.every(({ siblingValue, fieldValue }) => {
|
|
8630
9096
|
const existing = siblingToField.get(siblingValue);
|
|
@@ -8634,10 +9100,8 @@ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, sibl
|
|
|
8634
9100
|
}
|
|
8635
9101
|
return existing === fieldValue;
|
|
8636
9102
|
});
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
}
|
|
8640
|
-
return false;
|
|
9103
|
+
return isFunction && siblingToField.size === distinctSiblingValues.size;
|
|
9104
|
+
});
|
|
8641
9105
|
}
|
|
8642
9106
|
/**
|
|
8643
9107
|
* Finds every query param or JSON body leaf on `capture`'s own request that
|
|
@@ -9709,6 +10173,32 @@ function arrayElementIdentity(element) {
|
|
|
9709
10173
|
return undefined;
|
|
9710
10174
|
return ARRAY_ELEMENT_IDENTITY.exec(element)?.[1];
|
|
9711
10175
|
}
|
|
10176
|
+
/** The `<delimiter><name>` head of an {@link arrayElementIdentity} (its tag
|
|
10177
|
+
* dropped), the part that says what ROLE an element plays in its array. */
|
|
10178
|
+
function identityName(identity) {
|
|
10179
|
+
return identity?.slice(0, identity.indexOf("="));
|
|
10180
|
+
}
|
|
10181
|
+
/** Order-insensitive fingerprint of the identity suffixes an array carries —
|
|
10182
|
+
* two arrays with equal fingerprints are the same shape (reordered, or with
|
|
10183
|
+
* only values changed), anything else is a different shape or length. */
|
|
10184
|
+
function identityShape(elements) {
|
|
10185
|
+
return elements
|
|
10186
|
+
.map((element) => arrayElementIdentity(element) ?? "")
|
|
10187
|
+
.sort()
|
|
10188
|
+
.join("\u0000");
|
|
10189
|
+
}
|
|
10190
|
+
const ABSENT_ARRAY_SHAPE = "\u0001absent";
|
|
10191
|
+
/** Paths of every array in `body` that lists self-describing string elements
|
|
10192
|
+
* (see {@link arrayElementIdentity}). Which identity suffixes such an array
|
|
10193
|
+
* carries is itself a property of the request — a sibling field that differs
|
|
10194
|
+
* only because the array took a different shape is explained by that shape. */
|
|
10195
|
+
function selfDescribingArrayPaths(body, path = []) {
|
|
10196
|
+
if (body === null || typeof body !== "object")
|
|
10197
|
+
return [];
|
|
10198
|
+
const children = Object.entries(body).flatMap(([key, child]) => selfDescribingArrayPaths(child, [...path, key]));
|
|
10199
|
+
const isSelfDescribing = Array.isArray(body) && body.some((element) => arrayElementIdentity(element) !== undefined);
|
|
10200
|
+
return isSelfDescribing ? [[...path], ...children] : children;
|
|
10201
|
+
}
|
|
9712
10202
|
/**
|
|
9713
10203
|
* Index of the innermost path segment that indexes into an array (the array
|
|
9714
10204
|
* holding the leaf, or one holding an object element that carries it), or -1.
|
|
@@ -9728,7 +10218,13 @@ function innermostArrayIndexSegment(body, path) {
|
|
|
9728
10218
|
* field of an object element) is matched by its identity suffix within the same
|
|
9729
10219
|
* array, never by index, so differently shaped arrays are not mistaken for one
|
|
9730
10220
|
* array with a varying slot; an identity the other body lacks yields
|
|
9731
|
-
* `undefined`.
|
|
10221
|
+
* `undefined`. An identity shared by several elements of either array (e.g.
|
|
10222
|
+
* repeated `;tag=x` suffixes) is ambiguous and is paired only by exact value.
|
|
10223
|
+
* When the arrays differ in shape or length, an identity is trusted only if
|
|
10224
|
+
* its `;name=` role is also unique in both: a tag repeated by position
|
|
10225
|
+
* (`;tagId=t0`, `;tagId=t1`) in two unrelated arrays says nothing about the
|
|
10226
|
+
* elements being the same one.
|
|
10227
|
+
* A plain element of such an array is matched by value, since its
|
|
9732
10228
|
* slot says nothing about it once the arrays' shapes differ. Every other path
|
|
9733
10229
|
* defers to {@link readValueAtPath}.
|
|
9734
10230
|
*/
|
|
@@ -9755,7 +10251,19 @@ function readComparableBodyLeaf(body, otherBody, path) {
|
|
|
9755
10251
|
return leaf;
|
|
9756
10252
|
if (identity === undefined)
|
|
9757
10253
|
return undefined;
|
|
9758
|
-
|
|
10254
|
+
const ownCounterparts = ownArray.map((element) => readValueAtPath(element, elementSubpath));
|
|
10255
|
+
const sharesIdentity = (candidate) => arrayElementIdentity(candidate) === identity;
|
|
10256
|
+
const ownSharing = ownCounterparts.filter(sharesIdentity);
|
|
10257
|
+
const otherSharing = counterparts.filter(sharesIdentity);
|
|
10258
|
+
if (ownSharing.length !== 1 || otherSharing.length !== 1)
|
|
10259
|
+
return undefined;
|
|
10260
|
+
const name = identityName(identity);
|
|
10261
|
+
const nameIsUnique = (values) => values.filter((value) => identityName(arrayElementIdentity(value)) === name).length === 1;
|
|
10262
|
+
const isSameShape = identityShape(ownCounterparts) === identityShape(counterparts);
|
|
10263
|
+
if (!isSameShape && !(nameIsUnique(ownCounterparts) && nameIsUnique(counterparts))) {
|
|
10264
|
+
return undefined;
|
|
10265
|
+
}
|
|
10266
|
+
return otherSharing[0];
|
|
9759
10267
|
}
|
|
9760
10268
|
/**
|
|
9761
10269
|
* Turns a flow author's plain dot-separated {@link FoldReturnSpec.resultsPath}
|
|
@@ -11339,10 +11847,48 @@ function declaredSchemaCoversAccessorShape(declared, shape) {
|
|
|
11339
11847
|
const childShapes = [...(shape.element ? [shape.element] : []), ...shape.keys.values()];
|
|
11340
11848
|
return ([...shape.keys.keys()].every((key) => new RegExp(`(?:^|[\\s{,])"?${key.replace(/[$]/g, "\\$&")}"?\\s*:`).test(declared)) && childShapes.every((child) => declaredSchemaCoversAccessorShape(declared, child)));
|
|
11341
11849
|
}
|
|
11850
|
+
const applicantContactFieldNames = new Set([
|
|
11851
|
+
"FirstName",
|
|
11852
|
+
"LastName",
|
|
11853
|
+
"Phone",
|
|
11854
|
+
"AddressLine",
|
|
11855
|
+
"City",
|
|
11856
|
+
"State",
|
|
11857
|
+
"PostalCode",
|
|
11858
|
+
"Country",
|
|
11859
|
+
"County",
|
|
11860
|
+
"Resume",
|
|
11861
|
+
"ResumeContentType",
|
|
11862
|
+
"ResumeFilename",
|
|
11863
|
+
"ResumeBase64",
|
|
11864
|
+
]);
|
|
11865
|
+
const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
|
|
11866
|
+
const applicantContactEvidenceFieldNamesLower = new Set([...applicantContactFieldNames, ...baseContractFieldNames].map((name) => name.toLowerCase()));
|
|
11867
|
+
/**
|
|
11868
|
+
* Single source of truth for "the contract extends ApplicantContactSchema", so
|
|
11869
|
+
* the contract and browser-flow emitters can't disagree about whether the
|
|
11870
|
+
* payload carries Resume fields.
|
|
11871
|
+
*/
|
|
11872
|
+
function contractUsesApplicantContactSchema(opts) {
|
|
11873
|
+
const { inputBody, isSubmissionFlow, browserFlowOnly, payloadFieldNames } = opts;
|
|
11874
|
+
// A browser-flow-only submission flow has no HTTP body of its own: whatever
|
|
11875
|
+
// inputBody HTTP analysis captured describes a path the plugin won't emit,
|
|
11876
|
+
// so it is not evidence about the real caller's shape.
|
|
11877
|
+
if (isSubmissionFlow === true && browserFlowOnly === true)
|
|
11878
|
+
return true;
|
|
11879
|
+
// A body-less submission flow has no inputBody to inspect; the payload fields
|
|
11880
|
+
// its steps splice are the only evidence, whether or not it also has an HTTP
|
|
11881
|
+
// path (the browser-flow emitter can't know which the contract will take).
|
|
11882
|
+
if (inputBody != null) {
|
|
11883
|
+
return [...walkAllPrimitiveLeaves(inputBody)].some(({ path }) => path.some((segment) => applicantContactEvidenceFieldNamesLower.has(segment.toLowerCase())));
|
|
11884
|
+
}
|
|
11885
|
+
return (isSubmissionFlow === true &&
|
|
11886
|
+
[...(payloadFieldNames ?? [])].some((name) => applicantContactEvidenceFieldNamesLower.has(name.toLowerCase())));
|
|
11887
|
+
}
|
|
11342
11888
|
/** Generates a complete contract.ts source string for a plugin — exported so
|
|
11343
11889
|
* unit tests can drive the emitter directly without spawning the CLI. */
|
|
11344
11890
|
function emitContractTs(opts) {
|
|
11345
|
-
const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), flowSteps: contractFlowSteps = [], headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
|
|
11891
|
+
const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, createsAccount = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), flowSteps: contractFlowSteps = [], headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
|
|
11346
11892
|
const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
|
|
11347
11893
|
/** Single source of truth for every GraphQL-emission decision below
|
|
11348
11894
|
* (clientImport, gqlCacheBlock, queryConst, and both executeHttpBody
|
|
@@ -11419,26 +11965,13 @@ function emitContractTs(opts) {
|
|
|
11419
11965
|
// one of ApplicantContactSchema's own field names (case-insensitively,
|
|
11420
11966
|
// checked at any depth via walkAllPrimitiveLeaves) before the template
|
|
11421
11967
|
// applies (see recon-generate-payload-schema-mismatch.md).
|
|
11422
|
-
const
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
"PostalCode",
|
|
11430
|
-
"Country",
|
|
11431
|
-
"County",
|
|
11432
|
-
"Resume",
|
|
11433
|
-
"ResumeContentType",
|
|
11434
|
-
"ResumeFilename",
|
|
11435
|
-
"ResumeBase64",
|
|
11436
|
-
]);
|
|
11437
|
-
const baseContractFieldNames = new Set(["Email", "ClickUrl", "Answers"]);
|
|
11438
|
-
const applicantContactEvidenceFieldNamesLower = new Set([...applicantContactFieldNames, ...baseContractFieldNames].map((name) => name.toLowerCase()));
|
|
11439
|
-
const usesApplicantContactSchema = inputBody != null &&
|
|
11440
|
-
[...walkAllPrimitiveLeaves(inputBody)].some(({ path }) => path.some((segment) => applicantContactEvidenceFieldNamesLower.has(segment.toLowerCase())));
|
|
11441
|
-
const isReservedByApplicantContactSchema = (name) => usesApplicantContactSchema && applicantContactFieldNames.has(name);
|
|
11968
|
+
const usesApplicantContactSchema = contractUsesApplicantContactSchema({
|
|
11969
|
+
inputBody,
|
|
11970
|
+
isSubmissionFlow,
|
|
11971
|
+
browserFlowOnly: omitExecuteHttp,
|
|
11972
|
+
payloadFieldNames,
|
|
11973
|
+
});
|
|
11974
|
+
const isReservedByApplicantContactSchema = (name) => usesApplicantContactSchema && applicantContactFieldNames.has(canonicalPayloadField(name));
|
|
11442
11975
|
// A missing inputBody, or one with no ApplicantContactSchema evidence,
|
|
11443
11976
|
// means this is not a job-application submission flow. The former keeps
|
|
11444
11977
|
// its own read-flow `{ query }` contract untouched; the latter (a body WAS
|
|
@@ -11447,7 +11980,7 @@ function emitContractTs(opts) {
|
|
|
11447
11980
|
// the other sources unchanged.
|
|
11448
11981
|
const basePayloadSchemaExpr = usesApplicantContactSchema
|
|
11449
11982
|
? `ApplicantContactSchema`
|
|
11450
|
-
: inputBody
|
|
11983
|
+
: inputBody || (omitExecuteHttp && isSubmissionFlow)
|
|
11451
11984
|
? `z.object({})`
|
|
11452
11985
|
: `z.object({\n query: z.string().min(1),\n})`;
|
|
11453
11986
|
// Only the single-endpoint read path (a real primary operation, no
|
|
@@ -11563,11 +12096,16 @@ function emitContractTs(opts) {
|
|
|
11563
12096
|
// as required strings (z.email() for Email per the repo's z.string().email()→
|
|
11564
12097
|
// z.email() migration) so those references typecheck in the generated flow.
|
|
11565
12098
|
if (payloadFieldNames) {
|
|
11566
|
-
|
|
12099
|
+
// The browser-flow-only emit canonicalises its own splices already; this
|
|
12100
|
+
// covers a caller handing over vocabulary-row names directly, so one never
|
|
12101
|
+
// becomes a required key beside the base schema's own equivalent.
|
|
12102
|
+
const canonicaliseNames = usesApplicantContactSchema;
|
|
12103
|
+
for (const rawName of [...payloadFieldNames].sort()) {
|
|
12104
|
+
const name = canonicaliseNames ? canonicalPayloadField(rawName) : rawName;
|
|
11567
12105
|
if (isReservedByApplicantContactSchema(name))
|
|
11568
12106
|
continue;
|
|
11569
12107
|
const zod = name === "Email" ? "z.email()" : "z.string()";
|
|
11570
|
-
const suffix = optionalPayloadFieldNames.has(
|
|
12108
|
+
const suffix = optionalPayloadFieldNames.has(rawName) ? ".optional()" : "";
|
|
11571
12109
|
addExtendField(name, ` ${name}: ${zod}${suffix},`);
|
|
11572
12110
|
}
|
|
11573
12111
|
}
|
|
@@ -11778,7 +12316,12 @@ function emitContractTs(opts) {
|
|
|
11778
12316
|
extendFields.set(fieldName, ` ${key}: ${zod}${optional ? ".optional()" : ""},`);
|
|
11779
12317
|
}
|
|
11780
12318
|
const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
|
|
11781
|
-
|
|
12319
|
+
// zod strips unknown keys, so without composing it a job-application
|
|
12320
|
+
// contract drops the TrackingUrl the engine's fireTrackingClick reads. merge
|
|
12321
|
+
// rather than a second extend keeps the schema to a single `.extend()`.
|
|
12322
|
+
const composesJobTracking = usesApplicantContactSchema && !extendFields.has("TrackingUrl");
|
|
12323
|
+
const trackingMerge = composesJobTracking ? ".merge(JobTrackingSchema)" : "";
|
|
12324
|
+
const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}${trackingMerge}`;
|
|
11782
12325
|
// basePayloadSchemaExpr's own Answers field always wraps in
|
|
11783
12326
|
// multipartJsonObject() for job-application submission flows
|
|
11784
12327
|
// (usesApplicantContactSchema); multipartBoolean() is only imported when a
|
|
@@ -11798,6 +12341,9 @@ function emitContractTs(opts) {
|
|
|
11798
12341
|
// ApplicantContactSchema backs the job-application submission-flow payload
|
|
11799
12342
|
// schema (see basePayloadSchemaExpr above); only referenced when
|
|
11800
12343
|
// usesApplicantContactSchema is true.
|
|
12344
|
+
const jobTrackingImport = composesJobTracking
|
|
12345
|
+
? `import { JobTrackingSchema } from "${ENGINE_PKG}/lib/job-tracking";\n`
|
|
12346
|
+
: "";
|
|
11801
12347
|
const applicantContactImport = usesApplicantContactSchema
|
|
11802
12348
|
? `import { ApplicantContactSchema } from "${ENGINE_PKG}/lib/applicant-payload";\n`
|
|
11803
12349
|
: "";
|
|
@@ -11878,7 +12424,9 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
11878
12424
|
// the same captured request-variables object the signal was itself
|
|
11879
12425
|
// detected from (see paginationOperationIdentity above) — the default
|
|
11880
12426
|
// `{ q: payload.query }` REST body has no skip/count container to advance.
|
|
11881
|
-
|
|
12427
|
+
// A single-primary flow compiles no action steps, so its captured traffic
|
|
12428
|
+
// is the only place a declared facet's kind-suffixed element can be found.
|
|
12429
|
+
const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps, process.env), actionSteps.length > 0 ? actionSteps : allCaptures.map((capture) => ({ capture })));
|
|
11882
12430
|
const gqlVariablesExpr = gqlOperationName
|
|
11883
12431
|
? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
|
|
11884
12432
|
: paginationSignal
|
|
@@ -12266,6 +12814,19 @@ const limiter = new Bottleneck({ minTime: ${minTime} });
|
|
|
12266
12814
|
${executeHttpBody}
|
|
12267
12815
|
},
|
|
12268
12816
|
`;
|
|
12817
|
+
// Applicant contracts enter on payload.ClickUrl, so a browser-flow-only
|
|
12818
|
+
// execute has nothing left to read from the context.
|
|
12819
|
+
const executeOmitsContext = omitExecuteHttp && usesApplicantContactSchema;
|
|
12820
|
+
const executeContextParam = executeOmitsContext ? "" : `,\n context: SitePluginContext`;
|
|
12821
|
+
const sitePluginImports = executeOmitsContext
|
|
12822
|
+
? "SitePlugin, SitePluginResult"
|
|
12823
|
+
: "SitePlugin, SitePluginContext, SitePluginResult";
|
|
12824
|
+
const executeDocComment = omitExecuteHttp
|
|
12825
|
+
? ""
|
|
12826
|
+
: ` /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */\n`;
|
|
12827
|
+
const maxAttemptsLiteral = createsAccount
|
|
12828
|
+
? `\n // A retry would re-register the same account the first attempt created.\n maxAttempts: 1,`
|
|
12829
|
+
: "";
|
|
12269
12830
|
const pluginDocComment = omitExecuteHttp
|
|
12270
12831
|
? `/**
|
|
12271
12832
|
* Plugin for ${siteId}. Browser-flow-only: the captured multi-step submission
|
|
@@ -12283,9 +12844,9 @@ ${executeHttpBody}
|
|
|
12283
12844
|
|
|
12284
12845
|
${bottleneckImport}import { z } from "zod/v4";
|
|
12285
12846
|
|
|
12286
|
-
${fixtureImport}${applicantContactImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
|
|
12847
|
+
${fixtureImport}${applicantContactImport}${jobTrackingImport}${caseInsensitiveHeadersImport}${mergeFoldedPrimaryBodiesImport}${multipartBoolImport}${clientImport}
|
|
12287
12848
|
import type { BrowserSession } from "${ENGINE_PKG}/scraper/session";
|
|
12288
|
-
import type {
|
|
12849
|
+
import type { ${sitePluginImports} } from "${ENGINE_PKG}/site-plugin";
|
|
12289
12850
|
import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
|
|
12290
12851
|
${baseHeadersBlock}${limiterBlock}
|
|
12291
12852
|
export const ${pascal}ResponseSchema = ${responseSchemaExpr};
|
|
@@ -12315,14 +12876,12 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
|
|
|
12315
12876
|
// since the multipart wire format is what makes that field's
|
|
12316
12877
|
// JSON-stringified encoding parseable.
|
|
12317
12878
|
`
|
|
12318
|
-
: ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}${browserFallbackGateLiteral}
|
|
12879
|
+
: ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}${browserFallbackGateLiteral}${maxAttemptsLiteral}
|
|
12319
12880
|
},
|
|
12320
12881
|
${executeHttpMethodBlock}
|
|
12321
|
-
|
|
12322
|
-
async execute(
|
|
12882
|
+
${executeDocComment} async execute(
|
|
12323
12883
|
payload: ${pascal}Payload,
|
|
12324
|
-
session: BrowserSession
|
|
12325
|
-
context: SitePluginContext
|
|
12884
|
+
session: BrowserSession${executeContextParam}
|
|
12326
12885
|
): Promise<SitePluginResult<${pascal}Response>> {
|
|
12327
12886
|
const raw = await run${pascal}BrowserFlow(session.stagehand, ${usesApplicantContactSchema ? "payload.ClickUrl" : "context.baseUrl"}, payload, session.sessionProxy ?? null);
|
|
12328
12887
|
return { data: raw as ${pascal}Response };
|
|
@@ -12367,6 +12926,108 @@ function assertRequiredUrlFieldsReferenced(contractCode, browserFlowCode) {
|
|
|
12367
12926
|
throw new Error(`recon-generate: required URL field(s) ${unreferenced.join(", ")} declared on the payload schema ` +
|
|
12368
12927
|
`but never referenced by the emitted contract or browser flow — the flow would enter on the wrong page`);
|
|
12369
12928
|
}
|
|
12929
|
+
/**
|
|
12930
|
+
* Blank comments and plain string literals so a `payload.<name>` mention inside
|
|
12931
|
+
* prose or an unrelated string is not mistaken for a read. Template-literal
|
|
12932
|
+
* text is blanked but its `${…}` expressions are kept, because the browser flow
|
|
12933
|
+
* splices fields as `${payload.X}`.
|
|
12934
|
+
*/
|
|
12935
|
+
function stripNonCodeText(code) {
|
|
12936
|
+
const out = [];
|
|
12937
|
+
// Non-negative: brace depth at which a template literal's text resumes; negative: -(depth+1) of an open `${`.
|
|
12938
|
+
const templateDepths = [];
|
|
12939
|
+
let depth = 0;
|
|
12940
|
+
let i = 0;
|
|
12941
|
+
while (i < code.length) {
|
|
12942
|
+
const ch = code[i];
|
|
12943
|
+
const next = code[i + 1];
|
|
12944
|
+
const inTemplateText = templateDepths.length > 0 && templateDepths[templateDepths.length - 1] === depth;
|
|
12945
|
+
if (inTemplateText) {
|
|
12946
|
+
if (ch === "\\")
|
|
12947
|
+
i += 2;
|
|
12948
|
+
else if (ch === "`") {
|
|
12949
|
+
templateDepths.pop();
|
|
12950
|
+
i++;
|
|
12951
|
+
}
|
|
12952
|
+
else if (ch === "$" && next === "{") {
|
|
12953
|
+
templateDepths.push(-(depth + 1));
|
|
12954
|
+
depth++;
|
|
12955
|
+
out.push("{");
|
|
12956
|
+
i += 2;
|
|
12957
|
+
}
|
|
12958
|
+
else
|
|
12959
|
+
i++;
|
|
12960
|
+
continue;
|
|
12961
|
+
}
|
|
12962
|
+
if (ch === "/" && next === "/") {
|
|
12963
|
+
while (i < code.length && code[i] !== "\n")
|
|
12964
|
+
i++;
|
|
12965
|
+
}
|
|
12966
|
+
else if (ch === "/" && next === "*") {
|
|
12967
|
+
const end = code.indexOf("*/", i + 2);
|
|
12968
|
+
i = end === -1 ? code.length : end + 2;
|
|
12969
|
+
}
|
|
12970
|
+
else if (ch === '"' || ch === "'") {
|
|
12971
|
+
i++;
|
|
12972
|
+
while (i < code.length && code[i] !== ch && code[i] !== "\n")
|
|
12973
|
+
i += code[i] === "\\" ? 2 : 1;
|
|
12974
|
+
i++;
|
|
12975
|
+
out.push('""');
|
|
12976
|
+
}
|
|
12977
|
+
else if (ch === "`") {
|
|
12978
|
+
templateDepths.push(depth);
|
|
12979
|
+
i++;
|
|
12980
|
+
}
|
|
12981
|
+
else {
|
|
12982
|
+
if (ch === "{")
|
|
12983
|
+
depth++;
|
|
12984
|
+
if (ch === "}") {
|
|
12985
|
+
depth--;
|
|
12986
|
+
if (templateDepths[templateDepths.length - 1] === -(depth + 1))
|
|
12987
|
+
templateDepths.pop();
|
|
12988
|
+
}
|
|
12989
|
+
out.push(ch ?? "");
|
|
12990
|
+
i++;
|
|
12991
|
+
}
|
|
12992
|
+
}
|
|
12993
|
+
return out.join("");
|
|
12994
|
+
}
|
|
12995
|
+
/**
|
|
12996
|
+
* Names of the payload fields a flow explicitly declared via `payloadField`
|
|
12997
|
+
* that no emitted request surface reads as `payload.<field>`. The surface is
|
|
12998
|
+
* the emitted contract plus the browser flow: a field only the browser flow
|
|
12999
|
+
* dereferences still changes browser-executed requests, so it counts as
|
|
13000
|
+
* referenced. The schema declaration (`Field: z.…`) is not a `payload.` read,
|
|
13001
|
+
* so it never counts. Vocabulary-derived fields are out of scope. The generator emits the
|
|
13002
|
+
* fields no emitted request reads as optional schema fields, since a required field nothing
|
|
13003
|
+
* sends only burdens callers.
|
|
13004
|
+
*/
|
|
13005
|
+
function unreferencedDeclaredPayloadFields(contractCode, browserFlowCode, flowSteps) {
|
|
13006
|
+
const emittedCode = `${stripNonCodeText(contractCode)}\n${stripNonCodeText(browserFlowCode)}`;
|
|
13007
|
+
const declared = new Set();
|
|
13008
|
+
for (const step of flowSteps) {
|
|
13009
|
+
if (typeof step !== "string" && step.payloadField)
|
|
13010
|
+
declared.add(step.payloadField);
|
|
13011
|
+
}
|
|
13012
|
+
return [...declared].filter((name) => !new RegExp(`payload\\.${name}(?![\\w$])`).test(emittedCode));
|
|
13013
|
+
}
|
|
13014
|
+
/**
|
|
13015
|
+
* Declared payload fields the emitted requests never read. When the contract
|
|
13016
|
+
* replays HTTP requests only those count (the browser flow just changes
|
|
13017
|
+
* instruction text); a browser-only contract counts the browser flow too.
|
|
13018
|
+
* These are emitted optional so no caller has to supply a value that nothing
|
|
13019
|
+
* sends.
|
|
13020
|
+
*/
|
|
13021
|
+
function unreadDeclaredPayloadFields(contractCode, browserFlowCode, flowSteps) {
|
|
13022
|
+
return new Set(unreferencedDeclaredPayloadFields(contractCode, contractCode.includes("httpClient(") ? "" : browserFlowCode, flowSteps));
|
|
13023
|
+
}
|
|
13024
|
+
/**
|
|
13025
|
+
* Review-checklist lines for declared payload fields no emitted request reads,
|
|
13026
|
+
* so a reviewer sees the silently-optionalized field outside the log stream.
|
|
13027
|
+
*/
|
|
13028
|
+
function unreadPayloadFieldChecklistLines(fields) {
|
|
13029
|
+
return [...fields].map((field) => `Declared payloadField ${field} is read by no emitted request and was emitted optional — confirm it should affect the request, or drop it`);
|
|
13030
|
+
}
|
|
12370
13031
|
/**
|
|
12371
13032
|
* Escape a literal string segment so it is safe INSIDE a JS backtick template
|
|
12372
13033
|
* literal — backslashes, backticks, and `${` interpolation starts must all be
|
|
@@ -12386,13 +13047,17 @@ function escapeForTemplateLiteral(segment) {
|
|
|
12386
13047
|
* own choice so the browser-flow and HTTP-body emitters never disagree on
|
|
12387
13048
|
* which quoted span is the persona value.
|
|
12388
13049
|
*/
|
|
12389
|
-
function buildStepInstructionExpr(instruction, field) {
|
|
13050
|
+
function buildStepInstructionExpr(instruction, field, capturedFallback = false) {
|
|
12390
13051
|
if (field === null)
|
|
12391
13052
|
return JSON.stringify(instruction);
|
|
12392
13053
|
const site = locateSpliceSite(instruction);
|
|
12393
13054
|
if (site === null)
|
|
12394
13055
|
return JSON.stringify(instruction);
|
|
12395
|
-
|
|
13056
|
+
const captured = pickedQuotedValue(instruction);
|
|
13057
|
+
const read = capturedFallback && captured !== null
|
|
13058
|
+
? `payload.${field} ?? ${JSON.stringify(captured)}`
|
|
13059
|
+
: `payload.${field}`;
|
|
13060
|
+
return `\`${escapeForTemplateLiteral(site.before)}\${${read}}${escapeForTemplateLiteral(site.after)}\``;
|
|
12396
13061
|
}
|
|
12397
13062
|
/**
|
|
12398
13063
|
* Build the emitted instruction expression for a step whose value is the
|
|
@@ -12459,6 +13124,22 @@ function jsonSchemaTypeOf(value) {
|
|
|
12459
13124
|
return "object";
|
|
12460
13125
|
return "string";
|
|
12461
13126
|
}
|
|
13127
|
+
/**
|
|
13128
|
+
* Whether the flow's last step declares itself non-submit in its own wording
|
|
13129
|
+
* (and carries no explicit `submitStep` flag). The submission-flow forcing must
|
|
13130
|
+
* skip it: an explicit flag overrides declared intent at runtime, so forcing it
|
|
13131
|
+
* onto a trailing guardrail step would route that step through the submit judge.
|
|
13132
|
+
*/
|
|
13133
|
+
function lastFlowStepDeclaresNonSubmit(flowSteps) {
|
|
13134
|
+
const last = flowSteps.at(-1);
|
|
13135
|
+
if (last === undefined)
|
|
13136
|
+
return false;
|
|
13137
|
+
const isObj = typeof last !== "string";
|
|
13138
|
+
return (0, flow_runner_1.isStepDeclaredNonSubmit)({
|
|
13139
|
+
step: isObj ? last.step : last,
|
|
13140
|
+
submitStep: isObj ? last.submitStep === true : false,
|
|
13141
|
+
});
|
|
13142
|
+
}
|
|
12462
13143
|
/**
|
|
12463
13144
|
* Emits a config-only plugin manifest (`<siteId>.plugin.json`) from the recon
|
|
12464
13145
|
* flow, as an alternative to the `.ts` trio for browser-only sites. Reuses the
|
|
@@ -12478,9 +13159,18 @@ function jsonSchemaTypeOf(value) {
|
|
|
12478
13159
|
* browser `flow` is the only execution path, and the field is omitted.
|
|
12479
13160
|
*/
|
|
12480
13161
|
function emitConfigManifest(opts) {
|
|
12481
|
-
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, env = process.env, } = opts;
|
|
13162
|
+
const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, hasMultipartStep = false, browserFlowOnly = false, env = process.env, } = opts;
|
|
13163
|
+
// Raw (uncanonicalised) splice names are the evidence for a body-less flow;
|
|
13164
|
+
// the decision must be made on them before canonicalising, or it is circular.
|
|
13165
|
+
const canonicalise = contractUsesApplicantContactSchema({
|
|
13166
|
+
inputBody,
|
|
13167
|
+
isSubmissionFlow,
|
|
13168
|
+
browserFlowOnly,
|
|
13169
|
+
payloadFieldNames: computeFlowPayloadFieldNames(flowSteps, vocabulary, env, false)
|
|
13170
|
+
.payloadFieldNames,
|
|
13171
|
+
});
|
|
12482
13172
|
const payloadFieldNames = new Set();
|
|
12483
|
-
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
|
|
13173
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
|
|
12484
13174
|
const steps = flowSteps.map((step) => {
|
|
12485
13175
|
const isObj = typeof step !== "string";
|
|
12486
13176
|
const instruction = isObj ? step.step : step;
|
|
@@ -12507,7 +13197,7 @@ function emitConfigManifest(opts) {
|
|
|
12507
13197
|
...(emailStep ? { emailStep, emailStepConfig: emailStepConfig ?? {} } : {}),
|
|
12508
13198
|
};
|
|
12509
13199
|
}
|
|
12510
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
13200
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
|
|
12511
13201
|
if (field !== null)
|
|
12512
13202
|
payloadFieldNames.add(field);
|
|
12513
13203
|
const rewritten = buildManifestInstruction(instruction, field);
|
|
@@ -12532,7 +13222,7 @@ function emitConfigManifest(opts) {
|
|
|
12532
13222
|
// load of this manifest also gates on runHealingFlow's submitStep verification.
|
|
12533
13223
|
const lastStepIndex = steps.length - 1;
|
|
12534
13224
|
const lastStep = steps[lastStepIndex];
|
|
12535
|
-
if (isSubmissionFlow && lastStep !== undefined) {
|
|
13225
|
+
if (isSubmissionFlow && lastStep !== undefined && !lastFlowStepDeclaresNonSubmit(flowSteps)) {
|
|
12536
13226
|
steps[lastStepIndex] =
|
|
12537
13227
|
typeof lastStep === "string"
|
|
12538
13228
|
? { step: lastStep, optional: false, upload: false, submitStep: true }
|
|
@@ -12557,9 +13247,24 @@ function emitConfigManifest(opts) {
|
|
|
12557
13247
|
requestProperties[name] = { type: jsonSchemaTypeOf(value) };
|
|
12558
13248
|
}
|
|
12559
13249
|
}
|
|
13250
|
+
const usesApplicantContactSchema = contractUsesApplicantContactSchema({
|
|
13251
|
+
inputBody,
|
|
13252
|
+
isSubmissionFlow,
|
|
13253
|
+
browserFlowOnly,
|
|
13254
|
+
payloadFieldNames,
|
|
13255
|
+
});
|
|
13256
|
+
// Mirrors the .ts contract's JobTrackingSchema composition: a request schema
|
|
13257
|
+
// without it would drop the TrackingUrl the engine reads after submission.
|
|
13258
|
+
if (usesApplicantContactSchema)
|
|
13259
|
+
requestProperties.TrackingUrl ??= { type: "string" };
|
|
12560
13260
|
const sortedRequestProperties = Object.fromEntries(Object.keys(requestProperties)
|
|
12561
13261
|
.sort()
|
|
12562
13262
|
.map((name) => [name, requestProperties[name]]));
|
|
13263
|
+
// Same decision emitBrowserFlowTs makes: only a flow that uploads AND whose
|
|
13264
|
+
// contract carries Resume fields is multipart, so buildUploadFixture agrees.
|
|
13265
|
+
const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
|
|
13266
|
+
const contractCarriesResume = hasMultipartStep || usesApplicantContactSchema;
|
|
13267
|
+
const multipart = hasUploadStep && contractCarriesResume;
|
|
12563
13268
|
const manifest = {
|
|
12564
13269
|
apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
|
|
12565
13270
|
kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
|
|
@@ -12567,6 +13272,7 @@ function emitConfigManifest(opts) {
|
|
|
12567
13272
|
spec: {
|
|
12568
13273
|
defaultBaseUrl: baseUrl,
|
|
12569
13274
|
...(httpModulePath ? { httpModule: httpModulePath } : {}),
|
|
13275
|
+
...(multipart ? { multipart: true } : {}),
|
|
12570
13276
|
request: { type: "object", properties: sortedRequestProperties },
|
|
12571
13277
|
response: {
|
|
12572
13278
|
type: "object",
|
|
@@ -12601,7 +13307,7 @@ function emitConfigManifest(opts) {
|
|
|
12601
13307
|
* facet-splice pass can correlate against the exact same declared field set
|
|
12602
13308
|
* instead of re-deriving a second, potentially-drifting one.
|
|
12603
13309
|
*/
|
|
12604
|
-
function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
|
|
13310
|
+
function computeFlowPayloadFieldNames(flowSteps, vocabulary, env, canonicalise = false) {
|
|
12605
13311
|
const payloadFieldNames = new Set();
|
|
12606
13312
|
// Widest wins: a field is only optional if EVERY step that registers it
|
|
12607
13313
|
// does so as optional.
|
|
@@ -12615,7 +13321,7 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
|
|
|
12615
13321
|
optionalPayloadFieldNames.delete(field);
|
|
12616
13322
|
}
|
|
12617
13323
|
};
|
|
12618
|
-
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
|
|
13324
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
|
|
12619
13325
|
for (const step of flowSteps) {
|
|
12620
13326
|
const isObj = typeof step !== "string";
|
|
12621
13327
|
const instruction = isObj ? step.step : step;
|
|
@@ -12642,7 +13348,7 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
|
|
|
12642
13348
|
continue;
|
|
12643
13349
|
if (instruction.includes(RECON_PASSWORD_TOKEN))
|
|
12644
13350
|
continue;
|
|
12645
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
13351
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
|
|
12646
13352
|
const composite = field === null && !(isObj && step.payloadFieldNone)
|
|
12647
13353
|
? resolveCompositePersonaFields(instruction, knownFieldValues)
|
|
12648
13354
|
: null;
|
|
@@ -12665,10 +13371,22 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
|
|
|
12665
13371
|
return { payloadFieldNames, optionalPayloadFieldNames };
|
|
12666
13372
|
}
|
|
12667
13373
|
function emitBrowserFlowTs(opts) {
|
|
12668
|
-
const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, vocabulary, frameSelector, env = process.env, } = opts;
|
|
12669
|
-
const
|
|
13374
|
+
const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false, browserFlowOnly = false, inputBody, vocabulary, frameSelector, capturedFallbackFields = new Set(), env = process.env, } = opts;
|
|
13375
|
+
const canonicalise = contractUsesApplicantContactSchema({
|
|
13376
|
+
inputBody,
|
|
13377
|
+
isSubmissionFlow,
|
|
13378
|
+
browserFlowOnly,
|
|
13379
|
+
payloadFieldNames: computeFlowPayloadFieldNames(flowSteps, vocabulary, env, false)
|
|
13380
|
+
.payloadFieldNames,
|
|
13381
|
+
});
|
|
13382
|
+
const { payloadFieldNames, optionalPayloadFieldNames } = computeFlowPayloadFieldNames(flowSteps, vocabulary, env, canonicalise);
|
|
13383
|
+
for (const field of capturedFallbackFields) {
|
|
13384
|
+
if (payloadFieldNames.has(field))
|
|
13385
|
+
optionalPayloadFieldNames.add(field);
|
|
13386
|
+
}
|
|
12670
13387
|
const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
|
|
12671
|
-
const
|
|
13388
|
+
const contractCarriesResume = hasMultipartStep || canonicalise;
|
|
13389
|
+
const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env, canonicalise);
|
|
12672
13390
|
let usesThrowawayPassword = false;
|
|
12673
13391
|
let usesEmailStep = false;
|
|
12674
13392
|
const stepLiterals = flowSteps.map((step) => {
|
|
@@ -12710,14 +13428,14 @@ function emitBrowserFlowTs(opts) {
|
|
|
12710
13428
|
const submitStep = isObj ? step.submitStep === true : false;
|
|
12711
13429
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
12712
13430
|
}
|
|
12713
|
-
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
|
|
13431
|
+
const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues, canonicalise);
|
|
12714
13432
|
const composite = field === null && !(isObj && step.payloadFieldNone)
|
|
12715
13433
|
? resolveCompositePersonaFields(instruction, knownFieldValues)
|
|
12716
13434
|
: null;
|
|
12717
13435
|
const optional = isObj ? step.optional === true : false;
|
|
12718
13436
|
const instructionExpr = composite !== null
|
|
12719
13437
|
? buildCompositeStepInstructionExpr(instruction, composite.fieldA, composite.fieldB)
|
|
12720
|
-
: buildStepInstructionExpr(instruction, field);
|
|
13438
|
+
: buildStepInstructionExpr(instruction, field, field !== null && capturedFallbackFields.has(field));
|
|
12721
13439
|
const upload = isObj ? step.upload === true : false;
|
|
12722
13440
|
const submitStep = isObj ? step.submitStep === true : false;
|
|
12723
13441
|
return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
|
|
@@ -12726,7 +13444,9 @@ function emitBrowserFlowTs(opts) {
|
|
|
12726
13444
|
// submitStep:true even when the source recon-flow.json step never declared
|
|
12727
13445
|
// it, so the engine's pre-submit probe/StepVerificationError actually gates it.
|
|
12728
13446
|
const lastStepLiteral = stepLiterals.at(-1);
|
|
12729
|
-
if (isSubmissionFlow &&
|
|
13447
|
+
if (isSubmissionFlow &&
|
|
13448
|
+
lastStepLiteral !== undefined &&
|
|
13449
|
+
!lastFlowStepDeclaresNonSubmit(flowSteps)) {
|
|
12730
13450
|
stepLiterals[stepLiterals.length - 1] = lastStepLiteral.replace(/submitStep: (true|false)(?= \},)/, "submitStep: true");
|
|
12731
13451
|
}
|
|
12732
13452
|
const flowStepsBlock = stepLiterals.length > 0
|
|
@@ -12741,7 +13461,7 @@ function emitBrowserFlowTs(opts) {
|
|
|
12741
13461
|
// multipart detection is dropped), those payload fields don't exist yet — emit
|
|
12742
13462
|
// a null + TODO so the generated module still typechecks; the operator adds
|
|
12743
13463
|
// the multipart contract fields and wires the fixture during hand-finish.
|
|
12744
|
-
const uploadFixtureExpr = hasUploadStep &&
|
|
13464
|
+
const uploadFixtureExpr = hasUploadStep && contractCarriesResume
|
|
12745
13465
|
? `{
|
|
12746
13466
|
buffer: Buffer.from(payload.Resume),
|
|
12747
13467
|
name: payload.ResumeFilename ?? "resume.pdf",
|
|
@@ -12797,8 +13517,8 @@ export async function run${pascal}BrowserFlow(
|
|
|
12797
13517
|
): Promise<${pascal}Response> {
|
|
12798
13518
|
const page = await stagehand.context.awaitActivePage();
|
|
12799
13519
|
|
|
12800
|
-
await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "
|
|
12801
|
-
//
|
|
13520
|
+
await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "domcontentloaded" });
|
|
13521
|
+
// domcontentloaded fires before a bot-managed/CDN-fronted SPA hydrates; wait for
|
|
12802
13522
|
// the real DOM so the first steps don't probe an empty shell page and skip.
|
|
12803
13523
|
await waitForSpaReady(page, logger);
|
|
12804
13524
|
${usesThrowawayPassword ? "\n // Minted once per run — the flow needs a credential to authenticate, but\n // there is no caller-supplied Password field on the payload to splice.\n const throwawayPassword = generateThrowawayPassword();\n" : ""}${usesEmailStep ? "\n // Resolved once per run from the caller-supplied Email — the emailStep hook\n // polls this inbox rather than a freshly allocated one, since the applicant\n // (not this run) owns the address the ATS actually emails.\n const allocatedInbox = testmailInboxFromAddress(payload.Email);\n" : ""}
|
|
@@ -12855,7 +13575,7 @@ ${flowStepsBlock}
|
|
|
12855
13575
|
}
|
|
12856
13576
|
`}`;
|
|
12857
13577
|
assertNoLeakedPersonaConstant(code, knownFieldValues);
|
|
12858
|
-
return { code, payloadFieldNames, optionalPayloadFieldNames };
|
|
13578
|
+
return { code, payloadFieldNames, optionalPayloadFieldNames, usesThrowawayPassword };
|
|
12859
13579
|
}
|
|
12860
13580
|
/** Generates the site's index.ts barrel — exported so the out-of-tree e2e
|
|
12861
13581
|
* test can drive the emitter directly without spawning the CLI. */
|
|
@@ -13235,6 +13955,7 @@ async function main() {
|
|
|
13235
13955
|
};
|
|
13236
13956
|
}
|
|
13237
13957
|
})();
|
|
13958
|
+
const browserFlowOnlyOptIn = (0, node_fs_1.existsSync)(flowFile) ? (0, recon_shared_1.readBrowserFlowOnly)(flowFile) : false;
|
|
13238
13959
|
// Flow-declared signals that isolate the submission POSTs from same-origin
|
|
13239
13960
|
// page chrome. Threaded into action-sequence extraction and header derivation
|
|
13240
13961
|
// so both draw from the real submission, not incidental widget/chatbot POSTs.
|
|
@@ -13271,7 +13992,15 @@ async function main() {
|
|
|
13271
13992
|
* already written its output and there is nothing left for main() to do.
|
|
13272
13993
|
*/
|
|
13273
13994
|
function generateFromCaptures(activeCaptures) {
|
|
13274
|
-
|
|
13995
|
+
if (activeCaptures.length === 0 && !allowEmptyCapture) {
|
|
13996
|
+
throw new Error("empty capture pool: no captures remain to derive a base URL from (a placeholder origin would leak into the contract)");
|
|
13997
|
+
}
|
|
13998
|
+
// An opted-in empty capture has no origin to derive; "" leaves the
|
|
13999
|
+
// operator's siteBaseUrls config as the only source instead of a
|
|
14000
|
+
// placeholder origin leaking into the contract.
|
|
14001
|
+
const baseUrl = activeCaptures.length === 0
|
|
14002
|
+
? ""
|
|
14003
|
+
: deriveBaseUrl(activeCaptures, ownBackendHostnames, submitPatterns);
|
|
13275
14004
|
// Gate on the flow's declared own-backend hosts (or the registrable-domain
|
|
13276
14005
|
// fallback of baseUrl) via the same predicate recon-http.ts applies at
|
|
13277
14006
|
// write time, so a stale aux/ directory from before that filter existed
|
|
@@ -13417,7 +14146,7 @@ async function main() {
|
|
|
13417
14146
|
logger.warn(`submission selection: declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) disagrees with the unfiltered heuristic action sequence (${unfilteredHeuristicActionCaptures.length} capture(s)); using the declared pattern because requireSubmitEndpointMatch is set`);
|
|
13418
14147
|
}
|
|
13419
14148
|
else if (patternUndercounts) {
|
|
13420
|
-
logger.info(`submission selection:
|
|
14149
|
+
logger.info(`submission selection: using declared submitEndpointPattern/submitBodyPattern (${patternedHeuristicActionCaptures.length} capture(s)) although the unfiltered heuristic action sequence finds ${unfilteredHeuristicActionCaptures.length} capture(s); the unfiltered set would make the generator attempt an HTTP replay that aborts on frozen-varying fields`);
|
|
13421
14150
|
}
|
|
13422
14151
|
const heuristicActionCaptures = patternedHeuristicActionCaptures;
|
|
13423
14152
|
const manifestActionCaptures = resolveManifestActionSequence(runRoot, activeCaptures);
|
|
@@ -13508,7 +14237,12 @@ async function main() {
|
|
|
13508
14237
|
? indexStateValues(activeCaptures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
|
|
13509
14238
|
: new Map();
|
|
13510
14239
|
const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
|
|
13511
|
-
|
|
14240
|
+
// A multi-step flow whose declared submit pattern selects no captures has no
|
|
14241
|
+
// replayable HTTP sequence, yet is still not a search: treat it as a
|
|
14242
|
+
// browser-flow-only submission flow rather than the single-endpoint `{query}` branch.
|
|
14243
|
+
const noReplayableActionFlow = !gql && actionSteps.length === 0 && flowSteps.length > 1;
|
|
14244
|
+
const isSubmissionFlow = noReplayableActionFlow ||
|
|
14245
|
+
(actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation));
|
|
13512
14246
|
// Diagnostic for the FAILURE-3 shape (a flowless recon capture): a "submission flow"
|
|
13513
14247
|
// whose every action capture is landing-phase is almost certainly page-chrome
|
|
13514
14248
|
// bootstrap (e.g. page-chrome `POST /widgets`) misread as an apply flow, not a walked
|
|
@@ -13610,7 +14344,8 @@ async function main() {
|
|
|
13610
14344
|
// downgrade to the single-endpoint `{query}` branch either, since that's a
|
|
13611
14345
|
// fabrication of its own kind for a flow that isn't a single-action
|
|
13612
14346
|
// query/search to begin with.
|
|
13613
|
-
const browserFlowOnly = isSubmissionFlow &&
|
|
14347
|
+
const browserFlowOnly = isSubmissionFlow &&
|
|
14348
|
+
(browserFlowOnlyOptIn || noReplayableActionFlow || actionSteps.some((s) => s.isCrossDomain));
|
|
13614
14349
|
const multiStepBody = browserFlowOnly
|
|
13615
14350
|
? undefined
|
|
13616
14351
|
: isSubmissionFlow
|
|
@@ -13678,7 +14413,7 @@ async function main() {
|
|
|
13678
14413
|
if (foldReturnSpec !== null && effectiveFoldPlanCount > 0 && !declaredJoinFieldsApplied) {
|
|
13679
14414
|
logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but the declared spec resolved no fold plan — only structurally-detected fold plans (with their own guessed join fields) were emitted, so the declared joinFields were not applied; check that the endpointPattern names a capture in the action sequence and resultsPath resolves on the primary response`);
|
|
13680
14415
|
}
|
|
13681
|
-
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|
|
14416
|
+
logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (${browserFlowOnlyOptIn ? "forced by flow file" : noReplayableActionFlow ? "no replayable action sequence" : "cross-domain hop detected"})` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
|
|
13682
14417
|
if (emit === "config") {
|
|
13683
14418
|
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
13684
14419
|
(0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
|
|
@@ -13690,6 +14425,8 @@ async function main() {
|
|
|
13690
14425
|
inputBody,
|
|
13691
14426
|
recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
|
|
13692
14427
|
isSubmissionFlow,
|
|
14428
|
+
hasMultipartStep,
|
|
14429
|
+
browserFlowOnly,
|
|
13693
14430
|
// A submission flow is the case where the `.ts` emit carries an
|
|
13694
14431
|
// executeHttp hot path; point the manifest at where the operator drops
|
|
13695
14432
|
// the compiled module rather than silently dropping the direct path.
|
|
@@ -13712,6 +14449,8 @@ async function main() {
|
|
|
13712
14449
|
flowSteps,
|
|
13713
14450
|
isSubmissionFlow,
|
|
13714
14451
|
hasMultipartStep,
|
|
14452
|
+
browserFlowOnly,
|
|
14453
|
+
inputBody,
|
|
13715
14454
|
vocabulary,
|
|
13716
14455
|
frameSelector,
|
|
13717
14456
|
});
|
|
@@ -13750,6 +14489,7 @@ async function main() {
|
|
|
13750
14489
|
auxFiles,
|
|
13751
14490
|
multiStepBody,
|
|
13752
14491
|
omitExecuteHttp: browserFlowOnly,
|
|
14492
|
+
createsAccount: browserFlow.usesThrowawayPassword,
|
|
13753
14493
|
isSubmissionFlow,
|
|
13754
14494
|
inputBody,
|
|
13755
14495
|
hasMultipartStep,
|
|
@@ -13769,11 +14509,43 @@ async function main() {
|
|
|
13769
14509
|
unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
|
|
13770
14510
|
fallbackGateSpec,
|
|
13771
14511
|
};
|
|
13772
|
-
const
|
|
14512
|
+
const firstContractCode = emitContractTs(contractOpts);
|
|
14513
|
+
const unread = unreadDeclaredPayloadFields(firstContractCode, browserFlow.code, flowSteps);
|
|
14514
|
+
// A field no emitted request reads must not be required of callers. The
|
|
14515
|
+
// browser fallback keeps splicing it, from the captured value when the
|
|
14516
|
+
// caller omits it, so the field still takes effect when supplied.
|
|
14517
|
+
const settled = unread.size === 0
|
|
14518
|
+
? { browserFlow, contractOpts, contractCode: firstContractCode }
|
|
14519
|
+
: (() => {
|
|
14520
|
+
const fallbackBrowserFlow = emitBrowserFlowTs({
|
|
14521
|
+
siteId,
|
|
14522
|
+
pascal,
|
|
14523
|
+
baseUrl,
|
|
14524
|
+
flowSteps,
|
|
14525
|
+
isSubmissionFlow,
|
|
14526
|
+
hasMultipartStep,
|
|
14527
|
+
browserFlowOnly,
|
|
14528
|
+
vocabulary,
|
|
14529
|
+
frameSelector,
|
|
14530
|
+
capturedFallbackFields: unread,
|
|
14531
|
+
});
|
|
14532
|
+
const optionalized = {
|
|
14533
|
+
...contractOpts,
|
|
14534
|
+
optionalPayloadFieldNames: new Set([
|
|
14535
|
+
...fallbackBrowserFlow.optionalPayloadFieldNames,
|
|
14536
|
+
...unread,
|
|
14537
|
+
]),
|
|
14538
|
+
};
|
|
14539
|
+
return {
|
|
14540
|
+
browserFlow: fallbackBrowserFlow,
|
|
14541
|
+
contractOpts: optionalized,
|
|
14542
|
+
contractCode: emitContractTs(optionalized),
|
|
14543
|
+
};
|
|
14544
|
+
})();
|
|
13773
14545
|
return {
|
|
13774
|
-
contractCode,
|
|
13775
|
-
contractOpts,
|
|
13776
|
-
browserFlow,
|
|
14546
|
+
contractCode: settled.contractCode,
|
|
14547
|
+
contractOpts: settled.contractOpts,
|
|
14548
|
+
browserFlow: settled.browserFlow,
|
|
13777
14549
|
auxFiles,
|
|
13778
14550
|
resolvedPool: actionCaptures,
|
|
13779
14551
|
isSubmissionFlow,
|
|
@@ -13785,11 +14557,18 @@ async function main() {
|
|
|
13785
14557
|
if (result === null)
|
|
13786
14558
|
return; // emit === "config": already written above.
|
|
13787
14559
|
const final = healUnreferencedUrlFieldsOnce(captures, result, generateFromCaptures);
|
|
14560
|
+
const unreferencedEverywhere = new Set(unreferencedDeclaredPayloadFields(final.contractCode, final.browserFlow.code, flowSteps));
|
|
14561
|
+
for (const field of unreadDeclaredPayloadFields(final.contractCode, final.browserFlow.code, flowSteps)) {
|
|
14562
|
+
logger.warn(`declared payloadField ${field} is read by no emitted request; emitted as optional schema field${unreferencedEverywhere.has(field) ? "" : " (only the browser flow splices it)"}`);
|
|
14563
|
+
}
|
|
13788
14564
|
(0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, final.contractCode);
|
|
13789
14565
|
logger.info(`wrote ${outDir}/contract.ts`);
|
|
13790
14566
|
// Same opts fed to emitContractTs above, so this can never drift from what
|
|
13791
14567
|
// the header used to embed.
|
|
13792
|
-
const checklist =
|
|
14568
|
+
const checklist = [
|
|
14569
|
+
...buildContractChecklist(final.contractOpts),
|
|
14570
|
+
...unreadPayloadFieldChecklistLines(unreadDeclaredPayloadFields(final.contractCode, final.browserFlow.code, flowSteps)),
|
|
14571
|
+
];
|
|
13793
14572
|
logger.info(`review checklist for ${outDir}/contract.ts:\n${checklist.map((item) => ` [ ] ${item}`).join("\n")}`);
|
|
13794
14573
|
(0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, final.browserFlow.code);
|
|
13795
14574
|
logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
|