@enricai/barnacle 1.12.51 → 1.12.54

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.
@@ -3803,19 +3803,20 @@ function unknownValueAccessor(varName, path) {
3803
3803
  }
3804
3804
  /**
3805
3805
  * Builds a nested TypeScript assertion type matching a JSON path. e.g.
3806
- * ["Auth","Token"] -> `{ Auth: { Token: string } }`
3807
- * ["Sections","SectionIds","0"] -> `{ Sections: { SectionIds: { "0": string } } }`
3808
- * The leaf is always `string` because produces[] entries are only emitted for
3809
- * string leaves (see compileActionSteps + walkStringLeaves). Used to keep
3810
- * emitted code free of `any` casts while still letting nested-path access
3811
- * compile against `Record<string, unknown>`-typed response variables.
3812
- */
3813
- 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) {
3814
3815
  if (path.length === 0)
3815
- return "string";
3816
+ return leafType;
3816
3817
  const segment = path[0];
3817
3818
  const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
3818
- return `{ ${key}: ${pathToAssertionType(path.slice(1))} }`;
3819
+ return `{ ${key}: ${pathToAssertionType(path.slice(1), leafType)} }`;
3819
3820
  }
3820
3821
  /**
3821
3822
  * Same nesting as {@link pathToAssertionType} but the leaf types as
@@ -4112,7 +4113,18 @@ function compileActionSteps(actions, stateIndex) {
4112
4113
  name = `${pathToVarName(path)}${suffix}`;
4113
4114
  }
4114
4115
  seenNames.add(name);
4115
- 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
+ });
4116
4128
  }
4117
4129
  }
4118
4130
  const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
@@ -4380,8 +4392,35 @@ function replaceGuardedAgainstExistingPlaceholders(text, pattern, bindingByValue
4380
4392
  * bare-value splice into either is exactly the name-free URL-path-segment
4381
4393
  * threading the eligibility gate already intends to allow, so callers
4382
4394
  * rendering those pass `false` (the default).
4383
- */
4384
- function interpolateStateValues(template, priorSteps, targetCapture, payloadAccessorByValue = new Map(), isJsonBody = false) {
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()) {
4385
4424
  const stateBindings = deriveStateVarByValue(priorSteps, targetCapture);
4386
4425
  const bindingByValue = new Map();
4387
4426
  for (const [value, accessor] of payloadAccessorByValue) {
@@ -4391,6 +4430,11 @@ function interpolateStateValues(template, priorSteps, targetCapture, payloadAcce
4391
4430
  const unconditionalValues = new Set();
4392
4431
  const sourceNameByValue = new Map();
4393
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;
4394
4438
  bindingByValue.set(value, `\${${binding.varName}}`);
4395
4439
  sourceNameByValue.set(value, binding.sourceName);
4396
4440
  if (binding.restricted)
@@ -4569,11 +4613,14 @@ function deriveProducerBoundaryBindings(actions, alreadyBound) {
4569
4613
  * the exact `"<key>":` slot, so it only fires on a value's own JSON slot.
4570
4614
  *
4571
4615
  * `producerScoped` bindings fire only on their producing step
4572
- * (`producerIndex === stepIndex`): a later step re-sending the same coordinate
4573
- * threads the produced state var, the established behavior; only the producer,
4574
- * which cannot thread its own not-yet-existent response, needs the payload bind.
4575
- * `entryUrlBindings` (a caller coordinate lifted from the entry URL) fire on
4576
- * 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.
4577
4624
  */
4578
4625
  function applyWholeValuePayloadSubstitutions(template, parsedBody, producerScoped, entryUrlBindings, stepIndex) {
4579
4626
  if (producerScoped.size === 0 && entryUrlBindings.size === 0)
@@ -5207,6 +5254,32 @@ pascalName = null) {
5207
5254
  // skip non-JSON bodies (e.g. multipart raw bytes)
5208
5255
  }
5209
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
+ }
5210
5283
  // Detect the flow's THREADED transaction id: a single UUID the site mints
5211
5284
  // once (on page load) and reuses across every submit body to correlate the
5212
5285
  // multi-step wizard — observed on real ATS flows where one such id spans
@@ -5314,7 +5387,7 @@ pascalName = null) {
5314
5387
  // length/chain-eligibility scoping doesn't happen to catch the coincidence.
5315
5388
  const url = (0, capture_filters_1.isZeroVarianceRepeatCapture)(cap, actions.map((a) => a.capture))
5316
5389
  ? cap.url
5317
- : interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue);
5390
+ : interpolateStateValues(cap.url, prior, cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
5318
5391
  // Form-schema substitution runs first on the raw recon body so its
5319
5392
  // field-id-anchored matches see the original JSON. State-threading and
5320
5393
  // payload key-value passes then run on top. Option-id substitution runs
@@ -5384,7 +5457,7 @@ pascalName = null) {
5384
5457
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
5385
5458
  : rawBodyWithProducerBoundary;
5386
5459
  const bodyAfterStateAndKv = rawBodyWithUrlParams
5387
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
5460
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
5388
5461
  : "";
5389
5462
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
5390
5463
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -5412,8 +5485,22 @@ pascalName = null) {
5412
5485
  const perCallHeaders = {};
5413
5486
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
5414
5487
  const lower = k.toLowerCase();
5415
- if (lower === "api-token" || lower === "authorization" || joinCarryingHeaderNames?.has(k)) {
5416
- 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;
5417
5504
  }
5418
5505
  }
5419
5506
  // G1: emit baseUrl-derived headers (Origin, Referer) per-call from
@@ -5677,23 +5764,10 @@ pascalName = null) {
5677
5764
  // find it. Rebinding by structure, not value, lets referencesItemVar
5678
5765
  // (below) correctly stay false so the fetch hoists above the
5679
5766
  // per-item loop instead of pinning to it.
5680
- // Pairs each raw threaded field (whose VALUE is what's actually
5681
- // present in the captured text — the only thing a literal-value
5682
- // search can ever find) with the accessor it should render as.
5683
- // For an ancestor-scoped rebind these deliberately diverge: the
5684
- // literal search must still key off the item's own field (that's
5685
- // the value textually present), while the emitted accessor points
5686
- // at the ancestor's structurally-corresponding field instead —
5687
- // searching for the ANCESTOR field's (different) value would never
5688
- // match anything in `text` and silently freeze the literal.
5689
- const threadedFieldPairs = isAncestorScoped
5690
- ? rawThreadedFields.map((tf) => ({
5691
- valueField: tf,
5692
- accessorField: tf.varName === itemVar
5693
- ? (findStructurallyCorrespondingAncestorField(ancestorScopes, tf.field) ?? tf)
5694
- : tf,
5695
- }))
5696
- : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
5767
+ // See buildThreadedFieldPairs's doc for why the literal-value
5768
+ // search and the rendered accessor deliberately diverge for a
5769
+ // proven ancestor-scoped rebind.
5770
+ const threadedFieldPairs = buildThreadedFieldPairs(isAncestorScoped, rawThreadedFields, itemVar, ancestorScopes);
5697
5771
  // Accessor swap first: rewrites an already-templated `${payload.X}`
5698
5772
  // reference (from applyPayloadKeyValueSubstitutions) to this field's
5699
5773
  // real accessor. Each target (`${payload.X}`) is a unique, fully
@@ -5701,6 +5775,7 @@ pascalName = null) {
5701
5775
  // never re-shaped into `${payload.X}` form) can't re-match, so a
5702
5776
  // sequential pass here carries none of the reentrancy risk the
5703
5777
  // literal-value pass below has.
5778
+ let swapFiredForItemBoundField = false;
5704
5779
  const swapped = threadedFieldPairs.reduce((acc, { valueField, accessorField }) => {
5705
5780
  const replacement = `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`;
5706
5781
  // applyPayloadKeyValueSubstitutions only ever names a payload
@@ -5714,10 +5789,16 @@ pascalName = null) {
5714
5789
  // reference behind once the literal value itself has already
5715
5790
  // been replaced by the payload-key-value pass.
5716
5791
  const lastSegment = valueField.field.split(".").pop();
5792
+ const payloadFieldPlaceholder = `\${payload.${valueField.field}}`;
5793
+ const payloadLastSegmentPlaceholder = `\${payload.${lastSegment}}`;
5794
+ if (accessorField.varName === itemVar &&
5795
+ (acc.includes(payloadFieldPlaceholder) || acc.includes(payloadLastSegmentPlaceholder))) {
5796
+ swapFiredForItemBoundField = true;
5797
+ }
5717
5798
  return acc
5718
- .split(`\${payload.${valueField.field}}`)
5799
+ .split(payloadFieldPlaceholder)
5719
5800
  .join(replacement)
5720
- .split(`\${payload.${lastSegment}}`)
5801
+ .split(payloadLastSegmentPlaceholder)
5721
5802
  .join(replacement);
5722
5803
  }, text);
5723
5804
  // Literal-value substitution: ONE guarded regex-alternation pass over
@@ -5740,6 +5821,7 @@ pascalName = null) {
5740
5821
  : {
5741
5822
  value: stringValue,
5742
5823
  replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
5824
+ isItemBound: accessorField.varName === itemVar,
5743
5825
  };
5744
5826
  })
