@enricai/barnacle 1.12.29 → 1.12.31

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.
@@ -2890,6 +2890,78 @@ function pathToFoldAccessorExpr(expr, path, depth = 0) {
2890
2890
  const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
2891
2891
  return `${outerExpr}.flatMap((${groupVar}) => ${pathToFoldAccessorExpr(groupVar, after, depth + 1)})`;
2892
2892
  }
2893
+ /**
2894
+ * Emits nested `for` loops descending through every {@link
2895
+ * ARRAY_WILDCARD_SEGMENT} in `path`, instead of {@link
2896
+ * pathToFoldAccessorExpr}'s single `.flatMap` chain — a `.flatMap` callback
2897
+ * only ever sees the group it flattens FROM, so the moment it returns the
2898
+ * inner array, the outer (ancestor) object is gone from scope for the rest
2899
+ * of the emitted fold body. A nested loop keeps every intermediate binding
2900
+ * (`g0`, `g1`, ...) addressable inside the innermost loop, so a drill param
2901
+ * that only lives on an ancestor object (e.g. a parent group id) can be read
2902
+ * off that binding instead of being frozen as a literal. `varSuffix` mirrors
2903
+ * the `itemVar`/`foldItemsVar` disambiguation suffix used when multiple fold
2904
+ * plans share one function scope, so ancestor bindings from different plans
2905
+ * can't collide either. Returns the loop's opening/closing lines (for the
2906
+ * caller to splice its own loop-body lines between) plus the ordered
2907
+ * (outer-to-inner) ancestor variable names — NOT including the innermost
2908
+ * item, which the caller names itself via `itemVar`. Also splits the
2909
+ * combined open/close into an ancestor-scope segment
2910
+ * (`ancestorOpenLines`/`ancestorCloseLines`, empty when `path` crosses no
2911
+ * wildcard) and an item-scope segment (`itemOpenLines`/`itemCloseLines`),
2912
+ * so a caller whose per-target chain fetch never reads `itemVar` can splice
2913
+ * that fetch between the two segments — once per ancestor tuple — instead
2914
+ * of inside the item loop. `openLines`/`closeLines` remain the straight
2915
+ * concatenation of the two segments, unchanged, for callers that don't need
2916
+ * the split.
2917
+ */
2918
+ function pathToFoldLoopLines(expr, path, itemVar, indent, varSuffix = "", depth = 0) {
2919
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
2920
+ if (wildcardIndex === -1) {
2921
+ const finalExpr = `${expr}${pathToAccessor(path, { assertNonNull: false })}`;
2922
+ // At depth 0 with no wildcard crossing at all (the overwhelmingly common
2923
+ // single-level fold), keep emitting the original `const foldItems = ...;`
2924
+ // binding rather than inlining the accessor into the `for` — existing
2925
+ // single-scope folds must emit byte-identical code, not merely
2926
+ // equivalent code, since nothing about a flat fold needs the nested-loop
2927
+ // rewrite in the first place.
2928
+ const itemOpenLines = depth === 0
2929
+ ? [
2930
+ `${indent}const foldItems${varSuffix} = ${finalExpr};`,
2931
+ `${indent}for (const ${itemVar} of foldItems${varSuffix}) {`,
2932
+ ]
2933
+ : [`${indent}for (const ${itemVar} of ${finalExpr}) {`];
2934
+ const itemCloseLines = [`${indent}}`];
2935
+ return {
2936
+ openLines: itemOpenLines,
2937
+ closeLines: itemCloseLines,
2938
+ ancestorVars: [],
2939
+ ancestorOpenLines: [],
2940
+ itemOpenLines,
2941
+ itemCloseLines,
2942
+ ancestorCloseLines: [],
2943
+ };
2944
+ }
2945
+ const before = path.slice(0, wildcardIndex);
2946
+ const after = path.slice(wildcardIndex + 1);
2947
+ const groupVar = `g${depth}${varSuffix}`;
2948
+ const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
2949
+ const inner = pathToFoldLoopLines(groupVar, after, itemVar, `${indent} `, varSuffix, depth + 1);
2950
+ const ancestorOpenLines = [
2951
+ `${indent}for (const ${groupVar} of ${outerExpr}) {`,
2952
+ ...inner.ancestorOpenLines,
2953
+ ];
2954
+ const ancestorCloseLines = [...inner.ancestorCloseLines, `${indent}}`];
2955
+ return {
2956
+ openLines: [...ancestorOpenLines, ...inner.itemOpenLines],
2957
+ closeLines: [...inner.itemCloseLines, ...ancestorCloseLines],
2958
+ ancestorVars: [groupVar, ...inner.ancestorVars],
2959
+ ancestorOpenLines,
2960
+ itemOpenLines: inner.itemOpenLines,
2961
+ itemCloseLines: inner.itemCloseLines,
2962
+ ancestorCloseLines,
2963
+ };
2964
+ }
2893
2965
  /** Suggests a JS-camelCase variable name for a state value path. Falls back
2894
2966
  * up the path if the tail is numeric or not a valid JS identifier. */
2895
2967
  function pathToVarName(path) {
@@ -3692,29 +3764,40 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
3692
3764
  ];
3693
3765
  }
3694
3766
  const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
