@enricai/barnacle 1.12.28 → 1.12.30

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.
Files changed (37) hide show
  1. package/README.md +4 -0
  2. package/dist/config.d.ts +6 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +1 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/lib/llm/schemas.d.ts +2 -0
  7. package/dist/lib/llm/schemas.d.ts.map +1 -1
  8. package/dist/lib/llm/schemas.js +6 -0
  9. package/dist/lib/llm/schemas.js.map +1 -1
  10. package/dist/plugins/config-plugin.d.ts +1 -0
  11. package/dist/plugins/config-plugin.d.ts.map +1 -1
  12. package/dist/plugins/config-plugin.js +2 -0
  13. package/dist/plugins/config-plugin.js.map +1 -1
  14. package/dist/scraper/captcha-solver.d.ts +46 -0
  15. package/dist/scraper/captcha-solver.d.ts.map +1 -0
  16. package/dist/scraper/captcha-solver.js +147 -0
  17. package/dist/scraper/captcha-solver.js.map +1 -0
  18. package/dist/scraper/errors.d.ts +12 -1
  19. package/dist/scraper/errors.d.ts.map +1 -1
  20. package/dist/scraper/errors.js +24 -3
  21. package/dist/scraper/errors.js.map +1 -1
  22. package/dist/scraper/flow-runner.d.ts +53 -0
  23. package/dist/scraper/flow-runner.d.ts.map +1 -1
  24. package/dist/scraper/flow-runner.js +162 -11
  25. package/dist/scraper/flow-runner.js.map +1 -1
  26. package/dist/scripts/recon-browser.d.ts +5 -0
  27. package/dist/scripts/recon-browser.d.ts.map +1 -1
  28. package/dist/scripts/recon-browser.js +10 -1
  29. package/dist/scripts/recon-browser.js.map +1 -1
  30. package/dist/scripts/recon-generate-multicall-fixture.d.ts +34 -0
  31. package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
  32. package/dist/scripts/recon-generate-multicall-fixture.js +68 -0
  33. package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
  34. package/dist/scripts/recon-generate.d.ts.map +1 -1
  35. package/dist/scripts/recon-generate.js +410 -119
  36. package/dist/scripts/recon-generate.js.map +1 -1
  37. package/package.json +7 -7
