@enricai/barnacle 1.12.22 → 1.12.24

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.
@@ -63,6 +63,7 @@ exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHead
63
63
  exports.getFoldPlanResolutionCallCountForTests = getFoldPlanResolutionCallCountForTests;
64
64
  exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCountForTests;
65
65
  exports.resolveFoldPlan = resolveFoldPlan;
66
+ exports.resolveApplicableFoldPlans = resolveApplicableFoldPlans;
66
67
  exports.buildContractChecklist = buildContractChecklist;
67
68
  exports.emitContractTs = emitContractTs;
68
69
  exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
@@ -911,15 +912,22 @@ function selectReturnAction(steps) {
911
912
  * clobbering the other. A plan whose folded body isn't a plain object can't
912
913
  * be merged meaningfully, so in that case this falls back to the LAST plan's
913
914
  * folded body alone, exactly as before this merge was introduced.
915
+ *
916
+ * The `!isSubmissionFlow` short-circuit only applies once no fold plan
917
+ * resolves: a single-primary read flow (isSubmissionFlow false) with a
918
+ * declared `foldReturn` still needs its primary body folded here, or the
919
+ * inferred response shape would omit every field the single-primary
920
+ * getGql/httpClient emission's own fold-merge loop (see `emitContractTs`)
921
+ * adds at runtime.
914
922
  */
915
923
  function selectEffectiveResponseBody(isSubmissionFlow, actionSteps, replayResponseBody, foldReturnSpec = null) {
916
- if (!isSubmissionFlow)
917
- return replayResponseBody;
918
924
  const foldPlans = resolveFoldPlan(actionSteps, foldReturnSpec);
919
925
  const lastFoldPlan = foldPlans[foldPlans.length - 1] ?? null;
920
926
  if (foldPlans.length <= 1) {
921
927
  if (lastFoldPlan)
922
928
  return foldResponseBodyForShapeInference(actionSteps, lastFoldPlan);
929
+ if (!isSubmissionFlow)
930
+ return replayResponseBody;
923
931
  return selectReturnAction(actionSteps)?.capture.responseBody ?? replayResponseBody;
924
932
  }
925
933
  // Plans sharing a primaryStepIndex (independent arrays on one primary
@@ -3661,6 +3669,55 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
3661
3669
  }
3662
3670
  return out;
3663
3671
  }
