@enricai/barnacle 1.12.50 → 1.12.51

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,6 +3781,26 @@ 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
3806
  * ["Auth","Token"] -> `{ Auth: { Token: string } }`
@@ -3699,6 +3958,7 @@ function compileActionSteps(actions, stateIndex) {
3699
3958
  // so we only "produce" the values that are actually consumed downstream.
3700
3959
  for (const { capture } of actions) {
3701
3960
  const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
3961
+ const bodyLeafValuesByKey = jsonBodyLeafValuesByKey(capture.requestPostData);
3702
3962
  for (const sv of stateIndex.values()) {
3703
3963
  // A short value indexed only via the chain/force-include exemption
3704
3964
  // (see `StateValue.eligibleConsumers`) is a real dependency ONLY for
@@ -3726,10 +3986,26 @@ function compileActionSteps(actions, stateIndex) {
3726
3986
  if (bodyLeafValues === null) {
3727
3987
  if (capture.requestPostData?.includes(sv.value))
3728
3988
  usedValues.add(sv.value);
3989
+ continue;
3729
3990
  }
3730
- else if (bodyLeafValues.some((leaf) => leaf.includes(sv.value))) {
3991
+ // A produced value's SOURCE key name (the last segment of its response
3992
+ // JSON path) must correlate with the TARGET field it's found under —
3993
+ // same discipline `collectDependentDrillDownChainValues` already
3994
+ // applies to the short-value length-floor exemption (sameNameMatch),
3995
+ // now the universal gate rather than only that narrower one. A
3996
+ // name-free source (a bare array index, or a header/cookie origin with
3997
+ // no body accessor at all) is exempted, exactly as arrayIndexMatch
3998
+ // exempts a name-free source there — there's no name to correlate.
3999
+ const sourceKeyName = sv.headerOrigin ? undefined : sv.path.at(-1);
4000
+ const sourceIsNameFree = sv.headerOrigin !== undefined ||
4001
+ sourceKeyName === undefined ||
4002
+ ARRAY_INDEX_KEY_PATTERN.test(sourceKeyName);
4003
+ const matches = sourceIsNameFree
4004
+ ? bodyLeafValues.some((leaf) => leaf.includes(sv.value))
4005
+ : [...(bodyLeafValuesByKey?.entries() ?? [])].some(([targetKey, leaves]) => keyNamesCorrelate(sourceKeyName, targetKey) &&
4006
+ [...leaves].some((leaf) => leaf.includes(sv.value)));
4007
+ if (matches)
3731
4008
  usedValues.add(sv.value);
3732
- }
3733
4009
  }
3734
4010
  for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
3735
4011
  for (const sv of stateIndex.values()) {
@@ -3921,6 +4197,60 @@ function resolveResponsePathValue(responseBody, path) {
3921
4197
  function buildValueAlternationPattern(sortedValues) {
3922
4198
  return new RegExp(`(?<![A-Za-z0-9][-.])\\b(?:${sortedValues.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b(?![-.][A-Za-z0-9])`, "g");
3923
4199
  }
4200
+ /** Normalizes a JSON key or a produced var name to a bare comparable token —
4201
+ * lowercased, non-alphanumeric stripped, trailing disambiguation digits
4202
+ * (the `seenNames`-collision suffix `compileActionSteps` appends, e.g.
4203
+ * `displayOrder2`) dropped — so `sortOrder` and `SortOrder`/`sort_order`/
4204
+ * `sortOrder2` all normalize to the same token for {@link keysCorrelate}. */
4205
+ function normalizeCorrelationToken(raw) {
4206
+ return raw
4207
+ .toLowerCase()
4208
+ .replace(/[^a-z0-9]/g, "")
4209
+ .replace(/\d+$/, "");
4210
+ }
4211
+ /**
4212
+ * True when `sourceName` (a produced state var's own field name, e.g. the
4213
+ * `p.name` a produce was declared under) plausibly names the same coordinate
4214
+ * as `targetKey` (the JSON key a candidate splice would land under). Used to
4215
+ * gate {@link interpolateStateValues}'s substitution of a value that was only
4216
+ * indexed via the chain/force-include short-value exemption (see
4217
+ * `StateValue.eligibleConsumers`) — such a value cleared the ELIGIBILITY gate
4218
+ * via a name-free signal (a bare array index, a URL path segment), which says
4219
+ * nothing about whether the specific body key it's about to be spliced into
4220
+ * has anything to do with its own origin field. Requiring exact-token or
4221
+ * meaningful-substring correlation here is the second, independent check the
4222
+ * report calls for: an eligible value must still name/shape-correlate with
4223
+ * its actual splice target, not just have cleared chain detection for SOME
4224
+ * key in the target capture.
4225
+ */
4226
+ function keysCorrelate(sourceName, targetKey) {
4227
+ const a = normalizeCorrelationToken(sourceName);
4228
+ const b = normalizeCorrelationToken(targetKey);
4229
+ if (a.length === 0 || b.length === 0)
4230
+ return false;
4231
+ if (a === b)
4232
+ return true;
4233
+ const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
4234
+ return shorter.length >= 3 && longer.includes(shorter);
4235
+ }
4236
+ /** Finds the JSON key immediately governing the value at `matchStart` in a
4237
+ * (possibly partially-rewritten) JSON body template — the nearest preceding
4238
+ * `"key":` slot opener. Textual, not AST-based (matching this file's existing
4239
+ * `.split(target).join(replacement)` discipline elsewhere), which is
4240
+ * sufficient here: it only needs to identify the enclosing leaf's own key for
4241
+ * {@link keysCorrelate}'s name check, not to fully parse the document. */
4242
+ function findEnclosingJsonKey(text, matchStart) {
4243
+ const keyPattern = /"([^"\\]+)"\s*:\s*"?/g;
4244
+ let lastKey = null;
4245
+ let lastValueStart = -1;
4246
+ for (const m of text.matchAll(keyPattern)) {
4247
+ if (m.index === undefined || m.index >= matchStart)
4248
+ break;
4249
+ lastKey = m[1] ?? null;
4250
+ lastValueStart = m.index + m[0].length;
4251
+ }
4252
+ return lastValueStart <= matchStart ? lastKey : null;
4253
+ }
3924
4254
  /**
3925
4255
  * Finds every `${...}` span in `text` by brace-depth counting rather than a
3926
4256
  * non-nesting regex, so an ALREADY-nested placeholder (e.g. one produced by an
@@ -3972,17 +4302,55 @@ function findBalancedPlaceholderSpans(text) {
3972
4302
  * actually owns that span, which is the producer/consumer relationship this
3973
4303
  * mechanism is supposed to encode — and guarantees the output can never open
3974
4304
  * a `${` before a prior `${...}` closes.
3975
- */
3976
- function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue) {
4305
+ *
4306
+ * `keyCorrelationGuard`, when supplied, additionally requires a JSON-key
4307
+ * name/shape correlation before splicing a value flagged `restrictedValues`
4308
+ * — see {@link keysCorrelate}'s docstring for why: such a value cleared
4309
+ * ELIGIBILITY via a name-free chain signal (a bare array index, a URL path
4310
+ * segment) that says nothing about the specific key it's about to land
4311
+ * under. A match on a value not in `restrictedValues` (an unrestricted
4312
+ * payload accessor or a normally-length-qualified state value) is spliced
4313
+ * exactly as before — this guard only closes the coincidence-threading gap
4314
+ * for values that needed the short-value exemption to be indexed at all.
4315
+ */
4316
+ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue, keyCorrelationGuard) {
3977
4317
  const protectedSpans = findBalancedPlaceholderSpans(text);
4318
+ const matches = [...text.matchAll(pattern)].filter((match) => !protectedSpans.some(([spanStart, spanEnd]) => match.index < spanEnd && match.index + match[0].length > spanStart));
4319
+ const hasCorrelatedTargetByValue = new Map();
4320
+ if (keyCorrelationGuard) {
4321
+ for (const match of matches) {
4322
+ const value = match[0];
4323
+ if (!keyCorrelationGuard.restrictedValues.has(value))
4324
+ continue;
4325
+ if (keyCorrelationGuard.unconditionalValues.has(value))
4326
+ continue;
4327
+ if (hasCorrelatedTargetByValue.get(value))
4328
+ continue;
4329
+ const sourceName = keyCorrelationGuard.sourceNameByValue.get(value);
4330
+ const targetKey = findEnclosingJsonKey(text, match.index);
4331
+ if (sourceName !== undefined && targetKey !== null && keysCorrelate(sourceName, targetKey)) {
4332
+ hasCorrelatedTargetByValue.set(value, true);
4333
+ }
4334
+ }
4335
+ }
3978
4336
  let result = "";
3979
4337
  let cursor = 0;
3980
- for (const match of text.matchAll(pattern)) {
4338
+ for (const match of matches) {
3981
4339
  const start = match.index;
3982
4340
  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]);
4341
+ const value = match[0];
4342
+ const isGated = keyCorrelationGuard?.restrictedValues.has(value) &&
4343
+ (keyCorrelationGuard.unconditionalValues.has(value) || hasCorrelatedTargetByValue.get(value));
4344
+ if (isGated) {
4345
+ const sourceName = keyCorrelationGuard?.sourceNameByValue.get(value);
4346
+ const targetKey = findEnclosingJsonKey(text, start);
4347
+ if (sourceName === undefined || targetKey === null || !keysCorrelate(sourceName, targetKey)) {
4348
+ result += text.slice(cursor, end);
4349
+ cursor = end;
4350
+ continue;
4351
+ }
4352
+ }
4353
+ result += text.slice(cursor, start) + (bindingByValue.get(value) ?? value);
3986
4354
  cursor = end;
3987
4355
  }
3988
4356
  return result + text.slice(cursor);
@@ -4001,21 +4369,42 @@ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue
4001
4369
  * for the anchoring guarantee and {@link replaceGuardedAgainstExistingPlaceholders}
4002
4370
  * for why a match overlapping an already-emitted `${...}` is skipped rather
4003
4371
  * than spliced into.
4004
- */
4005
- function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map()) {
4006
- const varNameByValue = deriveStateVarByValue(priorSteps, targetCapture);
4372
+ *
4373
+ * `isJsonBody` gates the additional {@link keysCorrelate} check
4374
+ * `replaceGuardedAgainstExistingPlaceholders` applies to `restricted` state
4375
+ * bindings (see {@link StateVarBinding}) — a value indexed only via the
4376
+ * chain/force-include short-value exemption must also name/shape-correlate
4377
+ * with the JSON key it's about to be spliced into, not merely have cleared
4378
+ * eligibility for SOME key in this capture. Only a JSON request body has
4379
+ * "keys" to correlate against; a URL or a raw header value has none, and a
4380
+ * bare-value splice into either is exactly the name-free URL-path-segment
4381
+ * threading the eligibility gate already intends to allow, so callers
4382
+ * rendering those pass `false` (the default).
4383
+ */
4384
+ function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map(), isJsonBody = false) {
4385
+ const stateBindings = deriveStateVarByValue(priorSteps, targetCapture);
4007
4386
  const bindingByValue = new Map();
4008
4387
  for (const [value, accessor] of payloadAccessorByValue) {
4009
4388
  bindingByValue.set(value, `\${${accessor}}`);
4010
4389
  }
4011
- for (const [value, varName] of varNameByValue) {
4012
- bindingByValue.set(value, `\${${varName}}`);
4390
+ const restrictedValues = new Set();
4391
+ const unconditionalValues = new Set();
4392
+ const sourceNameByValue = new Map();
4393
+ for (const [value, binding] of stateBindings) {
4394
+ bindingByValue.set(value, `\${${binding.varName}}`);
4395
+ sourceNameByValue.set(value, binding.sourceName);
4396
+ if (binding.restricted)
4397
+ restrictedValues.add(value);
4398
+ if (binding.unconditional)
4399
+ unconditionalValues.add(value);
4013
4400
  }
4014
4401
  if (bindingByValue.size === 0)
4015
4402
  return template;
4016
4403
  const sortedValues = [...bindingByValue.keys()].sort((a, b) => b.length - a.length);
4017
4404
  const pattern = buildValueAlternationPattern(sortedValues);
4018
- return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue);
4405
+ return replaceGuardedAgainstExistingPlaceholders(template, pattern, bindingByValue, isJsonBody && restrictedValues.size > 0
4406
+ ? { restrictedValues, sourceNameByValue, unconditionalValues }
4407
+ : undefined);
4019
4408
  }
4020
4409
  /**
4021
4410
  * Rewrites every occurrence of a set of literal values to their accessor
@@ -4210,23 +4599,6 @@ function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScope
4210
4599
  * before giving up. Real captures observed a doubly-encoded value (`%2520`); the
4211
4600
  * extra headroom costs one cheap `decodeURIComponent` per level and stops runaway. */
4212
4601
  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
4602
  function deriveStateVarByValue(priorSteps, targetCapture) {
4231
4603
  const varNameByValue = new Map();
4232
4604
  for (const step of priorSteps) {
@@ -4236,8 +4608,34 @@ function deriveStateVarByValue(priorSteps, targetCapture) {
4236
4608
  if (p.eligibleConsumers && !p.eligibleConsumers.has(targetCapture))
4237
4609
  continue;
4238
4610
  const value = resolveResponsePathValue(step.capture.responseBody, p.path);
4239
- if (value !== null)
4240
- varNameByValue.set(value, p.name);
4611
+ if (value !== null) {
4612
+ // A source path with NO identifier segment anywhere (every segment a
4613
+ // bare array index — e.g. a top-level array response `[42]`, path
4614
+ // `["0"]`) has no name of its own to correlate against at all; that's
4615
+ // the genuinely name-free case `collectDependentDrillDownChainValues`'s
4616
+ // arrayIndexMatch exists for (see its docstring), not a named field
4617
+ // that merely sits inside an array. Only a source WITH a real
4618
+ // ancestor name (e.g. `flags` in `["flags","0"]`) must correlate —
4619
+ // its name existing at all is exactly the signal a target key
4620
+ // coincidence has to match to be a genuine splice, not a bare
4621
+ // array-index/path-segment eligibility coincidence.
4622
+ const sourceHasName = p.path.some((segment) => isValidJsIdentifier(segment));
4623
+ varNameByValue.set(value, {
4624
+ varName: p.name,
4625
+ sourceName: p.name,
4626
+ // Requiring name/shape correlation at the splice site is not just
4627
+ // for the chain/force-include short-value exemption — a
4628
+ // naturally-length-qualified value that legitimately correlates
4629
+ // with ONE downstream key (which is what got it produced at all,
4630
+ // see `compileActionSteps`' `keyNamesCorrelate` pre-scan) must not
4631
+ // also splice into an unrelated, differently-named key that merely
4632
+ // coincides in value. `sourceHasName` gates this the same way it
4633
+ // gates the chain-derived case: a name-free source (a bare array
4634
+ // index) has nothing to correlate, so it stays unrestricted.
4635
+ restricted: sourceHasName,
4636
+ unconditional: p.eligibleConsumers !== undefined && sourceHasName,
4637
+ });
4638
+ }
4241
4639
  }
4242
4640
  }
4243
4641
  return varNameByValue;
@@ -4360,21 +4758,22 @@ function applyUrlParamPayloadSubstitutions(template, parsedBody, bindings) {
4360
4758
  * top-level keys also become caller-supplied payload fields. Used in Phase F
4361
4759
  * to parameterize fields like SourceCode that appear in r1's body but not
4362
4760
  * r0's (inputBody).
4761
+ *
4762
+ * Registration is keyed per (key, value) pair, not per key alone: a field
4763
+ * name reused across two-plus steps with a DIFFERENT literal value on each
4764
+ * occurrence must have EVERY one of its own occurrences registered and
4765
+ * substituted, not just whichever occurrence this function's own body-array
4766
+ * walk reaches first. A first-seen-value-wins table would only ever match
4767
+ * (and thus only ever register) the ONE step whose literal happens to equal
4768
+ * that first-seen value — every other step's own `"key":<its own value>`
4769
+ * text would silently never become a `${payload.key}` reference at all here,
4770
+ * even though the field genuinely IS one this step's own request sends as
4771
+ * caller-supplied data.
4363
4772
  */
4364
4773
  function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map()) {
4365
4774
  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);
4775
+ const seenPairs = new Set();
4776
+ const seenValueByKey = new Map();
4378
4777
  const allBodies = [inputBody, ...additionalBodies];
4379
4778
  for (const body of allBodies) {
4380
4779
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
@@ -4386,26 +4785,45 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
4386
4785
  const key = path[0];
4387
4786
  if (!isValidJsIdentifier(key))
4388
4787
  continue;
4389
- if (seenKeys.has(key) && body !== inputBody)
4788
+ if (value === null)
4390
4789
  continue;
4391
- // For inputBody first pass: don't dedupe (we need all values).
4392
- if (body === inputBody && !inputBodyKeys.has(key))
4790
+ // Dedupe identical (key, value) pairs only — a repeated occurrence of
4791
+ // the SAME literal value for a key across bodies needs no second
4792
+ // substitution pass, but a DIFFERENT value under the same key is its
4793
+ // own distinct step's own occurrence and must still get one. EXCEPT a
4794
+ // pagination-cursor-shaped key ({@link PAGINATION_FIELD_NAME_PATTERN},
4795
+ // e.g. `page`/`offset`/`cursor`) whose value differs from the
4796
+ // first-seen one: that shape is a same-endpoint re-query bump (see
4797
+ // {@link isRedundantSameEndpointGroup}'s pagination-vs-payload
4798
+ // distinction), not a genuinely different step's own caller data —
4799
+ // aliasing both occurrences to the SAME `payload.<key>` accessor would
4800
+ // make the generated re-query call replay the FIRST page's request
4801
+ // instead of advancing to the next one, so the later occurrence stays
4802
+ // an unsubstituted literal, matching this key's pre-fix behavior.
4803
+ const priorValue = seenValueByKey.get(key);
4804
+ if (priorValue !== undefined &&
4805
+ priorValue !== value &&
4806
+ PAGINATION_FIELD_NAME_PATTERN.test(key)) {
4393
4807
  continue;
4394
- seenKeys.add(key);
4395
- if (value === null)
4808
+ }
4809
+ seenValueByKey.set(key, value);
4810
+ const pairKey = `${key} ${typeof value} ${value}`;
4811
+ if (seenPairs.has(pairKey))
4396
4812
  continue;
4813
+ seenPairs.add(pairKey);
4397
4814
  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
- }
4815
+ // Record every substituted key, including inputBody's own, so the
4816
+ // contract emitter can add it to the payload schema. inputBody keys
4817
+ // that ARE covered by basePayloadSchemaExpr (the ApplicantContactSchema
4818
+ // case) are filtered back out at the emitContractTs merge point via
4819
+ // isReservedByApplicantContactSchema — this function has no visibility
4820
+ // into that flag, so it must not special-case inputBody's own keys.
4821
+ if (typeof value === "string")
4822
+ outAdditionalKeys.set(key, "string");
4823
+ else if (typeof value === "number")
4824
+ outAdditionalKeys.set(key, "number");
4825
+ else if (typeof value === "boolean")
4826
+ outAdditionalKeys.set(key, "boolean");
4409
4827
  }
4410
4828
  }