@@ -2890,6 +2890,61 @@ 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`.
2909
+ */
2910
+ function pathToFoldLoopLines(expr, path, itemVar, indent, varSuffix = "", depth = 0) {
2911
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
2912
+ if (wildcardIndex === -1) {
2913
+ const finalExpr = `${expr}${pathToAccessor(path, { assertNonNull: false })}`;
2914
+ // At depth 0 with no wildcard crossing at all (the overwhelmingly common
2915
+ // single-level fold), keep emitting the original `const foldItems = ...;`
2916
+ // binding rather than inlining the accessor into the `for` — existing
2917
+ // single-scope folds must emit byte-identical code, not merely
2918
+ // equivalent code, since nothing about a flat fold needs the nested-loop
2919
+ // rewrite in the first place.
2920
+ if (depth === 0) {
2921
+ const foldItemsVar = `foldItems${varSuffix}`;
2922
+ return {
2923
+ openLines: [
2924
+ `${indent}const ${foldItemsVar} = ${finalExpr};`,
2925
+ `${indent}for (const ${itemVar} of ${foldItemsVar}) {`,
2926
+ ],
2927
+ closeLines: [`${indent}}`],
2928
+ ancestorVars: [],
2929
+ };
2930
+ }
2931
+ return {
2932
+ openLines: [`${indent}for (const ${itemVar} of ${finalExpr}) {`],
2933
+ closeLines: [`${indent}}`],
2934
+ ancestorVars: [],
2935
+ };
2936
+ }
2937
+ const before = path.slice(0, wildcardIndex);
2938
+ const after = path.slice(wildcardIndex + 1);
2939
+ const groupVar = `g${depth}${varSuffix}`;
2940
+ const outerExpr = `${expr}${pathToAccessor(before, { assertNonNull: false })}`;
2941
+ const inner = pathToFoldLoopLines(groupVar, after, itemVar, `${indent} `, varSuffix, depth + 1);
2942
+ return {
2943
+ openLines: [`${indent}for (const ${groupVar} of ${outerExpr}) {`, ...inner.openLines],
2944
+ closeLines: [...inner.closeLines, `${indent}}`],
2945
+ ancestorVars: [groupVar, ...inner.ancestorVars],
2946
+ };
2947
+ }
2893
2948
  /** Suggests a JS-camelCase variable name for a state value path. Falls back
2894
2949
  * up the path if the tail is numeric or not a valid JS identifier. */
2895
2950
  function pathToVarName(path) {
@@ -3692,29 +3747,40 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
3692
3747
  ];
3693
3748
  }
3694
3749
  const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
3750
+ const matchAccessorFor = (f, varName, optionalRoot) => {
3751
+ const segments = f.split(".");
3752
+ // The drill-down response is a DIFFERENT payload than the
3753
+ // primary item, so it has no obligation to mirror the
3754
+ // primary item's own nesting for the join key (e.g. a
3755
+ // primary item's `identifiers.sku` is typically echoed
3756
+ // back flat, as `sku`, on the drill response). Try the
3757
+ // full nested path first (optional-chained, since an
3758
+ // intermediate segment may not exist on a flat response),
3759
+ // then fall back to the bare last segment.
3760
+ const lastSegment = segments[segments.length - 1];
3761
+ const bracket = (segment) => optionalRoot ? `?.[${JSON.stringify(segment)}]` : `[${JSON.stringify(segment)}]`;
3762
+ const optionalBracketAccessor = segments
3763
+ .map((segment) => `?.[${JSON.stringify(segment)}]`)
3764
+ .join("");
3765
+ return segments.length > 1
3766
+ ? `(${varName}${optionalBracketAccessor} ?? ${varName}${bracket(lastSegment)})`
3767
+ : `${varName}${bracket(lastSegment)}`;
3768
+ };
3769
+ const joinCondition = target.joinFields
3770
+ .map((f) => `String(${matchAccessorFor(f, "m", false)}) === String(${joinAccessor(f)})`)
3771
+ .join(" && ");
3772
+ // A sole candidate whose join field(s) aren't present on it at all
3773
+ // (common: many drill endpoints don't echo the request key back onto
3774
+ // the response row) is trusted as-is — there's no sibling it could be
3775
+ // confused with. But a sole candidate that DOES carry the join field
3776
+ // with a different value is a genuine mismatch and must not be grafted
3777
+ // on; two-or-more candidates always require an actual join-key match.
3778
+ const soleCandidateFieldsAbsent = target.joinFields
3779
+ .map((f) => `${matchAccessorFor(f, `foldMatches${suffix}[0]`, true)} === undefined`)
3780
+ .join(" && ");
3695
3781
  return [
3696
3782
  ` 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];`,
3783
+ ` const foldMatch${suffix} = foldMatches${suffix}.length === 1 && ${soleCandidateFieldsAbsent} ? foldMatches${suffix}[0] : foldMatches${suffix}.find((m) => ${joinCondition});`,
3718
3784
  ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3719
3785
  ];
3720
3786
  }
@@ -4187,77 +4253,110 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4187
4253
  const primaryStep = actions[foldPlan.primaryStepIndex];
4188
4254
  // Read at the plan's OWN path rather than re-running the DFS: a
4189
4255
  // 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);
4256
+ // array than findObjectArrayField's first match. Ancestor-aware so a
4257
+ // drill param living only on a parent object (e.g. a group id) has a
4258
+ // real object to be read off of, not just the flattened leaf item.
4259
+ const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
4192
4260
  const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
4193
4261
  // Plan-level suffix mirrors the target-level suffix below: multiple
4194
4262
  // loop blocks now sharing the same function scope can't declare
4195
- // unsuffixed `foldItems`/`item` locals without colliding. The
4263
+ // unsuffixed `item`/ancestor-binding locals without colliding. The
4196
4264
  // overwhelmingly common single-plan case keeps the original
4197
4265
  // unsuffixed names.
4198
4266
  const planSuffix = foldPlans.length > 1 ? String(matchingPlanIndex) : "";
4199
- const foldItemsVar = `foldItems${planSuffix}`;
4200
- const foldItemsExpr = pathToFoldAccessorExpr(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
4201
4267
  const itemVar = `item${planSuffix}`;
4202
- lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
4268
+ // Nested `for` loops (not a `.flatMap`-derived collection) so every
4269
+ // intermediate array's binding stays addressable inside the innermost
4270
+ // loop body — see pathToFoldLoopLines's docstring.
4271
+ const { openLines, closeLines, ancestorVars } = pathToFoldLoopLines(`(${primaryStep.varName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
4272
+ lines.push(...openLines);
4203
4273
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
4204
4274
  // `firstItem` decides which captured literal `parameterize` rewrites
4205
4275
  // — it must be the item at `primaryMatchedItemIndex`, the one THIS
4206
4276
  // target's drill request was actually built from, not always index
4207
4277
  // 0, and can differ per target even though every target now shares
4208
4278
  // the same runtime loop item.
4209
- const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
4210
- if (!firstItem) {
4279
+ const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
4280
+ if (!matchedPrimaryItem) {
4211
4281
  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
4282
  }
4283
+ const { item: firstItem, ancestors: firstItemAncestors } = matchedPrimaryItem;
4284
+ // Each ancestor binding (`g0`, `g1`, ...) paired with the design-time
4285
+ // object it holds at runtime, so a threaded field resolved against
4286
+ // that binding can be read off the same object `readValueAtPath`
4287
+ // needs, and the runtime accessor built from the SAME variable name
4288
+ // the emitted nested loop actually declares.
4289
+ const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, firstItemAncestors[idx]]));
4290
+ // Searched innermost-scope-first: the item's own field wins over an
4291
+ // ancestor field of the same name.
4292
+ const threadingScopes = [
4293
+ { varName: itemVar, obj: firstItem },
4294
+ ...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
4295
+ ];
4213
4296
  // Each target's chain variables and merge result get their own