3767
+ const matchAccessorFor = (f, varName, optionalRoot) => {
3768
+ const segments = f.split(".");
3769
+ // The drill-down response is a DIFFERENT payload than the
3770
+ // primary item, so it has no obligation to mirror the
3771
+ // primary item's own nesting for the join key (e.g. a
3772
+ // primary item's `identifiers.sku` is typically echoed
3773
+ // back flat, as `sku`, on the drill response). Try the
3774
+ // full nested path first (optional-chained, since an
3775
+ // intermediate segment may not exist on a flat response),
3776
+ // then fall back to the bare last segment.
3777
+ const lastSegment = segments[segments.length - 1];
3778
+ const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
3779
+ const optionalBracketAccessor = segments
3780
+ .map((segment) => `?.[${JSON.stringify(segment)}]`)
3781
+ .join("");
3782
+ return segments.length > 1
3783
+ ? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
3784
+ : `${varName}${bracket(lastSegment)}`;
3785
+ };
3786
+ const joinCondition = target.joinFields
3787
+ .map((f) => `String(${matchAccessorFor(f, "m", false)}) === String(${joinAccessor(f)})`)
3788
+ .join(" && ");
3789
+ // A sole candidate whose join field(s) aren't present on it at all
3790
+ // (common: many drill endpoints don't echo the request key back onto
3791
+ // the response row) is trusted as-is — there's no sibling it could be
3792
+ // confused with. But a sole candidate that DOES carry the join field
3793
+ // with a different value is a genuine mismatch and must not be grafted
3794
+ // on; two-or-more candidates always require an actual join-key match.
3795
+ const soleCandidateFieldsAbsent = target.joinFields
3796
+ .map((f) => `${matchAccessorFor(f, `foldMatches${suffix}[0]`, true)} === undefined`)
3797
+ .join(" && ");
3695
3798
  return [
3696
3799
  ` const foldMatches${suffix} = ${foldMatchesExpr};`,
3697
- ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
3698
- .map((f) => {
3699
- const segments = f.split(".");
3700
- // The drill-down response is a DIFFERENT payload than the
3701
- // primary item, so it has no obligation to mirror the
3702
- // primary item's own nesting for the join key (e.g. a
3703
- // primary item's `identifiers.sku` is typically echoed
3704
- // back flat, as `sku`, on the drill response). Try the
3705
- // full nested path first (optional-chained, since an
3706
- // intermediate segment may not exist on a flat response),
3707
- // then fall back to the bare last segment.
3708
- const lastSegment = segments[segments.length - 1];
3709
- const optionalBracketAccessor = segments
3710
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
3711
- .join("");
3712
- const matchAccessor = segments.length > 1
3713
- ? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
3714
- : `m[${JSON.stringify(lastSegment)}]`;
3715
- return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
3716
- })
3717
- .join(" && ")}) ?? foldMatches${suffix}[0];`,
3800
+ ` const foldMatch${suffix} = foldMatches${suffix}.length === 1 && ${soleCandidateFieldsAbsent} ? foldMatches${suffix}[0] : foldMatches${suffix}.find((m) => ${joinCondition});`,
3718
3801
  ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3719
3802
  ];
3720
3803
  }
@@ -4187,77 +4270,118 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4187
4270
  const primaryStep = actions[foldPlan.primaryStepIndex];
4188
4271
  // Read at the plan's OWN path rather than re-running the DFS: a
4189
4272
  // flow-declared `resultsPath` (see FoldReturnSpec) can name a different
4190
- // array than findObjectArrayField's first match.
4191
- const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
4273
+ // array than findObjectArrayField's first match. Ancestor-aware so a
4274
+ // drill param living only on a parent object (e.g. a group id) has a
4275
+ // real object to be read off of, not just the flattened leaf item.
4276
+ const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
4192
4277
  const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
4193
4278
  // Plan-level suffix mirrors the target-level suffix below: multiple
4194
4279
  // loop blocks now sharing the same function scope can't declare
4195
- // unsuffixed `foldItems`/`item` locals without colliding. The
4280
+ // unsuffixed `item`/ancestor-binding locals without colliding. The
4196
4281
  // overwhelmingly common single-plan case keeps the original
4197
4282
  // unsuffixed names.
4198
4283
  const planSuffix = foldPlans.length > 1 ? String(matchingPlanIndex) : "";
4199
- const foldItemsVar = `foldItems${planSuffix}`;
4200
- const foldItemsExpr = pathToFoldAccessorExpr(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
4201
4284
  const itemVar = `item${planSuffix}`;
4202
- lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
4285
+ // Nested `for` loops (not a `.flatMap`-derived collection) so every
4286
+ // intermediate array's binding stays addressable inside the innermost
4287
+ // loop body — see pathToFoldLoopLines's docstring.
4288
+ const { ancestorOpenLines, itemOpenLines, itemCloseLines, ancestorCloseLines, ancestorVars } = pathToFoldLoopLines(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
4289
+ lines.push(...ancestorOpenLines);
4290
+ // Chain fetches for targets whose params + joinFields never reference
4291
+ // itemVar are spliced here, above the item loop but inside the
4292
+ // ancestor loop(s) — fetched once per ancestor tuple and reused by
4293
+ // every descendant item's join/merge, instead of once per item.
4294
+ const hoistedChainLines = [];
4295
+ // Every target's join/merge — plus any non-hoistable target's own
4296
+ // chain fetch — goes here, spliced inside the item loop.
4297
+ const itemScopedLines = [];
4203
4298
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
4204
4299
  // `firstItem` decides which captured literal `parameterize` rewrites
4205
4300
  // — it must be the item at `primaryMatchedItemIndex`, the one THIS
4206
4301
  // target's drill request was actually built from, not always index
4207
4302
  // 0, and can differ per target even though every target now shares
4208
4303
  // the same runtime loop item.
4209
- const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
4210
- if (!firstItem) {
4304
+ const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
4305
+ if (!matchedPrimaryItem) {
4211
4306
  throw new Error(`emitMultiStepExecuteHttp: fold plan primary step ${primaryStep.varName} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
4212
4307
  }
4308
+ const { item: firstItem, ancestors: firstItemAncestors } = matchedPrimaryItem;
4309
+ // Each ancestor binding (`g0`, `g1`, ...) paired with the design-time
4310
+ // object it holds at runtime, so a threaded field resolved against
4311
+ // that binding can be read off the same object `readValueAtPath`
4312
+ // needs, and the runtime accessor built from the SAME variable name
4313
+ // the emitted nested loop actually declares.
4314
+ const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, firstItemAncestors[idx]]));
4315
+ // Searched innermost-scope-first: the item's own field wins over an
4316
+ // ancestor field of the same name.
4317
+ const threadingScopes = [
4318
+ { varName: itemVar, obj: firstItem },
4319
+ ...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
4320
+ ];
4213
4321
  // Each target's chain variables and merge result get their own
4214
4322
  // suffixed local names so multiple independent targets sharing the
4215
4323
  // same loop body can each declare their own locals without
4216
4324
  // colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
4217
4325
  // single-target case keeps the original unsuffixed names.