3672
+ /**
3673
+ * Emits the lines that match a fold target's drill-down chain response
3674
+ * against the loop item and merge the match onto it — the last leg of a
3675
+ * fold, shared by {@link emitMultiStepExecuteHttp}'s per-item loop and
3676
+ * `emitContractTs`'s single-primary getGql/httpClient fold-merge loop, so
3677
+ * both emitters describe the exact same match/merge semantics rather than
3678
+ * two copies that could drift apart.
3679
+ */
3680
+ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor) {
3681
+ // An empty chainArrayPath means the terminal step's response IS the
3682
+ // implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
3683
+ // / objectItemsAtPath's flat-object branch): the response is a flat
3684
+ // object at runtime, not an array. There is exactly one candidate, so
3685
+ // no join-field match is needed (or even possible against an array
3686
+ // API) — emit a direct object reference instead of the array
3687
+ // `.find()` machinery the multi-item branch below needs.
3688
+ if (target.chainArrayPath.length === 0) {
3689
+ return [
3690
+ ` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`,
3691
+ ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3692
+ ];
3693
+ }
3694
+ const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
3695
+ return [
3696
+ ` 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];`,
3718
+ ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`,
3719
+ ];
3720
+ }
3664
3721
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
3665
3722
  * without going through the full emitContractTs pipeline. */
3666
3723
  function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map(), foldReturnSpec = null) {
@@ -4236,40 +4293,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
4236
4293
  }
4237
4294
  }
4238
4295
  const terminalStep = actions[target.chainTerminalIndex];
4239
- // An empty chainArrayPath means the terminal step's response IS the
4240
- // implicit one-item collection (see findAllObjectArrayFieldsOrWholeObject
4241
- // / objectItemsAtPath's flat-object branch): the response is a flat
4242
- // object at runtime, not an array. There is exactly one candidate, so
4243
- // no join-field match is needed (or even possible against an array
4244
- // API) — emit a direct object reference instead of the array
4245
- // `.find()` machinery the multi-item branch below needs.
4246
- if (target.chainArrayPath.length === 0) {
4247
- lines.push(` const foldMatch${suffix} = ${terminalStep.varName} as Record<string, unknown>;`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4248
- }
4249
- else {
4250
- const foldMatchesExpr = pathToFoldAccessorExpr(`(${terminalStep.varName} as ${foldArrayAssertionType(target.chainArrayPath)})`, target.chainArrayPath);
4251
- lines.push(` const foldMatches${suffix} = ${foldMatchesExpr};`, ` const foldMatch${suffix} = foldMatches${suffix}.find((m) => ${target.joinFields
4252
- .map((f) => {
4253
- const segments = f.split(".");
4254
- // The drill-down response is a DIFFERENT payload than the
4255
- // primary item, so it has no obligation to mirror the
4256
- // primary item's own nesting for the join key (e.g. a
4257
- // primary item's `identifiers.sku` is typically echoed
4258
- // back flat, as `sku`, on the drill response). Try the
4259
- // full nested path first (optional-chained, since an
4260
- // intermediate segment may not exist on a flat response),
4261
- // then fall back to the bare last segment.
4262
- const lastSegment = segments[segments.length - 1];
4263
- const optionalBracketAccessor = segments
4264
- .map((segment) => `?.[${JSON.stringify(segment)}]`)
4265
- .join("");
4266
- const matchAccessor = segments.length > 1
4267
- ? `(m${optionalBracketAccessor} ?? m[${JSON.stringify(lastSegment)}])`
4268
- : `m[${JSON.stringify(lastSegment)}]`;
4269
- return `String(${matchAccessor}) === String(${joinAccessor(f)})`;
4270
- })
4271
- .join(" && ")}) ?? foldMatches${suffix}[0];`, ` Object.assign(${itemVar}, foldMatch${suffix} ?? {});`);
4272
- }
4296
+ lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
4273
4297
  }
4274
4298
  lines.push(` }`, "");
4275
4299
  continue;
@@ -5369,6 +5393,29 @@ function resolveSpecMatchedPrimaryItemIndex(primaryItems, joinFields, drillCaptu
5369
5393
  }));
5370
5394
  return matchedIndex === -1 ? null : matchedIndex;
5371
5395
  }
5396
+ /** Like {@link resolveSpecMatchedPrimaryItemIndex}, but matches against the
5397
+ * drill capture's own RESPONSE instead of its request — a `foldReturn`
5398
+ * spec's `joinFields` names the field the AUTHOR knows the drill-down
5399
+ * response carries (so `emitFoldMatchAndMergeLines` can match it back onto a
5400
+ * primary item at runtime); it names nothing about what the drill's REQUEST
5401
+ * carries, which is very often a completely different per-item field (e.g. a
5402
+ * REST drill-down keyed by an item's `packageCode` whose response happens to
5403
+ * echo back that item's `id`). Tried only as a fallback, after the
5404
+ * request-based match, since a request-carried join value disambiguates a
5405
+ * drill occurrence unambiguously while a response-only match can in
5406
+ * principle collide across primary items with the same response shape. */
5407
+ function resolveSpecMatchedPrimaryItemIndexFromResponse(primaryItems, joinFields, drillCapture) {
5408
+ const responseValues = collectResponseLeafValues(drillCapture);
5409
+ if (responseValues.size === 0)
5410
+ return null;
5411
+ const matchedIndex = primaryItems.findIndex((item) => joinFields.every((field) => {
5412
+ const value = readValueAtPath(item, field.split("."));
5413
+ return ((typeof value === "string" && value.length > 0 && responseValues.has(value)) ||
5414
+ (typeof value === "number" && responseValues.has(String(value))) ||
5415
+ (typeof value === "boolean" && responseValues.has(String(value))));
5416
+ }));
5417
+ return matchedIndex === -1 ? null : matchedIndex;
5418
+ }
5372
5419
  /**
5373
5420
  * Maps every string/numeric/boolean value seen in any action's request (URL,
5374
5421
  * body, or headers — see {@link collectRequestValuesIncludingHeaders}) to the
@@ -5555,12 +5602,6 @@ function buildFoldPlanFromSpec(actions, spec) {
5555
5602
  continue;
5556
5603
  const joinValues = collectPrimaryJoinValues(primaryItems, spec.joinFields);
5557
5604
  const candidateEntryIndicesDescending = collectCandidateEntryIndicesDescending(requestValueIndex, joinValues, primaryStepIndex);
5558
- // No action anywhere carries any of this primary's join values, so no
5559
- // drillStepIndex candidate could ever resolve — skip straight to the
5560
- // next primary instead of scanning this one's drill candidates (each of
5561
- // which would only rediscover the same dead end).
5562
- if (candidateEntryIndicesDescending.length === 0)
5563
- continue;
5564
5605
  // Cheap regex-only pass first: collects every endpointPattern-matching
5565
5606
  // drillStepIndex without paying for the expensive backward-walk +
5566
5607
  // computeFoldChain resolution below. Scanned from the freshest (highest)
@@ -5569,12 +5610,24 @@ function buildFoldPlanFromSpec(actions, spec) {
5569
5610
  // ties in the original last-write-wins scan first, falling through to
5570
5611
  // the next-freshest only when a candidate fails to resolve, instead of
5571
5612
  // resolving every earlier occurrence just to have it overwritten.
5613
+ //
5614
+ // Computed even when `candidateEntryIndicesDescending` is empty (unlike
5615
+ // the request-based path below, which bails immediately in that case):
5616
+ // an endpointPattern match is a single regex test per action, O(actions)
5617
+ // regardless — not the O(actions)-per-candidate backward walk the
5618
+ // request-based prune exists to avoid — so it stays cheap even when no
5619
+ // request anywhere carries this primary's join values, which is exactly
5620
+ // the case {@link resolveSpecMatchedPrimaryItemIndexFromResponse}'s
5621
+ // response-only fallback below exists to resolve.
5572
5622
  const matchingDrillStepIndices = [];
5573
5623
  for (let drillStepIndex = primaryStepIndex + 1; drillStepIndex < actions.length; drillStepIndex++) {
5574
5624
  if (matchesFoldReturnEndpoint(actions[drillStepIndex].capture)) {
5575
5625
  matchingDrillStepIndices.push(drillStepIndex);
5576
5626
  }
5577
5627
  }
5628
+ if (candidateEntryIndicesDescending.length === 0 && matchingDrillStepIndices.length === 0) {
5629
+ continue;
5630
+ }
5578
5631
  // Shared across every matching-drill candidate tried below for THIS
5579
5632
  // primaryStepIndex — see resolveSpecMatchedPrimaryItemIndexAlongChain's
5580
5633
  // docstring on why a fresh cache per primary (not per drill candidate)
@@ -5598,7 +5651,18 @@ function buildFoldPlanFromSpec(actions, spec) {
5598
5651
  // now does (see detectDrillDownFoldPlan). Validated after the chain
5599
5652
  // resolves, below, since `[]` is a valid empty baseline for
5600
5653
  // computeFoldChain but not a valid final drillArrayPath on its own.
5601
- const matchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, drillStepIndex, matchedItemIndexCache, candidateEntryIndicesDescending);
5654
+ const chainMatchResult = resolveSpecMatchedPrimaryItemIndexAlongChain(actions, primaryItems, spec.joinFields, drillStepIndex, matchedItemIndexCache, candidateEntryIndicesDescending);
5655
+ // A request-carried join value (checked above, across this whole
5656
+ // primary/drill chain) disambiguates unambiguously; only fall back to
5657
+ // matching the drill's own RESPONSE — which never disambiguates an
5658
+ // upstream entry hop, only the drill step itself — when that fails.
5659
+ const matchResult = chainMatchResult ??
5660
+ (() => {
5661
+ const primaryMatchedItemIndex = resolveSpecMatchedPrimaryItemIndexFromResponse(primaryItems, spec.joinFields, drill.capture);
5662
+ return primaryMatchedItemIndex === null
5663
+ ? null
5664
+ : { entryIndex: drillStepIndex, primaryMatchedItemIndex };
5665
+ })();
5602
5666
  if (matchResult === null)
5603
5667
  continue;
5604
5668
  const { entryIndex, primaryMatchedItemIndex } = matchResult;
@@ -5674,14 +5738,26 @@ function mergeSpecPlanOntoSamePrimary(structuralPlans, actions, foldReturnSpec)
5674
5738
  const samePrimaryPlan = structuralPlans.find((plan) => plan.primaryStepIndex === specPlan.primaryStepIndex &&
5675
5739
  JSON.stringify(plan.primaryArrayPath) === JSON.stringify(specPlan.primaryArrayPath));
5676
5740
  if (samePrimaryPlan !== undefined) {
5741
+ // A drillStepIndex the structural heuristic ALSO resolved keeps its
5742
+ // structurally-resolved chain/drillArrayPath (already proven to reach
5743
+ // real per-item data), but its `joinFields` is overridden to the spec's
5744
+ // declared value — the heuristic's own joinFields only prove a field
5745
+ // threads INTO the drill's REQUEST, which says nothing about whether
5746
+ // that same field can be found on the drill's RESPONSE to match it back
5747
+ // onto a primary item (see emitFoldMatchAndMergeLines). A flow author
5748
+ // declaring `joinFields` on a foldReturn is asserting exactly that: this
5749
+ // is the field their drill-down's RESPONSE actually carries.
5750
+ const specTargetsByDrillStepIndex = new Map(specPlan.targets.map((target) => [target.drillStepIndex, target]));
5677
5751
  return structuralPlans.map((plan) => {
5678
5752
  if (plan !== samePrimaryPlan)
5679
5753
  return plan;
5754
+ const mergedTargets = plan.targets.map((target) => {
5755
+ const specTarget = specTargetsByDrillStepIndex.get(target.drillStepIndex);
5756
+ return specTarget === undefined ? target : { ...target, joinFields: specTarget.joinFields };
5757
+ });
5680
5758
  const existingDrillStepIndexes = new Set(plan.targets.map((target) => target.drillStepIndex));
5681
5759
  const newTargets = specPlan.targets.filter((target) => !existingDrillStepIndexes.has(target.drillStepIndex));
5682
- return newTargets.length === 0
5683
- ? plan
5684
- : { ...plan, targets: [...plan.targets, ...newTargets] };
5760
+ return { ...plan, targets: [...mergedTargets, ...newTargets] };
5685
5761
  });
5686
5762
  }
5687
5763
  // Keyed by the (primaryStepIndex, primaryArrayPath) pair, not
@@ -5801,6 +5877,19 @@ function resolveFoldPlan(actions, foldReturnSpec = null) {
5801
5877
  return targets.length === 0 ? [] : [{ ...plan, targets }];
5802
5878
  });
5803
5879
  }
5880
+ /**
5881
+ * The fold plans `emitContractTs` actually threads into its single-primary
5882
+ * hot path — a multi-step flow owns its own per-item fold loop (via
5883
+ * `emitMultiStepExecuteHttp`) instead, so this resolves to none there.
5884
+ * Exported and shared verbatim by `emitContractTs` and the "no fold plan
5885
+ * resolved" diagnostic in `main()` so the two can never independently decide
5886
+ * whether a declared foldReturn actually made it into the emitted output —
5887
+ * any future exclusion added to the single-primary hot path must be added
5888
+ * here too, and both call sites pick it up automatically.
5889
+ */
5890
+ function resolveApplicableFoldPlans(actions, foldReturnSpec, multiStepBody) {
5891
+ return multiStepBody ? [] : resolveFoldPlan(actions, foldReturnSpec);
5892
+ }
5804
5893
  /** Rebuilds `value` with every occurrence of `target` (compared by object
5805
5894
  * identity) replaced by `replacement`, spreading every ancestor
5806
5895
  * array/object level so sibling fields and sibling array elements survive
@@ -5915,9 +6004,17 @@ function buildNestedSpreadOverride(base, path, leafExpr) {
5915
6004
  * the observed page size on each call, stops once the response's own
5916
6005
  * reported total is reached or `MAX_PAGES` caps it, and merges pages by the
5917
6006
  * detected identity field rather than concatenating blindly.
6007
+ *
6008
+ * `foldMergeLines`, when non-empty, threads a resolved single-primary fold
6009
+ * plan (see `emitContractTs`'s `singlePrimaryFoldPlans`) additively into the
6010
+ * loop: it runs once, after every page has been fetched and de-duplicated
6011
+ * into `itemsById`, so the drill-down call/merge sees the final assembled
6012
+ * item set rather than only the first page's captured sample. Each merge
6013
+ * mutates its item in place (`Object.assign`), which is visible through
6014
+ * `itemsById`'s own stored references — no re-`set` needed.
5918
6015
  */
5919
6016
  function buildPaginatedGqlExecuteHttpBody(opts) {
5920
- const { pascal, gqlOperationNameExpr, queryConstName, gqlVariablesExpr, signal } = opts;
6017
+ const { pascal, gqlOperationNameExpr, queryConstName, gqlVariablesExpr, signal, foldMergeLines } = opts;
5921
6018
  const { totalPath, arrayPath, containerPath, countKey, skipKey, pageSize, identityField } = signal;
5922
6019
  const countKeyExpr = isValidJsIdentifier(countKey) ? countKey : JSON.stringify(countKey);
5923
6020
  const skipKeyExpr = isValidJsIdentifier(skipKey) ? skipKey : JSON.stringify(skipKey);
@@ -5957,7 +6054,7 @@ function buildPaginatedGqlExecuteHttpBody(opts) {
5957
6054
  }
5958
6055
  skip += PAGE_SIZE;
5959
6056
  }
5960
- const truncated = itemsById.size < total;
6057
+ ${foldMergeLines.length > 0 ? `${foldMergeLines.join("\n")}\n` : ""} const truncated = itemsById.size < total;
5961
6058
  const withItems = ${withItemsOverrideExpr};
5962
6059
  const data = ${withTotalOverrideExpr} as ${pascal}Response;
5963
6060
  return { data };`;
@@ -6001,7 +6098,7 @@ function buildContractChecklist(opts) {
6001
6098
  ].filter((line) => line !== "");
6002
6099
  }