4214
4297
  // suffixed local names so multiple independent targets sharing the
4215
4298
  // same loop body can each declare their own locals without
4216
4299
  // colliding on `foldMatches`/`foldMatch`. The overwhelmingly common
4217
4300
  // single-target case keeps the original unsuffixed names.
4218
4301
  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.
4302
+ const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
4303
+ const joinAccessor = (field) => scopedAccessor(itemVar, field);
4227
4304
  // Word-boundary anchored: a plain `.split(value).join(...)` would also
4228
4305
  // rewrite unrelated substrings that happen to contain the join value
4229
4306
  // (e.g. a "p1" product id colliding with a "/v1/" path segment or a
4230
4307
  // "p10" sibling id), corrupting parts of the request the join field
4231
4308
  // never touched.
4232
4309
  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);
4310
+ const parameterize = (text, chainCapture) => {
4311
+ // A join field can reach the render either as the raw captured
4312
+ // literal (URL query params) or as an already-generic
4313
+ // `${payload.<field>}` reference (top-level JSON body keys — see
4314
+ // applyPayloadKeyValueSubstitutions, which payload-ifies every
4315
+ // scalar body key regardless of length, running BEFORE this fold
4316
+ // branch ever sees the value). Both must resolve to the loop
4317
+ // item's (or ancestor's) own field, not a caller-supplied payload
4318
+ // value shared across every iteration. Widened beyond
4319
+ // `target.joinFields` to every field this specific chain hop's own
4320
+ // captured request actually varies on (via findThreadedJoinFields,
4321
+ // searched across the item AND every ancestor binding) — a param
4322
+ // living only on a parent object is otherwise structurally
4323
+ // invisible and gets frozen as a literal.
4324
+ const threadedFields = dedupeThreadedFields([
4325
+ ...target.joinFields.map((field) => ({ varName: itemVar, field })),
4326
+ ...findThreadedJoinFields(threadingScopes, chainCapture),
4327
+ ]);
4328
+ const result = threadedFields.reduce((acc, { varName, field }) => {
4329
+ const replacement = `\${${scopedAccessor(varName, field)}}`;
4330
+ // applyPayloadKeyValueSubstitutions only ever names a payload
4331
+ // accessor after the DRILL REQUEST's own top-level JSON key
4332
+ // (`${payload.sku}`), never after `field`'s dot path into the
4333
+ // PRIMARY ITEM — those are unrelated structures that only
4334
+ // happen to share a leaf name for a top-level join field. A
4335
+ // nested join field (e.g. `identifiers.sku`) must therefore
4336
+ // also match on its bare last segment, or the accessor swap
4337
+ // silently no-ops and leaves an undefined `payload.sku`
4338
+ // reference behind once the literal value itself has already
4339
+ // been replaced by the payload-key-value pass.
4340
+ const lastSegment = field.split(".").pop();
4341
+ const withAccessorSwapped = acc
4342
+ .split(`\${payload.${field}}`)
4343
+ .join(replacement)
4344
+ .split(`\${payload.${lastSegment}}`)
4345
+ .join(replacement);
4346
+ const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
4347
+ const value = readValueAtPath(scopeObj, field.split("."));
4348
+ const stringValue = typeof value === "string" && value.length > 0
4349
+ ? value
4350
+ : typeof value === "number" || typeof value === "boolean"
4351
+ ? String(value)
4352
+ : null;
4353
+ return stringValue !== null
4354
+ ? replaceWholeValue(withAccessorSwapped, stringValue, replacement)
4355
+ : withAccessorSwapped;
4356
+ }, text);
4357
+ assertNoFrozenVaryingDrillParams("emitMultiStepExecuteHttp", chainCapture, result, actions.map((a) => a.capture));
4358
+ return result;
4359
+ };
4261
4360
  // Every chain step's response and produces are block-scoped to this
4262
4361
  // `for` — they never escape to the rest of the function. That is
4263
4362
  // exactly the constraint the previous (now-removed) throw enforced by