4411
4829
  let result = template;
@@ -4601,11 +5019,17 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
4601
5019
  // then fall back to the bare last segment.
4602
5020
  const lastSegment = segments[segments.length - 1];
4603
5021
  const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
4604
- const optionalBracketAccessor = segments
4605
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
4606
- .join("");
5022
+ // Every intermediate hop off the (unknown-typed) candidate needs
5023
+ // re-asserting back to `Record<string, unknown>` before the next bracket
5024
+ // access — see {@link unknownValueAccessor}'s doc for why a bare chain of
5025
+ // `?.[...]` accessors fails to typecheck past the first segment.
5026
+ const nestedAccessor = segments.reduce((expr, segment, index) => {
5027
+ const isLast = index === segments.length - 1;
5028
+ const accessor = index === 0 ? bracket(segment) : `?.[${JSON.stringify(segment)}]`;
5029
+ return isLast ? `${expr}${accessor}` : `(${expr}${accessor} as Record<string, unknown>)`;
5030
+ }, varName);
4607
5031
  return segments.length > 1
4608
- ? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
5032
+ ? `(${nestedAccessor} ?? ${varName}${bracket(lastSegment)})`
4609
5033
  : `${varName}${bracket(lastSegment)}`;
4610
5034
  };
4611
5035
  const joinCondition = target.joinFields
