@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.
- package/dist/scraper/flow-runner.d.ts +13 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +73 -2
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +32 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +298 -56
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|