@@ -4270,10 +4369,10 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4270
4369
  for (const chainIndex of target.chain) {
4271
4370
  const chainStep = actions[chainIndex];
4272
4371
  const chainRendered = rendered[chainIndex];
4273
- lines.push(` const ${chainStep.varName} = (await httpClient(\`${parameterize(chainRendered.url)}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
4372
+ lines.push(` const ${chainStep.varName} = (await httpClient(\`${parameterize(chainRendered.url, chainStep.capture)}\`, {`, ` method: ${JSON.stringify(chainRendered.method)},`);
4274
4373
  const joined = [
4275
- parameterize(chainRendered.headersExpr),
4276
- parameterize(chainRendered.bodyArg),
4374
+ parameterize(chainRendered.headersExpr, chainStep.capture),
4375
+ parameterize(chainRendered.bodyArg, chainStep.capture),
4277
4376
  ]
4278
4377
  .filter((s) => s !== "")
4279
4378
  .join(" ");
@@ -4295,7 +4394,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4295
4394
  const terminalStep = actions[target.chainTerminalIndex];
4296
4395
  lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
4297
4396
  }
4298
- lines.push(` }`, "");
4397
+ lines.push(...closeLines, "");
4299
4398
  continue;
4300
4399
  }
4301
4400
  // Every other chain step (already fully emitted, inline, by the fold
@@ -4747,6 +4846,112 @@ function collectRequestStringValues(capture) {
4747
4846
  }
4748
4847
  return values;
4749
4848
  }