@@ -4628,7 +5052,18 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
4628
5052
  }
4629
5053
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
4630
5054
  * 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) {
5055
+ 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,
5056
+ /**
5057
+ * PascalCase plugin name used to cast the final `return { data: ... }`
5058
+ * back to `${pascalName}Response` — every intermediate `httpClient` call
5059
+ * this function emits is bound `as Record<string, unknown>` so per-item
5060
+ * fold/merge code can probe arbitrary fields, but that cast otherwise
5061
+ * widens the returned primary var past the richer response type the
5062
+ * caller's own schema inference already promised, which fails to
5063
+ * typecheck. `null` (the test-facing default) skips the cast, preserving
5064
+ * prior output for callers that don't exercise the full pipeline.
5065
+ */
5066
+ pascalName = null) {
4632
5067
  // Walk the first action's request body to map each leaf string value to its
4633
5068
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
4634
5069
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -4646,8 +5081,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4646
5081
  for (const { value, path } of walkStringLeaves(inputBody)) {
4647
5082
  if (value.length < MIN_STATE_VALUE_LENGTH)
4648
5083
  continue;
4649
- const accessor = `payload${pathToAccessor(path)}`;
5084
+ const { accessor, field: accessorField } = payloadAccessorForPath(path);
4650
5085
  payloadAccessorByValue.set(value, accessor);
5086
+ if (isValidJsIdentifier(accessorField))
5087
+ outDiscoveredFields.add(accessorField);
4651
5088
  // Phase F: register a lowercase variant for UUID-shaped values so case-
4652
5089
  // variant URL path segments (e.g. r9 echoes the requisition UUID in
4653
5090
  // lowercase even though r0's body had it uppercase) still get
@@ -4940,14 +5377,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4940
5377
  if (binding.producerIndex === i)
4941
5378
  urlParamBindings.set(value, binding.accessor);
4942
5379
  }
4943
- for (const [value, varName] of deriveStateVarByValue(prior, cap)) {
4944
- urlParamBindings.set(value, varName);
5380
+ for (const [value, binding] of deriveStateVarByValue(prior, cap)) {
5381
+ urlParamBindings.set(value, binding.varName);
4945
5382
  }
4946
5383
  const rawBodyWithUrlParams = parsedBody !== null
4947
5384
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
4948
5385
  : rawBodyWithProducerBoundary;
4949
5386
  const bodyAfterStateAndKv = rawBodyWithUrlParams
4950
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
5387
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
4951
5388
  : "";
4952
5389
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
4953
5390
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -5178,7 +5615,14 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
5178
5615
  // colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
5179
5616
  // single-target case keeps the original unsuffixed names.
5180
5617
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
5181
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
5618
+ // Only `itemVar` (and fold-match candidates) are `Record<string,
5619
+ // unknown>`-typed — ancestor loop vars keep the real response-derived
5620
+ // type, so re-asserting THEIR intermediate hops would be both
5621
+ // unnecessary and, worse, would replace a real property access with
5622
+ // an opaque cast in the emitted URL/body text.
5623
+ const scopedAccessor = (varName, field) => varName === itemVar
5624
+ ? unknownValueAccessor(varName, field.split("."))
5625
+ : `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
5182
5626
  const joinAccessor = (field) => scopedAccessor(itemVar, field);
5183
5627
  // Computed once per fold target instead of once per `parameterize`
5184
5628
  // call: `actions` never changes across the url/headers/body calls a
@@ -5527,16 +5971,27 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
5527
5971
  ...new Set(foldPlans.map((plan) => actions[plan.primaryStepIndex].varName)),
5528
5972
  ];
5529
5973
  const everyPrimaryIsPlainObject = foldPlans.every((plan) => isPlainObject(actions[plan.primaryStepIndex].capture.responseBody));
5974
+ // Every intermediate `httpClient` call above is bound `as Record<string,
5975
+ // unknown>` regardless of its own `schema:`, so per-item fold/merge code
5976
+ // can probe arbitrary fields without a per-step assertion type. That
5977
+ // widened intermediate type doesn't match `${pascalName}Response` — the
5978
+ // richer type schema inference already promised for THIS returned value —
5979
+ // so the return itself needs its own assertion back to that promised
5980
+ // type; `Record<string, unknown>` and the real inferred object type share
5981
+ // no ancestry TS can see, so a plain `as` needs the `as unknown as` detour.
5982
+ const castToResponseType = (expr) => pascalName ? `${expr} as unknown as ${pascalName}Response` : expr;
5530
5983
  if (uniquePrimaryVarNames.length > 1 && everyPrimaryIsPlainObject) {
5531
- lines.push(` return { data: mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")}) };`);
5984
+ lines.push(` return { data: ${castToResponseType(`mergeFoldedPrimaryBodies(${uniquePrimaryVarNames.join(", ")})`)} };`);
5532
5985
  }
5533
5986
  else {
5534
5987
  const returnVar = lastFoldPlan
5535
5988
  ? actions[lastFoldPlan.primaryStepIndex].varName
5536
5989
  : (returnAction?.varName ?? "undefined");
5537
- lines.push(` return { data: ${returnVar} };`);
5990
+ lines.push(` return { data: ${castToResponseType(returnVar)} };`);
5538
5991
  }
5539
- return lines.join("\n");
5992
+ const renderedMultiStepBody = lines.join("\n");
5993
+ assertBodyFieldSourceNameCorrelates("emitMultiStepExecuteHttp", renderedMultiStepBody);
5994
+ return renderedMultiStepBody;
5540
5995
  }
5541
5996
  function summariseResponseShape(value) {
5542
5997
  if (value === null || typeof value !== "object")
@@ -5897,13 +6352,96 @@ function findObjectArrayFieldOrWholeObject(value, path = []) {
5897
6352
  * other capture of this endpoint exists in `allCaptures`, variance can't be
5898
6353
  * observed either way, so every value is kept (unfiltered, matching the
5899
6354
  * 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
6355
+ /** Per-`allCaptures`-array grouping of captures by {@link endpointKey}, built
6356
+ * once per distinct `allCaptures` identity rather than re-filtering the
6357
+ * whole array on every {@link sameEndpointCapturesFor} call — the O(n)
6358
+ * per-call filter otherwise makes every caller that invokes it once per
6359
+ * capture (e.g. {@link findThreadedJoinFields}) O(n^2) overall. */
6360
+ const endpointGroupsCache = new WeakMap();
6361
+ function endpointGroupsFor(allCaptures) {
6362
+ const cached = endpointGroupsCache.get(allCaptures);
6363
+ if (cached)
6364
+ return cached;
6365
+ const groups = new Map();
6366
+ for (const c of allCaptures) {
6367
+ const key = endpointKey(c.url);
6368
+ const group = groups.get(key) ?? [];
6369
+ group.push(c);
6370
+ groups.set(key, group);
6371
+ }
6372
+ endpointGroupsCache.set(allCaptures, groups);
6373
+ return groups;
6374
+ }
6375
+ function sameEndpointCapturesFor(capture, allCaptures) {
6376
+ if (!allCaptures)
6377
+ return [];
6378
+ const group = endpointGroupsFor(allCaptures).get(endpointKey(capture.url)) ?? [];
6379
+ return group.filter((c) => c !== capture);
6380
+ }
6381
+ /** True when `own` differs from at least one same-endpoint sibling's value at
6382
+ * the same location — the cross-capture variance test {@link
6383
+ * collectRequestStringValues} and {@link collectRequestBodyValuesByKey} both
6384
+ * apply to their respective candidate values. When no sibling capture exists
6385
+ * (or `allCaptures` was omitted), variance can't be observed either way, so
6386
+ * every value passes (matching the unfiltered behavior when `allCaptures` is
6387
+ * omitted). */
6388
+ function requestValueVaries(own, others, sameEndpointCapturesLength) {
6389
+ return sameEndpointCapturesLength === 0
5905
6390
  ? true
5906
6391
  : others.some((other) => other !== undefined && other !== own);
6392
+ }
6393
+ /** Memoizes `JSON.parse(capture.requestPostData)` per capture — the same
6394
+ * capture is re-parsed once per SIBLING lookup by every same-endpoint
6395
+ * caller in {@link collectRequestBodyValuesByKey}'s leaf loop, so without
6396
+ * this cache a group of N same-endpoint captures re-parses each sibling's
6397
+ * body N times over (once per outer capture in the group). `undefined`
6398
+ * means "not a parseable JSON body", the same non-JSON signal the
6399
+ * unmemoized inline parse used to produce. */
6400
+ const parsedRequestBodyCache = new WeakMap();
6401
+ const PARSE_FAILED = Symbol("parse-failed");
6402
+ function parsedRequestBodyFor(capture) {
6403
+ if (parsedRequestBodyCache.has(capture)) {
6404
+ const cached = parsedRequestBodyCache.get(capture);
6405
+ return cached === PARSE_FAILED ? undefined : cached;
6406
+ }
6407
+ const parsed = (() => {
6408
+ if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
6409
+ return PARSE_FAILED;
6410
+ }
6411
+ try {
6412
+ return JSON.parse(capture.requestPostData);
6413
+ }
6414
+ catch {
6415
+ return PARSE_FAILED;
6416
+ }
6417
+ })();
6418
+ parsedRequestBodyCache.set(capture, parsed);
6419
+ return parsed === PARSE_FAILED ? undefined : parsed;
6420
+ }
6421
+ /** Per-(capture, allCaptures-identity) memoization for {@link
6422
+ * collectRequestUrlValues} and {@link collectRequestBodyValuesByKey} —
6423
+ * {@link findThreadedJoinFields} calls both fresh on every invocation and is
6424
+ * itself invoked once per fold/drill-loop item across several call sites, so
6425
+ * without caching the same capture's URL/body values are recomputed (and,
6426
+ * for the body, re-walked and every sibling re-parsed) once per call. */
6427
+ const requestUrlValuesCache = new WeakMap();
6428
+ const requestBodyValuesByKeyCache = new WeakMap();
6429
+ const NO_ALL_CAPTURES = Object.freeze([]);
6430
+ /** The path-segment and query-parameter values present in `capture`'s own
6431
+ * URL — the name-free half of {@link collectRequestStringValues}'s candidate
6432
+ * set. Kept separate from the JSON body's leaf values so callers needing a
6433
+ * by-key correlation gate on the body (e.g. {@link findThreadedJoinFields})
6434
+ * can still treat URL/query matches as the name-free signal they've always
6435
+ * been — a REST-style `/orders/{id}` path segment or query param carries no
6436
+ * JSON key to correlate against in the first place. */
6437
+ function collectRequestUrlValues(capture, allCaptures) {
6438
+ const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
6439
+ const perCaptureCache = requestUrlValuesCache.get(capture) ?? new WeakMap();
6440
+ requestUrlValuesCache.set(capture, perCaptureCache);
6441
+ const cached = perCaptureCache.get(cacheKey);
6442
+ if (cached)
6443
+ return cached;
6444
+ const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
5907
6445
  const values = new Set();
5908
6446
  try {
5909
6447
  const url = new URL(capture.url);
@@ -5918,45 +6456,82 @@ function collectRequestStringValues(capture, allCaptures) {
5918
6456
  return undefined;
5919
6457
  }
5920
6458
  });
5921
- if (varies(value, otherValues))
6459
+ if (requestValueVaries(value, otherValues, sameEndpointCaptures.length))
5922
6460
  values.add(value);
5923
6461
  }
5924
6462
  }
5925
6463
  catch {
5926
6464
  // Relative or malformed URL — no query params or path segments to contribute.
5927
6465
  }
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 {
6466
+ perCaptureCache.set(cacheKey, values);
6467
+ return values;
6468
+ }
6469
+ /** Same JSON-body walk and cross-capture variance gate as {@link
6470
+ * collectRequestStringValues}'s body block, but grouped by the JSON
6471
+ * key/array-index that carries each leaf value (see {@link
6472
+ * jsonBodyLeafValuesByKey}'s same grouping) — the by-key candidate set
6473
+ * {@link findThreadedJoinFields} correlates a threaded field's own name
6474
+ * against, so a value that only coincidentally equals something in an
6475
+ * UNRELATED body field can't be threaded onto it. Returns `null` when
6476
+ * `capture.requestPostData` isn't parseable JSON, the same non-JSON signal
6477
+ * {@link jsonBodyLeafValuesByKey} returns. */
6478
+ function collectRequestBodyValuesByKey(capture, allCaptures) {
6479
+ if (typeof capture.requestPostData !== "string" || capture.requestPostData.length === 0) {
6480
+ return null;
6481
+ }
6482
+ const cacheKey = allCaptures ?? NO_ALL_CAPTURES;
6483
+ const perCaptureCache = requestBodyValuesByKeyCache.get(capture) ?? new WeakMap();
6484
+ requestBodyValuesByKeyCache.set(capture, perCaptureCache);
6485
+ if (perCaptureCache.has(cacheKey))
6486
+ return perCaptureCache.get(cacheKey);
6487
+ const parsedBody = parsedRequestBodyFor(capture);
6488
+ if (parsedBody === undefined) {
6489
+ perCaptureCache.set(cacheKey, null);
6490
+ return null;
6491
+ }
6492
+ const sameEndpointCaptures = sameEndpointCapturesFor(capture, allCaptures);
6493
+ const byKey = new Map();
6494
+ for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
6495
+ if (value === null || path.length === 0)
6496
+ continue;
6497
+ const stringValue = String(value);
6498
+ const otherValues = sameEndpointCaptures.map((c) => {
6499
+ try {
6500
+ const otherBody = parsedRequestBodyFor(c);
6501
+ if (otherBody === undefined)
5954
6502
  return undefined;
5955
- }
5956
- });
5957
- if (varies(stringValue, otherValues))
5958
- values.add(stringValue);
5959
- }
6503
+ const otherValue = readValueAtPath(otherBody, path);
6504
+ return otherValue === undefined ? undefined : String(otherValue);
6505
+ }
6506
+ catch {
6507
+ return undefined;
6508
+ }
6509
+ });
6510
+ if (!requestValueVaries(stringValue, otherValues, sameEndpointCaptures.length))
6511
+ continue;
6512
+ const namedSegment = [...path]
6513
+ .reverse()
6514
+ .find((segment) => !ARRAY_INDEX_KEY_PATTERN.test(segment));
6515
+ const key = namedSegment ?? path[path.length - 1];
6516
+ const values = byKey.get(key) ?? new Set();
6517
+ values.add(stringValue);
6518
+ byKey.set(key, values);
6519
+ }
6520
+ perCaptureCache.set(cacheKey, byKey);
6521
+ return byKey;
6522
+ }
6523
+ function collectRequestStringValues(capture, allCaptures) {
6524
+ // Copied rather than mutated in place: collectRequestUrlValues now returns
6525
+ // a cached Set shared across every caller of this exact (capture,
6526
+ // allCaptures) pair, so merging body values directly into it would leak
6527
+ // them into every OTHER caller relying on collectRequestUrlValues' own
6528
+ // URL-only contract (e.g. findThreadedJoinFields's separate URL/body
6529
+ // gating).
6530
+ const values = new Set(collectRequestUrlValues(capture, allCaptures));
6531
+ const bodyValuesByKey = collectRequestBodyValuesByKey(capture, allCaptures);
6532
+ for (const leafValues of bodyValuesByKey?.values() ?? []) {
6533
+ for (const value of leafValues)
6534
+ values.add(value);
5960
6535
  }
5961
6536
  return values;
5962
6537
  }