6003
6100
  function emitContractTs(opts) {
6004
- const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
6101
+ const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
6005
6102
  // This is the CLIENT-level schema — createHttpClient's default, and the
6006
6103
  // plugin's caller-facing contract (what executeHttp's return value promises
6007
6104
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -6069,6 +6166,22 @@ function emitContractTs(opts) {
6069
6166
  const paginationSignal = !multiStepBody && gql && gqlOperationName
6070
6167
  ? detectPaginationSignal(responseBody, gqlVariables)
6071
6168
  : null;
6169
+ // A resolved drill-down fold plan on the single-primary getGql/httpClient
6170
+ // hot path (`multiStepBody` unset — see emitMultiStepExecuteHttp for the
6171
+ // multi-step equivalent) folds onto `data` in place, exactly as the
6172
+ // multi-step loop folds onto its own primary var: without this, a
6173
+ // single-primary read flow with a declared `foldReturn` would silently
6174
+ // drop the fold feature the flow author declared (see
6175
+ // recon-generate-foldreturn-regresses-primary-op-and-payload-to-ats-submission-shape.md).
6176
+ // Also threaded additively into `paginationSignal`'s fetch loop below (see
6177
+ // buildPaginatedGqlExecuteHttpBody) — the fold runs against the final
6178
+ // assembled/de-duplicated page items, not just the first page's captured
6179
+ // sample, so a paginated primary is no longer excluded from folding.
6180
+ const singlePrimaryFoldPlans = resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody);
6181
+ // A GraphQL primary with a resolved drill-down fold has no other REST
6182
+ // client to issue the drill request(s) with — getGql only ever speaks
6183
+ // GraphQL to the primary endpoint.
6184
+ const needsFoldHttpClient = gql && singlePrimaryFoldPlans.length > 0;
6072
6185
  // Every field source below (the base extend's own keys, form-schema
6073
6186
  // discovery, browser-flow splicing, option/raw-option enums, additional
6074
6187
  // body keys, and structured keys) is merged into a SINGLE `.extend({...})`
@@ -6312,7 +6425,7 @@ function emitContractTs(opts) {
6312
6425
  const clientImport = omitExecuteHttp
6313
6426
  ? ""
6314
6427
  : gql
6315
- ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";`
6428
+ ? `import { createGraphqlClient } from "${ENGINE_PKG}/scraper/graphql-client";${needsFoldHttpClient ? `\nimport { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";` : ""}`
6316
6429
  : `import { createHttpClient } from "${ENGINE_PKG}/scraper/http-client";`;
6317
6430
  const queryConst = !omitExecuteHttp && gql && gqlQuery
6318
6431
  ? `\n// Lifted verbatim from recon capture. The adjacent response schema is drift-tolerant by construction (dropped __typename, .loose() objects), so this query text is not hand-trimmed.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
@@ -6338,7 +6451,15 @@ function getGql(baseUrl: string): GqlFn {
6338
6451
  }
6339
6452
  return client;
6340
6453
  }
6454
+ ${
6455
+ // A GraphQL primary with a resolved drill-down fold has no other REST
6456
+ // client to issue the drill request(s) with — getGql only ever speaks
6457
+ // GraphQL to the primary endpoint.
6458
+ needsFoldHttpClient
6459
+ ? `
6460
+ const httpClient = createHttpClient({ schema: z.unknown(), bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
6341
6461
  `
6462
+ : ""}`
6342
6463
  : `
6343
6464
  const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
6344
6465
  `;
@@ -6348,6 +6469,107 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6348
6469
  const gqlVariablesExpr = gqlOperationName
6349
6470
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames)
6350
6471
  : "{ q: payload.query }";
6472
+ /** Builds the `for (const item of foldItems) { ... }` block(s) that fold
6473
+ * every resolved plan's drill-down data onto `dataVarName`'s primary array
6474
+ * — the single-primary counterpart of emitMultiStepExecuteHttp's own
6475
+ * per-item loop, sharing its match/merge tail via
6476
+ * {@link emitFoldMatchAndMergeLines} so the two can't describe different
6477
+ * merge semantics. Chain hops beyond the drill step itself are rendered
6478
+ * directly off each hop's own captured request (no state-threading
6479
+ * pipeline) since a single-primary read flow carries no submitted payload
6480
+ * for those calls to reference.
6481
+ *
6482
+ * `itemsExprOverride`, when given, replaces the `dataVarName`+`primaryArrayPath`
6483
+ * accessor with a caller-supplied items expression — used by the paginated
6484
+ * GraphQL fetch loop, whose merged/de-duplicated page items already sit in
6485
+ * a flat runtime collection (`itemsById.values()`) rather than nested at
6486
+ * `primaryArrayPath` inside a single response object. */
6487
+ const buildFoldMergeLines = (dataVarName, itemsExprOverride) => {
6488
+ const lines = [];
6489
+ for (const [planIndex, foldPlan] of singlePrimaryFoldPlans.entries()) {
6490
+ const primaryStep = actionSteps[foldPlan.primaryStepIndex];
6491
+ if (!primaryStep)
6492
+ continue;
6493
+ const primaryItems = objectItemsAtPath(primaryStep.capture.responseBody, foldPlan.primaryArrayPath);
6494
+ const primaryArrType = foldArrayAssertionType(foldPlan.primaryArrayPath);
6495
+ const planSuffix = singlePrimaryFoldPlans.length > 1 ? String(planIndex) : "";
6496
+ const foldItemsVar = `foldItems${planSuffix}`;
6497
+ const foldItemsExpr = itemsExprOverride ??
6498
+ pathToFoldAccessorExpr(`(${dataVarName} as ${primaryArrType})`, foldPlan.primaryArrayPath);
6499
+ const itemVar = `item${planSuffix}`;
6500
+ lines.push(` const ${foldItemsVar} = ${foldItemsExpr};`, ` for (const ${itemVar} of ${foldItemsVar}) {`);
6501
+ for (const [targetIndex, target] of foldPlan.targets.entries()) {
6502
+ const firstItem = primaryItems?.[target.primaryMatchedItemIndex];
6503
+ if (!firstItem) {
6504
+ 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`);
6505
+ }
6506
+ const suffix = foldPlan.targets.length > 1 ? `${planSuffix}${targetIndex}` : planSuffix;
6507
+ const joinAccessor = (field) => `${itemVar}${pathToAccessor(field.split("."), { assertNonNull: false })}`;
6508
+ // Same word-boundary-anchored swap emitMultiStepExecuteHttp's own
6509
+ // `parameterize` performs — a plain split/join would also rewrite
6510
+ // unrelated substrings that happen to contain the join value.
6511
+ //
6512
+ // Parameterizes off EVERY per-item field this specific chain hop's
6513
+ // own captured request actually varies on (via findThreadedJoinFields),
6514
+ // not just `target.joinFields` — `target.joinFields` names the field
6515
+ // used to MATCH the drill's RESPONSE back onto the primary item (see
6516
+ // emitFoldMatchAndMergeLines), which for a spec-declared foldReturn
6517
+ // can legitimately be a field the request never carries at all (e.g.
6518
+ // an `id` echoed only in the response, while the request is keyed by
6519
+ // an unrelated field like a package code). Building the URL from only
6520
+ // `target.joinFields` in that case would leave it unparameterized —
6521
+ // every item would fetch the SAME first-captured URL.
6522
+ const parameterizeUrl = (rawUrl, chainCapture) => {
6523
+ const withBase = baseUrl.length > 0 && rawUrl.startsWith(baseUrl)
6524
+ ? `\${context.baseUrl}${rawUrl.slice(baseUrl.length)}`
6525
+ : rawUrl;
6526
+ const threadedFields = new Set([
6527
+ ...target.joinFields,
6528
+ ...findThreadedJoinFields(firstItem, chainCapture),
6529
+ ]);
6530
+ return [...threadedFields].reduce((acc, field) => {
6531
+ const value = readValueAtPath(firstItem, field.split("."));
6532
+ const stringValue = typeof value === "string" && value.length > 0
6533
+ ? value
6534
+ : typeof value === "number" || typeof value === "boolean"
6535
+ ? String(value)
6536
+ : null;
6537
+ if (stringValue === null)
6538
+ return acc;
6539
+ return acc.replace(new RegExp(`\\b${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), `\${${joinAccessor(field)}}`);
6540
+ }, withBase);
6541
+ };
6542
+ for (const chainIndex of target.chain) {
6543
+ const chainStep = actionSteps[chainIndex];
6544
+ if (!chainStep)
6545
+ continue;
6546
+ const url = parameterizeUrl(chainStep.capture.url, chainStep.capture);
6547
+ const schemaExpr = inferZodSchema(chainStep.capture.responseBody, 0, "", {
6548
+ looseServerResponse: true,
6549
+ aggregateUnitBasisFindingsByPath: groupAggregateUnitBasisFindingsByPath([
6550
+ chainStep.capture.responseBody,
6551
+ ]),
6552
+ });
6553
+ lines.push(` const ${chainStep.varName} = (await httpClient(\`${url}\`, {`, ` method: ${JSON.stringify(chainStep.capture.method)},`, ` schema: ${schemaExpr},`, ` })) as Record<string, unknown>;`);
6554
+ }
6555
+ const terminalStep = actionSteps[target.chainTerminalIndex];
6556
+ if (!terminalStep)
6557
+ continue;
6558
+ lines.push(...emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor));
6559
+ }
6560
+ lines.push(` }`, "");
6561
+ }
6562
+ return lines;
6563
+ };
6564
+ const dataFoldMergeLines = buildFoldMergeLines("data");
6565
+ const dataFoldMergeBlock = dataFoldMergeLines.length > 0 ? `${dataFoldMergeLines.join("\n")}\n` : "";
6566
+ // The paginated fetch loop assembles its final page items into
6567
+ // `itemsById` — fold onto THAT flat, de-duplicated collection (once, after
6568
+ // the loop) rather than `data`/`primaryArrayPath`, so every item folded
6569
+ // is the final merged item across all fetched pages, not just page one's.
6570
+ const paginatedFoldMergeLines = paginationSignal
6571
+ ? buildFoldMergeLines("data", "[...itemsById.values()]")
6572
+ : [];
6351
6573
  const executeHttpBody = multiStepBody