4849
+ /**
4850
+ * Finds every query param or JSON body leaf on `capture`'s own request that
4851
+ * (a) was left as a literal in `renderedText` — never swapped for a
4852
+ * `${...}` accessor by the threading pass above — and (b) took a different
4853
+ * value on some OTHER capture matching the exact same endpoint (same
4854
+ * origin+pathname, via {@link endpointKey}) anywhere in the run. Freezing
4855
+ * such a value bakes ONE capture's drill parameter into every fold
4856
+ * iteration's request, exactly the defect described in
4857
+ * docs/recon-generate-nested-fold-flatmaps-away-the-parent-so-drill-params-freeze.md
4858
+ * (a `packageCode`/`groupId`/`sailDate` triple that provably varies per
4859
+ * cruise, silently frozen because no threaded field explained it). Path
4860
+ * segments are deliberately not checked: {@link endpointKey} requires an
4861
+ * identical pathname to group two captures at all, so no path segment can
4862
+ * ever be observed to vary within a matched group.
4863
+ */
4864
+ function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
4865
+ const key = endpointKey(capture.url);
4866
+ const sameEndpointCaptures = allCaptures.filter((c) => c !== capture && endpointKey(c.url) === key);
4867
+ if (sameEndpointCaptures.length === 0)
4868
+ return [];
4869
+ const isFrozenLiteral = (value) => value.length > 0 && renderedText.includes(value);
4870
+ const frozen = [];
4871
+ try {
4872
+ const url = new URL(capture.url);
4873
+ for (const [paramKey, value] of url.searchParams.entries()) {
4874
+ if (!isFrozenLiteral(value))
4875
+ continue;
4876
+ const differing = sameEndpointCaptures
4877
+ .map((c) => {
4878
+ try {
4879
+ return new URL(c.url).searchParams.get(paramKey);
4880
+ }
4881
+ catch {
4882
+ return null;
4883
+ }
4884
+ })
4885
+ .find((v) => v !== null && v !== value);
4886
+ if (differing !== undefined) {
4887
+ frozen.push({
4888
+ location: "query parameter",
4889
+ key: paramKey,
4890
+ frozenValue: value,
4891
+ differingValue: differing,
4892
+ });
4893
+ }
4894
+ }
4895
+ }
4896
+ catch {
4897
+ // Relative or malformed URL — no query params to check.
4898
+ }
4899
+ const parsedBody = (() => {
4900
+ try {
4901
+ return typeof capture.requestPostData === "string" && capture.requestPostData.length > 0
4902
+ ? JSON.parse(capture.requestPostData)
4903
+ : undefined;
4904
+ }
4905
+ catch {
4906
+ return undefined;
4907
+ }
4908
+ })();
4909
+ if (parsedBody !== undefined) {
4910
+ for (const { path, value } of walkAllPrimitiveLeaves(parsedBody)) {
4911
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
4912
+ continue;
4913
+ const stringValue = String(value);
4914
+ if (!isFrozenLiteral(stringValue))
4915
+ continue;
4916
+ const fieldPath = path.join(".");
4917
+ const differing = sameEndpointCaptures
4918
+ .map((c) => {
4919
+ try {
4920
+ const otherBody = typeof c.requestPostData === "string" && c.requestPostData.length > 0
4921
+ ? JSON.parse(c.requestPostData)
4922
+ : undefined;
4923
+ return otherBody === undefined ? undefined : readValueAtPath(otherBody, path);
4924
+ }
4925
+ catch {
4926
+ return undefined;
4927
+ }
4928
+ })
4929
+ .find((v) => v !== undefined && String(v) !== stringValue);
4930
+ if (differing !== undefined) {
4931
+ frozen.push({
4932
+ location: "body field",
4933
+ key: fieldPath,
4934
+ frozenValue: stringValue,
4935
+ differingValue: String(differing),
4936
+ });
4937
+ }
4938
+ }
4939
+ }
4940
+ return frozen;
4941
+ }
4942
+ /** Throws when {@link findFrozenVaryingDrillParams} finds any frozen-but-
4943
+ * varying literal — shared by {@link parameterizeUrl} (below) and
4944
+ * `emitMultiStepExecuteHttp`'s own `parameterize` so the two emitters can't
4945
+ * drift on this guard. */
4946
+ function assertNoFrozenVaryingDrillParams(emitterName, capture, renderedText, allCaptures) {
4947
+ const frozen = findFrozenVaryingDrillParams(capture, renderedText, allCaptures);
4948
+ if (frozen.length === 0)
4949
+ return;
4950
+ const described = frozen
4951
+ .map((f) => `${f.location} "${f.key}" (froze "${f.frozenValue}", also captured as "${f.differingValue}")`)
4952
+ .join("; ");
4953
+ 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`);
4954
+ }
4750
4955
  /**
4751
4956
  * Yields every string/numeric/boolean leaf reachable from `item` by walking nested
4752
4957
  * plain objects only (not arrays — a join key is a scalar field of the item
@@ -4834,15 +5039,38 @@ function collectCandidateIndicesAscending(requestStringValueIndex, values, after
4834
5039
  }
4835
5040
  return [...candidates].sort((a, b) => a - b);
4836
5041
  }
4837
- function findThreadedJoinFields(item, drillCapture) {
5042
+ /** Drops duplicate `(varName, field)` pairs, keeping the first (innermost-
5043
+ * scope-first, by {@link findThreadedJoinFields}'s scope ordering)
5044
+ * occurrence — the same field can otherwise appear twice when both
5045
+ * `target.joinFields` and the request-value scan resolve to it. */
5046
+ function dedupeThreadedFields(fields) {
5047
+ const seen = new Set();
5048
+ return fields.filter(({ varName, field }) => {
5049
+ const key = `${varName}.${field}`;
5050
+ if (seen.has(key))
5051
+ return false;
5052
+ seen.add(key);
5053
+ return true;
5054
+ });
5055
+ }
5056
+ /** Every per-item field, across `scopes`, whose value also appears somewhere
5057
+ * in `drillCapture`'s own request (URL, body, headers) — the fields a drill
5058
+ * request actually threads out of its per-item scope(s), used both to widen
5059
+ * URL/body parameterization beyond a fold target's own `joinFields` and (via
5060
+ * a single-scope call) to disambiguate which candidate array a chained step
5061
+ * depends on. `scopes` is searched in the given order (innermost fold item
5062
+ * first, then each ancestor binding a nested loop keeps addressable), since
5063
+ * an ancestor field with the same name as an item field must not shadow the
5064
+ * item's own value. */
5065
+ function findThreadedJoinFields(scopes, drillCapture) {
4838
5066
  const requestValues = collectRequestStringValues(drillCapture);
4839
5067
  if (requestValues.size === 0)
4840
5068
  return [];
4841
- return [...walkItemFieldPaths(item)]
5069
+ return scopes.flatMap(({ varName, obj }) => [...walkItemFieldPaths(obj)]
4842
5070
  .filter(({ value: v }) => (typeof v === "string" && v.length > 0 && requestValues.has(v)) ||
4843
5071
  (typeof v === "number" && requestValues.has(String(v))) ||
4844
5072
  (typeof v === "boolean" && requestValues.has(String(v))))
4845
- .map(({ path }) => path.join("."));
5073
+ .map(({ path }) => ({ varName, field: path.join(".") })));
4846
5074
  }
4847
5075
  /** Every string, numeric, and boolean leaf value present anywhere in a response —
4848
5076
  * the set a chained drill-down step's request must overlap with for that
@@ -4918,7 +5146,7 @@ function selectDisambiguatedCandidate(responseBody, capture) {
4918
5146
  if (candidates.length === 0)
4919
5147
  return null;
4920
5148
  const requestValues = collectRequestValuesIncludingHeaders(capture);
4921
- const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields(item, capture).length > 0));
5149
+ const threaded = candidates.find((candidate) => candidate.items.some((item) => findThreadedJoinFields([{ varName: "item", obj: item }], capture).length > 0));
4922
5150
  if (threaded)
4923
5151
  return threaded;
4924
5152
  return candidates.reduce((richest, candidate) => {
@@ -5070,10 +5298,10 @@ function scanPrimaryCandidateGroups(actions, primaryIndex, globallyConsumedIndic
5070
5298
  // Every item in THIS candidate array is searched, not just items[0]
5071
5299
  // — a flow that only ever drilled into a later item (never the
5072
5300
  // first) must still resolve.
5073
- const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields(item, drill.capture).length > 0);
5301
+ const primaryMatchedItemIndex = primaryArray.items.findIndex((item) => findThreadedJoinFields([{ varName: "item", obj: item }], drill.capture).length > 0);
5074
5302
  if (primaryMatchedItemIndex === -1)
5075
5303
  continue;
5076
- const joinFields = findThreadedJoinFields(primaryArray.items[primaryMatchedItemIndex], drill.capture);
5304
+ const joinFields = findThreadedJoinFields([{ varName: "item", obj: primaryArray.items[primaryMatchedItemIndex] }], drill.capture).map((f) => f.field);
5077
5305
  // Widened to a flat (non-array) object response when the drill step has
5078
5306
  // no object-array field of its own — see
5079
5307
  // findAllObjectArrayFieldsOrWholeObject. A detail-by-id response (e.g.
@@ -5341,6 +5569,32 @@ function objectItemsAtPath(body, path) {
5341
5569
  const items = outer.flatMap((element) => objectItemsAtPath(element, after) ?? []);
5342
5570
  return items.length > 0 ? items : null;
5343
5571
  }
5572
+ /** Same flattening as {@link objectItemsAtPath}, but pairs each flattened
5573
+ * leaf item with the ordered (outer-to-inner) chain of ancestor objects a
5574
+ * `.flatMap` accessor would discard — one entry per {@link
5575
+ * ARRAY_WILDCARD_SEGMENT} crossed on the way down. Index-aligned with
5576
+ * {@link objectItemsAtPath}'s own output (same DFS/outer-array order), so a
5577
+ * `FoldTarget.primaryMatchedItemIndex` resolves the same leaf item either
5578
+ * way; this variant additionally exposes the ancestor objects a nested-loop
5579
+ * emission ({@link pathToFoldLoopLines}) binds to `g0`/`g1`/... so drill
5580
+ * threading can read a param off an ancestor scope, not only the item. */
5581
+ function objectItemsWithAncestorsAtPath(body, path, ancestors = []) {
5582
+ const wildcardIndex = path.indexOf(ARRAY_WILDCARD_SEGMENT);
5583
+ if (wildcardIndex === -1) {
5584
+ const value = readValueAtPath(body, path);
5585
+ if (Array.isArray(value)) {
5586
+ return value.filter(isObjectArrayItem).map((item) => ({ item, ancestors: [...ancestors] }));
5587
+ }
5588
+ return isObjectArrayItem(value) ? [{ item: value, ancestors: [...ancestors] }] : [];
5589
+ }
5590
+ const outer = readValueAtPath(body, path.slice(0, wildcardIndex));
5591
+ if (!Array.isArray(outer))
5592
+ return [];
5593
+ const after = path.slice(wildcardIndex + 1);
5594
+ return outer.flatMap((element) => isObjectArrayItem(element)
5595
+ ? objectItemsWithAncestorsAtPath(element, after, [...ancestors, element])
5596
+ : []);
5597
+ }
5344
5598
  /**
5345
5599
  * Builds a {@link FoldPlan} from a flow-declared {@link FoldReturnSpec}, so a
5346
5600
  * site author can express a fold the structural heuristic misses.
@@ -6293,6 +6547,27 @@ function emitContractTs(opts) {
6293
6547
  // assembled/de-duplicated page items, not just the first page's captured
6294
6548
  // sample, so a paginated primary is no longer excluded from folding.
6295
6549
  const singlePrimaryFoldPlans = resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody);
6550
+ // A fold plan's primaryStepIndex is resolved purely structurally (does the
6551
+ // declared resultsPath resolve to an object array on that capture), with
6552
+ // no comparison against the operation actually selected as primary for
6553
+ // emission (gqlOperationName/gqlQuery, computed independently upstream).
6554
+ // When they diverge, buildFoldMergeLines below would cast `data` to the
6555
+ // fold plan's operation's shape even though `data` at runtime holds the
6556
+ // emitted primary operation's response — a cast that can never typecheck.
6557
+ // Scoped to gql: REST fold-plan/emitted-primary drift is a distinct,
6558
+ // undocumented concern out of scope here.
6559
+ if (gql) {
6560
+ const emittedPrimaryIdentity = `${endpointPath}::${gqlOperationName ?? parsedOperationName(gqlQuery ?? "") ?? "anonymous"}`;
6561
+ for (const plan of singlePrimaryFoldPlans) {
6562
+ const primaryStep = actionSteps[plan.primaryStepIndex];
6563
+ if (!primaryStep)
6564
+ continue;
6565
+ const foldPrimaryIdentity = operationGroupKey(primaryStep.capture);
6566
+ if (foldPrimaryIdentity !== emittedPrimaryIdentity) {
6567
+ throw new Error(`emitContractTs: fold plan primary operation ${foldPrimaryIdentity} differs from the emitted primary operation ${emittedPrimaryIdentity} — the declared foldReturn no longer applies to this run's selected primary; drop or re-resolve the foldReturn spec against the emitted primary operation`);
6568
+ }
6569
+ }
6570
+ }
6296
6571
  // A GraphQL primary with a resolved drill-down fold has no other REST