@@ -6270,13 +6845,36 @@ function dedupeThreadedFields(fields) {
6270
6845
  * where narrowing by variance is a different concern than the URL/body
6271
6846
  * over-threading this gate exists to prevent. */
6272
6847
  function findThreadedJoinFields(scopes, drillCapture, allCaptures) {
6273
- const requestValues = collectRequestStringValues(drillCapture, allCaptures);
6274
- if (requestValues.size === 0)
6848
+ const urlValues = collectRequestUrlValues(drillCapture, allCaptures);
6849
+ const bodyValuesByKey = collectRequestBodyValuesByKey(drillCapture, allCaptures);
6850
+ if (urlValues.size === 0 && (bodyValuesByKey === null || bodyValuesByKey.size === 0))
6275
6851
  return [];
6852
+ // A candidate field's value must EITHER surface name-free in the drill
6853
+ // request's own URL/query (a path segment or query param carries no JSON
6854
+ // key to correlate against, matching interpolateStateValues' isJsonBody
6855
+ // distinction — see its docstring) OR surface in the JSON body under a
6856
+ // key that plausibly names the same concept as the field's own last path
6857
+ // segment (via keyNamesCorrelate, the same discipline compileActionSteps'
6858
+ // pre-scan already applies to body-value reuse). A value that ONLY
6859
+ // coincidentally equals an unrelated body field's value — no URL match,
6860
+ // no name correlation to the body key it landed under — is not threading;
6861
+ // it's the value-coincidence bug this gate exists to close.
6276
6862
  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))))