4218
4326
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
4219
- const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4220
- // A join field can reach the render either as the raw captured literal
4221
- // (URL query params) or as an already-generic `${payload.<field>}`
4222
- // reference (top-level JSON body keys — see
4223
- // applyPayloadKeyValueSubstitutions, which payload-ifies every scalar
4224
- // body key regardless of length, running BEFORE this fold branch ever
4225
- // sees the value). Both must resolve to the loop item's own field, not
4226
- // a caller-supplied payload value shared across every iteration.
4327
+ const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4328
+ const joinAccessor = (field) => scopedAccessor(itemVar, field);
4227
4329
  // Word-boundary anchored: a plain `.split(value).join(...)` would also
4228
4330
  // rewrite unrelated substrings that happen to contain the join value
4229
4331
  // (e.g. a "p1" product id colliding with a "/v1/" path segment or a
4230
4332
  // "p10" sibling id), corrupting parts of the request the join field
4231
4333
  // never touched.
4232
4334
  const replaceWholeValue = (haystack, value, replacement) => haystack.replace(new RegExp(`\\b${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), replacement);
4233
- const parameterize = (text) => target.joinFields.reduce((acc, field) => {
4234
- const replacement = `\${${joinAccessor(field)}}`;
4235
- // applyPayloadKeyValueSubstitutions only ever names a payload
4236
- // accessor after the DRILL REQUEST's own top-level JSON key
4237
- // (`${payload.sku}`), never after `field`'s dot path into the
4238
- // PRIMARY ITEM — those are unrelated structures that only
4239
- // happen to share a leaf name for a top-level join field. A
4240
- // nested join field (e.g. `identifiers.sku`) must therefore
4241
- // also match on its bare last segment, or the accessor swap
4242
- // silently no-ops and leaves an undefined `payload.sku`
4243
- // reference behind once the literal value itself has already
4244
- // been replaced by the payload-key-value pass.
4245
- const lastSegment = field.split(".").pop();
4246
- const withAccessorSwapped = acc
4247
- .split(`\${payload.${field}}`)
4248
- .join(replacement)
4249
- .split(`\${payload.${lastSegment}}`)
4250
- .join(replacement);
4251
- const value = readValueAtPath(firstItem, field.split("."));
4252
- const stringValue = typeof value === "string" && value.length > 0
4253
- ? value
4254
- : typeof value === "number" || typeof value === "boolean"
4255
- ? String(value)
4256
- : null;
4257
- return stringValue !== null
4258
- ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4259
- : withAccessorSwapped;
4260
- }, text);
4335
+ const parameterize = (text, chainCapture) => {
4336
+ // A join field can reach the render either as the raw captured
4337
+ // literal (URL query params) or as an already-generic
4338
+ // `${payload.<field>}` reference (top-level JSON body keys — see
4339
+ // applyPayloadKeyValueSubstitutions, which payload-ifies every
4340
+ // scalar body key regardless of length, running BEFORE this fold
4341
+ // branch ever sees the value). Both must resolve to the loop
4342
+ // item's (or ancestor's) own field, not a caller-supplied payload
4343
+ // value shared across every iteration. Widened beyond
4344
+ // `target.joinFields` to every field this specific chain hop's own
4345
+ // captured request actually varies on (via findThreadedJoinFields,
4346
+ // searched across the item AND every ancestor binding) — a param
4347
+ // living only on a parent object is otherwise structurally
4348
+ // invisible and gets frozen as a literal.
4349
+ const threadedFields = dedupeThreadedFields([
4350
+ ...target.joinFields.map((field) => ({ varName: itemVar, field })),
4351
+ ...findThreadedJoinFields(threadingScopes, chainCapture, actions.map((a) => a.capture)),
4352
+ ]);
4353
+ const result = threadedFields.reduce((acc, { varName, field }) => {
4354
+ const replacement = `\${${scopedAccessor(varName, field)}}`;
4355
+ // applyPayloadKeyValueSubstitutions only ever names a payload
4356
+ // accessor after the DRILL REQUEST's own top-level JSON key
4357
+ // (`${payload.sku}`), never after `field`'s dot path into the
4358
+ // PRIMARY ITEM — those are unrelated structures that only
4359
+ // happen to share a leaf name for a top-level join field. A
4360
+ // nested join field (e.g. `identifiers.sku`) must therefore
4361
+ // also match on its bare last segment, or the accessor swap
4362
+ // silently no-ops and leaves an undefined `payload.sku`
4363
+ // reference behind once the literal value itself has already
4364
+ // been replaced by the payload-key-value pass.
4365
+ const lastSegment = field.split(".").pop();
4366
+ const withAccessorSwapped = acc
4367
+ .split(`\${payload.${field}}`)
4368
+ .join(replacement)
4369
+ .split(`\${payload.${lastSegment}}`)
4370
+ .join(replacement);
4371
+ const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
4372
+ const value = readValueAtPath(scopeObj, field.split("."));
4373
+ const stringValue = typeof value === "string" && value.length > 0
4374
+ ? value
4375
+ : typeof value === "number" || typeof value === "boolean"
4376
+ ? String(value)
4377
+ : null;
4378
+ return stringValue !== null
4379
+ ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4380
+ : withAccessorSwapped;
4381
+ }, text);
4382
+ assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, result, actions.map((a) => a.capture));
4383
+ return result;
4384
+ };
4261
4385
  // Every chain step's response and produces are block-scoped to this
4262
4386
  // `for` — they never escape to the rest of the function. That is
4263
4387
  // exactly the constraint the previous (now-removed) throw enforced by
@@ -4267,19 +4391,33 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4267
4391
  // templates by the pass above, same as it would be for any two
4268
4392
  // sequential non-fold steps) resolves them from this narrower scope.
4269
4393
  const chainDeclared = new Set();
4394
+ const chainLines = [];
4395
+ // True once any chain step's parameterized url/headers/body actually
4396
+ // ends up referencing `itemVar` — determined off the SAME
4397
+ // `parameterize` substitutions the emitted call itself uses (not a
4398
+ // fresh value scan), so this can never disagree with what the
4399
+ // request literally interpolates. `ancestorVars.length === 0` means
4400
+ // there is no ancestor loop to hoist into in the first place (a
4401
+ // flat, single-level fold), so it is treated as item-scoped too —
4402
+ // existing flat folds must keep emitting byte-identical code.
4403
+ const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
4404
+ let referencesItemVar = ancestorVars.length === 0;
4270
4405
  for (const chainIndex of target.chain) {
4271
4406
  const chainStep = actions[chainIndex];
4272
4407
  const chainRendered = rendered[chainIndex];
4273
- lines.push(` const ${chainStep.varName} = (await httpClient(\`${parameterize(chainRendered.url)}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
4274
- const joined = [
4275
- parameterize(chainRendered.headersExpr),
4276
- parameterize(chainRendered.bodyArg),
4277
- ]
4278
- .filter((s) => s !== "")
4279
- .join(" ");
4408
+ const paramUrl = parameterize(chainRendered.url, chainStep.capture);
4409
+ const paramHeaders = parameterize(chainRendered.headersExpr, chainStep.capture);
4410
+ const paramBody = parameterize(chainRendered.bodyArg, chainStep.capture);
4411
+ if (itemVarRefPattern.test(paramUrl) ||
4412
+ itemVarRefPattern.test(paramHeaders) ||
4413
+ itemVarRefPattern.test(paramBody)) {
4414
+ referencesItemVar = true;
4415
+ }
4416
+ chainLines.push(` const ${chainStep.varName} = (await httpClient(\`${paramUrl}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
4417
+ const joined = [paramHeaders, paramBody].filter((s) => s !== "").join(" ");
4280
4418
  if (joined !== "")
4281
- lines.push(` ${joined}`);
4282
- lines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
4419
+ chainLines.push(` ${joined}`);
4420
+ chainLines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
4283
4421
  for (const p of chainStep.produces) {
4284
4422
  if (p.kind === "header")
4285
4423
  continue;
@@ -4289,13 +4427,20 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4289
4427
  continue;
4290
4428
  chainDeclared.add(p.name);
4291
4429
  const assertion = pathToAssertionType(p.path);
4292
- lines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
4430
+ chainLines.push(` const ${p.name} = (${chainStep.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
4293
4431
  }
4294
4432
  }
4295
4433
  const terminalStep = actions[target.chainTerminalIndex];
4296
- lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
4434
+ const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
4435
+ if (referencesItemVar) {
4436
+ itemScopedLines.push(...chainLines, ...matchLines);
4437
+ }
4438
+ else {
4439
+ hoistedChainLines.push(...chainLines);
4440
+ itemScopedLines.push(...matchLines);
4441
+ }
4297
4442
  }
4298
- lines.push(` }`, "");
4443
+ lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
4299
4444
  continue;
4300
4445
  }
4301
4446
  // Every other chain step (already fully emitted, inline, by the fold
@@ -4714,21 +4859,53 @@ function findObjectArrayFieldOrWholeObject(value, path = []) {
4714
4859
  * path segment (e.g. `/orders/{id}`) rather than a query param or body
4715
4860
  * field. Numeric leaves are included because a join key is just as often a
4716
4861
  * numeric id (threaded as a query param string or a JSON body number
4717
- * literal) as a string one. */
4718
- function collectRequestStringValues(capture) {
4862
+ * literal) as a string one.
4863
+ *
4864
+ * `allCaptures`, when passed, gates every query param and body leaf on
4865
+ * whether its OWN value (by param key / body path, via {@link endpointKey}'s
4866
+ * same-endpoint grouping — the same grouping {@link
4867
+ * findFrozenVaryingDrillParams} uses) ever differs on some other capture of
4868
+ * the same endpoint. A value that never varies (e.g. `adults=2`) is left
4869
+ * out, so callers matching by pure value equality (e.g. {@link
4870
+ * findThreadedJoinFields}) can't bind that constant onto an unrelated field
4871
+ * that coincidentally holds the same literal (e.g. a zero-valued `children`
4872
+ * param matching a discount amount that also happens to be `0` in one
4873
+ * capture, then diverges to a fraction in another). Path segments are
4874
+ * exempt from this gate and always pass through — {@link endpointKey}
4875
+ * already fixes the pathname, so a path segment can never be observed to
4876
+ * vary within a matched group, the same rationale {@link
4877
+ * findFrozenVaryingDrillParams} documents for the same exclusion. When no
4878
+ * other capture of this endpoint exists in `allCaptures`, variance can't be
4879
+ * observed either way, so every value is kept (unfiltered, matching the
4880
+ * behavior when `allCaptures` is omitted). */
4881
+ function collectRequestStringValues(capture, allCaptures) {
4882
+ const sameEndpointCaptures = allCaptures
4883
+ ? allCaptures.filter((c) => c !== capture && endpointKey(c.url) === endpointKey(capture.url))
4884
+ : [];
4885
+ const varies = (own, others) => !allCaptures || sameEndpointCaptures.length === 0
4886
+ ? true
4887
+ : others.some((other) => other !== undefined && other !== own);
4719
4888
  const values = new Set();
4720
4889
  try {
4721
4890
  const url = new URL(capture.url);
4722
- for (const v of url.searchParams.values())
4723
- values.add(v);
4724
4891
  for (const segment of url.pathname.split("/").filter(Boolean))
4725
4892
  values.add(segment);
4893
+ for (const [paramKey, value] of url.searchParams.entries()) {
4894
+ const otherValues = sameEndpointCaptures.map((c) => {
4895
+ try {
4896
+ return new URL(c.url).searchParams.get(paramKey) ?? undefined;
4897
+ }
4898
+ catch {
4899
+ return undefined;
4900
+ }
4901
+ });
4902
+ if (varies(value, otherValues))
4903
+ values.add(value);
4904
+ }
4726
4905
  }
4727
4906
  catch {
4728
4907
  // Relative or malformed URL — no query params or path segments to contribute.
4729
4908
  }
4730
- for (const v of jsonBodyLeafValues(capture.requestPostData) ?? [])
4731
- values.add(v);
4732
4909
  const parsedBody = (() => {
4733
4910
  try {
4734
4911
  return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
@@ -4740,13 +4917,136 @@ function collectRequestStringValues(capture) {
4740
4917
  }
4741
4918
  })();
4742
4919
  if (parsedBody !== undefined) {
4743
- for (const { value } of walkAllPrimitiveLeaves(parsedBody)) {
4744
- if (typeof value === "number" || typeof value === "boolean")
4745
- values.add(String(value));
4920
+ for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
4921
+ if (value === null)
4922
+ continue;
4923
+ const stringValue = String(value);
4924
+ const otherValues = sameEndpointCaptures.map((c) => {
4925
+ try {
4926
+ const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
4927
+ ? JSON.parse(c.requestPostData)
4928
+ : undefined;
4929
+ if (otherBody === undefined)
4930
+ return undefined;
4931
+ const otherValue = readValueAtPath(otherBody, path);
4932
+ return otherValue === undefined ? undefined : String(otherValue);
4933
+ }
4934
+ catch {
4935
+ return undefined;
4936
+ }
4937
+ });
4938
+ if (varies(stringValue, otherValues))
4939
+ values.add(stringValue);
4746
4940
  }
4747
4941
  }
4748
4942
  return values;
4749
4943
  }
4944
+ /**
4945
+ * Finds every query param or JSON body leaf on `capture`'s own request that
4946
+ * (a) was left as a literal in `renderedText` — never swapped for a
4947
+ * `${...}` accessor by the threading pass above — and (b) took a different
4948
+ * value on some OTHER capture matching the exact same endpoint (same
4949
+ * origin+pathname, via {@link endpointKey}) anywhere in the run. Freezing
4950
+ * such a value bakes ONE capture's drill parameter into every fold
4951
+ * iteration's request, exactly the defect described in
4952
+ * docs/recon-generate-nested-fold-flatmaps-away-the-parent-so-drill-params-freeze.md
4953
+ * (a `packageCode`/`groupId`/`sailDate` triple that provably varies per
4954
+ * cruise, silently frozen because no threaded field explained it). Path
4955
+ * segments are deliberately not checked: {@link endpointKey} requires an
4956
+ * identical pathname to group two captures at all, so no path segment can
4957
+ * ever be observed to vary within a matched group.
4958
+ */
4959
+ function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
4960
+ const key = endpointKey(capture.url);
4961
+ const sameEndpointCaptures = allCaptures.filter((c) => c !== capture && endpointKey(c.url) === key);
4962
+ if (sameEndpointCaptures.length === 0)
4963
+ return [];
4964
+ const isFrozenLiteral = (value) => value.length > 0 && renderedText.includes(value);
4965
+ const frozen = [];
4966
+ try {
4967
+ const url = new URL(capture.url);
4968
+ for (const [paramKey, value] of url.searchParams.entries()) {
4969
+ if (!isFrozenLiteral(value))
4970
+ continue;
4971
+ const differing = sameEndpointCaptures
4972
+ .map((c) => {
4973
+ try {
4974
+ return new URL(c.url).searchParams.get(paramKey);
4975
+ }
4976
+ catch {
4977
+ return null;
4978
+ }
4979
+ })
4980
+ .find((v) => v !== null && v !== value);
4981
+ if (differing !== undefined) {
4982
+ frozen.push({
4983
+ location: "query parameter",
4984
+ key: paramKey,
4985
+ frozenValue: value,
4986
+ differingValue: differing,
4987
+ });
4988
+ }
4989
+ }
4990
+ }
4991
+ catch {
4992
+ // Relative or malformed URL — no query params to check.
4993
+ }
4994
+ const parsedBody = (() => {
4995
+ try {
4996
+ return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
4997
+ ? JSON.parse(capture.requestPostData)
4998
+ : undefined;
4999
+ }
5000
+ catch {
5001
+ return undefined;
5002
+ }
5003
+ })();
5004
+ if (parsedBody !== undefined) {
5005
+ for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
5006
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
5007
+ continue;
5008
+ const stringValue = String(value);
5009
+ if (!isFrozenLiteral(stringValue))
5010
+ continue;
5011
+ const fieldPath = path.join(".");
5012
+ const differing = sameEndpointCaptures
5013
+ .map((c) => {
5014
+ try {
5015
+ const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
5016
+ ? JSON.parse(c.requestPostData)
5017
+ : undefined;
5018
+ return otherBody === undefined ? undefined : readValueAtPath(otherBody, path);
5019
+ }
5020
+ catch {
5021
+ return undefined;
5022
+ }
5023
+ })
5024
+ .find((v) => v !== undefined && String(v) !== stringValue);
5025
+ if (differing !== undefined) {
5026
+ frozen.push({
5027
+ location: "body field",
5028
+ key: fieldPath,
5029
+ frozenValue: stringValue,
5030
+ differingValue: String(differing),
5031
+ });
5032
+ }
5033
+ }
5034
+ }
5035
+ return frozen;
5036
+ }
5037
+ /** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
5038
+ * varying literal — shared by {@link parameterizeUrl} (below) and
5039
+ * `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
5040
+ * drift on this guard. */
5041
+ function assertNoFrozenVaryingDrillParams(emitterName, capture, renderedText, allCaptures) {
5042
+ const frozen = findFrozenVaryingDrillParams(capture, renderedText, allCaptures);
5043
+ if (frozen.length === 0)
5044
+ return;
5045
+ const described = frozen
5046
+ .map((f) => `${f.location} "${f.key}" (froze "${f.frozenValue}", also captured as "${f.differingValue}")`)
5047
+ .join("; ");
5048
+ throw new Error(`${emitterName}: drill request to ${endpointKey(capture.url)} would freeze a value that varied across this run's own captures: ${described} — no threaded field (item or ancestor) explains it, so baking in one capture's literal would silently reuse it for every fold iteration; add the missing field to joinFields or make it resolvable from an ancestor binding`);
5049
+ }
4750
5050
  /**
4751
5051
  * Yields every string/numeric/boolean leaf reachable from `item` by walking nested
4752
5052
  * plain objects only (not arrays — a join key is a scalar field of the item
@@ -4834,15 +5134,48 @@ function collectCandidateIndicesAscending(requestStringValueIndex, values, after
4834
5134
  }
4835
5135
  return [...candidates].sort((a, b) => a - b);
4836
5136
  }
4837
- function findThreadedJoinFields(item, drillCapture) {
4838
- const requestValues = collectRequestStringValues(drillCapture);
5137
+ /** Drops duplicate `(varName, field)` pairs, keeping the first (innermost-
5138
+ * scope-first, by {@link findThreadedJoinFields}'s scope ordering)
5139
+ * occurrence — the same field can otherwise appear twice when both
5140
+ * `target.joinFields` and the request-value scan resolve to it. */
5141
+ function dedupeThreadedFields(fields) {
5142
+ const seen = new Set();
5143
+ return fields.filter(({ varName, field }) => {
5144
+ const key = `${varName}.${field}`;
5145
+ if (seen.has(key))
5146
+ return false;
5147
+ seen.add(key);
5148
+ return true;
5149
+ });
5150
+ }
5151
+ /** Every per-item field, across `scopes`, whose value also appears somewhere
5152
+ * in `drillCapture`'s own request (URL, body, headers) — the fields a drill
5153
+ * request actually threads out of its per-item scope(s), used both to widen
5154
+ * URL/body parameterization beyond a fold target's own `joinFields` and (via
5155
+ * a single-scope call) to disambiguate which candidate array a chained step
5156
+ * depends on. `scopes` is searched in the given order (innermost fold item
5157
+ * first, then each ancestor binding a nested loop keeps addressable), since
5158
+ * an ancestor field with the same name as an item field must not shadow the
5159
+ * item's own value.
5160
+ *
5161
+ * `allCaptures`, when passed, is forwarded to {@link
5162
+ * collectRequestStringValues} to gate matching on cross-capture variance:
5163
+ * a param whose own value never varies across other captures of the same
5164
+ * endpoint is excluded from the candidate set entirely, so a constant like
5165
+ * `children=0` can't bind to an unrelated item field (a discount amount, a
5166
+ * departure port) purely because both happen to equal the same literal at
5167
+ * generation time. Omitted for the candidate-array disambiguation callers,
5168
+ * where narrowing by variance is a different concern than the URL/body
5169
+ * over-threading this gate exists to prevent. */
5170
+ function findThreadedJoinFields(scopes, drillCapture, allCaptures) {
5171
+ const requestValues = collectRequestStringValues(drillCapture, allCaptures);
4839
5172
  if (requestValues.size === 0)
4840
5173
  return [];
4841
- return [...walkItemFieldPaths(item)]
5174
+ return scopes.flatMap(({ varName, obj }) => [...walkItemFieldPaths(obj)]
4842
5175
  .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
4843
5176
  (typeof v === "number" && requestValues.has(String(v))) ||
4844
5177
  (typeof v === "boolean" && requestValues.has(String(v))))
4845
- .map(({ path }) => path.join("."));
5178
+ .map(({ path }) => ({ varName, field: path.join(".") })));
4846
5179
  }
4847
5180
  /** Every string, numeric, and boolean leaf value present anywhere in a response —
4848
5181
  * the set a chained drill-down step's request must overlap with for that
@@ -4918,7 +5251,7 @@ function selectDisambiguatedCandidate(responseBody, capture) {
4918
5251
  if (candidates.length === 0)
4919
5252
  return null;
4920
5253
  const requestValues = collectRequestValuesIncludingHeaders(capture);
4921
- const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields(item, capture).length > 0));
5254
+ const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields([{ varName: "item", obj: item }], capture).length > 0));
4922
5255
  if (threaded)
4923
5256
  return threaded;
4924
5257
  return candidates.reduce((richest, candidate) => {
@@ -5070,10 +5403,10 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5070
5403
  // Every item in THIS candidate array is searched, not just items[0]
5071
5404
  // — a flow that only ever drilled into a later item (never the
5072
5405
  // first) must still resolve.
5073
- const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields(item, drill.capture).length > 0);
5406
+ const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields([{ varName: "item", obj: item }], drill.capture).length > 0);
5074
5407
  if (primaryMatchedItemIndex === -1)
5075
5408
  continue;
5076
- const joinFields = findThreadedJoinFields(primaryArray.items[primaryMatchedItemIndex], drill.capture);
5409
+ const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
5077
5410
  // Widened to a flat (non-array) object response when the drill step has
5078
5411
  // no object-array field of its own — see
5079
5412
  // findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
@@ -5341,6 +5674,32 @@ function objectItemsAtPath(body, path) {
5341
5674
  const items = outer.flatMap((element) => objectItemsAtPath(element, after) ?? []);
5342
5675
  return items.length > 0 ? items : null;
5343
5676
  }
5677
+ /** Same flattening as {@link objectItemsAtPath}, but pairs each flattened
5678
+ * leaf item with the ordered (outer-to-inner) chain of ancestor objects a
5679
+ * `.flatMap` accessor would discard — one entry per {@link
5680
+ * ARRAY_WILDCARD_SEGMENT} crossed on the way down. Index-aligned with
5681
+ * {@link objectItemsAtPath}'s own output (same DFS/outer-array order), so a
5682
+ * `FoldTarget.primaryMatchedItemIndex` resolves the same leaf item either
5683
+ * way; this variant additionally exposes the ancestor objects a nested-loop
5684
+ * emission ({@link pathToFoldLoopLines}) binds to `g0`/`g1`/... so drill
5685
+ * threading can read a param off an ancestor scope, not only the item. */
5686
+ function objectItemsWithAncestorsAtPath(body, path, ancestors = []) {
5687
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
5688
+ if (wildcardIndex === -1) {
5689
+ const value = readValueAtPath(body, path);
5690
+ if (Array.isArray(value)) {
5691
+ return value.filter(isObjectArrayItem).map((item) => ({ item, ancestors: [...ancestors] }));
5692
+ }
5693
+ return isObjectArrayItem(value) ? [{ item: value, ancestors: [...ancestors] }] : [];
5694
+ }
5695
+ const outer = readValueAtPath(body, path.slice(0, wildcardIndex));
5696
+ if (!Array.isArray(outer))
5697
+ return [];
5698
+ const after = path.slice(wildcardIndex + 1);
5699
+ return outer.flatMap((element) => isObjectArrayItem(element)
5700
+ ? objectItemsWithAncestorsAtPath(element, after, [...ancestors, element])
5701
+ : []);
5702
+ }
5344
5703
  /**
5345
5704
  * Builds a {@link FoldPlan} from a flow-declared {@link FoldReturnSpec}, so a
5346
5705
  * site author can express a fold the structural heuristic misses.
@@ -6605,15 +6964,15 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6605
6964
  const gqlVariablesExpr = gqlOperationName
6606
6965
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
6607
6966
  : "{ q: payload.query }";
6608
- /** Builds the `for (const item of foldItems) { ... }` block(s) that fold
6609
- * every resolved plan's drill-down data onto `dataVarName`'s primary array
6610
- * — the single-primary counterpart of emitMultiStepExecuteHttp's own
6611
- * per-item loop, sharing its match/merge tail via
6612
- * {@link emitFoldMatchAndMergeLines} so the two can't describe different
6613
- * merge semantics. Chain hops beyond the drill step itself are rendered
6614
- * directly off each hop's own captured request (no state-threading
6615
- * pipeline) since a single-primary read flow carries no submitted payload
6616
- * for those calls to reference.
6967
+ /** Builds the nested `for` loop block(s) — see {@link pathToFoldLoopLines}
6968
+ * — that fold every resolved plan's drill-down data onto `dataVarName`'s
6969
+ * primary array — the single-primary counterpart of
6970
+ * emitMultiStepExecuteHttp's own per-item loop, sharing its match/merge
6971
+ * tail via {@link emitFoldMatchAndMergeLines} so the two can't describe
6972
+ * different merge semantics. Chain hops beyond the drill step itself are
6973
+ * rendered directly off each hop's own captured request (no state-
6974
+ * threading pipeline) since a single-primary read flow carries no
6975
+ * submitted payload for those calls to reference.
6617
6976
  *
6618
6977
  * `itemsOverride`, when given, replaces the `dataVarName`+`primaryArrayPath`
6619
6978
  * accessor with a caller-supplied items expression — used by the paginated
@@ -6623,73 +6982,97 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6623
6982
  * response object. `foldPlan.primaryArrayPath` may still declare depth
6624
6983
  * BELOW `itemsOverride.level` (a fold plan resolved against a nested array
6625
6984
  * inside each paginated item) — the residual suffix beyond `level` is
6626
- * descended into via the same `.flatMap`-based {@link pathToFoldAccessorExpr}
6627
- * the non-override branch already uses, so no depth the fold plan declares
6628
- * is silently dropped. */
6985
+ * descended into via the same nested-loop emission the non-override branch
6986
+ * already uses, so no depth the fold plan declares is silently dropped.
6987
+ * Ancestor bindings inside `level` itself have no runtime loop variable
6988
+ * (the paginated merge already flattened across them), so only the
6989
+ * residual's OWN ancestor crossings are addressable for threading here. */
6629
6990
  const buildFoldMergeLines = (dataVarName, itemsOverride) => {
6630
6991
  const lines = [];
6631
6992
  for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
6632
6993
  const primaryStep = actionSteps[foldPlan.primaryStepIndex];
6633
6994
  if (!primaryStep)
6634
6995
  continue;
6635
- const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6996
+ const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6636
6997
  const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
6637
6998
  const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
6638
- const foldItemsVar = `foldItems${planSuffix}`;
6639
- const foldItemsExpr = itemsOverride
6999
+ const itemVar = `item${planSuffix}`;
7000
+ const residualPath = itemsOverride
6640
7001
  ? (() => {
6641
- const { expr, level } = itemsOverride;
7002
+ const { level } = itemsOverride;
6642
7003
  const levelPrefixesPrimaryArrayPath = level.length <= foldPlan.primaryArrayPath.length &&
6643
7004
  level.every((segment, i) => segment === foldPlan.primaryArrayPath[i]);
6644
7005
  if (!levelPrefixesPrimaryArrayPath) {
6645
7006
  throw new Error(`emitContractTs: fold plan primary array path ${foldPlan.primaryArrayPath.join(".")} no longer extends the paginated collection's own array path ${level.join(".")} — the fold plan and this emitter have drifted out of sync`);
6646
7007
  }
6647
- // The residual already carries its own leading
6648
- // ARRAY_WILDCARD_SEGMENT whenever it crosses the array boundary
6649
- // `level` itself sits at (by construction, since `level` names
6650
- // an array path) — `expr` stands in for that same array, so
6651
- // pathToFoldAccessorExpr's own flatMap over `expr` consumes it;
6652
- // prepending a second one here would double-flatten.
6653
- const residualPath = foldPlan.primaryArrayPath.slice(level.length);
6654
- if (residualPath.length === 0)
6655
- return expr;
6656
- const itemTypeExpr = `${pathAccessTypeExpr(`${pascal}Response`, level)}[number]`;
6657
- return pathToFoldAccessorExpr(`(${expr} as ${itemTypeExpr}[])`, residualPath);
7008
+ return foldPlan.primaryArrayPath.slice(level.length);
6658
7009
  })()
6659
- : pathToFoldAccessorExpr(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
6660
- const itemVar = `item${planSuffix}`;
6661
- lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
7010
+ : foldPlan.primaryArrayPath;
7011
+ const { ancestorOpenLines, itemOpenLines, itemCloseLines, ancestorCloseLines, ancestorVars } = itemsOverride
7012
+ ? pathToFoldLoopLines(residualPath.length === 0
7013
+ ? itemsOverride.expr
7014
+ : `(${itemsOverride.expr} as ${pathAccessTypeExpr(`${pascal}Response`, itemsOverride.level)}[number][])`, residualPath, itemVar, " ", planSuffix)
7015
+ : pathToFoldLoopLines(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
7016
+ // Ancestor bindings within `itemsOverride.level` (if any) have no
7017
+ // runtime loop variable, since the paginated merge already flattened
7018
+ // across them — only the trailing `residualPath.length` ancestor
7019
+ // crossings correspond to `ancestorVars` above, so slice the
7020
+ // design-time ancestor chain to match.
7021
+ const residualAncestorCount = residualPath.filter((s) => s === ARRAY_WILDCARD_SEGMENT).length;
7022
+ lines.push(...ancestorOpenLines);
7023
+ // Chain fetches for targets whose parameterized URL never interpolates
7024
+ // `itemVar` are spliced here, above the item loop but inside the
7025
+ // ancestor loop(s) — fetched once per ancestor tuple and reused by
7026
+ // every descendant item's join/merge, mirroring
7027
+ // emitMultiStepExecuteHttp's identical hoist (see its own comment).
7028
+ const hoistedChainLines = [];
7029
+ // Every target's join/merge — plus any non-hoistable target's own
7030
+ // chain fetch — goes here, spliced inside the item loop.
7031
+ const itemScopedLines = [];
7032
+ const itemVarRefPattern = new RegExp(`\\$\\{${itemVar}[.[]`);
6662
7033
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
6663
- const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
6664
- if (!firstItem) {
7034
+ const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
7035
+ if (!matchedPrimaryItem) {
6665
7036
  throw new Error(`emitContractTs: fold plan primary step ${foldPlan.primaryStepIndex} no longer resolves an object array at ${foldPlan.primaryArrayPath.join(".")} — the fold plan and this emitter have drifted out of sync`);
6666
7037
  }
7038
+ const { item: firstItem, ancestors: fullAncestors } = matchedPrimaryItem;
7039
+ const residualAncestors = fullAncestors.slice(fullAncestors.length - residualAncestorCount);
7040
+ const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, residualAncestors[idx]]));
7041
+ const threadingScopes = [
7042
+ { varName: itemVar, obj: firstItem },
7043
+ ...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
7044
+ ];
6667
7045
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
6668
- const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
7046
+ const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
7047
+ const joinAccessor = (field) => scopedAccessor(itemVar, field);
6669
7048
  // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
6670
7049
  // `parameterize` performs — a plain split/join would also rewrite
6671
7050
  // unrelated substrings that happen to contain the join value.
6672
7051
  //
6673
7052
  // Parameterizes off EVERY per-item field this specific chain hop's
6674
- // own captured request actually varies on (via findThreadedJoinFields),
6675
- // not just `target.joinFields` — `target.joinFields` names the field
6676
- // used to MATCH the drill's RESPONSE back onto the primary item (see
7053
+ // own captured request actually varies on (via findThreadedJoinFields,
7054
+ // searched across the item AND every ancestor binding), not just
7055
+ // `target.joinFields` — `target.joinFields` names the field used to
7056
+ // MATCH the drill's RESPONSE back onto the primary item (see
6677
7057
  // emitFoldMatchAndMergeLines), which for a spec-declared foldReturn
6678
7058
  // can legitimately be a field the request never carries at all (e.g.
6679
7059
  // an `id` echoed only in the response, while the request is keyed by
6680
7060
  // an unrelated field like a package code). Building the URL from only
6681
7061
  // `target.joinFields` in that case would leave it unparameterized —
6682
- // every item would fetch the SAME first-captured URL.
7062
+ // every item would fetch the SAME first-captured URL. Searching
7063
+ // ancestor scopes too is what lets a param that only lives on a
7064
+ // parent object (e.g. a group id) resolve at all.
6683
7065
  const parameterizeUrl = (rawUrl, chainCapture) => {
6684
7066
  const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
6685
7067
  ? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
6686
7068
  : rawUrl;
6687
- const threadedFields = new Set([
6688
- ...target.joinFields,
6689
- ...findThreadedJoinFields(firstItem, chainCapture),
7069
+ const threadedFields = dedupeThreadedFields([
7070
+ ...target.joinFields.map((field) => ({ varName: itemVar, field })),
7071
+ ...findThreadedJoinFields(threadingScopes, chainCapture, actionSteps.map((s) => s.capture)),
6690
7072
  ]);
6691
- return [...threadedFields].reduce((acc, field) => {
6692
- const value = readValueAtPath(firstItem, field.split("."));
7073
+ const result = threadedFields.reduce((acc, { varName, field }) => {
7074
+ const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
7075
+ const value = readValueAtPath(scopeObj, field.split("."));
6693
7076
  const stringValue = typeof value === "string" && value.length > 0
6694
7077
  ? value
6695
7078
  : typeof value === "number" || typeof value === "boolean"
@@ -6697,28 +7080,49 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6697
7080
  : null;
6698
7081
  if (stringValue === null)
6699
7082
  return acc;
6700
- return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${joinAccessor(field)}}`);
7083
+ return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${scopedAccessor(varName, field)}}`);
6701
7084
  }, withBase);
7085
+ assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, result, actionSteps.map((s) => s.capture));
7086
+ return result;
6702
7087
  };
7088
+ const chainLines = [];
7089
+ // True once any chain step's parameterized URL actually ends up
7090
+ // referencing `itemVar`, off the SAME `parameterizeUrl` call the
7091
+ // request literally interpolates — see emitMultiStepExecuteHttp's
7092
+ // identical `referencesItemVar` for why this can't disagree with
7093
+ // what's emitted. `ancestorVars.length === 0` means there's no
7094
+ // ancestor loop to hoist into (a flat, single-level fold), so it's
7095
+ // treated as item-scoped too — flat folds must keep emitting
7096
+ // byte-identical code.
7097
+ let referencesItemVar = ancestorVars.length === 0;
6703
7098
  for (const chainIndex of target.chain) {
6704
7099
  const chainStep = actionSteps[chainIndex];
6705
7100
  if (!chainStep)
6706
7101
  continue;
6707
7102
  const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
7103
+ if (itemVarRefPattern.test(url))
7104
+ referencesItemVar = true;
6708
7105
  const schemaExpr = inferZodSchema(chainStep.capture.responseBody, 0, "", {
6709
7106
  looseServerResponse: true,
6710
7107
  aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([
6711
7108
  chainStep.capture.responseBody,
6712
7109
  ]),
6713
7110
  });
6714
- lines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
7111
+ chainLines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
6715
7112
  }
6716
7113
  const terminalStep = actionSteps[target.chainTerminalIndex];
6717
7114
  if (!terminalStep)
6718
7115
  continue;
6719
- lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
7116
+ const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
7117
+ if (referencesItemVar) {
7118
+ itemScopedLines.push(...chainLines, ...matchLines);
7119
+ }
7120
+ else {
7121
+ hoistedChainLines.push(...chainLines);
7122
+ itemScopedLines.push(...matchLines);
7123
+ }
6720
7124
  }
6721
- lines.push(` }`, "");
7125
+ lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
6722
7126
  }
6723
7127
  return lines;
6724
7128
  };