5745
5827
  .filter((b) => b !== null);
@@ -5754,6 +5836,25 @@ pascalName = null) {
5754
5836
  const filteredValueBindings = isProvenInvariant
5755
5837
  ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
5756
5838
  : valueBindings;
5839
+ // Ground truth for the hoist gate, computed per FIELD from what
5840
+ // this call actually spliced into the request, rather than
5841
+ // inferred after the fact by re-scanning the fully rendered text
5842
+ // for `itemVar`: a target's OTHER join fields can independently
5843
+ // prove isAncestorScoped and rebind cleanly while THIS field's own
5844
+ // rebind attempt still falls back to `?? tf` (no structurally-
5845
+ // corresponding ancestor field exists for it). Deriving
5846
+ // `referencesItemVar` straight from the same value/placeholder
5847
+ // bindings that feed the splice below — rather than relying
5848
+ // solely on the post-hoc itemVar word-boundary scan — closes the
5849
+ // gap where a fallback accessor's occurrence in the final text
5850
+ // could otherwise be missed. A binding whose value never survives
5851
+ // to `filteredValueBindings` (unresolved, or excluded by the
5852
+ // zero-variance guard) is correctly excluded here too — it never
5853
+ // reaches the rendered request, so it can't leak an out-of-scope
5854
+ // identifier into it.
5855
+ if (swapFiredForItemBoundField || filteredValueBindings.some((b) => b.isItemBound)) {
5856
+ referencesItemVar = true;
5857
+ }
5757
5858
  const result = substituteThreadedValues(swapped, filteredValueBindings);
5758
5859
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
5759
5860
  // The frozen-varying-param safety net exists to catch a
@@ -5785,7 +5886,15 @@ pascalName = null) {
5785
5886
  // there is no ancestor loop to hoist into in the first place (a
5786
5887
  // flat, single-level fold), so it is treated as item-scoped too —
5787
5888
  // existing flat folds must keep emitting byte-identical code.
5788
- const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
5889
+ // Word-boundary match on the bare identifier — not anchored to
5890
+ // `${itemVar` — because `scopedAccessor`/`unknownValueAccessor`
5891
+ // wraps every non-leaf hop in a `(itemVar.field as Record<string,
5892
+ // unknown>)` cast for a nested field path, so the identifier can
5893
+ // appear as `${(itemVar...` rather than immediately after `${`. An
5894
+ // anchored pattern misses that shape entirely and lets a genuinely
5895
+ // item-scoped reference get hoisted above the item loop, where
5896
+ // `itemVar` is never declared.
5897
+ const itemVarRefPattern = new RegExp(`\\b${itemVar}\\b`);
5789
5898
  let referencesItemVar = ancestorVars.length === 0;
5790
5899
  for (const chainIndex of target.chain) {
5791
5900
  const chainStep = actions[chainIndex];
@@ -5814,7 +5923,7 @@ pascalName = null) {
5814
5923
  if (!referencedNames.has(p.name))
5815
5924
  continue;
5816
5925
  chainDeclared.add(p.name);
5817
- const assertion = pathToAssertionType(p.path);
5926
+ const assertion = pathToAssertionType(p.path, p.leafType);
5818
5927
  chainLines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
5819
5928
  }
5820
5929
  }
