@enricai/barnacle 1.12.50 → 1.12.52
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/scraper/captcha-callback-capture.d.ts +5 -1
- package/dist/scraper/captcha-callback-capture.d.ts.map +1 -1
- package/dist/scraper/captcha-callback-capture.js +46 -1
- package/dist/scraper/captcha-callback-capture.js.map +1 -1
- package/dist/scraper/cdp-frame-init-script.d.ts +8 -3
- package/dist/scraper/cdp-frame-init-script.d.ts.map +1 -1
- package/dist/scraper/cdp-frame-init-script.js +8 -3
- package/dist/scraper/cdp-frame-init-script.js.map +1 -1
- package/dist/scraper/session.d.ts.map +1 -1
- package/dist/scraper/session.js +5 -1
- package/dist/scraper/session.js.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.d.ts +26 -20
- package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
- package/dist/scripts/recon-generate-multicall-fixture.js +28 -22
- package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +43 -1
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +1036 -145
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +2 -2
|
@@ -44,6 +44,7 @@ exports.extractActionSequence = extractActionSequence;
|
|
|
44
44
|
exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
|
|
45
45
|
exports.dedupRedundantSameOperationCaptures = dedupRedundantSameOperationCaptures;
|
|
46
46
|
exports.isRedundantSameEndpointGroup = isRedundantSameEndpointGroup;
|
|
47
|
+
exports.assertBodyFieldSourceNameCorrelates = assertBodyFieldSourceNameCorrelates;
|
|
47
48
|
exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
|
|
48
49
|
exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
|
|
49
50
|
exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
|
|
@@ -2261,6 +2262,199 @@ function jsonBodyLeafValues(requestPostData) {
|
|
|
2261
2262
|
}
|
|
2262
2263
|
return values;
|
|
2263
2264
|
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Same JSON body walk as {@link jsonBodyLeafValues}, but grouped by the JSON
|
|
2267
|
+
* key/array-index that carries each leaf value — the by-name correlation
|
|
2268
|
+
* `compileActionSteps`' consumption pre-scan needs so a produced value is
|
|
2269
|
+
* only treated as reused when the SOURCE field's name correlates with the
|
|
2270
|
+
* TARGET field it's found in (mirrors {@link
|
|
2271
|
+
* collectDependentDrillDownChainValues}'s sameNameMatch/arrayIndexMatch,
|
|
2272
|
+
* applied here as the general eligibility gate rather than only the
|
|
2273
|
+
* short-value length-floor exemption). Returns null under the same
|
|
2274
|
+
* conditions as `jsonBodyLeafValues`, for the same non-JSON fallback.
|
|
2275
|
+
*/
|
|
2276
|
+
function jsonBodyLeafValuesByKey(requestPostData) {
|
|
2277
|
+
if (typeof requestPostData !== "string" || requestPostData.length === 0)
|
|
2278
|
+
return null;
|
|
2279
|
+
const parsed = (() => {
|
|
2280
|
+
try {
|
|
2281
|
+
return JSON.parse(requestPostData);
|
|
2282
|
+
}
|
|
2283
|
+
catch {
|
|
2284
|
+
return undefined;
|
|
2285
|
+
}
|
|
2286
|
+
})();
|
|
2287
|
+
if (parsed === undefined)
|
|
2288
|
+
return null;
|
|
2289
|
+
const byKey = new Map();
|
|
2290
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
|
|
2291
|
+
if (value === null || path.length === 0)
|
|
2292
|
+
continue;
|
|
2293
|
+
// An array ELEMENT carries no field name of its own (its last path
|
|
2294
|
+
// segment is a bare numeric index) — the array's own key, one or more
|
|
2295
|
+
// segments up, is the nearest name to correlate against (e.g.
|
|
2296
|
+
// `{"tokens":[12345678]}"` correlates on "tokens", not "0"). A leaf at
|
|
2297
|
+
// the top of an unnamed array (no non-numeric ancestor at all) has
|
|
2298
|
+
// truly no name; it keeps its numeric key so callers can still detect
|
|
2299
|
+
// it as name-free via {@link ARRAY_INDEX_KEY_PATTERN}.
|
|
2300
|
+
const namedSegment = [...path]
|
|
2301
|
+
.reverse()
|
|
2302
|
+
.find((segment) => !ARRAY_INDEX_KEY_PATTERN.test(segment));
|
|
2303
|
+
const key = namedSegment ?? path[path.length - 1];
|
|
2304
|
+
const values = byKey.get(key) ?? new Set();
|
|
2305
|
+
values.add(String(value));
|
|
2306
|
+
byKey.set(key, values);
|
|
2307
|
+
}
|
|
2308
|
+
return byKey;
|
|
2309
|
+
}
|
|
2310
|
+
/** Splits a `camelCase`/`snake_case`/`kebab-case` field name into its
|
|
2311
|
+
* constituent lowercase words, dropping words shorter than 3 characters (an
|
|
2312
|
+
* "id"/"no"/"ok"-shaped word is too generic on its own to prove two field
|
|
2313
|
+
* names name the same concept). Used by {@link keyNamesCorrelate}. */
|
|
2314
|
+
function keyNameWords(key) {
|
|
2315
|
+
return key
|
|
2316
|
+
.split(/(?=[A-Z])|[_\-\s]+/)
|
|
2317
|
+
.map((word) => word.toLowerCase())
|
|
2318
|
+
.filter((word) => word.length >= 3);
|
|
2319
|
+
}
|
|
2320
|
+
/** Words common enough as a naming SUFFIX/PREFIX that sharing one proves
|
|
2321
|
+
* nothing on its own — `startDate`/`endDate` and `firstName`/`lastName` each
|
|
2322
|
+
* share a word under this set's length-≥3 threshold while naming opposite
|
|
2323
|
+
* concepts. Used by {@link keyNamesCorrelate} to require a more specific
|
|
2324
|
+
* word overlap whenever both keys also carry a non-generic word to compare. */
|
|
2325
|
+
const GENERIC_KEY_WORDS = new Set([
|
|
2326
|
+
"name",
|
|
2327
|
+
"date",
|
|
2328
|
+
"type",
|
|
2329
|
+
"code",
|
|
2330
|
+
"email",
|
|
2331
|
+
"phone",
|
|
2332
|
+
"address",
|
|
2333
|
+
"flag",
|
|
2334
|
+
"count",
|
|
2335
|
+
"number",
|
|
2336
|
+
"value",
|
|
2337
|
+
"status",
|
|
2338
|
+
"key",
|
|
2339
|
+
"time",
|
|
2340
|
+
]);
|
|
2341
|
+
/**
|
|
2342
|
+
* True when a SOURCE field name and a TARGET field name plausibly name the
|
|
2343
|
+
* same concept — an exact match, or a shared word (one a substring of the
|
|
2344
|
+
* other, so a plural/prefix variant like `token`/`tokens` or a compound like
|
|
2345
|
+
* `jobId`/`jobSeqNo` or `draftId`/`applicationDraftId` still correlates)
|
|
2346
|
+
* once both are split into their constituent camelCase words. This is the
|
|
2347
|
+
* general-purpose sibling of {@link collectDependentDrillDownChainValues}'s
|
|
2348
|
+
* stricter exact-key `sameNameMatch`, used by `compileActionSteps`' body-
|
|
2349
|
+
* value consumption gate where the source/target key casing and compounding
|
|
2350
|
+
* legitimately differ across endpoints.
|
|
2351
|
+
*
|
|
2352
|
+
* A shared {@link GENERIC_KEY_WORDS} word is insufficient PROOF when both
|
|
2353
|
+
* keys also carry a more specific, non-generic word — `startDate` and
|
|
2354
|
+
* `endDate` both reduce to `["start"]`/`["end"]` once `date` is set aside,
|
|
2355
|
+
* and those don't overlap, so the pair must NOT correlate despite sharing
|
|
2356
|
+
* `date`. A generic word is only trusted when one side has no non-generic
|
|
2357
|
+
* word to fall back on (e.g. `statusToken` vs. the bare `tokens` key).
|
|
2358
|
+
*/
|
|
2359
|
+
function keyNamesCorrelate(sourceKey, targetKey) {
|
|
2360
|
+
if (sourceKey === targetKey)
|
|
2361
|
+
return true;
|
|
2362
|
+
const sourceWords = keyNameWords(sourceKey);
|
|
2363
|
+
const targetWords = keyNameWords(targetKey);
|
|
2364
|
+
const wordsMatch = (a, b) => a.includes(b) || b.includes(a);
|
|
2365
|
+
const sourceSpecific = sourceWords.filter((w) => !GENERIC_KEY_WORDS.has(w));
|
|
2366
|
+
const targetSpecific = targetWords.filter((w) => !GENERIC_KEY_WORDS.has(w));
|
|
2367
|
+
if (sourceSpecific.length > 0 && targetSpecific.length > 0) {
|
|
2368
|
+
return sourceSpecific.some((sw) => targetSpecific.some((tw) => wordsMatch(sw, tw)));
|
|
2369
|
+
}
|
|
2370
|
+
return sourceWords.some((sw) => targetWords.some((tw) => wordsMatch(sw, tw)));
|
|
2371
|
+
}
|
|
2372
|
+
/** A spliced `${...}` accessor/varName whose own derived name is
|
|
2373
|
+
* legitimately name-free — it can never be required to correlate with the
|
|
2374
|
+
* JSON key it lands under. A `payload.<field>` accessor matches its target
|
|
2375
|
+
* BY DEFINITION (the schema field IS the body key), and a bare loop
|
|
2376
|
+
* index/counter (`i`, `i0`, `idx0`) names a position, not a concept. Used by
|
|
2377
|
+
* {@link deriveSplicedSourceName}. */
|
|
2378
|
+
const NAME_FREE_ACCESSOR_PATTERN = /^payload\./;
|
|
2379
|
+
const ARRAY_INDEX_VAR_PATTERN = /^(?:i|idx)\d*$/;
|
|
2380
|
+
/** {@link deriveSplicedSourceName} only derives a correlatable name for an
|
|
2381
|
+
* accessor rooted at one of this file's own fold/drill per-item or
|
|
2382
|
+
* ancestor-scope bindings — `item`/`item0`/... (see {@link
|
|
2383
|
+
* pathToFoldLoopLines}'s `itemVar`) or `g0`/`g1`/... (its `groupVar`). These
|
|
2384
|
+
* are exactly the bindings {@link findThreadedJoinFields} and {@link
|
|
2385
|
+
* applyDrillParamBindings} thread a per-item/per-ancestor FIELD (as opposed
|
|
2386
|
+
* to a whole produced value) into, which is the specific "picks the wrong
|
|
2387
|
+
* source field for a given key" bug class this net closes. A top-level
|
|
2388
|
+
* chain-produced var (`r0`, `token`, a response-derived `const label = ...`)
|
|
2389
|
+
* is threaded by exact VALUE identity across steps, a different, already
|
|
2390
|
+
* value-gated mechanism this net intentionally leaves alone — genuinely
|
|
2391
|
+
* different names on either side of that kind of splice (a rotated `token`
|
|
2392
|
+
* landing under an `auth` key, a `label` re-sent as a `ref`) are expected,
|
|
2393
|
+
* not a bug. */
|
|
2394
|
+
const FOLD_SCOPED_ROOT_PATTERN = /^(?:item\d*|g\d+)\./;
|
|
2395
|
+
/**
|
|
2396
|
+
* Derives the "own name" a spliced `${...}` accessor carries for {@link
|
|
2397
|
+
* assertBodyFieldSourceNameCorrelates} to correlate against its enclosing
|
|
2398
|
+
* JSON key — the last dot-separated path segment (e.g. `g0.identifiers.sku`
|
|
2399
|
+
* -> `sku`, matching {@link keyNamesCorrelate}'s own source-key convention
|
|
2400
|
+
* elsewhere in this file). Returns `null` for anything not rooted at a
|
|
2401
|
+
* fold/drill per-item or ancestor binding (see {@link
|
|
2402
|
+
* FOLD_SCOPED_ROOT_PATTERN}'s docstring for why only those are in scope) or
|
|
2403
|
+
* that is otherwise legitimately name-free (see {@link
|
|
2404
|
+
* NAME_FREE_ACCESSOR_PATTERN} / {@link ARRAY_INDEX_VAR_PATTERN}).
|
|
2405
|
+
*/
|
|
2406
|
+
function deriveSplicedSourceName(accessor) {
|
|
2407
|
+
const trimmed = accessor.trim();
|
|
2408
|
+
if (NAME_FREE_ACCESSOR_PATTERN.test(trimmed) || ARRAY_INDEX_VAR_PATTERN.test(trimmed)) {
|
|
2409
|
+
return null;
|
|
2410
|
+
}
|
|
2411
|
+
if (!FOLD_SCOPED_ROOT_PATTERN.test(trimmed))
|
|
2412
|
+
return null;
|
|
2413
|
+
// A non-identifier expression (a template literal, a ternary, a function
|
|
2414
|
+
// call) carries no single derivable name to correlate — only a bare
|
|
2415
|
+
// dotted-path accessor is in scope for this check.
|
|
2416
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(trimmed))
|
|
2417
|
+
return null;
|
|
2418
|
+
const segments = trimmed.split(".");
|
|
2419
|
+
return segments[segments.length - 1] ?? null;
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Mechanism-agnostic, generation-time safety net closing the door on ANY
|
|
2423
|
+
* fold/drill body-field/source-name correlation bug, regardless of which of
|
|
2424
|
+
* this file's several independent per-item/ancestor threading passes
|
|
2425
|
+
* (fold-item join fields, ancestor-scope rebinding, drill-param binding,
|
|
2426
|
+
* ...) produced the offending splice — architecturally the same kind of
|
|
2427
|
+
* final structural gate as {@link assertNoFrozenVaryingDrillParams}, not a
|
|
2428
|
+
* fix specific to one mechanism. Walks the fully-assembled `renderedBody`
|
|
2429
|
+
* for every `"<key>":${<accessor>}` pair whose accessor is rooted at a
|
|
2430
|
+
* fold/drill binding (see {@link deriveSplicedSourceName}) and requires its
|
|
2431
|
+
* own derived field name to plausibly name the same concept as the
|
|
2432
|
+
* enclosing JSON key ({@link keyNamesCorrelate}) it was spliced under.
|
|
2433
|
+
* Throws a site-agnostic description naming only the key/accessor pair
|
|
2434
|
+
* (never a specific site or plugin) so a SEVENTH recurrence of this bug
|
|
2435
|
+
* class, in a mechanism not yet built, fails generation loudly instead of
|
|
2436
|
+
* silently shipping a body field assigned from an unrelated per-item/
|
|
2437
|
+
* ancestor source field.
|
|
2438
|
+
*
|
|
2439
|
+
* Exported for unit testing — see `applyDrillParamBindings`/
|
|
2440
|
+
* `compileActionSteps` for this file's existing precedent of exporting an
|
|
2441
|
+
* otherwise-internal structural gate so it can be probed directly with a
|
|
2442
|
+
* synthetic `renderedBody` string, independent of the fold-plan-detection
|
|
2443
|
+
* machinery that decides which mechanism produces a given splice.
|
|
2444
|
+
*/
|
|
2445
|
+
function assertBodyFieldSourceNameCorrelates(emitterName, renderedBody) {
|
|
2446
|
+
const pattern = /"([^"\\]+)"\s*:\s*"?\$\{([^{}]+)\}/g;
|
|
2447
|
+
for (const match of renderedBody.matchAll(pattern)) {
|
|
2448
|
+
const key = match[1];
|
|
2449
|
+
const accessor = match[2];
|
|
2450
|
+
const sourceName = deriveSplicedSourceName(accessor);
|
|
2451
|
+
if (sourceName === null)
|
|
2452
|
+
continue;
|
|
2453
|
+
if (keyNamesCorrelate(sourceName, key))
|
|
2454
|
+
continue;
|
|
2455
|
+
throw new Error(`${emitterName}: body field "${key}" is spliced from "\${${accessor}}", whose own inferred name ("${sourceName}") doesn't correlate with "${key}" — a field must be assigned from a source whose own name plausibly names the same concept as the key it lands under, not from a value that only coincidentally matches`);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2264
2458
|
/**
|
|
2265
2459
|
* Yields every primitive leaf (string, number, boolean, null) in the JSON
|
|
2266
2460
|
* value with its path. Used by the body-literal substitution pass to find
|
|
@@ -3509,6 +3703,51 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
|
|
|
3509
3703
|
function isValidJsIdentifier(s) {
|
|
3510
3704
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
|
|
3511
3705
|
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Collapses a JSON path (e.g. `["formData", "firstName"]`) into a single flat
|
|
3708
|
+
* payload field name (`formDataFirstName`). The payload schema `emitContractTs`
|
|
3709
|
+
* builds is always a flat, single-level `z.object({...})` — no pass anywhere
|
|
3710
|
+
* in this file constructs a nested Zod shape — so a `payload.<field>` accessor
|
|
3711
|
+
* must always resolve a single top-level identifier, never a dotted/bracketed
|
|
3712
|
+
* chain. This is the single place that turns a (possibly multi-segment)
|
|
3713
|
+
* request-body path into that field name, so the accessor text emitted into a
|
|
3714
|
+
* template and the field registered in the schema can never diverge: both
|
|
3715
|
+
* always derive from this same flat name. A non-identifier segment (an array
|
|
3716
|
+
* index, a key with punctuation) is sanitized via {@link fieldNameToPascalCase}
|
|
3717
|
+
* rather than dropped, so every path still yields a usable field name.
|
|
3718
|
+
*/
|
|
3719
|
+
function pathToPayloadFieldName(path) {
|
|
3720
|
+
return path
|
|
3721
|
+
.map((segment, index) => {
|
|
3722
|
+
const clean = isValidJsIdentifier(segment)
|
|
3723
|
+
? segment
|
|
3724
|
+
: (fieldNameToPascalCase(segment, null) ?? `Field${index}`);
|
|
3725
|
+
return index === 0 ? clean : clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
3726
|
+
})
|
|
3727
|
+
.join("");
|
|
3728
|
+
}
|
|
3729
|
+
/**
|
|
3730
|
+
* Builds the `payload.<...>` accessor and the field name to register for a
|
|
3731
|
+
* request-body leaf path. Array-index segments (`["sorts", "0"]`) are NOT
|
|
3732
|
+
* flattened: the array itself (`sorts`) is registered as a single field
|
|
3733
|
+
* elsewhere as a whole, and the element access stays a bracket-indexed
|
|
3734
|
+
* `pathToAccessor` suffix into that same field — flattening it (`sorts0`)
|
|
3735
|
+
* would target a field the schema never declares. Only the object-key
|
|
3736
|
+
* segments before the first array index are collapsed via
|
|
3737
|
+
* {@link pathToPayloadFieldName}; segments from the first array index onward
|
|
3738
|
+
* are rendered with {@link pathToAccessor} against that flat prefix.
|
|
3739
|
+
*/
|
|
3740
|
+
function payloadAccessorForPath(path) {
|
|
3741
|
+
const arrayIndexPos = path.findIndex((segment) => /^\d+$/.test(segment));
|
|
3742
|
+
if (arrayIndexPos === -1) {
|
|
3743
|
+
const field = pathToPayloadFieldName(path);
|
|
3744
|
+
return { accessor: `payload.${field}`, field };
|
|
3745
|
+
}
|
|
3746
|
+
const objectPath = path.slice(0, arrayIndexPos);
|
|
3747
|
+
const field = objectPath.length > 0 ? pathToPayloadFieldName(objectPath) : (path[0] ?? "");
|
|
3748
|
+
const suffix = pathToAccessor(path.slice(arrayIndexPos), { assertNonNull: true });
|
|
3749
|
+
return { accessor: `payload.${field}${suffix}`, field };
|
|
3750
|
+
}
|
|
3512
3751
|
/** Derives a valid camelCase identifier from a fixture filename (e.g.
|
|
3513
3752
|
* "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
|
|
3514
3753
|
* "acmeMetricsConfig") for use in generated `loadFixture` const lines. */
|
|
@@ -3542,21 +3781,42 @@ function pathToAccessor(path, opts = { assertNonNull: true }) {
|
|
|
3542
3781
|
.map((p) => isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]${opts.assertNonNull ? "!" : ""}`)
|
|
3543
3782
|
.join("");
|
|
3544
3783
|
}
|
|
3784
|
+
/**
|
|
3785
|
+
* Builds a JS access expression reading `path` off `varName`, where `varName`
|
|
3786
|
+
* is a runtime value typed `Record<string, unknown>` (an itemVar, ancestor
|
|
3787
|
+
* loop var, or fold-match candidate) — NOT the real Zod-inferred payload type
|
|
3788
|
+
* {@link pathToAccessor} targets. A single-segment path is a plain `.prop` /
|
|
3789
|
+
* `["prop"]` access, typed `unknown` by the index signature, which compiles
|
|
3790
|
+
* fine wherever the caller only interpolates or `String()`s it. But chaining
|
|
3791
|
+
* a SECOND segment off that same access (`item.identifiers.sku`) fails to
|
|
3792
|
+
* typecheck (TS18046 "is of type 'unknown'") because the index signature's
|
|
3793
|
+
* `unknown` return doesn't itself support further property access — so every
|
|
3794
|
+
* intermediate hop (all but the last segment) is re-asserted back to
|
|
3795
|
+
* `Record<string, unknown>` before the next access.
|
|
3796
|
+
*/
|
|
3797
|
+
function unknownValueAccessor(varName, path) {
|
|
3798
|
+
return path.reduce((expr, segment, index) => {
|
|
3799
|
+
const accessor = isValidJsIdentifier(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
|
|
3800
|
+
const isLast = index === path.length - 1;
|
|
3801
|
+
return isLast ? `${expr}${accessor}` : `(${expr}${accessor} as Record<string, unknown>)`;
|
|
3802
|
+
}, varName);
|
|
3803
|
+
}
|
|
3545
3804
|
/**
|
|
3546
3805
|
* Builds a nested TypeScript assertion type matching a JSON path. e.g.
|
|
3547
|
-
* ["Auth","Token"] -> `{ Auth: { Token: string } }`
|
|
3548
|
-
* ["Sections","
|
|
3549
|
-
* The leaf is
|
|
3550
|
-
* string
|
|
3551
|
-
*
|
|
3552
|
-
*
|
|
3553
|
-
|
|
3554
|
-
|
|
3806
|
+
* ["Auth","Token"], "string" -> `{ Auth: { Token: string } }`
|
|
3807
|
+
* ["Sections","Complete","0"], "boolean" -> `{ Sections: { Complete: { "0": boolean } } }`
|
|
3808
|
+
* The leaf type is the produce's actual captured {@link BodyProduce.leafType}
|
|
3809
|
+
* (string/number/boolean), so this always agrees with the same field's
|
|
3810
|
+
* schema-inferred Zod type from {@link inferZodSchemaFromSamples}. Used to
|
|
3811
|
+
* keep emitted code free of `any` casts while still letting nested-path
|
|
3812
|
+
* access compile against `Record<string, unknown>`-typed response variables.
|
|
3813
|
+
*/
|
|
3814
|
+
function pathToAssertionType(path, leafType) {
|
|
3555
3815
|
if (path.length === 0)
|
|
3556
|
-
return
|
|
3816
|
+
return leafType;
|
|
3557
3817
|
const segment = path[0];
|
|
3558
3818
|
const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
|
|
3559
|
-
return `{ ${key}: ${pathToAssertionType(path.slice(1))} }`;
|
|
3819
|
+
return `{ ${key}: ${pathToAssertionType(path.slice(1), leafType)} }`;
|
|
3560
3820
|
}
|
|
3561
3821
|
/**
|
|
3562
3822
|
* Same nesting as {@link pathToAssertionType} but the leaf types as
|
|
@@ -3699,6 +3959,7 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3699
3959
|
// so we only "produce" the values that are actually consumed downstream.
|
|
3700
3960
|
for (const { capture } of actions) {
|
|
3701
3961
|
const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
|
|
3962
|
+
const bodyLeafValuesByKey = jsonBodyLeafValuesByKey(capture.requestPostData);
|
|
3702
3963
|
for (const sv of stateIndex.values()) {
|
|
3703
3964
|
// A short value indexed only via the chain/force-include exemption
|
|
3704
3965
|
// (see `StateValue.eligibleConsumers`) is a real dependency ONLY for
|
|
@@ -3726,10 +3987,26 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3726
3987
|
if (bodyLeafValues === null) {
|
|
3727
3988
|
if (capture.requestPostData?.includes(sv.value))
|
|
3728
3989
|
usedValues.add(sv.value);
|
|
3990
|
+
continue;
|
|
3729
3991
|
}
|
|
3730
|
-
|
|
3992
|
+
// A produced value's SOURCE key name (the last segment of its response
|
|
3993
|
+
// JSON path) must correlate with the TARGET field it's found under —
|
|
3994
|
+
// same discipline `collectDependentDrillDownChainValues` already
|
|
3995
|
+
// applies to the short-value length-floor exemption (sameNameMatch),
|
|
3996
|
+
// now the universal gate rather than only that narrower one. A
|
|
3997
|
+
// name-free source (a bare array index, or a header/cookie origin with
|
|
3998
|
+
// no body accessor at all) is exempted, exactly as arrayIndexMatch
|
|
3999
|
+
// exempts a name-free source there — there's no name to correlate.
|
|
4000
|
+
const sourceKeyName = sv.headerOrigin ? undefined : sv.path.at(-1);
|
|
4001
|
+
const sourceIsNameFree = sv.headerOrigin !== undefined ||
|
|
4002
|
+
sourceKeyName === undefined ||
|
|
4003
|
+
ARRAY_INDEX_KEY_PATTERN.test(sourceKeyName);
|
|
4004
|
+
const matches = sourceIsNameFree
|
|
4005
|
+
? bodyLeafValues.some((leaf) => leaf.includes(sv.value))
|
|
4006
|
+
: [...(bodyLeafValuesByKey?.entries() ?? [])].some(([targetKey, leaves]) => keyNamesCorrelate(sourceKeyName, targetKey) &&
|
|
4007
|
+
[...leaves].some((leaf) => leaf.includes(sv.value)));
|
|
4008
|
+
if (matches)
|
|
3731
4009
|
usedValues.add(sv.value);
|
|
3732
|
-
}
|
|
3733
4010
|
}
|
|
3734
4011
|
for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
|
|
3735
4012
|
for (const sv of stateIndex.values()) {
|
|
@@ -3836,7 +4113,18 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
3836
4113
|
name = `${pathToVarName(path)}${suffix}`;
|
|
3837
4114
|
}
|
|
3838
4115
|
seenNames.add(name);
|
|
3839
|
-
|
|
4116
|
+
const leafType = typeof rawValue === "number"
|
|
4117
|
+
? "number"
|
|
4118
|
+
: typeof rawValue === "boolean"
|
|
4119
|
+
? "boolean"
|
|
4120
|
+
: "string";
|
|
4121
|
+
produces.push({
|
|
4122
|
+
kind: "body",
|
|
4123
|
+
name,
|
|
4124
|
+
path,
|
|
4125
|
+
leafType,
|
|
4126
|
+
eligibleConsumers: sv.eligibleConsumers,
|
|
4127
|
+
});
|
|
3840
4128
|
}
|
|
3841
4129
|
}
|
|
3842
4130
|
const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
@@ -3921,6 +4209,60 @@ function resolveResponsePathValue(responseBody, path) {
|
|
|
3921
4209
|
function buildValueAlternationPattern(sortedValues) {
|
|
3922
4210
|
return new RegExp(`(?<![A-Za-z0-9][-.])\\b(?:${sortedValues.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b(?![-.][A-Za-z0-9])`, "g");
|
|
3923
4211
|
}
|
|
4212
|
+
/** Normalizes a JSON key or a produced var name to a bare comparable token —
|
|
4213
|
+
* lowercased, non-alphanumeric stripped, trailing disambiguation digits
|
|
4214
|
+
* (the `seenNames`-collision suffix `compileActionSteps` appends, e.g.
|
|
4215
|
+
* `displayOrder2`) dropped — so `sortOrder` and `SortOrder`/`sort_order`/
|
|
4216
|
+
* `sortOrder2` all normalize to the same token for {@link keysCorrelate}. */
|
|
4217
|
+
function normalizeCorrelationToken(raw) {
|
|
4218
|
+
return raw
|
|
4219
|
+
.toLowerCase()
|
|
4220
|
+
.replace(/[^a-z0-9]/g, "")
|
|
4221
|
+
.replace(/\d+$/, "");
|
|
4222
|
+
}
|
|
4223
|
+
/**
|
|
4224
|
+
* True when `sourceName` (a produced state var's own field name, e.g. the
|
|
4225
|
+
* `p.name` a produce was declared under) plausibly names the same coordinate
|
|
4226
|
+
* as `targetKey` (the JSON key a candidate splice would land under). Used to
|
|
4227
|
+
* gate {@link interpolateStateValues}'s substitution of a value that was only
|
|
4228
|
+
* indexed via the chain/force-include short-value exemption (see
|
|
4229
|
+
* `StateValue.eligibleConsumers`) — such a value cleared the ELIGIBILITY gate
|
|
4230
|
+
* via a name-free signal (a bare array index, a URL path segment), which says
|
|
4231
|
+
* nothing about whether the specific body key it's about to be spliced into
|
|
4232
|
+
* has anything to do with its own origin field. Requiring exact-token or
|
|
4233
|
+
* meaningful-substring correlation here is the second, independent check the
|
|
4234
|
+
* report calls for: an eligible value must still name/shape-correlate with
|
|
4235
|
+
* its actual splice target, not just have cleared chain detection for SOME
|
|
4236
|
+
* key in the target capture.
|
|
4237
|
+
*/
|
|
4238
|
+
function keysCorrelate(sourceName, targetKey) {
|
|
4239
|
+
const a = normalizeCorrelationToken(sourceName);
|
|
4240
|
+
const b = normalizeCorrelationToken(targetKey);
|
|
4241
|
+
if (a.length === 0 || b.length === 0)
|
|
4242
|
+
return false;
|
|
4243
|
+
if (a === b)
|
|
4244
|
+
return true;
|
|
4245
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
4246
|
+
return shorter.length >= 3 && longer.includes(shorter);
|
|
4247
|
+
}
|
|
4248
|
+
/** Finds the JSON key immediately governing the value at `matchStart` in a
|
|
4249
|
+
* (possibly partially-rewritten) JSON body template — the nearest preceding
|
|
4250
|
+
* `"key":` slot opener. Textual, not AST-based (matching this file's existing
|
|
4251
|
+
* `.split(target).join(replacement)` discipline elsewhere), which is
|
|
4252
|
+
* sufficient here: it only needs to identify the enclosing leaf's own key for
|
|
4253
|
+
* {@link keysCorrelate}'s name check, not to fully parse the document. */
|
|
4254
|
+
function findEnclosingJsonKey(text, matchStart) {
|
|
4255
|
+
const keyPattern = /"([^"\\]+)"\s*:\s*"?/g;
|
|
4256
|
+
let lastKey = null;
|
|
4257
|
+
let lastValueStart = -1;
|
|
4258
|
+
for (const m of text.matchAll(keyPattern)) {
|
|
4259
|
+
if (m.index === undefined || m.index >= matchStart)
|
|
4260
|
+
break;
|
|
4261
|
+
lastKey = m[1] ?? null;
|
|
4262
|
+
lastValueStart = m.index + m[0].length;
|
|
4263
|
+
}
|
|
4264
|
+
return lastValueStart <= matchStart ? lastKey : null;
|
|
4265
|
+
}
|
|
3924
4266
|
/**
|
|
3925
4267
|
* Finds every `${...}` span in `text` by brace-depth counting rather than a
|
|
3926
4268
|
* non-nesting regex, so an ALREADY-nested placeholder (e.g. one produced by an
|
|
@@ -3972,17 +4314,55 @@ function findBalancedPlaceholderSpans(text) {
|
|
|
3972
4314
|
* actually owns that span, which is the producer/consumer relationship this
|
|
3973
4315
|
* mechanism is supposed to encode — and guarantees the output can never open
|
|
3974
4316
|
* a `${` before a prior `${...}` closes.
|
|
3975
|
-
|
|
3976
|
-
|
|
4317
|
+
*
|
|
4318
|
+
* `keyCorrelationGuard`, when supplied, additionally requires a JSON-key
|
|
4319
|
+
* name/shape correlation before splicing a value flagged `restrictedValues`
|
|
4320
|
+
* — see {@link keysCorrelate}'s docstring for why: such a value cleared
|
|
4321
|
+
* ELIGIBILITY via a name-free chain signal (a bare array index, a URL path
|
|
4322
|
+
* segment) that says nothing about the specific key it's about to land
|
|
4323
|
+
* under. A match on a value not in `restrictedValues` (an unrestricted
|
|
4324
|
+
* payload accessor or a normally-length-qualified state value) is spliced
|
|
4325
|
+
* exactly as before — this guard only closes the coincidence-threading gap
|
|
4326
|
+
* for values that needed the short-value exemption to be indexed at all.
|
|
4327
|
+
*/
|
|
4328
|
+
function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue, keyCorrelationGuard) {
|
|
3977
4329
|
const protectedSpans = findBalancedPlaceholderSpans(text);
|
|
4330
|
+
const matches = [...text.matchAll(pattern)].filter((match) => !protectedSpans.some(([spanStart, spanEnd]) => match.index < spanEnd && match.index + match[0].length > spanStart));
|
|
4331
|
+
const hasCorrelatedTargetByValue = new Map();
|
|
4332
|
+
if (keyCorrelationGuard) {
|
|
4333
|
+
for (const match of matches) {
|
|
4334
|
+
const value = match[0];
|
|
4335
|
+
if (!keyCorrelationGuard.restrictedValues.has(value))
|
|
4336
|
+
continue;
|
|
4337
|
+
if (keyCorrelationGuard.unconditionalValues.has(value))
|
|
4338
|
+
continue;
|
|
4339
|
+
if (hasCorrelatedTargetByValue.get(value))
|
|
4340
|
+
continue;
|
|
4341
|
+
const sourceName = keyCorrelationGuard.sourceNameByValue.get(value);
|
|
4342
|
+
const targetKey = findEnclosingJsonKey(text, match.index);
|
|
4343
|
+
if (sourceName !== undefined && targetKey !== null && keysCorrelate(sourceName, targetKey)) {
|
|
4344
|
+
hasCorrelatedTargetByValue.set(value, true);
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
3978
4348
|
let result = "";
|
|
3979
4349
|
let cursor = 0;
|
|
3980
|
-
for (const match of
|
|
4350
|
+
for (const match of matches) {
|
|
3981
4351
|
const start = match.index;
|
|
3982
4352
|
const end = start + match[0].length;
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
4353
|
+
const value = match[0];
|
|
4354
|
+
const isGated = keyCorrelationGuard?.restrictedValues.has(value) &&
|
|
4355
|
+
(keyCorrelationGuard.unconditionalValues.has(value) || hasCorrelatedTargetByValue.get(value));
|
|
4356
|
+
if (isGated) {
|
|
4357
|
+
const sourceName = keyCorrelationGuard?.sourceNameByValue.get(value);
|
|
4358
|
+
const targetKey = findEnclosingJsonKey(text, start);
|
|
4359
|
+
if (sourceName === undefined || targetKey === null || !keysCorrelate(sourceName, targetKey)) {
|
|
4360
|
+
result += text.slice(cursor, end);
|
|
4361
|
+
cursor = end;
|
|
4362
|
+
continue;
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
result += text.slice(cursor, start) + (bindingByValue.get(value) ?? value);
|
|
3986
4366
|
cursor = end;
|
|
3987
4367
|
}
|
|
3988
4368
|
return result + text.slice(cursor);
|
|
@@ -4001,21 +4381,74 @@ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue
|
|
|
4001
4381
|
* for the anchoring guarantee and {@link replaceGuardedAgainstExistingPlaceholders}
|
|
4002
4382
|
* for why a match overlapping an already-emitted `${...}` is skipped rather
|
|
4003
4383
|
* than spliced into.
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4384
|
+
*
|
|
4385
|
+
* `isJsonBody` gates the additional {@link keysCorrelate} check
|
|
4386
|
+
* `replaceGuardedAgainstExistingPlaceholders` applies to `restricted` state
|
|
4387
|
+
* bindings (see {@link StateVarBinding}) — a value indexed only via the
|
|
4388
|
+
* chain/force-include short-value exemption must also name/shape-correlate
|
|
4389
|
+
* with the JSON key it's about to be spliced into, not merely have cleared
|
|
4390
|
+
* eligibility for SOME key in this capture. Only a JSON request body has
|
|
4391
|
+
* "keys" to correlate against; a URL or a raw header value has none, and a
|
|
4392
|
+
* bare-value splice into either is exactly the name-free URL-path-segment
|
|
4393
|
+
* threading the eligibility gate already intends to allow, so callers
|
|
4394
|
+
* rendering those pass `false` (the default).
|
|
4395
|
+
*
|
|
4396
|
+
* A value already bound to a `payload.<field>` accessor keeps that accessor
|
|
4397
|
+
* on EVERY call EXCEPT the value's own producing step, where
|
|
4398
|
+
* {@link deriveProducerBoundaryBindings} has deliberately scoped a binding to
|
|
4399
|
+
* THAT step (`producerIndex === stepIndex`, threaded in as `stepIndex` below):
|
|
4400
|
+
* the producer cannot thread its own not-yet-existent response, so its own
|
|
4401
|
+
* request body needs the ordinary produced-value handling elsewhere (see
|
|
4402
|
+
* {@link applyWholeValuePayloadSubstitutions}'s docstring) rather than a second,
|
|
4403
|
+
* redundant payload bind here. Every OTHER step — including every step AFTER
|
|
4404
|
+
* the producer that re-sends the same coordinate — keeps payload precedence:
|
|
4405
|
+
* a value legitimately sourced from `payload.<field>` on one call must resolve
|
|
4406
|
+
* to that same accessor on every call that re-sends it, never fall back to the
|
|
4407
|
+
* coincidentally-equal scraped `${var}` just because the value also happens to
|
|
4408
|
+
* qualify as a producer-boundary coordinate. A value with no producer-boundary
|
|
4409
|
+
* binding at all is a coincidental echo — never re-sent by the step whose
|
|
4410
|
+
* response produced it — so payload precedence is unconditional for it.
|
|
4411
|
+
*
|
|
4412
|
+
* `topLevelPayloadKvValues` extends that same precedence to values too SHORT
|
|
4413
|
+
* to ever enter `payloadAccessorByValue` (which only registers inputBody
|
|
4414
|
+
* string leaves >= MIN_STATE_VALUE_LENGTH): a short top-level scalar
|
|
4415
|
+
* (`{"currency":"usd"}`) is still unconditionally payload-ified by the later
|
|
4416
|
+
* `applyPayloadKeyValueSubstitutions` key/value pass, but only if its literal
|
|
4417
|
+
* survives THIS pass untouched. Without this set, a short value that also
|
|
4418
|
+
* clears the chain/force-include exemption as a coincidentally-equal
|
|
4419
|
+
* response-produced state var on an intervening call gets spliced here
|
|
4420
|
+
* first, and the literal is gone by the time the KV pass runs — masking
|
|
4421
|
+
* payload precedence on every call after the one that scraped it.
|
|
4422
|
+
*/
|
|
4423
|
+
function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map(), isJsonBody = false, producerBoundaryBindings = new Map(), stepIndex = -1, topLevelPayloadKvValues = new Set()) {
|
|
4424
|
+
const stateBindings = deriveStateVarByValue(priorSteps, targetCapture);
|
|
4007
4425
|
const bindingByValue = new Map();
|
|
4008
4426
|
for (const [value, accessor] of payloadAccessorByValue) {
|
|
4009
4427
|
bindingByValue.set(value, `\${${accessor}}`);
|
|
4010
4428
|
}
|
|
4011
|
-
|
|
4012
|
-
|
|
4429
|
+
const restrictedValues = new Set();
|
|
4430
|
+
const unconditionalValues = new Set();
|
|
4431
|
+
const sourceNameByValue = new Map();
|
|
4432
|
+
for (const [value, binding] of stateBindings) {
|
|
4433
|
+
const isProducerStep = producerBoundaryBindings.get(value)?.producerIndex === stepIndex;
|
|
4434
|
+
if (payloadAccessorByValue.has(value) && !isProducerStep)
|
|
4435
|
+
continue;
|
|
4436
|
+
if (topLevelPayloadKvValues.has(value) && !isProducerStep)
|
|
4437
|
+
continue;
|
|
4438
|
+
bindingByValue.set(value, `\${${binding.varName}}`);
|
|
4439
|
+
sourceNameByValue.set(value, binding.sourceName);
|
|
4440
|
+
if (binding.restricted)
|
|
4441
|
+
restrictedValues.add(value);
|
|
4442
|
+
if (binding.unconditional)
|
|
4443
|
+
unconditionalValues.add(value);
|
|
4013
4444
|
}
|
|
4014
4445
|
if (bindingByValue.size === 0)
|
|
4015
4446
|
return template;
|
|
4016
4447
|
const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
|
|
4017
4448
|
const pattern = buildValueAlternationPattern(sortedValues);
|
|
4018
|
-
return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue
|
|
4449
|
+
return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue, isJsonBody && restrictedValues.size > 0
|
|
4450
|
+
? { restrictedValues, sourceNameByValue, unconditionalValues }
|
|
4451
|
+
: undefined);
|
|
4019
4452
|
}
|
|
4020
4453
|
/**
|
|
4021
4454
|
* Rewrites every occurrence of a set of literal values to their accessor
|
|
@@ -4180,11 +4613,14 @@ function deriveProducerBoundaryBindings(actions, alreadyBound) {
|
|
|
4180
4613
|
* the exact `"<key>":` slot, so it only fires on a value's own JSON slot.
|
|
4181
4614
|
*
|
|
4182
4615
|
* `producerScoped` bindings fire only on their producing step
|
|
4183
|
-
* (`producerIndex === stepIndex`):
|
|
4184
|
-
*
|
|
4185
|
-
*
|
|
4186
|
-
*
|
|
4187
|
-
*
|
|
4616
|
+
* (`producerIndex === stepIndex`): only the producer, which cannot thread its
|
|
4617
|
+
* own not-yet-existent response, needs this whole-value bind. A later step
|
|
4618
|
+
* re-sending the same coordinate still resolves to `payload.<field>` — via
|
|
4619
|
+
* {@link interpolateStateValues}'s own producer-scoped exemption, which lets
|
|
4620
|
+
* the produced state var win ONLY on the producing step itself — never via a
|
|
4621
|
+
* second whole-value bind here. `entryUrlBindings` (a caller coordinate lifted
|
|
4622
|
+
* from the entry URL) fire on EVERY step — they are the caller's data on every
|
|
4623
|
+
* request, never a produced var.
|
|
4188
4624
|
*/
|
|
4189
4625
|
function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScoped, entryUrlBindings, stepIndex) {
|
|
4190
4626
|
if (producerScoped.size === 0 && entryUrlBindings.size === 0)
|
|
@@ -4210,23 +4646,6 @@ function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScope
|
|
|
4210
4646
|
* before giving up. Real captures observed a doubly-encoded value (`%2520`); the
|
|
4211
4647
|
* extra headroom costs one cheap `decodeURIComponent` per level and stops runaway. */
|
|
4212
4648
|
const MAX_URL_PARAM_DECODE_DEPTH = 3;
|
|
4213
|
-
/**
|
|
4214
|
-
* Maps each response-produced value to the `${var}` name later steps thread it as.
|
|
4215
|
-
* Shared by {@link interpolateStateValues} (the body/URL substitution) and the
|
|
4216
|
-
* URL-param pass, so a threaded coordinate (e.g. a jobId a prior step produced)
|
|
4217
|
-
* resolves to the same var in both — one source of truth, they can never diverge.
|
|
4218
|
-
*
|
|
4219
|
-
* Header/cookie-origin produces are skipped: they have no body path and their
|
|
4220
|
-
* value never appears as a literal in a URL/body template (http-client's `bind`
|
|
4221
|
-
* forwards it directly as a request header), so there is nothing to interpolate.
|
|
4222
|
-
*
|
|
4223
|
-
* `targetCapture` is the capture the returned bindings are about to be spliced
|
|
4224
|
-
* INTO. A produce whose value is chain/force-include-exempt (see
|
|
4225
|
-
* `BodyProduce.eligibleConsumers`) is a real dependency only for the specific
|
|
4226
|
-
* capture(s) the chain detector proved it threads into — everywhere else, a
|
|
4227
|
-
* coincidental substring match must not bind, or `interpolateStateValues`
|
|
4228
|
-
* splices it into an unrelated capture's URL/body/headers.
|
|
4229
|
-
*/
|
|
4230
4649
|
function deriveStateVarByValue(priorSteps, targetCapture) {
|
|
4231
4650
|
const varNameByValue = new Map();
|
|
4232
4651
|
for (const step of priorSteps) {
|
|
@@ -4236,8 +4655,34 @@ function deriveStateVarByValue(priorSteps, targetCapture) {
|
|
|
4236
4655
|
if (p.eligibleConsumers && !p.eligibleConsumers.has(targetCapture))
|
|
4237
4656
|
continue;
|
|
4238
4657
|
const value = resolveResponsePathValue(step.capture.responseBody, p.path);
|
|
4239
|
-
if (value !== null)
|
|
4240
|
-
|
|
4658
|
+
if (value !== null) {
|
|
4659
|
+
// A source path with NO identifier segment anywhere (every segment a
|
|
4660
|
+
// bare array index — e.g. a top-level array response `[42]`, path
|
|
4661
|
+
// `["0"]`) has no name of its own to correlate against at all; that's
|
|
4662
|
+
// the genuinely name-free case `collectDependentDrillDownChainValues`'s
|
|
4663
|
+
// arrayIndexMatch exists for (see its docstring), not a named field
|
|
4664
|
+
// that merely sits inside an array. Only a source WITH a real
|
|
4665
|
+
// ancestor name (e.g. `flags` in `["flags","0"]`) must correlate —
|
|
4666
|
+
// its name existing at all is exactly the signal a target key
|
|
4667
|
+
// coincidence has to match to be a genuine splice, not a bare
|
|
4668
|
+
// array-index/path-segment eligibility coincidence.
|
|
4669
|
+
const sourceHasName = p.path.some((segment) => isValidJsIdentifier(segment));
|
|
4670
|
+
varNameByValue.set(value, {
|
|
4671
|
+
varName: p.name,
|
|
4672
|
+
sourceName: p.name,
|
|
4673
|
+
// Requiring name/shape correlation at the splice site is not just
|
|
4674
|
+
// for the chain/force-include short-value exemption — a
|
|
4675
|
+
// naturally-length-qualified value that legitimately correlates
|
|
4676
|
+
// with ONE downstream key (which is what got it produced at all,
|
|
4677
|
+
// see `compileActionSteps`' `keyNamesCorrelate` pre-scan) must not
|
|
4678
|
+
// also splice into an unrelated, differently-named key that merely
|
|
4679
|
+
// coincides in value. `sourceHasName` gates this the same way it
|
|
4680
|
+
// gates the chain-derived case: a name-free source (a bare array
|
|
4681
|
+
// index) has nothing to correlate, so it stays unrestricted.
|
|
4682
|
+
restricted: sourceHasName,
|
|
4683
|
+
unconditional: p.eligibleConsumers !== undefined && sourceHasName,
|
|
4684
|
+
});
|
|
4685
|
+
}
|
|
4241
4686
|
}
|
|
4242
4687
|
}
|
|
4243
4688
|
return varNameByValue;
|
|
@@ -4360,21 +4805,22 @@ function applyUrlParamPayloadSubstitutions(template, parsedBody, bindings) {
|
|
|
4360
4805
|
* top-level keys also become caller-supplied payload fields. Used in Phase F
|
|
4361
4806
|
* to parameterize fields like SourceCode that appear in r1's body but not
|
|
4362
4807
|
* r0's (inputBody).
|
|
4808
|
+
*
|
|
4809
|
+
* Registration is keyed per (key, value) pair, not per key alone: a field
|
|
4810
|
+
* name reused across two-plus steps with a DIFFERENT literal value on each
|
|
4811
|
+
* occurrence must have EVERY one of its own occurrences registered and
|
|
4812
|
+
* substituted, not just whichever occurrence this function's own body-array
|
|
4813
|
+
* walk reaches first. A first-seen-value-wins table would only ever match
|
|
4814
|
+
* (and thus only ever register) the ONE step whose literal happens to equal
|
|
4815
|
+
* that first-seen value — every other step's own `"key":<its own value>`
|
|
4816
|
+
* text would silently never become a `${payload.key}` reference at all here,
|
|
4817
|
+
* even though the field genuinely IS one this step's own request sends as
|
|
4818
|
+
* caller-supplied data.
|
|
4363
4819
|
*/
|
|
4364
4820
|
function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map()) {
|
|
4365
4821
|
const merged = [];
|
|
4366
|
-
const
|
|
4367
|
-
|
|
4368
|
-
// Only NEW keys need to be added to discovered-form-fields — inputBody's
|
|
4369
|
-
// own keys stay internal to the site request template, not the public
|
|
4370
|
-
// payload schema (see basePayloadSchemaExpr in emitContractTs).
|
|
4371
|
-
if (inputBody !== null && typeof inputBody === "object" && !Array.isArray(inputBody)) {
|
|
4372
|
-
for (const { path } of walkAllPrimitiveLeaves(inputBody)) {
|
|
4373
|
-
if (path.length === 1)
|
|
4374
|
-
seenKeys.add(path[0]);
|
|
4375
|
-
}
|
|
4376
|
-
}
|
|
4377
|
-
const inputBodyKeys = new Set(seenKeys);
|
|
4822
|
+
const seenPairs = new Set();
|
|
4823
|
+
const seenValueByKey = new Map();
|
|
4378
4824
|
const allBodies = [inputBody, ...additionalBodies];
|
|
4379
4825
|
for (const body of allBodies) {
|
|
4380
4826
|
if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -4386,26 +4832,45 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
|
|
|
4386
4832
|
const key = path[0];
|
|
4387
4833
|
if (!isValidJsIdentifier(key))
|
|
4388
4834
|
continue;
|
|
4389
|
-
if (
|
|
4835
|
+
if (value === null)
|
|
4390
4836
|
continue;
|
|
4391
|
-
//
|
|
4392
|
-
|
|
4837
|
+
// Dedupe identical (key, value) pairs only — a repeated occurrence of
|
|
4838
|
+
// the SAME literal value for a key across bodies needs no second
|
|
4839
|
+
// substitution pass, but a DIFFERENT value under the same key is its
|
|
4840
|
+
// own distinct step's own occurrence and must still get one. EXCEPT a
|
|
4841
|
+
// pagination-cursor-shaped key ({@link PAGINATION_FIELD_NAME_PATTERN},
|
|
4842
|
+
// e.g. `page`/`offset`/`cursor`) whose value differs from the
|
|
4843
|
+
// first-seen one: that shape is a same-endpoint re-query bump (see
|
|
4844
|
+
// {@link isRedundantSameEndpointGroup}'s pagination-vs-payload
|
|
4845
|
+
// distinction), not a genuinely different step's own caller data —
|
|
4846
|
+
// aliasing both occurrences to the SAME `payload.<key>` accessor would
|
|
4847
|
+
// make the generated re-query call replay the FIRST page's request
|
|
4848
|
+
// instead of advancing to the next one, so the later occurrence stays
|
|
4849
|
+
// an unsubstituted literal, matching this key's pre-fix behavior.
|
|
4850
|
+
const priorValue = seenValueByKey.get(key);
|
|
4851
|
+
if (priorValue !== undefined &&
|
|
4852
|
+
priorValue !== value &&
|
|
4853
|
+
PAGINATION_FIELD_NAME_PATTERN.test(key)) {
|
|
4393
4854
|
continue;
|
|
4394
|
-
|
|
4395
|
-
|
|
4855
|
+
}
|
|
4856
|
+
seenValueByKey.set(key, value);
|
|
4857
|
+
const pairKey = `${key} ${typeof value} ${value}`;
|
|
4858
|
+
if (seenPairs.has(pairKey))
|
|
4396
4859
|
continue;
|
|
4860
|
+
seenPairs.add(pairKey);
|
|
4397
4861
|
merged.push([key, value]);
|
|
4398
|
-
// Record
|
|
4399
|
-
// can add
|
|
4400
|
-
//
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4862
|
+
// Record every substituted key, including inputBody's own, so the
|
|
4863
|
+
// contract emitter can add it to the payload schema. inputBody keys
|
|
4864
|
+
// that ARE covered by basePayloadSchemaExpr (the ApplicantContactSchema
|
|
4865
|
+
// case) are filtered back out at the emitContractTs merge point via
|
|
4866
|
+
// isReservedByApplicantContactSchema — this function has no visibility
|
|
4867
|
+
// into that flag, so it must not special-case inputBody's own keys.
|
|
4868
|
+
if (typeof value === "string")
|
|
4869
|
+
outAdditionalKeys.set(key, "string");
|
|
4870
|
+
else if (typeof value === "number")
|
|
4871
|
+
outAdditionalKeys.set(key, "number");
|
|
4872
|
+
else if (typeof value === "boolean")
|
|
4873
|
+
outAdditionalKeys.set(key, "boolean");
|
|
4409
4874
|
}
|
|
4410
4875
|
}
|
|
4411
4876
|
let result = template;
|
|
@@ -4601,11 +5066,17 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
|
|
|
4601
5066
|
// then fall back to the bare last segment.
|
|
4602
5067
|
const lastSegment = segments[segments.length - 1];
|
|
4603
5068
|
const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
5069
|
+
// Every intermediate hop off the (unknown-typed) candidate needs
|
|
5070
|
+
// re-asserting back to `Record<string, unknown>` before the next bracket
|
|
5071
|
+
// access — see {@link unknownValueAccessor}'s doc for why a bare chain of
|
|
5072
|
+
// `?.[...]` accessors fails to typecheck past the first segment.
|
|
5073
|
+
const nestedAccessor = segments.reduce((expr, segment, index) => {
|
|
5074
|
+
const isLast = index === segments.length - 1;
|
|
5075
|
+
const accessor = index === 0 ? bracket(segment) : `?.[${JSON.stringify(segment)}]`;
|
|
5076
|
+
return isLast ? `${expr}${accessor}` : `(${expr}${accessor} as Record<string, unknown>)`;
|
|
5077
|
+
}, varName);
|
|
4607
5078
|
return segments.length > 1
|
|
4608
|
-
? `(${
|
|
5079
|
+
? `(${nestedAccessor} ?? ${varName}${bracket(lastSegment)})`
|
|
4609
5080
|
: `${varName}${bracket(lastSegment)}`;
|
|
4610
5081
|
};
|
|
4611
5082
|
const joinCondition = target.joinFields
|
|
@@ -4628,7 +5099,18 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
|
|
|
4628
5099
|
}
|
|
4629
5100
|
/** Exported for unit testing — lets tests drive the multipart-upload code path directly
|
|
4630
5101
|
* without going through the full emitContractTs pipeline. */
|
|
4631
|
-
function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null
|
|
5102
|
+
function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null,
|
|
5103
|
+
/**
|
|
5104
|
+
* PascalCase plugin name used to cast the final `return { data: ... }`
|
|
5105
|
+
* back to `${pascalName}Response` — every intermediate `httpClient` call
|
|
5106
|
+
* this function emits is bound `as Record<string, unknown>` so per-item
|
|
5107
|
+
* fold/merge code can probe arbitrary fields, but that cast otherwise
|
|
5108
|
+
* widens the returned primary var past the richer response type the
|
|
5109
|
+
* caller's own schema inference already promised, which fails to
|
|
5110
|
+
* typecheck. `null` (the test-facing default) skips the cast, preserving
|
|
5111
|
+
* prior output for callers that don't exercise the full pipeline.
|
|
5112
|
+
*/
|
|
5113
|
+
pascalName = null) {
|
|
4632
5114
|
// Walk the first action's request body to map each leaf string value to its
|
|
4633
5115
|
// `payload.<accessor>` expression. The emit's second interpolation pass uses
|
|
4634
5116
|
// this to substitute literal occurrences (e.g. "Reginald") with their
|
|
@@ -4646,8 +5128,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4646
5128
|
for (const { value, path } of walkStringLeaves(inputBody)) {
|
|
4647
5129
|
if (value.length < MIN_STATE_VALUE_LENGTH)
|
|
4648
5130
|
continue;
|
|
4649
|
-
const accessor =
|
|
5131
|
+
const { accessor, field: accessorField } = payloadAccessorForPath(path);
|
|
4650
5132
|
payloadAccessorByValue.set(value, accessor);
|
|
5133
|
+
if (isValidJsIdentifier(accessorField))
|
|
5134
|
+
outDiscoveredFields.add(accessorField);
|
|
4651
5135
|
// Phase F: register a lowercase variant for UUID-shaped values so case-
|
|
4652
5136
|
// variant URL path segments (e.g. r9 echoes the requisition UUID in
|
|
4653
5137
|
// lowercase even though r0's body had it uppercase) still get
|
|
@@ -4770,6 +5254,32 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4770
5254
|
// skip non-JSON bodies (e.g. multipart raw bytes)
|
|
4771
5255
|
}
|
|
4772
5256
|
}
|
|
5257
|
+
// Every value `applyPayloadKeyValueSubstitutions` will unconditionally
|
|
5258
|
+
// payload-ify from the ENTRY payload's own top-level key/value pairs,
|
|
5259
|
+
// gathered here so {@link interpolateStateValues} can give it payload
|
|
5260
|
+
// precedence too, regardless of MIN_STATE_VALUE_LENGTH — see
|
|
5261
|
+
// {@link interpolateStateValues}'s `topLevelPayloadKvValues` docstring for
|
|
5262
|
+
// why this must run BEFORE that later pass, not just alongside it.
|
|
5263
|
+
// `inputBody` only, deliberately NOT `additionalBodies`: a later call's own
|
|
5264
|
+
// top-level field is exactly as likely to be a genuinely re-sent
|
|
5265
|
+
// response-produced state value (draftId minted by an earlier call and
|
|
5266
|
+
// resent as that later call's own `applicationDraftId`) as a caller-
|
|
5267
|
+
// supplied literal, so extending precedence to it would wrongly freeze
|
|
5268
|
+
// real cross-step state threading. Only the caller's OWN entry payload is
|
|
5269
|
+
// an unambiguous non-state origin.
|
|
5270
|
+
const topLevelPayloadKvValues = new Set();
|
|
5271
|
+
if (inputBody !== undefined && inputBody !== null && !Array.isArray(inputBody)) {
|
|
5272
|
+
for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
|
|
5273
|
+
if (path.length !== 1)
|
|
5274
|
+
continue;
|
|
5275
|
+
const key = path[0];
|
|
5276
|
+
if (!isValidJsIdentifier(key))
|
|
5277
|
+
continue;
|
|
5278
|
+
if (value === null)
|
|
5279
|
+
continue;
|
|
5280
|
+
topLevelPayloadKvValues.add(String(value));
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
4773
5283
|
// Detect the flow's THREADED transaction id: a single UUID the site mints
|
|
4774
5284
|
// once (on page load) and reuses across every submit body to correlate the
|
|
4775
5285
|
// multi-step wizard — observed on real ATS flows where one such id spans
|
|
@@ -4877,7 +5387,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4877
5387
|
// length/chain-eligibility scoping doesn't happen to catch the coincidence.
|
|
4878
5388
|
const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, actions.map((a) => a.capture))
|
|
4879
5389
|
? cap.url
|
|
4880
|
-
: interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue);
|
|
5390
|
+
: interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
|
|
4881
5391
|
// Form-schema substitution runs first on the raw recon body so its
|
|
4882
5392
|
// field-id-anchored matches see the original JSON. State-threading and
|
|
4883
5393
|
// payload key-value passes then run on top. Option-id substitution runs
|
|
@@ -4940,14 +5450,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4940
5450
|
if (binding.producerIndex === i)
|
|
4941
5451
|
urlParamBindings.set(value, binding.accessor);
|
|
4942
5452
|
}
|
|
4943
|
-
for (const [value,
|
|
4944
|
-
urlParamBindings.set(value, varName);
|
|
5453
|
+
for (const [value, binding] of deriveStateVarByValue(prior, cap)) {
|
|
5454
|
+
urlParamBindings.set(value, binding.varName);
|
|
4945
5455
|
}
|
|
4946
5456
|
const rawBodyWithUrlParams = parsedBody !== null
|
|
4947
5457
|
? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
|
|
4948
5458
|
: rawBodyWithProducerBoundary;
|
|
4949
5459
|
const bodyAfterStateAndKv = rawBodyWithUrlParams
|
|
4950
|
-
? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
|
|
5460
|
+
? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
|
|
4951
5461
|
: "";
|
|
4952
5462
|
// Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
|
|
4953
5463
|
// rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
|
|
@@ -4975,8 +5485,22 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
4975
5485
|
const perCallHeaders = {};
|
|
4976
5486
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
4977
5487
|
const lower = k.toLowerCase();
|
|
4978
|
-
|
|
4979
|
-
|
|
5488
|
+
const interpolated = interpolateStateValues(v, prior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
|
|
5489
|
+
// Authorization/Api-Token and a structurally-detected join-carrying
|
|
5490
|
+
// header are always emitted per-call (even when interpolation finds
|
|
5491
|
+
// nothing to thread, matching this gate's prior behavior exactly).
|
|
5492
|
+
// Any OTHER header name also gets a per-call entry once interpolation
|
|
5493
|
+
// actually recognizes its value as a prior step's produced state var
|
|
5494
|
+
// — interpolateStateValues itself is the source of truth for whether
|
|
5495
|
+
// a value is genuinely threadable; restricting that recognition to a
|
|
5496
|
+
// closed set of header names left every other header name frozen as
|
|
5497
|
+
// a literal BASE_HEADERS entry (or dropped per-call) even when its
|
|
5498
|
+
// captured value was a real, already-produced response field.
|
|
5499
|
+
if (lower === "api-token" ||
|
|
5500
|
+
lower === "authorization" ||
|
|
5501
|
+
joinCarryingHeaderNames?.has(k) ||
|
|
5502
|
+
interpolated !== v) {
|
|
5503
|
+
perCallHeaders[k] = interpolated;
|
|
4980
5504
|
}
|
|
4981
5505
|
}
|
|
4982
5506
|
// G1: emit baseUrl-derived headers (Origin, Referer) per-call from
|
|
@@ -5178,7 +5702,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
5178
5702
|
// colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
|
|
5179
5703
|
// single-target case keeps the original unsuffixed names.
|
|
5180
5704
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
5181
|
-
|
|
5705
|
+
// Only `itemVar` (and fold-match candidates) are `Record<string,
|
|
5706
|
+
// unknown>`-typed — ancestor loop vars keep the real response-derived
|
|
5707
|
+
// type, so re-asserting THEIR intermediate hops would be both
|
|
5708
|
+
// unnecessary and, worse, would replace a real property access with
|
|
5709
|
+
// an opaque cast in the emitted URL/body text.
|
|
5710
|
+
const scopedAccessor = (varName, field) => varName === itemVar
|
|
5711
|
+
? unknownValueAccessor(varName, field.split("."))
|
|
5712
|
+
: `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
5182
5713
|
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
5183
5714
|
// Computed once per fold target instead of once per `parameterize`
|
|
5184
5715
|
// call: `actions` never changes across the url/headers/body calls a
|
|
@@ -5370,7 +5901,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
5370
5901
|
if (!referencedNames.has(p.name))
|
|
5371
5902
|
continue;
|
|
5372
5903
|
chainDeclared.add(p.name);
|
|
5373
|
-
const assertion = pathToAssertionType(p.path);
|
|
5904
|
+
const assertion = pathToAssertionType(p.path, p.leafType);
|
|
5374
5905
|
chainLines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
|
|
5375
5906
|
}
|
|
5376
5907
|
}
|
|
@@ -5412,7 +5943,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
5412
5943
|
if (!referencedNames.has(p.name))
|
|
5413
5944
|
continue;
|
|
5414
5945
|
declaredNames.add(p.name);
|
|
5415
|
-
const assertion = pathToAssertionType(p.path);
|
|
5946
|
+
const assertion = pathToAssertionType(p.path, p.leafType);
|
|
5416
5947
|
produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
|
|
5417
5948
|
}
|
|
5418
5949
|
const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
|
|
@@ -5439,8 +5970,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
5439
5970
|
const perCallHeaderEntries = [];
|
|
5440
5971
|
for (const [k, v] of Object.entries(cap.requestHeaders)) {
|
|
5441
5972
|
const lower = k.toLowerCase();
|
|
5442
|
-
|
|
5443
|
-
|
|
5973
|
+
const interpolated = interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
|
|
5974
|
+
// Mirrors the non-multipart per-call header builder above: any
|
|
5975
|
+
// header name (not just Authorization/Api-Token) that interpolation
|
|
5976
|
+
// recognizes as a prior step's produced state var must thread
|
|
5977
|
+
// per-call too, or its custom name freezes into an invariant
|
|
5978
|
+
// BASE_HEADERS-equivalent literal.
|
|
5979
|
+
if (lower === "api-token" || lower === "authorization" || interpolated !== v) {
|
|
5980
|
+
perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolated}\``);
|
|
5444
5981
|
}
|
|
5445
5982
|
}
|
|
5446
5983
|
// G1+G2: include tenant-derived headers in the multipart fetch too.
|
|
@@ -5527,16 +6064,27 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
|
|
|
5527
6064
|
...new Set(foldPlans.map((plan) => actions[plan.primaryStepIndex].varName)),
|
|
5528
6065
|
];
|
|
5529
6066
|
const everyPrimaryIsPlainObject = foldPlans.every((plan) => isPlainObject(actions[plan.primaryStepIndex].capture.responseBody));
|
|
6067
|
+
// Every intermediate `httpClient` call above is bound `as Record<string,
|
|
6068
|
+
// unknown>` regardless of its own `schema:`, so per-item fold/merge code
|
|
6069
|
+
// can probe arbitrary fields without a per-step assertion type. That
|
|
6070
|
+
// widened intermediate type doesn't match `${pascalName}Response` — the
|
|
6071
|
+
// richer type schema inference already promised for THIS returned value —
|
|
6072
|
+
// so the return itself needs its own assertion back to that promised
|
|
6073
|
+
// type; `Record<string, unknown>` and the real inferred object type share
|
|
6074
|
+
// no ancestry TS can see, so a plain `as` needs the `as unknown as` detour.
|
|
6075
|
+
const castToResponseType = (expr) => pascalName ? `${expr} as unknown as ${pascalName}Response` : expr;
|
|
5530
6076
|
if (uniquePrimaryVarNames.length > 1 && everyPrimaryIsPlainObject) {
|
|
5531
|
-
lines.push(` return { data: mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")}) };`);
|
|
6077
|
+
lines.push(` return { data: ${castToResponseType(`mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")})`)} };`);
|
|
5532
6078
|
}
|
|
5533
6079
|
else {
|
|
5534
6080
|
const returnVar = lastFoldPlan
|
|
5535
6081
|
? actions[lastFoldPlan.primaryStepIndex].varName
|
|
5536
6082
|
: (returnAction?.varName ?? "undefined");
|
|
5537
|
-
lines.push(` return { data: ${returnVar} };`);
|
|
6083
|
+
lines.push(` return { data: ${castToResponseType(returnVar)} };`);
|
|
5538
6084
|
}
|
|
5539
|
-
|
|
6085
|
+
const renderedMultiStepBody = lines.join("\n");
|
|
6086
|
+
assertBodyFieldSourceNameCorrelates("emitMultiStepExecuteHttp", renderedMultiStepBody);
|
|
6087
|
+
return renderedMultiStepBody;
|
|
5540
6088
|
}
|
|
5541
6089
|
function summariseResponseShape(value) {
|
|
5542
6090
|
if (value === null || typeof value !== "object")
|
|
@@ -5897,13 +6445,96 @@ function findObjectArrayFieldOrWholeObject(value, path = []) {
|
|
|
5897
6445
|
* other capture of this endpoint exists in `allCaptures`, variance can't be
|
|
5898
6446
|
* observed either way, so every value is kept (unfiltered, matching the
|
|
5899
6447
|
* behavior when `allCaptures` is omitted). */
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
6448
|
+
/** Per-`allCaptures`-array grouping of captures by {@link endpointKey}, built
|
|
6449
|
+
* once per distinct `allCaptures` identity rather than re-filtering the
|
|
6450
|
+
* whole array on every {@link sameEndpointCapturesFor} call — the O(n)
|
|
6451
|
+
* per-call filter otherwise makes every caller that invokes it once per
|
|
6452
|
+
* capture (e.g. {@link findThreadedJoinFields}) O(n^2) overall. */
|
|
6453
|
+
const endpointGroupsCache = new WeakMap();
|
|
6454
|
+
function endpointGroupsFor(allCaptures) {
|
|
6455
|
+
const cached = endpointGroupsCache.get(allCaptures);
|
|
6456
|
+
if (cached)
|
|
6457
|
+
return cached;
|
|
6458
|
+
const groups = new Map();
|
|
6459
|
+
for (const c of allCaptures) {
|
|
6460
|
+
const key = endpointKey(c.url);
|
|
6461
|
+
const group = groups.get(key) ?? [];
|
|
6462
|
+
group.push(c);
|
|
6463
|
+
groups.set(key, group);
|
|
6464
|
+
}
|
|
6465
|
+
endpointGroupsCache.set(allCaptures, groups);
|
|
6466
|
+
return groups;
|
|
6467
|
+
}
|
|
6468
|
+
function sameEndpointCapturesFor(capture, allCaptures) {
|
|
6469
|
+
if (!allCaptures)
|
|
6470
|
+
return [];
|
|
6471
|
+
const group = endpointGroupsFor(allCaptures).get(endpointKey(capture.url)) ?? [];
|
|
6472
|
+
return group.filter((c) => c !== capture);
|
|
6473
|
+
}
|
|
6474
|
+
/** True when `own` differs from at least one same-endpoint sibling's value at
|
|
6475
|
+
* the same location — the cross-capture variance test {@link
|
|
6476
|
+
* collectRequestStringValues} and {@link collectRequestBodyValuesByKey} both
|
|
6477
|
+
* apply to their respective candidate values. When no sibling capture exists
|
|
6478
|
+
* (or `allCaptures` was omitted), variance can't be observed either way, so
|
|
6479
|
+
* every value passes (matching the unfiltered behavior when `allCaptures` is
|
|
6480
|
+
* omitted). */
|
|
6481
|
+
function requestValueVaries(own, others, sameEndpointCapturesLength) {
|
|
6482
|
+
return sameEndpointCapturesLength === 0
|
|
5905
6483
|
? true
|
|
5906
6484
|
: others.some((other) => other !== undefined && other !== own);
|
|
6485
|
+
}
|
|
6486
|
+
/** Memoizes `JSON.parse(capture.requestPostData)` per capture — the same
|
|
6487
|
+
* capture is re-parsed once per SIBLING lookup by every same-endpoint
|
|
6488
|
+
* caller in {@link collectRequestBodyValuesByKey}'s leaf loop, so without
|
|
6489
|
+
* this cache a group of N same-endpoint captures re-parses each sibling's
|
|
6490
|
+
* body N times over (once per outer capture in the group). `undefined`
|
|
6491
|
+
* means "not a parseable JSON body", the same non-JSON signal the
|
|
6492
|
+
* unmemoized inline parse used to produce. */
|
|
6493
|
+
const parsedRequestBodyCache = new WeakMap();
|
|
6494
|
+
const PARSE_FAILED = Symbol("parse-failed");
|
|
6495
|
+
function parsedRequestBodyFor(capture) {
|
|
6496
|
+
if (parsedRequestBodyCache.has(capture)) {
|
|
6497
|
+
const cached = parsedRequestBodyCache.get(capture);
|
|
6498
|
+
return cached === PARSE_FAILED ? undefined : cached;
|
|
6499
|
+
}
|
|
6500
|
+
const parsed = (() => {
|
|
6501
|
+
if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
|
|
6502
|
+
return PARSE_FAILED;
|
|
6503
|
+
}
|
|
6504
|
+
try {
|
|
6505
|
+
return JSON.parse(capture.requestPostData);
|
|
6506
|
+
}
|
|
6507
|
+
catch {
|
|
6508
|
+
return PARSE_FAILED;
|
|
6509
|
+
}
|
|
6510
|
+
})();
|
|
6511
|
+
parsedRequestBodyCache.set(capture, parsed);
|
|
6512
|
+
return parsed === PARSE_FAILED ? undefined : parsed;
|
|
6513
|
+
}
|
|
6514
|
+
/** Per-(capture, allCaptures-identity) memoization for {@link
|
|
6515
|
+
* collectRequestUrlValues} and {@link collectRequestBodyValuesByKey} —
|
|
6516
|
+
* {@link findThreadedJoinFields} calls both fresh on every invocation and is
|
|
6517
|
+
* itself invoked once per fold/drill-loop item across several call sites, so
|
|
6518
|
+
* without caching the same capture's URL/body values are recomputed (and,
|
|
6519
|
+
* for the body, re-walked and every sibling re-parsed) once per call. */
|
|
6520
|
+
const requestUrlValuesCache = new WeakMap();
|
|
6521
|
+
const requestBodyValuesByKeyCache = new WeakMap();
|
|
6522
|
+
const NO_ALL_CAPTURES = Object.freeze([]);
|
|
6523
|
+
/** The path-segment and query-parameter values present in `capture`'s own
|
|
6524
|
+
* URL — the name-free half of {@link collectRequestStringValues}'s candidate
|
|
6525
|
+
* set. Kept separate from the JSON body's leaf values so callers needing a
|
|
6526
|
+
* by-key correlation gate on the body (e.g. {@link findThreadedJoinFields})
|
|
6527
|
+
* can still treat URL/query matches as the name-free signal they've always
|
|
6528
|
+
* been — a REST-style `/orders/{id}` path segment or query param carries no
|
|
6529
|
+
* JSON key to correlate against in the first place. */
|
|
6530
|
+
function collectRequestUrlValues(capture, allCaptures) {
|
|
6531
|
+
const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
|
|
6532
|
+
const perCaptureCache = requestUrlValuesCache.get(capture) ?? new WeakMap();
|
|
6533
|
+
requestUrlValuesCache.set(capture, perCaptureCache);
|
|
6534
|
+
const cached = perCaptureCache.get(cacheKey);
|
|
6535
|
+
if (cached)
|
|
6536
|
+
return cached;
|
|
6537
|
+
const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
|
|
5907
6538
|
const values = new Set();
|
|
5908
6539
|
try {
|
|
5909
6540
|
const url = new URL(capture.url);
|
|
@@ -5918,45 +6549,82 @@ function collectRequestStringValues(capture, allCaptures) {
|
|
|
5918
6549
|
return undefined;
|
|
5919
6550
|
}
|
|
5920
6551
|
});
|
|
5921
|
-
if (
|
|
6552
|
+
if (requestValueVaries(value, otherValues, sameEndpointCaptures.length))
|
|
5922
6553
|
values.add(value);
|
|
5923
6554
|
}
|
|
5924
6555
|
}
|
|
5925
6556
|
catch {
|
|
5926
6557
|
// Relative or malformed URL — no query params or path segments to contribute.
|
|
5927
6558
|
}
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
6559
|
+
perCaptureCache.set(cacheKey, values);
|
|
6560
|
+
return values;
|
|
6561
|
+
}
|
|
6562
|
+
/** Same JSON-body walk and cross-capture variance gate as {@link
|
|
6563
|
+
* collectRequestStringValues}'s body block, but grouped by the JSON
|
|
6564
|
+
* key/array-index that carries each leaf value (see {@link
|
|
6565
|
+
* jsonBodyLeafValuesByKey}'s same grouping) — the by-key candidate set
|
|
6566
|
+
* {@link findThreadedJoinFields} correlates a threaded field's own name
|
|
6567
|
+
* against, so a value that only coincidentally equals something in an
|
|
6568
|
+
* UNRELATED body field can't be threaded onto it. Returns `null` when
|
|
6569
|
+
* `capture.requestPostData` isn't parseable JSON, the same non-JSON signal
|
|
6570
|
+
* {@link jsonBodyLeafValuesByKey} returns. */
|
|
6571
|
+
function collectRequestBodyValuesByKey(capture, allCaptures) {
|
|
6572
|
+
if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
|
|
6573
|
+
return null;
|
|
6574
|
+
}
|
|
6575
|
+
const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
|
|
6576
|
+
const perCaptureCache = requestBodyValuesByKeyCache.get(capture) ?? new WeakMap();
|
|
6577
|
+
requestBodyValuesByKeyCache.set(capture, perCaptureCache);
|
|
6578
|
+
if (perCaptureCache.has(cacheKey))
|
|
6579
|
+
return perCaptureCache.get(cacheKey);
|
|
6580
|
+
const parsedBody = parsedRequestBodyFor(capture);
|
|
6581
|
+
if (parsedBody === undefined) {
|
|
6582
|
+
perCaptureCache.set(cacheKey, null);
|
|
6583
|
+
return null;
|
|
6584
|
+
}
|
|
6585
|
+
const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
|
|
6586
|
+
const byKey = new Map();
|
|
6587
|
+
for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
|
|
6588
|
+
if (value === null || path.length === 0)
|
|
6589
|
+
continue;
|
|
6590
|
+
const stringValue = String(value);
|
|
6591
|
+
const otherValues = sameEndpointCaptures.map((c) => {
|
|
6592
|
+
try {
|
|
6593
|
+
const otherBody = parsedRequestBodyFor(c);
|
|
6594
|
+
if (otherBody === undefined)
|
|
5954
6595
|
return undefined;
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
6596
|
+
const otherValue = readValueAtPath(otherBody, path);
|
|
6597
|
+
return otherValue === undefined ? undefined : String(otherValue);
|
|
6598
|
+
}
|
|
6599
|
+
catch {
|
|
6600
|
+
return undefined;
|
|
6601
|
+
}
|
|
6602
|
+
});
|
|
6603
|
+
if (!requestValueVaries(stringValue, otherValues, sameEndpointCaptures.length))
|
|
6604
|
+
continue;
|
|
6605
|
+
const namedSegment = [...path]
|
|
6606
|
+
.reverse()
|
|
6607
|
+
.find((segment) => !ARRAY_INDEX_KEY_PATTERN.test(segment));
|
|
6608
|
+
const key = namedSegment ?? path[path.length - 1];
|
|
6609
|
+
const values = byKey.get(key) ?? new Set();
|
|
6610
|
+
values.add(stringValue);
|
|
6611
|
+
byKey.set(key, values);
|
|
6612
|
+
}
|
|
6613
|
+
perCaptureCache.set(cacheKey, byKey);
|
|
6614
|
+
return byKey;
|
|
6615
|
+
}
|
|
6616
|
+
function collectRequestStringValues(capture, allCaptures) {
|
|
6617
|
+
// Copied rather than mutated in place: collectRequestUrlValues now returns
|
|
6618
|
+
// a cached Set shared across every caller of this exact (capture,
|
|
6619
|
+
// allCaptures) pair, so merging body values directly into it would leak
|
|
6620
|
+
// them into every OTHER caller relying on collectRequestUrlValues' own
|
|
6621
|
+
// URL-only contract (e.g. findThreadedJoinFields's separate URL/body
|
|
6622
|
+
// gating).
|
|
6623
|
+
const values = new Set(collectRequestUrlValues(capture, allCaptures));
|
|
6624
|
+
const bodyValuesByKey = collectRequestBodyValuesByKey(capture, allCaptures);
|
|
6625
|
+
for (const leafValues of bodyValuesByKey?.values() ?? []) {
|
|
6626
|
+
for (const value of leafValues)
|
|
6627
|
+
values.add(value);
|
|
5960
6628
|
}
|
|
5961
6629
|
return values;
|
|
5962
6630
|
}
|
|
@@ -6270,13 +6938,36 @@ function dedupeThreadedFields(fields) {
|
|
|
6270
6938
|
* where narrowing by variance is a different concern than the URL/body
|
|
6271
6939
|
* over-threading this gate exists to prevent. */
|
|
6272
6940
|
function findThreadedJoinFields(scopes, drillCapture, allCaptures) {
|
|
6273
|
-
const
|
|
6274
|
-
|
|
6941
|
+
const urlValues = collectRequestUrlValues(drillCapture, allCaptures);
|
|
6942
|
+
const bodyValuesByKey = collectRequestBodyValuesByKey(drillCapture, allCaptures);
|
|
6943
|
+
if (urlValues.size === 0 && (bodyValuesByKey === null || bodyValuesByKey.size === 0))
|
|
6275
6944
|
return [];
|
|
6945
|
+
// A candidate field's value must EITHER surface name-free in the drill
|
|
6946
|
+
// request's own URL/query (a path segment or query param carries no JSON
|
|
6947
|
+
// key to correlate against, matching interpolateStateValues' isJsonBody
|
|
6948
|
+
// distinction — see its docstring) OR surface in the JSON body under a
|
|
6949
|
+
// key that plausibly names the same concept as the field's own last path
|
|
6950
|
+
// segment (via keyNamesCorrelate, the same discipline compileActionSteps'
|
|
6951
|
+
// pre-scan already applies to body-value reuse). A value that ONLY
|
|
6952
|
+
// coincidentally equals an unrelated body field's value — no URL match,
|
|
6953
|
+
// no name correlation to the body key it landed under — is not threading;
|
|
6954
|
+
// it's the value-coincidence bug this gate exists to close.
|
|
6276
6955
|
return scopes.flatMap(({ varName, obj }) => [...walkItemFieldPaths(obj)]
|
|
6277
|
-
.filter(({ value: v }) =>
|
|
6278
|
-
|
|
6279
|
-
|
|
6956
|
+
.filter(({ path, value: v }) => {
|
|
6957
|
+
const stringValue = typeof v === "string" && v.length > 0
|
|
6958
|
+
? v
|
|
6959
|
+
: typeof v === "number" || typeof v === "boolean"
|
|
6960
|
+
? String(v)
|
|
6961
|
+
: null;
|
|
6962
|
+
if (stringValue === null)
|
|
6963
|
+
return false;
|
|
6964
|
+
if (urlValues.has(stringValue))
|
|
6965
|
+
return true;
|
|
6966
|
+
if (bodyValuesByKey === null)
|
|
6967
|
+
return false;
|
|
6968
|
+
const sourceKeyName = path.at(-1);
|
|
6969
|
+
return [...bodyValuesByKey.entries()].some(([targetKey, leaves]) => leaves.has(stringValue) && keyNamesCorrelate(sourceKeyName, targetKey));
|
|
6970
|
+
})
|
|
6280
6971
|
.map(({ path }) => ({ varName, field: path.join(".") })));
|
|
6281
6972
|
}
|
|
6282
6973
|
/** True when `target`'s own drill/chain-terminal response resolves onto MORE
|
|
@@ -7536,6 +8227,21 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
|
|
|
7536
8227
|
* in one response — keeps this exact to the shape state-threading is
|
|
7537
8228
|
* actually needed for.
|
|
7538
8229
|
*
|
|
8230
|
+
* A later hop's request is only counted as threading a prior hop's response
|
|
8231
|
+
* value when the two agree on the field/header NAME that carries it (or the
|
|
8232
|
+
* value shows up as a bare, name-free URL PATH segment on the later
|
|
8233
|
+
* request) — the same discipline {@link requestAndResponseValuesByKey}/
|
|
8234
|
+
* {@link isFieldValueThreadedElsewhere} already enforce for the identical
|
|
8235
|
+
* hazard elsewhere in this file. Bare cross-capture value equality alone
|
|
8236
|
+
* (what this used before) lets a deeply-nested, unrelated response scalar —
|
|
8237
|
+
* a UI sort-order integer, an unrelated feature-flag boolean — that merely
|
|
8238
|
+
* happens to numerically coincide with some later request field's true
|
|
8239
|
+
* value get proven "threaded" and then, via `indexStateValues`'
|
|
8240
|
+
* `MIN_STATE_VALUE_LENGTH` exemption below, spliced into that unrelated
|
|
8241
|
+
* field. Requiring the SAME name on both sides is what tells a value a
|
|
8242
|
+
* later step genuinely re-reads under its own name apart from that
|
|
8243
|
+
* coincidence.
|
|
8244
|
+
*
|
|
7539
8245
|
* Runs directly off raw actions (not `resolveFoldPlan`, which needs
|
|
7540
8246
|
* `isMultipart` — unavailable before `compileActionSteps` has run) since
|
|
7541
8247
|
* fold-plan DETECTION depends only on each action's `capture`.
|
|
@@ -7549,6 +8255,98 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
|
|
|
7549
8255
|
* two unrelated steps can coincidentally match inside a totally unrelated
|
|
7550
8256
|
* capture's own URL/body and get spliced into it.
|
|
7551
8257
|
*/
|
|
8258
|
+
/** Per-capture memoized: every response BODY leaf, grouped by the field NAME
|
|
8259
|
+
* that carries it — gives {@link collectDependentDrillDownChainValues} the
|
|
8260
|
+
* same name-correlation signal {@link requestAndResponseValuesByKey} already
|
|
8261
|
+
* provides elsewhere in this file, instead of the bare, name-blind value set
|
|
8262
|
+
* {@link collectResponseLeafValues} supplies. Deliberately BODY-only, unlike
|
|
8263
|
+
* {@link collectResponseLeafValues}: a response HEADER (and especially a
|
|
8264
|
+
* `Set-Cookie` token mint) is already a strong structural signal on its own
|
|
8265
|
+
* — issuing a header/cookie at all is a deliberate server action, unlike an
|
|
8266
|
+
* arbitrary deeply-nested body scalar that merely happens to be present — so
|
|
8267
|
+
* header-sourced values keep the pre-existing bare-value match further down
|
|
8268
|
+
* in {@link collectDependentDrillDownChainValues} rather than being held to
|
|
8269
|
+
* a body-field's name correlation. */
|
|
8270
|
+
const responseBodyValuesByKeyCache = new WeakMap();
|
|
8271
|
+
function responseBodyValuesByKey(capture) {
|
|
8272
|
+
const cached = responseBodyValuesByKeyCache.get(capture);
|
|
8273
|
+
if (cached)
|
|
8274
|
+
return cached;
|
|
8275
|
+
const byKey = new Map();
|
|
8276
|
+
const add = (key, value) => {
|
|
8277
|
+
const values = byKey.get(key) ?? new Set();
|
|
8278
|
+
values.add(value);
|
|
8279
|
+
byKey.set(key, values);
|
|
8280
|
+
};
|
|
8281
|
+
for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
|
|
8282
|
+
if (value !== null && path.length > 0)
|
|
8283
|
+
add(path[path.length - 1], String(value));
|
|
8284
|
+
}
|
|
8285
|
+
responseBodyValuesByKeyCache.set(capture, byKey);
|
|
8286
|
+
return byKey;
|
|
8287
|
+
}
|
|
8288
|
+
/** Per-capture memoized request-side twin of {@link responseBodyValuesByKey}:
|
|
8289
|
+
* every URL query param, JSON body leaf, and request header value, grouped
|
|
8290
|
+
* by field/param/header NAME (header names lower-cased, since HTTP header
|
|
8291
|
+
* names are case-insensitive and a capture's minted header casing need not
|
|
8292
|
+
* match the later request's own casing of the same header), plus bare URL
|
|
8293
|
+
* PATH segments kept name-free in {@link
|
|
8294
|
+
* RequestAndResponseValues.pathSegments} — a REST-style detail fetch threads
|
|
8295
|
+
* an id through its URL PATH, not a named field, so requiring a name match
|
|
8296
|
+
* there too would blind chain-value correlation to that shape of genuine
|
|
8297
|
+
* threading. Headers are included here (unlike {@link
|
|
8298
|
+
* responseBodyValuesByKey}) so a body-sourced response value that a later
|
|
8299
|
+
* hop re-sends as a request HEADER under the matching name still
|
|
8300
|
+
* correlates. */
|
|
8301
|
+
const requestValuesByKeyCache = new WeakMap();
|
|
8302
|
+
function requestValuesByKeyIncludingHeaders(capture) {
|
|
8303
|
+
const cached = requestValuesByKeyCache.get(capture);
|
|
8304
|
+
if (cached)
|
|
8305
|
+
return cached;
|
|
8306
|
+
const byKey = new Map();
|
|
8307
|
+
const pathSegments = new Set();
|
|
8308
|
+
const add = (key, value) => {
|
|
8309
|
+
const values = byKey.get(key) ?? new Set();
|
|
8310
|
+
values.add(value);
|
|
8311
|
+
byKey.set(key, values);
|
|
8312
|
+
};
|
|
8313
|
+
try {
|
|
8314
|
+
const url = new URL(capture.url);
|
|
8315
|
+
for (const segment of url.pathname.split("/").filter(Boolean))
|
|
8316
|
+
pathSegments.add(segment);
|
|
8317
|
+
for (const [key, value] of url.searchParams)
|
|
8318
|
+
add(key, value);
|
|
8319
|
+
}
|
|
8320
|
+
catch {
|
|
8321
|
+
// Relative/invalid URLs carry no path/query signal to contribute.
|
|
8322
|
+
}
|
|
8323
|
+
if (capture.requestPostData) {
|
|
8324
|
+
try {
|
|
8325
|
+
const parsed = JSON.parse(capture.requestPostData);
|
|
8326
|
+
for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
|
|
8327
|
+
if (value !== null && path.length > 0)
|
|
8328
|
+
add(path[path.length - 1], String(value));
|
|
8329
|
+
}
|
|
8330
|
+
}
|
|
8331
|
+
catch {
|
|
8332
|
+
// A non-JSON body carries no leaf values to contribute.
|
|
8333
|
+
}
|
|
8334
|
+
}
|
|
8335
|
+
for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
|
|
8336
|
+
add(headerName.toLowerCase(), headerValue);
|
|
8337
|
+
}
|
|
8338
|
+
const result = { byKey, pathSegments };
|
|
8339
|
+
requestValuesByKeyCache.set(capture, result);
|
|
8340
|
+
return result;
|
|
8341
|
+
}
|
|
8342
|
+
/** Matches a JSON path segment that's a bare array INDEX ("0", "12", ...)
|
|
8343
|
+
* rather than an object field/header NAME. An array index carries no
|
|
8344
|
+
* semantic meaning of its own — a top-level array response (`[42]`) or a
|
|
8345
|
+
* value nested inside a request array (`{"tokens":[42]}`) has no field name
|
|
8346
|
+
* to correlate on either side — so {@link collectDependentDrillDownChainValues}
|
|
8347
|
+
* treats a leaf keyed by one as name-free, the same way it already treats a
|
|
8348
|
+
* bare URL path segment, instead of requiring an impossible name match. */
|
|
8349
|
+
const ARRAY_INDEX_KEY_PATTERN = /^\d+$/;
|
|
7552
8350
|
function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
|
|
7553
8351
|
const structuralPlans = detectDrillDownFoldPlan(actions);
|
|
7554
8352
|
const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
|
|
@@ -7561,19 +8359,61 @@ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
|
|
|
7561
8359
|
const priorCapture = actions[priorIndex]?.capture;
|
|
7562
8360
|
if (!priorCapture)
|
|
7563
8361
|
continue;
|
|
7564
|
-
const
|
|
8362
|
+
const priorResponseBodyByKey = responseBodyValuesByKey(priorCapture);
|
|
8363
|
+
// Header/cookie-origin response values are matched by bare value
|
|
8364
|
+
// further down, not by name — see {@link responseBodyValuesByKey}'s
|
|
8365
|
+
// docstring for why a header/cookie mint doesn't need that
|
|
8366
|
+
// correlation to already be a trustworthy threading signal.
|
|
8367
|
+
const priorHeaderValues = new Set(Object.values(priorCapture.responseHeaders));
|
|
7565
8368
|
const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
|
|
7566
8369
|
for (let k = j + 1; k < target.chain.length; k++) {
|
|
7567
8370
|
const laterCapture = actions[target.chain[k]]?.capture;
|
|
7568
8371
|
if (!laterCapture)
|
|
7569
8372
|
continue;
|
|
8373
|
+
const { byKey: laterRequestByKey, pathSegments: laterPathSegments } = requestValuesByKeyIncludingHeaders(laterCapture);
|
|
7570
8374
|
const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
8375
|
+
// Reverse index (value -> every later-side key it appears under),
|
|
8376
|
+
// built once per (priorCapture, laterCapture) pair rather than
|
|
8377
|
+
// once per value, so the array-index name-free fallback below
|
|
8378
|
+
// doesn't re-walk laterRequestByKey per value.
|
|
8379
|
+
const laterKeysByValue = new Map();
|
|
8380
|
+
for (const [k2, vs] of laterRequestByKey) {
|
|
8381
|
+
for (const v of vs) {
|
|
8382
|
+
const keys = laterKeysByValue.get(v) ?? new Set();
|
|
8383
|
+
keys.add(k2);
|
|
8384
|
+
laterKeysByValue.set(v, keys);
|
|
8385
|
+
}
|
|
8386
|
+
}
|
|
8387
|
+
const addConsumer = (v) => {
|
|
7574
8388
|
const consumers = consumersByValue.get(v) ?? new Set();
|
|
7575
8389
|
consumers.add(laterCapture);
|
|
7576
8390
|
consumersByValue.set(v, consumers);
|
|
8391
|
+
};
|
|
8392
|
+
for (const [key, values] of priorResponseBodyByKey) {
|
|
8393
|
+
const priorKeyIsArrayIndex = ARRAY_INDEX_KEY_PATTERN.test(key);
|
|
8394
|
+
for (const v of values) {
|
|
8395
|
+
if (echoedValues.has(v))
|
|
8396
|
+
continue;
|
|
8397
|
+
const laterKeysForValue = laterKeysByValue.get(v);
|
|
8398
|
+
const sameNameMatch = laterKeysForValue?.has(key) ?? false;
|
|
8399
|
+
const pathSegmentMatch = laterPathSegments.has(v);
|
|
8400
|
+
// Only the SOURCE side being name-free (an array element with
|
|
8401
|
+
// no field name of its own) exempts this from name matching —
|
|
8402
|
+
// a genuinely NAMED source field must still correlate by name
|
|
8403
|
+
// even if it happens to land inside a later array element,
|
|
8404
|
+
// otherwise a named `sortOrder` could dodge correlation just
|
|
8405
|
+
// by coincidentally equaling a value inside an unrelated
|
|
8406
|
+
// later-side array (`{"tokens":[7]}`).
|
|
8407
|
+
const arrayIndexMatch = priorKeyIsArrayIndex && laterKeysForValue !== undefined;
|
|
8408
|
+
if (!sameNameMatch && !pathSegmentMatch && !arrayIndexMatch)
|
|
8409
|
+
continue;
|
|
8410
|
+
addConsumer(v);
|
|
8411
|
+
}
|
|
8412
|
+
}
|
|
8413
|
+
for (const v of priorHeaderValues) {
|
|
8414
|
+
if (echoedValues.has(v) || !laterRequestValues.has(v))
|
|
8415
|
+
continue;
|
|
8416
|
+
addConsumer(v);
|
|
7577
8417
|
}
|
|
7578
8418
|
}
|
|
7579
8419
|
}
|
|
@@ -8212,6 +9052,33 @@ function emitContractTs(opts) {
|
|
|
8212
9052
|
const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
|
|
8213
9053
|
addExtendField(name, ` ${key}: ${value},`);
|
|
8214
9054
|
}
|
|
9055
|
+
// Closing-the-loop safety net: every discovered-field source above tracks
|
|
9056
|
+
// its own registration as it splices a `payload.<field>` accessor into the
|
|
9057
|
+
// emitted body/url/headers text, but that tracking is scattered across N
|
|
9058
|
+
// independent passes (form-schema discovery, option mappings, additional
|
|
9059
|
+
// body keys, structured keys, drill-param bindings, and — inside
|
|
9060
|
+
// emitMultiStepExecuteHttp's fold-loop `parameterize` closure — threaded
|
|
9061
|
+
// join-field rebinding), any one of which can add an accessor to the
|
|
9062
|
+
// rendered text without remembering to register it in the matching map
|
|
9063
|
+
// above. Rather than trust each source to stay perfectly in sync with the
|
|
9064
|
+
// text it emits, derive completeness from the actual rendered output: scan
|
|
9065
|
+
// `multiStepBody` (already fully assembled at this point — every chain
|
|
9066
|
+
// step's url/headers/body substitutions are done) for every
|
|
9067
|
+
// `payload.<field>` reference and union in any name the sources above
|
|
9068
|
+
// missed, with a conservative `z.string()` default. This closes the gap at
|
|
9069
|
+
// its structural root regardless of which upstream pass forgot to record a
|
|
9070
|
+
// field, instead of adding a fifth registration site that could itself be
|
|
9071
|
+
// forgotten by a future pass.
|
|
9072
|
+
if (multiStepBody) {
|
|
9073
|
+
const bodyReferencedFields = new Set([...multiStepBody.matchAll(/\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]));
|
|
9074
|
+
for (const name of [...bodyReferencedFields].sort()) {
|
|
9075
|
+
if (extendFields.has(name))
|
|
9076
|
+
continue;
|
|
9077
|
+
if (isReservedByApplicantContactSchema(name))
|
|
9078
|
+
continue;
|
|
9079
|
+
addExtendField(name, ` ${name}: z.string(),`);
|
|
9080
|
+
}
|
|
9081
|
+
}
|
|
8215
9082
|
// The structural walk over the captured request body that used to BE the
|
|
8216
9083
|
// public payload schema (see basePayloadSchemaExpr above) is still the
|
|
8217
9084
|
// right starting point for the plugin author's internal builder — it's
|
|
@@ -8434,7 +9301,14 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
|
|
|
8434
9301
|
// referencesItemVar (below) never has a chance to hoist it.
|
|
8435
9302
|
const isAncestorScoped = isFoldTargetAncestorScoped(target, actionSteps.map((s) => ({ capture: s.capture })), primaryItemsWithAncestors, fullAncestors);
|
|
8436
9303
|
const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
|
|
8437
|
-
|
|
9304
|
+
// Only `itemVar` (and fold-match candidates) are `Record<string,
|
|
9305
|
+
// unknown>`-typed — ancestor loop vars keep the real response-derived
|
|
9306
|
+
// type, so re-asserting THEIR intermediate hops would be both
|
|
9307
|
+
// unnecessary and, worse, would replace a real property access with
|
|
9308
|
+
// an opaque cast in the emitted URL/body text.
|
|
9309
|
+
const scopedAccessor = (varName, field) => varName === itemVar
|
|
9310
|
+
? unknownValueAccessor(varName, field.split("."))
|
|
9311
|
+
: `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
|
|
8438
9312
|
const joinAccessor = (field) => scopedAccessor(itemVar, field);
|
|
8439
9313
|
// Computed once per fold target instead of once per `parameterizeUrl`
|
|
8440
9314
|
// call: `actionSteps` never changes across the calls this target's
|
|
@@ -9823,6 +10697,16 @@ async function main() {
|
|
|
9823
10697
|
: undefined;
|
|
9824
10698
|
const errorSignals = detectErrorSignals(actionSteps);
|
|
9825
10699
|
const discoveredFormFields = new Set();
|
|
10700
|
+
// emitMultiStepExecuteHttp's outDiscoveredFields parameter is a generic
|
|
10701
|
+
// payload-accessor accumulator — BaseUrl substitution, persona/producer-
|
|
10702
|
+
// boundary bindings, entryUrlParams, tenant-subdomain headers, and
|
|
10703
|
+
// walkStringLeaves-derived accessors all write into it, independent of
|
|
10704
|
+
// form-schema discovery. It gets its own Set (rather than aliasing
|
|
10705
|
+
// discoveredFormFields positionally) so the two concerns stay separately
|
|
10706
|
+
// named; the explicit merge below is what actually wires its fields into
|
|
10707
|
+
// emitContractTs's schema — never an incidental byproduct of sharing one
|
|
10708
|
+
// reference across unrelated call sites.
|
|
10709
|
+
const discoveredPayloadAccessorFields = new Set();
|
|
9826
10710
|
const discoveredOptionFields = new Set();
|
|
9827
10711
|
// Phase E: maps label-derived raw-option payload field name (e.g.
|
|
9828
10712
|
// "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
|
|
@@ -9895,8 +10779,15 @@ async function main() {
|
|
|
9895
10779
|
const multiStepBody = browserFlowOnly
|
|
9896
10780
|
? undefined
|
|
9897
10781
|
: isSubmissionFlow
|
|
9898
|
-
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap,
|
|
10782
|
+
? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredPayloadAccessorFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec, pascal)
|
|
9899
10783
|
: undefined;
|
|
10784
|
+
// Explicit merge — every field emitMultiStepExecuteHttp registered as a
|
|
10785
|
+
// `payload.<field>` accessor (BaseUrl, persona, entryUrlParams, tenant-
|
|
10786
|
+
// subdomain headers, walkStringLeaves) flows into the same discovered-
|
|
10787
|
+
// fields set emitContractTs's schema `.extend()` reads from below.
|
|
10788
|
+
for (const field of discoveredPayloadAccessorFields) {
|
|
10789
|
+
discoveredFormFields.add(field);
|
|
10790
|
+
}
|
|
9900
10791
|
const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
|
|
9901
10792
|
const headerBindings = collectHeaderBindings(actionSteps);
|
|
9902
10793
|
// Shape inference targets the SAME call executeHttp returns — see
|