6352
6574
  ? multiStepBody
6353
6575
  : paginationSignal
@@ -6357,15 +6579,16 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
6357
6579
  queryConstName: `${pascal.toUpperCase()}_QUERY`,
6358
6580
  gqlVariablesExpr,
6359
6581
  signal: paginationSignal,
6582
+ foldMergeLines: paginatedFoldMergeLines,
6360
6583
  })
6361
6584
  : gql
6362
6585
  ? ` const data = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${pascal.toUpperCase()}_QUERY, ${gqlVariablesExpr});
6363
- return { data };`
6586
+ ${dataFoldMergeBlock} return { data };`
6364
6587
  : ` const data = await httpClient(\`\${context.baseUrl}${endpointPath}\`, {
6365
6588
  method: "POST",
6366
6589
  body: JSON.stringify({ query: payload.query }),
6367
6590
  });
6368
- return { data };`;
6591
+ ${dataFoldMergeBlock} return { data };`;
6369
6592
  const fixtureComments = auxFiles.length > 0
6370
6593
  ? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
6371
6594
  auxFiles
@@ -7155,7 +7378,14 @@ async function main() {
7155
7378
  const graphqlActionSequence = gql
7156
7379
  ? extractGraphQLActionSequence(captures, submitPatterns, foldReturnSpec)
7157
7380
  : [];
7158
- const primaryGraphQLOperation = gql && graphqlActionSequence.length === 0
7381
+ // A foldReturn-admitted read/drill capture (see extractGraphQLActionSequence's
7382
+ // doc comment) can put 2+ entries in graphqlActionSequence with none of them
7383
+ // an actual `mutation` — a GraphQL-primary query plus its drill-down, not a
7384
+ // transactional multi-step submission. Only a real mutation makes this a
7385
+ // submission flow; an admitted read/drill capture must not, on its own, null
7386
+ // out primaryGraphQLOperation below or flip isSubmissionFlow further down.
7387
+ const graphqlActionSequenceHasMutation = graphqlActionSequence.some((a) => a.capture.query !== null && /^\s*mutation\b/.test(a.capture.query));
7388
+ const primaryGraphQLOperation = gql && !graphqlActionSequenceHasMutation
7159
7389
  ? selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, process.env, ownBackendHostnames, fallbackDomain)
7160
7390
  : null;
7161
7391
  if (primaryGraphQLOperation && primaryGraphQLOperation.unpopulatedDeclaredVariables.length > 0) {
@@ -7311,7 +7541,7 @@ async function main() {
7311
7541
  ? indexStateValues(captures, shieldedUuids, actionCaptureIndices, dependentDrillDownChainValues)
7312
7542
  : new Map();
7313
7543
  const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
7314
- const isSubmissionFlow = actionSteps.length > 1;
7544
+ const isSubmissionFlow = actionSteps.length > 1 && (!gql || graphqlActionSequenceHasMutation);
7315
7545
  // Loud failure for a submitEndpointPattern that under-matches the raw traffic badly
7316
7546
  // enough to collapse the flow to the single-endpoint fallback: heuristicActionCaptures
7317
7547
  // is the pattern-filtered sequence (used both directly and as rawActionCaptures' floor
@@ -7429,8 +7659,18 @@ async function main() {
7429
7659
  const effectiveResponseBody = selectEffectiveResponseBody(isSubmissionFlow, actionSteps, responseBody, foldReturnSpec);
7430
7660
  // A declared foldReturn that resolves to no plan is a silent no-op otherwise
7431
7661
  // — the flow author gets the discarding selectReturnAction path with nothing
7432
- // in the output saying their declaration never applied.
7433
- if (foldReturnSpec !== null && resolveFoldPlan(actionSteps, foldReturnSpec).length === 0) {
7662
+ // in the output saying their declaration never applied. A multi-step
7663
+ // (submission) flow applies its fold via emitMultiStepExecuteHttp's own
7664
+ // resolveFoldPlan call, entirely independent of resolveApplicableFoldPlans
7665
+ // (which exists only to gate emitContractTs's single-primary hot path, and
7666
+ // unconditionally reports zero plans once multiStepBody is set) — so this
7667
+ // diagnostic must consult the SAME resolution each path actually applies,
7668
+ // or it falsely reports "no fold plan resolved" for every multi-step flow
7669
+ // with a working foldReturn.
7670
+ const effectiveFoldPlanCount = multiStepBody
7671
+ ? resolveFoldPlan(actionSteps, foldReturnSpec).length
7672
+ : resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody).length;
7673
+ if (foldReturnSpec !== null && effectiveFoldPlanCount === 0) {
7434
7674
  logger.warn(`flow declares foldReturn (endpointPattern: ${foldReturnSpec.endpointPattern}, resultsPath: ${foldReturnSpec.resultsPath}, joinFields: ${foldReturnSpec.joinFields.join(", ")}) but no fold plan resolved — no later capture matched the endpoint pattern, resultsPath resolved to no object array, or the matched drill-down is multipart; the drill-down's response will not be folded`);
7435
7675
  }
7436
7676
  logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : browserFlowOnly ? `submission flow, ${actionSteps.length} steps, browser-flow-only (cross-domain hop detected)` : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
@@ -7502,6 +7742,8 @@ async function main() {
7502
7742
  isSubmissionFlow,
7503
7743
  inputBody,
7504
7744
  hasMultipartStep,
7745
+ actionSteps,
7746
+ foldReturnSpec,
7505
7747
  discoveredFormFields,
7506
7748
  fieldOptionsMap,
7507
7749
  discoveredOptionFields,