@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.
@@ -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","SectionIds","0"] -> `{ Sections: { SectionIds: { "0": string } } }`
3549
- * The leaf is always `string` because produces[] entries are only emitted for
3550
- * string leaves (see compileActionSteps + walkStringLeaves). Used to keep
3551
- * emitted code free of `any` casts while still letting nested-path access
3552
- * compile against `Record<string, unknown>`-typed response variables.
3553
- */
3554
- function pathToAssertionType(path) {
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 "string";
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
- else if (bodyLeafValues.some((leaf) => leaf.includes(sv.value))) {
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
- produces.push({ kind: "body", name, path, eligibleConsumers: sv.eligibleConsumers });
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
- function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue) {
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 text.matchAll(pattern)) {
4350
+ for (const match of matches) {
3981
4351
  const start = match.index;
3982
4352
  const end = start + match[0].length;
3983
- if (protectedSpans.some(([spanStart, spanEnd]) => start < spanEnd && end > spanStart))
3984
- continue;
3985
- result += text.slice(cursor, start) + (bindingByValue.get(match[0]) ?? match[0]);
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
- function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map()) {
4006
- const varNameByValue = deriveStateVarByValue(priorSteps, targetCapture);
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
- for (const [value, varName] of varNameByValue) {
4012
- bindingByValue.set(value, `\${${varName}}`);
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`): a later step re-sending the same coordinate
4184
- * threads the produced state var, the established behavior; only the producer,
4185
- * which cannot thread its own not-yet-existent response, needs the payload bind.
4186
- * `entryUrlBindings` (a caller coordinate lifted from the entry URL) fire on
4187
- * EVERY step — they are the caller's data on every request, never a produced var.
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
- varNameByValue.set(value, p.name);
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 seenKeys = new Set();
4367
- // Track keys from inputBody (r0) separately so we know which ones are NEW.
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 (seenKeys.has(key) && body !== inputBody)
4835
+ if (value === null)
4390
4836
  continue;
4391
- // For inputBody first pass: don't dedupe (we need all values).
4392
- if (body === inputBody && !inputBodyKeys.has(key))
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
- seenKeys.add(key);
4395
- if (value === null)
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 only the NEW keys (not in inputBody) so the contract emitter
4399
- // can add them to the payload schema — inputBody's own keys stay
4400
- // internal to the site request template (see basePayloadSchemaExpr).
4401
- if (!inputBodyKeys.has(key)) {
4402
- if (typeof value === "string")
4403
- outAdditionalKeys.set(key, "string");
4404
- else if (typeof value === "number")
4405
- outAdditionalKeys.set(key, "number");
4406
- else if (typeof value === "boolean")
4407
- outAdditionalKeys.set(key, "boolean");
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
- const optionalBracketAccessor = segments
4605
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
4606
- .join("");
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
- ? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
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 = `payload${pathToAccessor(path)}`;
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, varName] of deriveStateVarByValue(prior, cap)) {
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
- if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
4979
- perCallHeaders[k] = interpolateStateValues(v, prior, cap, payloadAccessorByValue);
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
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
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
- if (lower === "api-token" || lower === "authorization") {
5443
- perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue)}\``);
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
- return lines.join("\n");
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
- function collectRequestStringValues(capture, allCaptures) {
5901
- const sameEndpointCaptures = allCaptures
5902
- ? allCaptures.filter((c) => c !== capture && endpointKey(c.url) === endpointKey(capture.url))
5903
- : [];
5904
- const varies = (own, others) => !allCaptures || sameEndpointCaptures.length === 0
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 (varies(value, otherValues))
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
- const parsedBody = (() => {
5929
- try {
5930
- return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
5931
- ? JSON.parse(capture.requestPostData)
5932
- : undefined;
5933
- }
5934
- catch {
5935
- return undefined;
5936
- }
5937
- })();
5938
- if (parsedBody !== undefined) {
5939
- for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
5940
- if (value === null)
5941
- continue;
5942
- const stringValue = String(value);
5943
- const otherValues = sameEndpointCaptures.map((c) => {
5944
- try {
5945
- const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
5946
- ? JSON.parse(c.requestPostData)
5947
- : undefined;
5948
- if (otherBody === undefined)
5949
- return undefined;
5950
- const otherValue = readValueAtPath(otherBody, path);
5951
- return otherValue === undefined ? undefined : String(otherValue);
5952
- }
5953
- catch {
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
- if (varies(stringValue, otherValues))
5958
- values.add(stringValue);
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 requestValues = collectRequestStringValues(drillCapture, allCaptures);
6274
- if (requestValues.size === 0)
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 }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
6278
- (typeof v === "number" && requestValues.has(String(v))) ||
6279
- (typeof v === "boolean" && requestValues.has(String(v))))
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 responseValues = collectResponseLeafValues(priorCapture);
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
- for (const v of responseValues) {
7572
- if (echoedValues.has(v) || !laterRequestValues.has(v))
7573
- continue;
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
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
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, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
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