6297
6572
  // client to issue the drill request(s) with — getGql only ever speaks
6298
6573
  // GraphQL to the primary endpoint.
@@ -6584,15 +6859,15 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6584
6859
  const gqlVariablesExpr = gqlOperationName
6585
6860
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
6586
6861
  : "{ q: payload.query }";
6587
- /** Builds the `for (const item of foldItems) { ... }` block(s) that fold
6588
- * every resolved plan's drill-down data onto `dataVarName`'s primary array
6589
- * — the single-primary counterpart of emitMultiStepExecuteHttp's own
6590
- * per-item loop, sharing its match/merge tail via
6591
- * {@link emitFoldMatchAndMergeLines} so the two can't describe different
6592
- * merge semantics. Chain hops beyond the drill step itself are rendered
6593
- * directly off each hop's own captured request (no state-threading
6594
- * pipeline) since a single-primary read flow carries no submitted payload
6595
- * for those calls to reference.
6862
+ /** Builds the nested `for` loop block(s) see {@link pathToFoldLoopLines}
6863
+ * — that fold every resolved plan's drill-down data onto `dataVarName`'s
6864
+ * primary array — the single-primary counterpart of
6865
+ * emitMultiStepExecuteHttp's own per-item loop, sharing its match/merge
6866
+ * tail via {@link emitFoldMatchAndMergeLines} so the two can't describe
6867
+ * different merge semantics. Chain hops beyond the drill step itself are
6868
+ * rendered directly off each hop's own captured request (no state-
6869
+ * threading pipeline) since a single-primary read flow carries no
6870
+ * submitted payload for those calls to reference.
6596
6871
  *
6597
6872
  * `itemsOverride`, when given, replaces the `dataVarName`+`primaryArrayPath`
6598
6873
  * accessor with a caller-supplied items expression — used by the paginated
@@ -6602,73 +6877,87 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6602
6877
  * response object. `foldPlan.primaryArrayPath` may still declare depth
6603
6878
  * BELOW `itemsOverride.level` (a fold plan resolved against a nested array
6604
6879
  * inside each paginated item) — the residual suffix beyond `level` is
6605
- * descended into via the same `.flatMap`-based {@link pathToFoldAccessorExpr}
6606
- * the non-override branch already uses, so no depth the fold plan declares
6607
- * is silently dropped. */
6880
+ * descended into via the same nested-loop emission the non-override branch
6881
+ * already uses, so no depth the fold plan declares is silently dropped.
6882
+ * Ancestor bindings inside `level` itself have no runtime loop variable
6883
+ * (the paginated merge already flattened across them), so only the
6884
+ * residual's OWN ancestor crossings are addressable for threading here. */
6608
6885
  const buildFoldMergeLines = (dataVarName, itemsOverride) => {
6609
6886
  const lines = [];
6610
6887
  for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
6611
6888
  const primaryStep = actionSteps[foldPlan.primaryStepIndex];
6612
6889
  if (!primaryStep)
6613
6890
  continue;
6614
- const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6891
+ const primaryItemsWithAncestors = objectItemsWithAncestorsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6615
6892
  const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
6616
6893
  const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
6617
- const foldItemsVar = `foldItems${planSuffix}`;
6618
- const foldItemsExpr = itemsOverride
6894
+ const itemVar = `item${planSuffix}`;
6895
+ const residualPath = itemsOverride
6619
6896
  ? (() => {
6620
- const { expr, level } = itemsOverride;
6897
+ const { level } = itemsOverride;
6621
6898
  const levelPrefixesPrimaryArrayPath = level.length <= foldPlan.primaryArrayPath.length &&
6622
6899
  level.every((segment, i) => segment === foldPlan.primaryArrayPath[i]);
6623
6900
  if (!levelPrefixesPrimaryArrayPath) {
6624
6901
  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`);
6625
6902
  }
6626
- // The residual already carries its own leading
6627
- // ARRAY_WILDCARD_SEGMENT whenever it crosses the array boundary
6628
- // `level` itself sits at (by construction, since `level` names
6629
- // an array path) — `expr` stands in for that same array, so
6630
- // pathToFoldAccessorExpr's own flatMap over `expr` consumes it;
6631
- // prepending a second one here would double-flatten.
6632
- const residualPath = foldPlan.primaryArrayPath.slice(level.length);
6633
- if (residualPath.length === 0)
6634
- return expr;
6635
- const itemTypeExpr = `${pathAccessTypeExpr(`${pascal}Response`, level)}[number]`;
6636
- return pathToFoldAccessorExpr(`(${expr} as ${itemTypeExpr}[])`, residualPath);
6903
+ return foldPlan.primaryArrayPath.slice(level.length);
6637
6904
  })()
6638
- : pathToFoldAccessorExpr(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
6639
- const itemVar = `item${planSuffix}`;
6640
- lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
6905
+ : foldPlan.primaryArrayPath;
6906
+ const { openLines, closeLines, ancestorVars } = itemsOverride
6907
+ ? pathToFoldLoopLines(residualPath.length === 0
6908
+ ? itemsOverride.expr
6909
+ : `(${itemsOverride.expr} as ${pathAccessTypeExpr(`${pascal}Response`, itemsOverride.level)}[number][])`, residualPath, itemVar, " ", planSuffix)
6910
+ : pathToFoldLoopLines(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath, itemVar, " ", planSuffix);
6911
+ // Ancestor bindings within `itemsOverride.level` (if any) have no
6912
+ // runtime loop variable, since the paginated merge already flattened
6913
+ // across them — only the trailing `residualPath.length` ancestor
6914
+ // crossings correspond to `ancestorVars` above, so slice the
6915
+ // design-time ancestor chain to match.
6916
+ const residualAncestorCount = residualPath.filter((s) => s === ARRAY_WILDCARD_SEGMENT).length;
6917
+ lines.push(...openLines);
6641
6918
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
6642
- const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
6643
- if (!firstItem) {
6919
+ const matchedPrimaryItem = primaryItemsWithAncestors[target.primaryMatchedItemIndex];
6920
+ if (!matchedPrimaryItem) {
6644
6921
  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`);
6645
6922
  }
6923
+ const { item: firstItem, ancestors: fullAncestors } = matchedPrimaryItem;
6924
+ const residualAncestors = fullAncestors.slice(fullAncestors.length - residualAncestorCount);
6925
+ const ancestorObjByVar = new Map(ancestorVars.map((varName, idx) => [varName, residualAncestors[idx]]));
6926
+ const threadingScopes = [
6927
+ { varName: itemVar, obj: firstItem },
6928
+ ...ancestorVars.map((varName) => ({ varName, obj: ancestorObjByVar.get(varName) })),
6929
+ ];
6646
6930
  const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
6647
- const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
6931
+ const scopedAccessor = (varName, field) => `${varName}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
6932
+ const joinAccessor = (field) => scopedAccessor(itemVar, field);
6648
6933
  // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
6649
6934
  // `parameterize` performs — a plain split/join would also rewrite
6650
6935
  // unrelated substrings that happen to contain the join value.
6651
6936
  //
6652
6937
  // Parameterizes off EVERY per-item field this specific chain hop's
6653
- // own captured request actually varies on (via findThreadedJoinFields),
6654
- // not just `target.joinFields` `target.joinFields` names the field
6655
- // used to MATCH the drill's RESPONSE back onto the primary item (see
6938
+ // own captured request actually varies on (via findThreadedJoinFields,
6939
+ // searched across the item AND every ancestor binding), not just
6940
+ // `target.joinFields` `target.joinFields` names the field used to
6941
+ // MATCH the drill's RESPONSE back onto the primary item (see
6656
6942
  // emitFoldMatchAndMergeLines), which for a spec-declared foldReturn
6657
6943
  // can legitimately be a field the request never carries at all (e.g.
6658
6944
  // an `id` echoed only in the response, while the request is keyed by
6659
6945
  // an unrelated field like a package code). Building the URL from only
6660
6946
  // `target.joinFields` in that case would leave it unparameterized —
6661
- // every item would fetch the SAME first-captured URL.
6947
+ // every item would fetch the SAME first-captured URL. Searching
6948
+ // ancestor scopes too is what lets a param that only lives on a
6949
+ // parent object (e.g. a group id) resolve at all.
6662
6950
  const parameterizeUrl = (rawUrl, chainCapture) => {
6663
6951
  const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
6664
6952
  ? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
6665
6953
  : rawUrl;
6666
- const threadedFields = new Set([
6667
- ...target.joinFields,
6668
- ...findThreadedJoinFields(firstItem, chainCapture),
6954
+ const threadedFields = dedupeThreadedFields([
6955
+ ...target.joinFields.map((field) => ({ varName: itemVar, field })),
6956
+ ...findThreadedJoinFields(threadingScopes, chainCapture),
6669
6957
  ]);
6670
- return [...threadedFields].reduce((acc, field) => {
6671
- const value = readValueAtPath(firstItem, field.split("."));
6958
+ const result = threadedFields.reduce((acc, { varName, field }) => {
6959
+ const scopeObj = varName === itemVar ? firstItem : ancestorObjByVar.get(varName);
6960
+ const value = readValueAtPath(scopeObj, field.split("."));
6672
6961
  const stringValue = typeof value === "string" && value.length > 0
6673
6962
  ? value
6674
6963
  : typeof value === "number" || typeof value === "boolean"
@@ -6676,8 +6965,10 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6676
6965
  : null;
6677
6966
  if (stringValue === null)
6678
6967
  return acc;
6679
- return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${joinAccessor(field)}}`);
6968
+ return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${scopedAccessor(varName, field)}}`);
6680
6969
  }, withBase);
6970
+ assertNoFrozenVaryingDrillParams("emitContractTs", chainCapture, result, actionSteps.map((s) => s.capture));
6971
+ return result;
6681
6972
  };
6682
6973
  for (const chainIndex of target.chain) {
6683
6974
  const chainStep = actionSteps[chainIndex];
@@ -6697,7 +6988,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6697
6988
  continue;
6698
6989
  lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
6699
6990
  }
6700
- lines.push(` }`, "");
6991
+ lines.push(...closeLines, "");
6701
6992
  }
6702
6993
  return lines;
6703
6994
  };