6863
+ .filter(({ path, value: v }) => {
6864
+ const stringValue = typeof v === "string" && v.length > 0
6865
+ ? v
6866
+ : typeof v === "number" || typeof v === "boolean"
6867
+ ? String(v)
6868
+ : null;
6869
+ if (stringValue === null)
6870
+ return false;
6871
+ if (urlValues.has(stringValue))
6872
+ return true;
6873
+ if (bodyValuesByKey === null)
6874
+ return false;
6875
+ const sourceKeyName = path.at(-1);
6876
+ return [...bodyValuesByKey.entries()].some(([targetKey, leaves]) => leaves.has(stringValue) && keyNamesCorrelate(sourceKeyName, targetKey));
6877
+ })
6280
6878
  .map(({ path }) => ({ varName, field: path.join(".") })));
6281
6879
  }
6282
6880
  /** True when `target`'s own drill/chain-terminal response resolves onto MORE
@@ -7536,6 +8134,21 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
7536
8134
  * in one response — keeps this exact to the shape state-threading is
7537
8135
  * actually needed for.
7538
8136
  *
8137
+ * A later hop's request is only counted as threading a prior hop's response
8138
+ * value when the two agree on the field/header NAME that carries it (or the
8139
+ * value shows up as a bare, name-free URL PATH segment on the later
8140
+ * request) — the same discipline {@link requestAndResponseValuesByKey}/
8141
+ * {@link isFieldValueThreadedElsewhere} already enforce for the identical
8142
+ * hazard elsewhere in this file. Bare cross-capture value equality alone
8143
+ * (what this used before) lets a deeply-nested, unrelated response scalar —
8144
+ * a UI sort-order integer, an unrelated feature-flag boolean — that merely
8145
+ * happens to numerically coincide with some later request field's true
8146
+ * value get proven "threaded" and then, via `indexStateValues`'
8147
+ * `MIN_STATE_VALUE_LENGTH` exemption below, spliced into that unrelated
8148
+ * field. Requiring the SAME name on both sides is what tells a value a
8149
+ * later step genuinely re-reads under its own name apart from that
8150
+ * coincidence.
8151
+ *
7539
8152
  * Runs directly off raw actions (not `resolveFoldPlan`, which needs
7540
8153
  * `isMultipart` — unavailable before `compileActionSteps` has run) since
7541
8154
  * fold-plan DETECTION depends only on each action's `capture`.
@@ -7549,6 +8162,98 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
7549
8162
  * two unrelated steps can coincidentally match inside a totally unrelated
7550
8163
  * capture's own URL/body and get spliced into it.
7551
8164
  */