@@ -5856,7 +5965,7 @@ pascalName = null) {
5856
5965
  if (!referencedNames.has(p.name))
5857
5966
  continue;
5858
5967
  declaredNames.add(p.name);
5859
- const assertion = pathToAssertionType(p.path);
5968
+ const assertion = pathToAssertionType(p.path, p.leafType);
5860
5969
  produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
5861
5970
  }
5862
5971
  const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
@@ -5883,8 +5992,14 @@ pascalName = null) {
5883
5992
  const perCallHeaderEntries = [];
5884
5993
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
5885
5994
  const lower = k.toLowerCase();
5886
- if (lower === "api-token" || lower === "authorization") {
5887
- perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue)}\``);
5995
+ const interpolated = interpolateStateValues(v, actions.slice(0, i), cap, payloadAccessorByValue, false, producerBoundaryBindings, i);
5996
+ // Mirrors the non-multipart per-call header builder above: any
5997
+ // header name (not just Authorization/Api-Token) that interpolation
5998
+ // recognizes as a prior step's produced state var must thread
5999
+ // per-call too, or its custom name freezes into an invariant
6000
+ // BASE_HEADERS-equivalent literal.
6001
+ if (lower === "api-token" || lower === "authorization" || interpolated !== v) {
6002
+ perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolated}\``);
5888
6003
  }
5889
6004
  }
5890
6005
  // G1+G2: include tenant-derived headers in the multipart fetch too.
@@ -6934,29 +7049,44 @@ function findStructurallyCorrespondingAncestorField(ancestorScopes, field) {
6934
7049
  }
6935
7050
  return null;
6936
7051
  }
6937
- /** Recursively searches `obj` for the first array-valued property whose
6938
- * first element (itself a plain object) contains `lastSegment` as one of
6939
- * its own field paths' trailing segments, returning the full dot-path from
6940
- * `obj`'s root through the array's `0` index down to that field (e.g.
6941
- * `["entries", "0", "code"]`) — an accessor {@link pathToAccessor} renders
6942
- * as `.entries["0"].code`, valid whether the numeric segment is treated as
6943
- * a bracketed literal key or an array index. */
7052
+ /** Recursively searches `obj` for an array-valued property holding an
7053
+ * element (itself a plain object) that contains `lastSegment` as one of its
7054
+ * own field paths' trailing segments, returning the full dot-path from
7055
+ * `obj`'s root through the array's index down to that field (e.g.
7056
+ * `["entries", "1", "code"]`) — an accessor {@link pathToAccessor} renders
7057
+ * as `.entries["1"].code`, valid whether the numeric segment is treated as
7058
+ * a bracketed literal key or an array index. Scans every element of a
7059
+ * candidate array, not only the first: a sparse first element (e.g. a
7060
+ * sold-out entry missing an optional nested object that every OTHER sibling
7061
+ * genuinely carries) must not silently defeat a structural correspondence
7062
+ * that a later element in the same array would prove. This applies equally
7063
+ * to the recursive descent into a NESTED array-within-array: it also walks
7064
+ * every sibling element rather than only element `0`, so a sparse first
7065
+ * element one level up can't hide a match that lives in a nested array on a
7066
+ * later sibling. */
6944
7067
  function findFieldInFirstArrayElement(obj, lastSegment, path = []) {
6945
7068
  for (const [key, value] of Object.entries(obj)) {
6946
7069
  const childPath = [...path, key];
6947
7070
  if (Array.isArray(value)) {
6948
- const first = value[0];
6949
- if (first === null || typeof first !== "object" || Array.isArray(first))
6950
- continue;
6951
- const firstObj = first;
6952
- for (const { path: subPath } of walkItemFieldPaths(firstObj)) {
6953
- if (subPath[subPath.length - 1] === lastSegment) {
6954
- return [...childPath, "0", ...subPath];
7071
+ for (let i = 0; i < value.length; i++) {
7072
+ const candidate = value[i];
7073
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate))
7074
+ continue;
7075
+ const candidateObj = candidate;
7076
+ for (const { path: subPath } of walkItemFieldPaths(candidateObj)) {
7077
+ if (subPath[subPath.length - 1] === lastSegment) {
7078
+ return [...childPath, String(i), ...subPath];
7079
+ }
6955
7080
  }
6956
7081
  }
6957
- const nested = findFieldInFirstArrayElement(firstObj, lastSegment, [...childPath, "0"]);
6958
- if (nested)
6959
- return nested;
7082
+ for (let i = 0; i < value.length; i++) {
7083
+ const candidate = value[i];
7084
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate))
7085
+ continue;
7086
+ const nested = findFieldInFirstArrayElement(candidate, lastSegment, [...childPath, String(i)]);
7087
+ if (nested)
7088
+ return nested;
7089
+ }
6960
7090
  continue;
6961
7091
  }