8165
+ /** Per-capture memoized: every response BODY leaf, grouped by the field NAME
8166
+ * that carries it — gives {@link collectDependentDrillDownChainValues} the
8167
+ * same name-correlation signal {@link requestAndResponseValuesByKey} already
8168
+ * provides elsewhere in this file, instead of the bare, name-blind value set
8169
+ * {@link collectResponseLeafValues} supplies. Deliberately BODY-only, unlike
8170
+ * {@link collectResponseLeafValues}: a response HEADER (and especially a
8171
+ * `Set-Cookie` token mint) is already a strong structural signal on its own
8172
+ * — issuing a header/cookie at all is a deliberate server action, unlike an
8173
+ * arbitrary deeply-nested body scalar that merely happens to be present — so
8174
+ * header-sourced values keep the pre-existing bare-value match further down
8175
+ * in {@link collectDependentDrillDownChainValues} rather than being held to
8176
+ * a body-field's name correlation. */
8177
+ const responseBodyValuesByKeyCache = new WeakMap();
8178
+ function responseBodyValuesByKey(capture) {
8179
+ const cached = responseBodyValuesByKeyCache.get(capture);
8180
+ if (cached)
8181
+ return cached;
8182
+ const byKey = new Map();
8183
+ const add = (key, value) => {
8184
+ const values = byKey.get(key) ?? new Set();
8185
+ values.add(value);
8186
+ byKey.set(key, values);
8187
+ };
8188
+ for (const { value, path } of walkAllPrimitiveLeaves(capture.responseBody)) {
8189
+ if (value !== null && path.length > 0)
8190
+ add(path[path.length - 1], String(value));
8191
+ }
8192
+ responseBodyValuesByKeyCache.set(capture, byKey);
8193
+ return byKey;
8194
+ }
8195
+ /** Per-capture memoized request-side twin of {@link responseBodyValuesByKey}:
8196
+ * every URL query param, JSON body leaf, and request header value, grouped
8197
+ * by field/param/header NAME (header names lower-cased, since HTTP header
8198
+ * names are case-insensitive and a capture's minted header casing need not
8199
+ * match the later request's own casing of the same header), plus bare URL
8200
+ * PATH segments kept name-free in {@link
8201
+ * RequestAndResponseValues.pathSegments} — a REST-style detail fetch threads
8202
+ * an id through its URL PATH, not a named field, so requiring a name match
8203
+ * there too would blind chain-value correlation to that shape of genuine
8204
+ * threading. Headers are included here (unlike {@link
8205
+ * responseBodyValuesByKey}) so a body-sourced response value that a later
8206
+ * hop re-sends as a request HEADER under the matching name still
8207
+ * correlates. */
8208
+ const requestValuesByKeyCache = new WeakMap();
8209
+ function requestValuesByKeyIncludingHeaders(capture) {
8210
+ const cached = requestValuesByKeyCache.get(capture);
8211
+ if (cached)
8212
+ return cached;
8213
+ const byKey = new Map();
8214
+ const pathSegments = new Set();
8215
+ const add = (key, value) => {
8216
+ const values = byKey.get(key) ?? new Set();
8217
+ values.add(value);
8218
+ byKey.set(key, values);
8219
+ };
8220
+ try {
8221
+ const url = new URL(capture.url);
8222
+ for (const segment of url.pathname.split("/").filter(Boolean))
8223
+ pathSegments.add(segment);
8224
+ for (const [key, value] of url.searchParams)
8225
+ add(key, value);
8226
+ }
8227
+ catch {
8228
+ // Relative/invalid URLs carry no path/query signal to contribute.
8229
+ }
8230
+ if (capture.requestPostData) {
8231
+ try {
8232
+ const parsed = JSON.parse(capture.requestPostData);
8233
+ for (const { value, path } of walkAllPrimitiveLeaves(parsed)) {
8234
+ if (value !== null && path.length > 0)
8235
+ add(path[path.length - 1], String(value));
8236
+ }
8237
+ }
8238
+ catch {
8239
+ // A non-JSON body carries no leaf values to contribute.
8240
+ }
8241
+ }
8242
+ for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
8243
+ add(headerName.toLowerCase(), headerValue);
8244
+ }
8245
+ const result = { byKey, pathSegments };
8246
+ requestValuesByKeyCache.set(capture, result);
8247
+ return result;
8248
+ }
8249
+ /** Matches a JSON path segment that's a bare array INDEX ("0", "12", ...)
8250
+ * rather than an object field/header NAME. An array index carries no
8251
+ * semantic meaning of its own — a top-level array response (`[42]`) or a
8252
+ * value nested inside a request array (`{"tokens":[42]}`) has no field name
8253
+ * to correlate on either side — so {@link collectDependentDrillDownChainValues}
8254
+ * treats a leaf keyed by one as name-free, the same way it already treats a
8255
+ * bare URL path segment, instead of requiring an impossible name match. */
8256
+ const ARRAY_INDEX_KEY_PATTERN = /^\d+$/;
7552
8257
  function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
7553
8258
  const structuralPlans = detectDrillDownFoldPlan(actions);
7554
8259
  const specPlan = foldReturnSpec === null ? null : buildFoldPlanFromSpec(actions, foldReturnSpec);
@@ -7561,19 +8266,61 @@ function collectDependentDrillDownChainValues(actions, foldReturnSpec) {
7561
8266
  const priorCapture = actions[priorIndex]?.capture;
7562
8267
  if (!priorCapture)
7563
8268
  continue;
7564
- const responseValues = collectResponseLeafValues(priorCapture);
8269
+ const priorResponseBodyByKey = responseBodyValuesByKey(priorCapture);
8270
+ // Header/cookie-origin response values are matched by bare value
8271
+ // further down, not by name — see {@link responseBodyValuesByKey}'s
8272
+ // docstring for why a header/cookie mint doesn't need that
8273
+ // correlation to already be a trustworthy threading signal.
8274
+ const priorHeaderValues = new Set(Object.values(priorCapture.responseHeaders));
7565
8275
  const echoedValues = collectRequestValuesIncludingHeaders(priorCapture);
7566
8276
  for (let k = j + 1; k < target.chain.length; k++) {
7567
8277
  const laterCapture = actions[target.chain[k]]?.capture;
7568
8278
  if (!laterCapture)
7569
8279
  continue;
8280
+ const { byKey: laterRequestByKey, pathSegments: laterPathSegments } = requestValuesByKeyIncludingHeaders(laterCapture);
7570
8281
  const laterRequestValues = collectRequestValuesIncludingHeaders(laterCapture);
7571
- for (const v of responseValues) {
7572
- if (echoedValues.has(v) || !laterRequestValues.has(v))
7573
- continue;
8282
+ // Reverse index (value -> every later-side key it appears under),
8283
+ // built once per (priorCapture, laterCapture) pair rather than
8284
+ // once per value, so the array-index name-free fallback below
8285
+ // doesn't re-walk laterRequestByKey per value.
8286
+ const laterKeysByValue = new Map();
8287
+ for (const [k2, vs] of laterRequestByKey) {
8288
+ for (const v of vs) {
8289
+ const keys = laterKeysByValue.get(v) ?? new Set();
8290
+ keys.add(k2);
8291
+ laterKeysByValue.set(v, keys);
8292
+ }
8293
+ }
8294
+ const addConsumer = (v) => {
7574
8295
  const consumers = consumersByValue.get(v) ?? new Set();
7575
8296
  consumers.add(laterCapture);
7576
8297
  consumersByValue.set(v, consumers);
8298
+ };
8299
+ for (const [key, values] of priorResponseBodyByKey) {
8300
+ const priorKeyIsArrayIndex = ARRAY_INDEX_KEY_PATTERN.test(key);
8301
+ for (const v of values) {
8302
+ if (echoedValues.has(v))
8303
+ continue;
8304
+ const laterKeysForValue = laterKeysByValue.get(v);
8305
+ const sameNameMatch = laterKeysForValue?.has(key) ?? false;
8306
+ const pathSegmentMatch = laterPathSegments.has(v);
8307
+ // Only the SOURCE side being name-free (an array element with
8308
+ // no field name of its own) exempts this from name matching —
8309
+ // a genuinely NAMED source field must still correlate by name
8310
+ // even if it happens to land inside a later array element,
8311
+ // otherwise a named `sortOrder` could dodge correlation just
8312
+ // by coincidentally equaling a value inside an unrelated
8313
+ // later-side array (`{"tokens":[7]}`).
8314
+ const arrayIndexMatch = priorKeyIsArrayIndex && laterKeysForValue !== undefined;
8315
+ if (!sameNameMatch && !pathSegmentMatch && !arrayIndexMatch)
8316
+ continue;
8317
+ addConsumer(v);
8318
+ }
8319
+ }
8320
+ for (const v of priorHeaderValues) {
8321
+ if (echoedValues.has(v) || !laterRequestValues.has(v))
8322
+ continue;
8323
+ addConsumer(v);
7577
8324
  }
7578
8325
  }
7579
8326
  }
@@ -8212,6 +8959,33 @@ function emitContractTs(opts) {
8212
8959
  const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
8213
8960
  addExtendField(name, ` ${key}: ${value},`);
8214
8961
  }
8962
+ // Closing-the-loop safety net: every discovered-field source above tracks
8963
+ // its own registration as it splices a `payload.<field>` accessor into the
8964
+ // emitted body/url/headers text, but that tracking is scattered across N
8965
+ // independent passes (form-schema discovery, option mappings, additional
8966
+ // body keys, structured keys, drill-param bindings, and — inside
8967
+ // emitMultiStepExecuteHttp's fold-loop `parameterize` closure — threaded
8968
+ // join-field rebinding), any one of which can add an accessor to the
8969
+ // rendered text without remembering to register it in the matching map
8970
+ // above. Rather than trust each source to stay perfectly in sync with the
8971
+ // text it emits, derive completeness from the actual rendered output: scan
8972
+ // `multiStepBody` (already fully assembled at this point — every chain
8973
+ // step's url/headers/body substitutions are done) for every
8974
+ // `payload.<field>` reference and union in any name the sources above
8975
+ // missed, with a conservative `z.string()` default. This closes the gap at
8976
+ // its structural root regardless of which upstream pass forgot to record a
8977
+ // field, instead of adding a fifth registration site that could itself be
8978
+ // forgotten by a future pass.
8979
+ if (multiStepBody) {
8980
+ const bodyReferencedFields = new Set([...multiStepBody.matchAll(/\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]));
8981
+ for (const name of [...bodyReferencedFields].sort()) {
8982
+ if (extendFields.has(name))
8983
+ continue;
8984
+ if (isReservedByApplicantContactSchema(name))
8985
+ continue;
8986
+ addExtendField(name, ` ${name}: z.string(),`);
8987
+ }
8988
+ }
8215
8989
  // The structural walk over the captured request body that used to BE the
8216
8990
  // public payload schema (see basePayloadSchemaExpr above) is still the
8217
8991
  // right starting point for the plugin author's internal builder — it's
@@ -8434,7 +9208,14 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
8434
9208
  // referencesItemVar (below) never has a chance to hoist it.
8435
9209
  const isAncestorScoped = isFoldTargetAncestorScoped(target, actionSteps.map((s) => ({ capture: s.capture })), primaryItemsWithAncestors, fullAncestors);
8436
9210
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
8437
- const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
9211
+ // Only `itemVar` (and fold-match candidates) are `Record<string,
9212
+ // unknown>`-typed — ancestor loop vars keep the real response-derived
9213
+ // type, so re-asserting THEIR intermediate hops would be both
9214
+ // unnecessary and, worse, would replace a real property access with
9215
+ // an opaque cast in the emitted URL/body text.
9216
+ const scopedAccessor = (varName, field) => varName === itemVar
9217
+ ? unknownValueAccessor(varName, field.split("."))
9218
+ : `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
8438
9219
  const joinAccessor = (field) => scopedAccessor(itemVar, field);
8439
9220
  // Computed once per fold target instead of once per `parameterizeUrl`
8440
9221
  // call: `actionSteps` never changes across the calls this target's
@@ -9823,6 +10604,16 @@ async function main() {
9823
10604
  : undefined;
9824
10605
  const errorSignals = detectErrorSignals(actionSteps);
9825
10606
  const discoveredFormFields = new Set();
10607
+ // emitMultiStepExecuteHttp's outDiscoveredFields parameter is a generic
10608
+ // payload-accessor accumulator — BaseUrl substitution, persona/producer-
10609
+ // boundary bindings, entryUrlParams, tenant-subdomain headers, and
10610
+ // walkStringLeaves-derived accessors all write into it, independent of
10611
+ // form-schema discovery. It gets its own Set (rather than aliasing
10612
+ // discoveredFormFields positionally) so the two concerns stay separately
10613
+ // named; the explicit merge below is what actually wires its fields into
10614
+ // emitContractTs's schema — never an incidental byproduct of sharing one
10615
+ // reference across unrelated call sites.
10616
+ const discoveredPayloadAccessorFields = new Set();
9826
10617
  const discoveredOptionFields = new Set();
9827
10618
  // Phase E: maps label-derived raw-option payload field name (e.g.
9828
10619
  // "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
@@ -9895,8 +10686,15 @@ async function main() {
9895
10686
  const multiStepBody = browserFlowOnly
9896
10687
  ? undefined
9897
10688
  : isSubmissionFlow
9898
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec)
10689
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredPayloadAccessorFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields, foldReturnSpec, pascal)
9899
10690
  : undefined;
10691
+ // Explicit merge — every field emitMultiStepExecuteHttp registered as a
10692
+ // `payload.<field>` accessor (BaseUrl, persona, entryUrlParams, tenant-
10693
+ // subdomain headers, walkStringLeaves) flows into the same discovered-
10694
+ // fields set emitContractTs's schema `.extend()` reads from below.
10695
+ for (const field of discoveredPayloadAccessorFields) {
10696
+ discoveredFormFields.add(field);
10697
+ }
9900
10698
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
9901
10699
  const headerBindings = collectHeaderBindings(actionSteps);
9902
10700
  // Shape inference targets the SAME call executeHttp returns — see