6962
7092
  if (value !== null && typeof value === "object") {
@@ -6967,6 +7097,34 @@ function findFieldInFirstArrayElement(obj, lastSegment, path = []) {
6967
7097
  }
6968
7098
  return null;
6969
7099
  }
7100
+ /** Pairs each raw threaded field (whose VALUE is what a drill request's
7101
+ * captured text actually contains — the only thing a literal-value search
7102
+ * can ever find) with the accessor it should render as, shared by
7103
+ * {@link emitMultiStepExecuteHttp} and {@link emitContractTs}'s identical
7104
+ * fold-hoist rebind so the two emitters can never drift on this logic (see
7105
+ * this module's fold-plan investigation notes on the two call sites having
7106
+ * previously diverged). For a proven-ancestor-scoped target these
7107
+ * deliberately diverge: the literal search must still key off the item's own
7108
+ * field (the value textually present in the captured request), while the
7109
+ * emitted accessor points at the ancestor's structurally-corresponding field
7110
+ * instead — searching for the ANCESTOR field's (different) value would never
7111
+ * match anything in the rendered text and silently freeze the literal. When
7112
+ * no structurally-corresponding ancestor field exists, the accessor stays
7113
+ * item-bound (`?? tf`) — safe only because every caller's own itemVar
7114
+ * word-boundary scan over the FINAL rendered text (after this pairing feeds
7115
+ * substitution) is what actually decides whether the target may hoist above
7116
+ * its item loop, not this function. */
7117
+ function buildThreadedFieldPairs(isAncestorScoped, rawThreadedFields, itemVar, ancestorScopes) {
7118
+ if (!isAncestorScoped) {
7119
+ return rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
7120
+ }
7121
+ return rawThreadedFields.map((tf) => ({
7122
+ valueField: tf,
7123
+ accessorField: tf.varName === itemVar
7124
+ ? (findStructurallyCorrespondingAncestorField(ancestorScopes, tf.field) ?? tf)
7125
+ : tf,
7126
+ }));
7127
+ }
6970
7128
  /** Every string, numeric, and boolean leaf value present anywhere in a response —
6971
7129
  * the set a chained drill-down step's request must overlap with for that
6972
7130
  * step to count as depending on this response. Deliberately walks the WHOLE
@@ -9180,7 +9338,11 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9180
9338
  // Every target's join/merge — plus any non-hoistable target's own
9181
9339
  // chain fetch — goes here, spliced inside the item loop.
9182
9340
  const itemScopedLines = [];
9183
- const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
9341
+ // Word-boundary match — see emitMultiStepExecuteHttp's identical
9342
+ // `itemVarRefPattern` for why an anchored `${itemVar` pattern misses a
9343
+ // nested field path's `${(itemVar.field as Record<string,
9344
+ // unknown>)...}` cast wrapper.
9345
+ const itemVarRefPattern = new RegExp(`\\b${itemVar}\\b`);
9184
9346
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
9185
9347
  const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
9186
9348
  if (!matchedPrimaryItem) {
@@ -9250,29 +9412,11 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9250
9412
  ...target.joinFields.map((field) => ({ varName: itemVar, field })),
9251
9413
  ...findThreadedJoinFields(threadingScopes, chainCapture, allCaptures),
9252
9414
  ]);
9253
- // Mirrors emitMultiStepExecuteHttp's identical rebind (see
9254
- // isAncestorScoped above): a proven ancestor-scoped drill still
9255
- // rebinds fields findThreadedJoinFields left on itemVar purely
9256
- // because the matched item's own field was the only object in
9257
- // scope whose value equalled the captured literal.
9258
- // Pairs each raw threaded field (whose VALUE is what's actually
9259
- // present in `rawUrl` — the only thing a literal-value search can
9260
- // ever find) with the accessor it should render as. For an
9261
- // ancestor-scoped rebind these deliberately diverge: the literal
9262
- // search must still key off the item's own field (that's the
9263
- // value textually present in the URL), while the emitted accessor
9264
- // points at the ancestor's structurally-corresponding field
9265
- // instead — searching for the ANCESTOR field's (different) value
9266
- // would never match anything in the URL and silently freeze the
9267
- // literal.
9268
- const threadedFieldPairs = isAncestorScoped
9269
- ? rawThreadedFields.map((tf) => ({
9270
- valueField: tf,
9271
- accessorField: tf.varName === itemVar
9272
- ? (findStructurallyCorrespondingAncestorField(ancestorScopes, tf.field) ?? tf)
9273
- : tf,
9274
- }))
9275
- : rawThreadedFields.map((tf) => ({ valueField: tf, accessorField: tf }));
9415
+ // Mirrors emitMultiStepExecuteHttp's identical rebind — see
9416
+ // buildThreadedFieldPairs's doc for why the literal-value search
9417
+ // and the rendered accessor deliberately diverge for a proven
9418
+ // ancestor-scoped rebind.
9419
+ const threadedFieldPairs = buildThreadedFieldPairs(isAncestorScoped, rawThreadedFields, itemVar, ancestorScopes);
9276
9420
  // ONE guarded regex-alternation pass over `withBase` for every
9277
9421
  // threaded field's value, longest first — see substituteThreadedValues's
9278
9422
  // doc for why a per-field sequential `.replace()` loop here (the bug
@@ -9294,6 +9438,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9294
9438
  : {
9295
9439
  value: stringValue,
9296
9440
  replacement: `\${${scopedAccessor(accessorField.varName, accessorField.field)}}`,
9441
+ isItemBound: accessorField.varName === itemVar,
9297
9442
  };
9298
9443
  })
9299
9444
  .filter((b) => b !== null);
@@ -9306,6 +9451,20 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9306
9451
  const filteredValueBindings = isProvenInvariant
9307
9452
  ? valueBindings.filter((b) => isGenuineVaryingQueryValue(b.value, chainCapture, allCaptures))
9308
9453
  : valueBindings;
9454
+ // Mirrors emitMultiStepExecuteHttp's identical ground-truth hoist
9455
+ // signal: derive `referencesItemVar` straight from the same
9456
+ // value bindings that feed the splice below, rather than relying
9457
+ // solely on the post-hoc itemVar word-boundary scan — a target's
9458
+ // OTHER join fields can independently prove isAncestorScoped and
9459
+ // rebind cleanly while THIS field's own rebind attempt still
9460
+ // falls back to `?? tf` (no structurally-corresponding ancestor
9461
+ // field exists for it). A binding excluded from
9462
+ // `filteredValueBindings` (unresolved, or dropped by the
9463
+ // zero-variance guard) never reaches the rendered URL, so it's
9464
+ // correctly excluded here too.
9465
+ if (filteredValueBindings.some((b) => b.isItemBound)) {
9466
+ referencesItemVar = true;
9467
+ }
9309
9468
  const result = substituteThreadedValues(withBase, filteredValueBindings);
9310
9469
  const withDrillParamBindings = applyDrillParamBindings(foldReturnSpec, chainCapture, result);
9311
9470
  // See emitMultiStepExecuteHttp's identical guard: the frozen-