@enricai/barnacle 1.12.54 → 1.12.55

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 (58) hide show
  1. package/dist/api/schemas/common.d.ts +28 -0
  2. package/dist/api/schemas/common.d.ts.map +1 -1
  3. package/dist/api/schemas/common.js +51 -0
  4. package/dist/api/schemas/common.js.map +1 -1
  5. package/dist/plugins/config-plugin.d.ts +8 -0
  6. package/dist/plugins/config-plugin.d.ts.map +1 -1
  7. package/dist/plugins/config-plugin.js +56 -3
  8. package/dist/plugins/config-plugin.js.map +1 -1
  9. package/dist/plugins/json-schema-to-zod.d.ts +6 -3
  10. package/dist/plugins/json-schema-to-zod.d.ts.map +1 -1
  11. package/dist/plugins/json-schema-to-zod.js +18 -6
  12. package/dist/plugins/json-schema-to-zod.js.map +1 -1
  13. package/dist/plugins/loader.d.ts.map +1 -1
  14. package/dist/plugins/loader.js +7 -1
  15. package/dist/plugins/loader.js.map +1 -1
  16. package/dist/recon/capture-filters.d.ts +2 -55
  17. package/dist/recon/capture-filters.d.ts.map +1 -1
  18. package/dist/recon/capture-filters.js +152 -4
  19. package/dist/recon/capture-filters.js.map +1 -1
  20. package/dist/recon/load-value-constraints.d.ts +22 -0
  21. package/dist/recon/load-value-constraints.d.ts.map +1 -0
  22. package/dist/recon/load-value-constraints.js +61 -0
  23. package/dist/recon/load-value-constraints.js.map +1 -0
  24. package/dist/recon/value-constraints.d.ts +46 -0
  25. package/dist/recon/value-constraints.d.ts.map +1 -0
  26. package/dist/recon/value-constraints.js +31 -0
  27. package/dist/recon/value-constraints.js.map +1 -0
  28. package/dist/scraper/errors.d.ts +12 -0
  29. package/dist/scraper/errors.d.ts.map +1 -1
  30. package/dist/scraper/errors.js +21 -1
  31. package/dist/scraper/errors.js.map +1 -1
  32. package/dist/scraper/flow-runner.d.ts +87 -23
  33. package/dist/scraper/flow-runner.d.ts.map +1 -1
  34. package/dist/scraper/flow-runner.js +417 -71
  35. package/dist/scraper/flow-runner.js.map +1 -1
  36. package/dist/scraper/http-client.d.ts +11 -0
  37. package/dist/scraper/http-client.d.ts.map +1 -1
  38. package/dist/scraper/http-client.js +96 -2
  39. package/dist/scraper/http-client.js.map +1 -1
  40. package/dist/scraper/phantom-click.d.ts +2 -2
  41. package/dist/scraper/phantom-click.d.ts.map +1 -1
  42. package/dist/scraper/phantom-click.js +6 -5
  43. package/dist/scraper/phantom-click.js.map +1 -1
  44. package/dist/scraper/submit-control.d.ts +11 -6
  45. package/dist/scraper/submit-control.d.ts.map +1 -1
  46. package/dist/scraper/submit-control.js +38 -7
  47. package/dist/scraper/submit-control.js.map +1 -1
  48. package/dist/scripts/recon-browser.d.ts +45 -8
  49. package/dist/scripts/recon-browser.d.ts.map +1 -1
  50. package/dist/scripts/recon-browser.js +145 -16
  51. package/dist/scripts/recon-browser.js.map +1 -1
  52. package/dist/scripts/recon-generate.d.ts +65 -4
  53. package/dist/scripts/recon-generate.d.ts.map +1 -1
  54. package/dist/scripts/recon-generate.js +387 -49
  55. package/dist/scripts/recon-generate.js.map +1 -1
  56. package/dist/site-plugin.d.ts +9 -0
  57. package/dist/site-plugin.d.ts.map +1 -1
  58. package/package.json +1 -1
@@ -66,6 +66,7 @@ exports.getScanPrimaryCandidateGroupsCallCountForTest = getScanPrimaryCandidateG
66
66
  exports.resetScanPrimaryCandidateGroupsCallCountForTest = resetScanPrimaryCandidateGroupsCallCountForTest;
67
67
  exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
68
68
  exports.parseFoldReturnSpec = parseFoldReturnSpec;
69
+ exports.parseFallbackGateSpec = parseFallbackGateSpec;
69
70
  exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHeaders;
70
71
  exports.getFoldPlanResolutionCallCountForTests = getFoldPlanResolutionCallCountForTests;
71
72
  exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCountForTests;
@@ -89,7 +90,9 @@ const plugin_api_version_1 = require("../plugins/plugin-api-version");
89
90
  const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
90
91
  const capture_filters_1 = require("../recon/capture-filters");
91
92
  const load_form_schema_1 = require("../recon/load-form-schema");
93
+ const load_value_constraints_1 = require("../recon/load-value-constraints");
92
94
  const load_vocabulary_1 = require("../recon/load-vocabulary");
95
+ const value_constraints_1 = require("../recon/value-constraints");
93
96
  const vocabulary_1 = require("../recon/vocabulary");
94
97
  const recon_shared_1 = require("./recon-shared");
95
98
  const logger = (0, logging_1.getScriptLogger)("recon-generate");
@@ -1798,6 +1801,17 @@ function collapseRedundantPatches(actions) {
1798
1801
  * counter, unpaired with an explicit page-size key) since that is the common
1799
1802
  * REST shape, unlike GraphQL's paired variables convention. */
1800
1803
  const PAGINATION_FIELD_NAME_PATTERN = /^(page|pagenum|pagenumber|pageindex|pageno|offset|skip|start|cursor)$/i;
1804
+ /** Response leaf-key shapes that name a discoverable ceiling on some other
1805
+ * numeric quantity (a capacity, a maximum, a limit) -- structural, like
1806
+ * {@link PAGINATION_FIELD_NAME_PATTERN}, not tied to any domain's field
1807
+ * vocabulary. Used to bound a request-side numeric field whose observed
1808
+ * values never exceed a same-run response's declared ceiling. */
1809
+ const CAPACITY_FIELD_NAME_PATTERN = /(max|capacity|limit)/i;
1810
+ /** Upper bound on how many distinct string values a request field may show
1811
+ * across a run's captures before it stops looking like a closed-set facet
1812
+ * vocabulary and starts looking like free text -- past this, emitting
1813
+ * z.enum(...) would lock callers out of values recon simply never sampled. */
1814
+ const VOCABULARY_ENUM_MAX_DISTINCT_VALUES = 8;
1801
1815
  /** Request-field key names that name known client-generated scaffolding
1802
1816
  * (a monotonic sequence counter, a correlation/trace id, an idempotency
1803
1817
  * nonce) rather than genuine payload data. Gates {@link
@@ -3928,6 +3942,41 @@ function pathToFoldLoopLines(expr, path, itemVar, indent, varSuffix = "", depth
3928
3942
  ancestorCloseLines,
3929
3943
  };
3930
3944
  }
3945
+ /**
3946
+ * Splices a fold's per-item body between `itemOpenLines`/`itemCloseLines`
3947
+ * (see {@link pathToFoldLoopLines}) — either as the original sequential
3948
+ * `for` loop, when the body never issues its own per-item drill fetch (so
3949
+ * existing byte-identical fold output is preserved for every fold that has
3950
+ * nothing to parallelize), or rewritten into a `Promise.allSettled`-based
3951
+ * parallel form when it does: `for (const item of X) {` becomes `await
3952
+ * Promise.allSettled((X).map(async (item) => { ... }));`. Every item's
3953
+ * fetch then fires concurrently instead of one at a time, and a rejected
3954
+ * item's promise can't abort a sibling's — `allSettled` never short-
3955
+ * circuits on a rejection, and the sibling's `Object.assign` onto its own
3956
+ * item already only runs once ITS OWN fetch resolves, so a failed item
3957
+ * simply keeps its original (unmerged) fields instead of taking every other
3958
+ * item's already-fetched data down with it.
3959
+ */
3960
+ function emitItemLoopLines(itemOpenLines, itemScopedLines, itemCloseLines, itemVar, hasItemScopedFetch) {
3961
+ if (!hasItemScopedFetch) {
3962
+ return [...itemOpenLines, ...itemScopedLines, ...itemCloseLines];
3963
+ }
3964
+ const forLineIndex = itemOpenLines.length - 1;
3965
+ const forLine = itemOpenLines[forLineIndex];
3966
+ const forLineMatch = forLine.match(/^(\s*)for \(const \w+ of (.+)\) \{$/);
3967
+ if (!forLineMatch) {
3968
+ throw new Error(`emitItemLoopLines: unrecognized fold item-loop shape: ${forLine}`);
3969
+ }
3970
+ const [, indent, iterableExpr] = forLineMatch;
3971
+ return [
3972
+ ...itemOpenLines.slice(0, forLineIndex),
3973
+ `${indent}await Promise.allSettled(`,
3974
+ `${indent} (${iterableExpr}).map(async (${itemVar}) => {`,
3975
+ ...itemScopedLines,
3976
+ `${indent} })`,
3977
+ `${indent});`,
3978
+ ];
3979
+ }
3931
3980
  /** Suggests a JS-camelCase variable name for a state value path. Falls back
3932
3981
  * up the path if the tail is numeric or not a valid JS identifier. */
3933
3982
  function pathToVarName(path) {
@@ -4817,10 +4866,15 @@ function applyUrlParamPayloadSubstitutions(template, parsedBody, bindings) {
4817
4866
  * even though the field genuinely IS one this step's own request sends as
4818
4867
  * caller-supplied data.
4819
4868
  */
4820
- function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map()) {
4869
+ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map(),
4870
+ /** Every capture's response body from the same run, scanned only for
4871
+ * {@link CAPACITY_FIELD_NAME_PATTERN}-shaped numeric leaves — the
4872
+ * evidence source for a discovered "number" key's `capacityMax`. */
4873
+ responseBodiesForCapacitySignal = []) {
4821
4874
  const merged = [];
4822
4875
  const seenPairs = new Set();
4823
4876
  const seenValueByKey = new Map();
4877
+ const distinctValuesByKey = new Map();
4824
4878
  const allBodies = [inputBody, ...additionalBodies];
4825
4879
  for (const body of allBodies) {
4826
4880
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
@@ -4854,6 +4908,17 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
4854
4908
  continue;
4855
4909
  }
4856
4910
  seenValueByKey.set(key, value);
4911
+ // Tracked independently of the (key, value) dedupe below — a facet's
4912
+ // vocabulary or a capacity ceiling needs every distinct value it took,
4913
+ // not just the deduped substitution list, and pagination cursors are
4914
+ // excluded from both (their values grow monotonically and are never a
4915
+ // closed set).
4916
+ if ((typeof value === "string" || typeof value === "number") &&
4917
+ !PAGINATION_FIELD_NAME_PATTERN.test(key)) {
4918
+ const distinct = distinctValuesByKey.get(key) ?? new Set();
4919
+ distinct.add(value);
4920
+ distinctValuesByKey.set(key, distinct);
4921
+ }
4857
4922
  const pairKey = `${key} ${typeof value} ${value}`;
4858
4923
  if (seenPairs.has(pairKey))
4859
4924
  continue;
@@ -4866,11 +4931,54 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
4866
4931
  // isReservedByApplicantContactSchema — this function has no visibility
4867
4932
  // into that flag, so it must not special-case inputBody's own keys.
4868
4933
  if (typeof value === "string")
4869
- outAdditionalKeys.set(key, "string");
4934
+ outAdditionalKeys.set(key, { kind: "string" });
4870
4935
  else if (typeof value === "number")
4871
- outAdditionalKeys.set(key, "number");
4936
+ outAdditionalKeys.set(key, { kind: "number" });
4872
4937
  else if (typeof value === "boolean")
4873
- outAdditionalKeys.set(key, "boolean");
4938
+ outAdditionalKeys.set(key, { kind: "boolean" });
4939
+ }
4940
+ }
4941
+ // Vocabulary-derived enum: a "string" key whose distinct observed values
4942
+ // form a small closed set is a facet field, not free text — emit the set
4943
+ // so the generated schema rejects a caller value recon never sampled.
4944
+ // Capacity-bounded number: a "number" key gets a ceiling only when some
4945
+ // same-run response carries a max/capacity/limit-shaped numeric leaf that
4946
+ // is >= every value this key was observed to carry; the tightest such
4947
+ // ceiling wins so the bound is never looser than the evidence supports.
4948
+ const capacityCandidatesByMinBound = () => {
4949
+ const candidates = [];
4950
+ for (const body of responseBodiesForCapacitySignal) {
4951
+ if (body === undefined || body === null || typeof body !== "object")
4952
+ continue;
4953
+ for (const { value, path } of walkAllPrimitiveLeaves(body)) {
4954
+ const leafName = path[path.length - 1] ?? "";
4955
+ if (typeof value !== "number")
4956
+ continue;
4957
+ if (!CAPACITY_FIELD_NAME_PATTERN.test(leafName))
4958
+ continue;
4959
+ candidates.push(value);
4960
+ }
4961
+ }
4962
+ return candidates;
4963
+ };
4964
+ const capacitySignals = capacityCandidatesByMinBound();
4965
+ for (const [key, info] of outAdditionalKeys) {
4966
+ const distinct = distinctValuesByKey.get(key);
4967
+ if (!distinct)
4968
+ continue;
4969
+ if (info.kind === "string") {
4970
+ if (distinct.size >= 2 && distinct.size <= VOCABULARY_ENUM_MAX_DISTINCT_VALUES) {
4971
+ info.enumValues = [...distinct].map(String).sort();
4972
+ }
4973
+ continue;
4974
+ }
4975
+ if (info.kind === "number") {
4976
+ const maxObserved = Math.max(...[...distinct].map(Number));
4977
+ const tightestCeiling = capacitySignals
4978
+ .filter((v) => v >= maxObserved)
4979
+ .sort((a, b) => a - b)[0];
4980
+ if (tightestCeiling !== undefined)
4981
+ info.capacityMax = tightestCeiling;
4874
4982
  }
4875
4983
  }
4876
4984
  let result = template;
@@ -5254,6 +5362,12 @@ pascalName = null) {
5254
5362
  // skip non-JSON bodies (e.g. multipart raw bytes)
5255
5363
  }
5256
5364
  }
5365
+ // Every action's response body, gathered once for the whole flow so a
5366
+ // capacity/max ceiling discovered on ANY capture (not just the one that
5367
+ // carries the request field itself) is available to bound a request-side
5368
+ // numeric field — the ceiling and the field it bounds are frequently
5369
+ // declared on different calls (an availability check, then a booking).
5370
+ const allResponseBodies = actions.map((a) => a.capture.responseBody);
5257
5371
  // Every value `applyPayloadKeyValueSubstitutions` will unconditionally
5258
5372
  // payload-ify from the ENTRY payload's own top-level key/value pairs,
5259
5373
  // gathered here so {@link interpolateStateValues} can give it payload
@@ -5457,7 +5571,7 @@ pascalName = null) {
5457
5571
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
5458
5572
  : rawBodyWithProducerBoundary;
5459
5573
  const bodyAfterStateAndKv = rawBodyWithUrlParams
5460
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
5574
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys, allResponseBodies)
5461
5575
  : "";
5462
5576
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
5463
5577
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -5662,6 +5776,15 @@ pascalName = null) {
5662
5776
  // Every target's join/merge — plus any non-hoistable target's own
5663
5777
  // chain fetch — goes here, spliced inside the item loop.
5664
5778
  const itemScopedLines = [];
5779
+ // True once any target's own chain fetch ends up item-scoped (not
5780
+ // hoisted above the item loop) — only then does the item loop have
5781
+ // anything to parallelize; see emitItemLoopLines.
5782
+ let hasItemScopedFetch = false;
5783
+ // True once any item-scoped target's chain threads a response header
5784
+ // through createHttpClient's shared `bind` store — see
5785
+ // chainUsesHeaderThreading below for why that disqualifies
5786
+ // parallelizing the item loop.
5787
+ let hasItemScopedHeaderThreading = false;
5665
5788
  for (const [targetIndex, target] of foldPlan.targets.entries()) {
5666
5789
  // `firstItem` decides which captured literal `parameterize` rewrites
5667
5790
  // — it must be the item at `primaryMatchedItemIndex`, the one THIS
@@ -5896,6 +6019,16 @@ pascalName = null) {
5896
6019
  // `itemVar` is never declared.
5897
6020
  const itemVarRefPattern = new RegExp(`\\b${itemVar}\\b`);
5898
6021
  let referencesItemVar = ancestorVars.length === 0;
6022
+ // A chain step that produces a response HEADER threads it through
6023
+ // `createHttpClient`'s single shared `bind` store (see
6024
+ // HttpClientOptions.bind), not a local variable — that store is
6025
+ // mutated in call order and read by whichever call happens to run
6026
+ // next, regardless of which item minted it. Running items
6027
+ // concurrently would let one item's header overwrite another's
6028
+ // in-flight header before its own later chain hop reads it back, so
6029
+ // any target relying on header threading keeps the item loop
6030
+ // sequential rather than risk cross-item header contamination.
6031
+ let chainUsesHeaderThreading = false;
5899
6032
  for (const chainIndex of target.chain) {
5900
6033
  const chainStep = actions[chainIndex];
5901
6034
  const chainRendered = rendered[chainIndex];
@@ -5916,8 +6049,10 @@ pascalName = null) {
5916
6049
  chainLines.push(` ${joined}`);
5917
6050
  chainLines.push(` schema: ${chainRendered.schemaExpr},`, ` })) as Record<string, unknown>;`);
5918
6051
  for (const p of chainStep.produces) {
5919
- if (p.kind === "header")
6052
+ if (p.kind === "header") {
6053
+ chainUsesHeaderThreading = true;
5920
6054
  continue;
6055
+ }
5921
6056
  if (chainDeclared.has(p.name))
5922
6057
  continue;
5923
6058
  if (!referencedNames.has(p.name))
@@ -5931,13 +6066,17 @@ pascalName = null) {
5931
6066
  const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
5932
6067
  if (referencesItemVar) {
5933
6068
  itemScopedLines.push(...chainLines, ...matchLines);
6069
+ if (chainLines.length > 0)
6070
+ hasItemScopedFetch = true;
6071
+ if (chainUsesHeaderThreading)
6072
+ hasItemScopedHeaderThreading = true;
5934
6073
  }
5935
6074
  else {
5936
6075
  hoistedChainLines.push(...chainLines);
5937
6076
  itemScopedLines.push(...matchLines);
5938
6077
  }
5939
6078
  }
5940
- lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
6079
+ lines.push(...hoistedChainLines, ...emitItemLoopLines(itemOpenLines, itemScopedLines, itemCloseLines, itemVar, hasItemScopedFetch && !hasItemScopedHeaderThreading), ...ancestorCloseLines, "");
5941
6080
  continue;
5942
6081
  }
5943
6082
  // Every other chain step (already fully emitted, inline, by the fold
@@ -7616,6 +7755,41 @@ function parseFoldReturnSpec(flowFileContents) {
7616
7755
  return null;
7617
7756
  }
7618
7757
  }
7758
+ /**
7759
+ * Parses `browserFallbackGate`/`httpTimeoutMs` out of an object-form
7760
+ * recon-flow.json, mirroring {@link parseFoldReturnSpec}'s null-safe pattern:
7761
+ * a missing file, a legacy bare-array flow, or a malformed declaration all
7762
+ * resolve to an empty spec (today's behavior) rather than aborting
7763
+ * generation over a field that only tightens an opt-in guard.
7764
+ */
7765
+ function parseFallbackGateSpec(flowFileContents) {
7766
+ try {
7767
+ const raw = JSON.parse(flowFileContents);
7768
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
7769
+ return {};
7770
+ const { browserFallbackGate, httpTimeoutMs } = raw;
7771
+ const resolvedGate = (() => {
7772
+ if (browserFallbackGate === false)
7773
+ return false;
7774
+ if (Array.isArray(browserFallbackGate) &&
7775
+ browserFallbackGate.length > 0 &&
7776
+ browserFallbackGate.every((n) => typeof n === "string" && n.length > 0)) {
7777
+ return browserFallbackGate;
7778
+ }
7779
+ return undefined;
7780
+ })();
7781
+ const resolvedTimeout = typeof httpTimeoutMs === "number" && Number.isFinite(httpTimeoutMs) && httpTimeoutMs > 0
7782
+ ? httpTimeoutMs
7783
+ : undefined;
7784
+ return {
7785
+ ...(resolvedGate !== undefined ? { browserFallbackGate: resolvedGate } : {}),
7786
+ ...(resolvedTimeout !== undefined ? { httpTimeoutMs: resolvedTimeout } : {}),
7787
+ };
7788
+ }
7789
+ catch {
7790
+ return {};
7791
+ }
7792
+ }
7619
7793
  /** The JS `typeof` a `drillParamBindings` entry's `default` must match for
7620
7794
  * its declared `type` — the same "assert the type, then check the default
7621
7795
  * agrees" pattern the rest of {@link parseFoldReturnSpec} uses for its other
@@ -8724,11 +8898,17 @@ function buildNestedSpreadOverride(base, path, leafExpr) {
8724
8898
  return `{ ...${base}, ${keyExpr}: ${nested} }`;
8725
8899
  }
8726
8900
  /**
8727
- * Emits a bounded paging loop for a read-only GraphQL primary operation
8728
- * exposing {@link PaginationSignal}: advances the skip/offset variable by
8729
- * the observed page size on each call, stops once the response's own
8730
- * reported total is reached or `MAX_PAGES` caps it, and merges pages by the
8731
- * detected identity field rather than concatenating blindly.
8901
+ * Emits a bounded paging loop for a read-only primary operation exposing
8902
+ * {@link PaginationSignal}: advances the skip/offset variable by the
8903
+ * observed page size on each call, stops once the response's own reported
8904
+ * total is reached or `MAX_PAGES` caps it, and merges pages by the detected
8905
+ * identity field rather than concatenating blindly.
8906
+ *
8907
+ * The primary operation may be GraphQL (each page issued via `getGql`) or a
8908
+ * plain REST endpoint (each page issued via `httpClient`) — `fetchCall`
8909
+ * selects which, and is the only call-shape-specific piece of the loop; the
8910
+ * skip/PAGE_SIZE/MAX_PAGES bookkeeping, de-dup, and halt guards below are
8911
+ * shared verbatim between both.
8732
8912
  *
8733
8913
  * `foldMergeLines`, when non-empty, threads a resolved single-primary fold
8734
8914
  * plan (see `emitContractTs`'s `singlePrimaryFoldPlans`) additively into the
@@ -8738,9 +8918,14 @@ function buildNestedSpreadOverride(base, path, leafExpr) {
8738
8918
  * mutates its item in place (`Object.assign`), which is visible through
8739
8919
  * `itemsById`'s own stored references — no re-`set` needed.
8740
8920
  */
8741
- function buildPaginatedGqlExecuteHttpBody(opts) {
8742
- const { pascal, gqlOperationNameExpr, queryConstName, gqlVariablesExpr, signal, foldMergeLines } = opts;
8921
+ function buildPaginatedFetchLoopExecuteHttpBody(opts) {
8922
+ const { pascal, gqlVariablesExpr, signal, foldMergeLines, fetchCall, valueConstraints } = opts;
8743
8923
  const { totalPath, arrayPath, containerPath, countKey, skipKey, pageSize, identityField } = signal;
8924
+ const declaredMax = valueConstraints?.[countKey]?.max;
8925
+ const pageSizeDefault = declaredMax ?? pageSize;
8926
+ const pageFetchExpr = (variablesExpr) => fetchCall.kind === "gql"
8927
+ ? `getGql(context.baseUrl)(${fetchCall.gqlOperationNameExpr}, ${fetchCall.queryConstName}, ${variablesExpr})`
8928
+ : `httpClient(\`\${context.baseUrl}${fetchCall.endpointPath}\`, { method: "POST", body: JSON.stringify(${variablesExpr}) })`;
8744
8929
  const countKeyExpr = isValidJsIdentifier(countKey) ? countKey : JSON.stringify(countKey);
8745
8930
  const skipKeyExpr = isValidJsIdentifier(skipKey) ? skipKey : JSON.stringify(skipKey);
8746
8931
  const paginationBase = pathAccessExpr("baseVariables", containerPath);
@@ -8753,32 +8938,55 @@ function buildPaginatedGqlExecuteHttpBody(opts) {
8753
8938
  // `lastPage` is typed as ${pascal}Response (never null): the first page is
8754
8939
  // fetched before the loop starts, so there's nothing left to narrow.
8755
8940
  const withItemsOverrideExpr = buildNestedSpreadOverride("lastPage", arrayPath, "[...itemsById.values()]");
8756
- // When MAX_PAGES caps the loop before `total` converges, the merged
8757
- // envelope's own total must say so instead of repeating the API's
8758
- // original (larger) reported total — otherwise a truncated payload still
8759
- // claims to be complete.
8760
- const withTotalOverrideExpr = buildNestedSpreadOverride("withItems", totalPath, `truncated ? itemsById.size : ${pathAccessExpr("withItems", totalPath)}`);
8941
+ // The server's own reported total is left untouched — overwriting it with
8942
+ // the delivered count would make a MAX_PAGES-capped fetch indistinguishable
8943
+ // from a genuinely complete one. `deliveredCount`/`truncated` are added as
8944
+ // siblings on the response so a caller can tell "total=5000, delivered=100,
8945
+ // truncated=true" apart from "total=436, delivered=436, truncated=false".
8946
+ const withTotalOverrideExpr = `{ ...withItems, deliveredCount: itemsById.size, truncated }`;
8761
8947
  return ` const baseVariables = ${gqlVariablesExpr};
8762
- const PAGE_SIZE = ${pageSize};
8948
+ const PAGE_SIZE = payload.pageSize ?? ${pageSizeDefault};
8763
8949
  // Bounded so a paging bug (a total that never converges) can't loop forever.
8764
8950
  const MAX_PAGES = payload.maxPages ?? 50;
8765
8951
  const itemsById = new Map<string, ${itemTypeExpr}>();
8766
8952
  let skip = 0;
8767
- const page = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${queryConstName}, ${variablesForCall});
8953
+ const page = await ${pageFetchExpr(variablesForCall)};
8768
8954
  let lastPage: ${pascal}Response = page;
8769
8955
  let total = ${totalAccessExpr};
8770
- for (const item of ${arrayAccessExpr}) {
8956
+ const firstPageItems = ${arrayAccessExpr};
8957
+ for (const item of firstPageItems) {
8771
8958
  itemsById.set(String(${identityAccessExpr}), item);
8772
8959
  }
8773
8960
  skip += PAGE_SIZE;
8774
- for (let pageIndex = 1; pageIndex < MAX_PAGES && itemsById.size < total; pageIndex++) {
8775
- const page = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${queryConstName}, ${variablesForCall});
8961
+ // A first page shorter than what was asked for is the same "nothing left"
8962
+ // signal as a short subsequent page — stop before ever entering the loop
8963
+ // instead of issuing a request the server already told us would come
8964
+ // back empty.
8965
+ const firstPageWasShort = firstPageItems.length < PAGE_SIZE;
8966
+ for (
8967
+ let pageIndex = 1;
8968
+ !firstPageWasShort && pageIndex < MAX_PAGES && itemsById.size < total;
8969
+ pageIndex++
8970
+ ) {
8971
+ const page = await ${pageFetchExpr(variablesForCall)};
8776
8972
  lastPage = page;
8777
8973
  total = ${totalAccessExpr};
8778
- for (const item of ${arrayAccessExpr}) {
8974
+ const sizeBeforePage = itemsById.size;
8975
+ const pageItems = ${arrayAccessExpr};
8976
+ for (const item of pageItems) {
8779
8977
  itemsById.set(String(${identityAccessExpr}), item);
8780
8978
  }
8979
+ // A server-reported total that doesn't exactly match the count of
8980
+ // distinct items actually returned would otherwise drive the loop to
8981
+ // MAX_PAGES worth of wasted empty requests; stop as soon as a page
8982
+ // contributes nothing new.
8983
+ if (itemsById.size === sizeBeforePage) break;
8781
8984
  skip += PAGE_SIZE;
8985
+ // A page shorter than what was asked for is the server's own signal
8986
+ // that nothing is left, whether or not its reported total agrees —
8987
+ // stop here instead of issuing a request the server already told us
8988
+ // would come back empty.
8989
+ if (pageItems.length < PAGE_SIZE) break;
8782
8990
  }
8783
8991
  ${foldMergeLines.length > 0 ? `${foldMergeLines.join("\n")}\n` : ""} const truncated = itemsById.size < total;
8784
8992
  const withItems = ${withItemsOverrideExpr};
@@ -8826,7 +9034,21 @@ function buildContractChecklist(opts) {
8826
9034
  /** Generates a complete contract.ts source string for a plugin — exported so
8827
9035
  * unit tests can drive the emitter directly without spawning the CLI. */
8828
9036
  function emitContractTs(opts) {
8829
- const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, optionalPayloadFieldNames = new Set(), headerBindings = [], unpopulatedDeclaredVariables = [], } = opts;
9037
+ const { siteId, displayName, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, responseBodySamples = [responseBody], gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, allCaptures = [], auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, actionSteps = [], foldReturnSpec = null, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, valueConstraints = value_constraints_1.EMPTY_VALUE_CONSTRAINTS, payloadFieldNames, optionalPayloadFieldNames = new Set(), headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
9038
+ const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
9039
+ /** Rendered `meta.browserFallbackGate` literal: `false` verbatim, a
9040
+ * list-derived arrow-function predicate matching against `error.name`, or
9041
+ * "" (omitted) when the flow declared no gate — preserving byte-identical
9042
+ * output for every flow that doesn't opt in. */
9043
+ const browserFallbackGateLiteral = browserFallbackGate === false
9044
+ ? "\n browserFallbackGate: false,"
9045
+ : browserFallbackGate !== undefined
9046
+ ? `\n browserFallbackGate: (error) => ${JSON.stringify(browserFallbackGate)}.includes(error.name),`
9047
+ : "";
9048
+ /** Rendered `createHttpClient({ ..., defaultTimeoutMs })` fragment — "" when
9049
+ * the flow declared no `httpTimeoutMs`, so the call is byte-identical to
9050
+ * today's when the key is absent. */
9051
+ const defaultTimeoutMsOption = httpTimeoutMs !== undefined ? `, defaultTimeoutMs: ${httpTimeoutMs}` : "";
8830
9052
  // This is the CLIENT-level schema — createHttpClient's default, and the
8831
9053
  // plugin's caller-facing contract (what executeHttp's return value promises
8832
9054
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -8915,11 +9137,20 @@ function emitContractTs(opts) {
8915
9137
  : inputBody
8916
9138
  ? `z.object({})`
8917
9139
  : `z.object({\n query: z.string().min(1),\n})`;
8918
- // Only the single-endpoint GraphQL read path (a real primary operation, no
9140
+ // Only the single-endpoint read path (a real primary operation, no
8919
9141
  // multi-step flow) is a candidate for a paging signal — multiStepBody
8920
- // already owns its own per-call semantics.
8921
- const paginationSignal = !multiStepBody && gql && gqlOperationName
8922
- ? detectPaginationSignal(responseBody, gqlVariables, `${endpointPath}::${gqlOperationName}`, allCaptures)
9142
+ // already owns its own per-call semantics. A REST primary has no
9143
+ // operationName/query, so it already collapses to the same identity
9144
+ // operationGroupKey falls back to for such a capture
9145
+ // (`${endpointPath}::anonymous`) — this is the same identity
9146
+ // detectPaginationSignal uses to match sibling captures below.
9147
+ const paginationOperationIdentity = gql
9148
+ ? gqlOperationName
9149
+ ? `${endpointPath}::${gqlOperationName}`
9150
+ : null
9151
+ : `${endpointPath}::anonymous`;
9152
+ const paginationSignal = !multiStepBody && paginationOperationIdentity
9153
+ ? detectPaginationSignal(responseBody, gqlVariables, paginationOperationIdentity, allCaptures)
8923
9154
  : null;
8924
9155
  // A resolved drill-down fold plan on the single-primary getGql/httpClient
8925
9156
  // hot path (`multiStepBody` unset — see emitMultiStepExecuteHttp for the
@@ -8929,7 +9160,7 @@ function emitContractTs(opts) {
8929
9160
  // drop the fold feature the flow author declared (see
8930
9161
  // recon-generate-foldreturn-regresses-primary-op-and-payload-to-ats-submission-shape.md).
8931
9162
  // Also threaded additively into `paginationSignal`'s fetch loop below (see
8932
- // buildPaginatedGqlExecuteHttpBody) — the fold runs against the final
9163
+ // buildPaginatedFetchLoopExecuteHttpBody) — the fold runs against the final
8933
9164
  // assembled/de-duplicated page items, not just the first page's captured
8934
9165
  // sample, so a paginated primary is no longer excluded from folding.
8935
9166
  const singlePrimaryFoldPlans = resolveApplicableFoldPlans(actionSteps, foldReturnSpec, multiStepBody);
@@ -8970,12 +9201,13 @@ function emitContractTs(opts) {
8970
9201
  const addExtendField = (name, line) => {
8971
9202
  extendFields.set(name, line);
8972
9203
  };
8973
- // A detected bounded-paging signal means buildPaginatedGqlExecuteHttpBody
9204
+ // A detected bounded-paging signal means buildPaginatedFetchLoopExecuteHttpBody
8974
9205
  // will emit a loop bounded by MAX_PAGES — expose that bound as a caller-
8975
9206
  // overridable payload field, mirroring how PAGE_SIZE is already sourced
8976
9207
  // from the detected signal.
8977
9208
  if (paginationSignal) {
8978
9209
  addExtendField("maxPages", " maxPages: z.number().int().positive().optional(),");
9210
+ addExtendField("pageSize", " pageSize: z.number().int().positive().optional(),");
8979
9211
  }
8980
9212
  // The base extend's own keys — job-application submission flows only.
8981
9213
  if (usesApplicantContactSchema) {
@@ -9084,18 +9316,27 @@ function emitContractTs(opts) {
9084
9316
  ? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
9085
9317
  : [];
9086
9318
  let usesMultipartBoolean = false;
9087
- for (const [name, kind] of sortedAdditionalKeys) {
9319
+ for (const [name, info] of sortedAdditionalKeys) {
9088
9320
  if (isReservedByApplicantContactSchema(name))
9089
9321
  continue;
9090
9322
  // Use multipartBoolean() for booleans when multipart is in play, so
9091
9323
  // multipart string-encoded "true"/"false" round-trip to native booleans
9092
9324
  // (matches the inputBody boolean handling for parity).
9093
- const zod = kind === "string"
9094
- ? "z.string()"
9095
- : kind === "number"
9096
- ? payloadNeedsMultipart
9097
- ? "z.coerce.number()"
9098
- : "z.number()"
9325
+ // A closed-set vocabulary (string) or a discovered capacity ceiling
9326
+ // (number) narrows the caller-facing schema instead of falling through
9327
+ // to the unconstrained default — see {@link AdditionalBodyKeyInfo}.
9328
+ const zod = info.kind === "string"
9329
+ ? info.enumValues
9330
+ ? `z.enum([${info.enumValues.map((v) => JSON.stringify(v)).join(", ")}])`
9331
+ : "z.string()"
9332
+ : info.kind === "number"
9333
+ ? info.capacityMax !== undefined
9334
+ ? payloadNeedsMultipart
9335
+ ? `z.coerce.number().max(${info.capacityMax})`
9336
+ : `z.number().max(${info.capacityMax})`
9337
+ : payloadNeedsMultipart
9338
+ ? "z.coerce.number()"
9339
+ : "z.number()"
9099
9340
  : payloadNeedsMultipart
9100
9341
  ? "multipartBoolean()"
9101
9342
  : "z.boolean()";
@@ -9182,6 +9423,32 @@ function emitContractTs(opts) {
9182
9423
  extendFields.set(fieldName, line.replace(/,\s*$/, ".optional(),"));
9183
9424
  }
9184
9425
  }
9426
+ // A consumer-declared value constraint overrides its matching field's
9427
+ // emitted Zod expression here, at the single merge point every discovery
9428
+ // source above funnels through — see {@link ReconValueConstraints}. A
9429
+ // declared field name with no entry in extendFields never appeared in any
9430
+ // capture this run and is a no-op, not an error: there is no schema line
9431
+ // for it to attach to.
9432
+ for (const [fieldName, constraint] of Object.entries(valueConstraints)) {
9433
+ const line = extendFields.get(fieldName);
9434
+ if (line === null || line === undefined)
9435
+ continue;
9436
+ if (constraint.enumValues === undefined &&
9437
+ constraint.min === undefined &&
9438
+ constraint.max === undefined) {
9439
+ continue;
9440
+ }
9441
+ const key = isValidJsIdentifier(fieldName) ? fieldName : JSON.stringify(fieldName);
9442
+ const zod = constraint.enumValues
9443
+ ? `z.enum([${constraint.enumValues.map((v) => JSON.stringify(v)).join(", ")}])`
9444
+ : [
9445
+ "z.number()",
9446
+ ...(constraint.min !== undefined ? [`.min(${constraint.min})`] : []),
9447
+ ...(constraint.max !== undefined ? [`.max(${constraint.max})`] : []),
9448
+ ].join("");
9449
+ const optional = line.trimEnd().endsWith(".optional(),");
9450
+ extendFields.set(fieldName, ` ${key}: ${zod}${optional ? ".optional()" : ""},`);
9451
+ }
9185
9452
  const mergedExtension = extendFields.size > 0 ? `.extend({\n${[...extendFields.values()].join("\n")}\n})` : "";
9186
9453
  const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
9187
9454
  // basePayloadSchemaExpr's own Answers field always wraps in
@@ -9261,18 +9528,24 @@ ${
9261
9528
  // GraphQL to the primary endpoint.
9262
9529
  needsFoldHttpClient
9263
9530
  ? `
9264
- const httpClient = createHttpClient({ schema: z.unknown(), bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
9531
+ const httpClient = createHttpClient({ schema: z.unknown(), bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)}${defaultTimeoutMsOption} });
9265
9532
  `
9266
9533
  : ""}`
9267
9534
  : `
9268
- const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)} });
9535
+ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS${bindOptionLiteral(headerBindings)}${defaultTimeoutMsOption} });
9269
9536
  `;
9270
9537
  const gqlOperationNameExpr = gqlOperationName
9271
9538
  ? JSON.stringify(gqlOperationName)
9272
9539
  : JSON.stringify(`${pascal}Search`);
9540
+ // A REST primary with a detected paging signal renders `baseVariables` from
9541
+ // the same captured request-variables object the signal was itself
9542
+ // detected from (see paginationOperationIdentity above) — the default
9543
+ // `{ q: payload.query }` REST body has no skip/count container to advance.
9273
9544
  const gqlVariablesExpr = gqlOperationName
9274
9545
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
9275
- : "{ q: payload.query }";
9546
+ : paginationSignal
9547
+ ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
9548
+ : "{ q: payload.query }";
9276
9549
  /** Builds the nested `for` loop block(s) — see {@link pathToFoldLoopLines}
9277
9550
  * — that fold every resolved plan's drill-down data onto `dataVarName`'s
9278
9551
  * primary array — the single-primary counterpart of
@@ -9338,6 +9611,14 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9338
9611
  // Every target's join/merge — plus any non-hoistable target's own
9339
9612
  // chain fetch — goes here, spliced inside the item loop.
9340
9613
  const itemScopedLines = [];
9614
+ // True once any target's own chain fetch ends up item-scoped — see
9615
+ // emitMultiStepExecuteHttp's identical flag and emitItemLoopLines.
9616
+ let hasItemScopedFetch = false;
9617
+ // True once any item-scoped target's chain threads a response header
9618
+ // through createHttpClient's shared `bind` store — see
9619
+ // emitMultiStepExecuteHttp's identical flag for why that disqualifies
9620
+ // parallelizing the item loop.
9621
+ let hasItemScopedHeaderThreading = false;
9341
9622
  // Word-boundary match — see emitMultiStepExecuteHttp's identical
9342
9623
  // `itemVarRefPattern` for why an anchored `${itemVar` pattern misses a
9343
9624
  // nested field path's `${(itemVar.field as Record<string,
@@ -9485,10 +9766,20 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9485
9766
  // treated as item-scoped too — flat folds must keep emitting
9486
9767
  // byte-identical code.
9487
9768
  let referencesItemVar = ancestorVars.length === 0;
9769
+ // See emitMultiStepExecuteHttp's identical `chainUsesHeaderThreading`
9770
+ // for why any header-kind produce disqualifies parallelizing the
9771
+ // item loop — httpClient's shared `bind` store applies to every call
9772
+ // this function's own chainLines make too, even though (unlike
9773
+ // emitMultiStepExecuteHttp) this codegen path never renders a local
9774
+ // variable for the produced header itself.
9775
+ let chainUsesHeaderThreading = false;
9488
9776
  for (const chainIndex of target.chain) {
9489
9777
  const chainStep = actionSteps[chainIndex];
9490
9778
  if (!chainStep)
9491
9779
  continue;
9780
+ if (chainStep.produces.some((p) => p.kind === "header")) {
9781
+ chainUsesHeaderThreading = true;
9782
+ }
9492
9783
  // The zero-variance guard lives inside `parameterizeUrl` itself
9493
9784
  // (see above) so it can skip only the threaded-value splice while
9494
9785
  // still letting a spec-declared drillParamBindings substitution
@@ -9510,13 +9801,17 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9510
9801
  const matchLines = emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinAccessor);
9511
9802
  if (referencesItemVar) {
9512
9803
  itemScopedLines.push(...chainLines, ...matchLines);
9804
+ if (chainLines.length > 0)
9805
+ hasItemScopedFetch = true;
9806
+ if (chainUsesHeaderThreading)
9807
+ hasItemScopedHeaderThreading = true;
9513
9808
  }
9514
9809
  else {
9515
9810
  hoistedChainLines.push(...chainLines);
9516
9811
  itemScopedLines.push(...matchLines);
9517
9812
  }
9518
9813
  }
9519
- lines.push(...hoistedChainLines, ...itemOpenLines, ...itemScopedLines, ...itemCloseLines, ...ancestorCloseLines, "");
9814
+ lines.push(...hoistedChainLines, ...emitItemLoopLines(itemOpenLines, itemScopedLines, itemCloseLines, itemVar, hasItemScopedFetch && !hasItemScopedHeaderThreading), ...ancestorCloseLines, "");
9520
9815
  }
9521
9816
  return lines;
9522
9817
  };
@@ -9535,13 +9830,19 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
9535
9830
  const executeHttpBody = multiStepBody
9536
9831
  ? multiStepBody
9537
9832
  : paginationSignal
9538
- ? buildPaginatedGqlExecuteHttpBody({
9833
+ ? buildPaginatedFetchLoopExecuteHttpBody({
9539
9834
  pascal,
9540
- gqlOperationNameExpr,
9541
- queryConstName: `${pascal.toUpperCase()}_QUERY`,
9542
9835
  gqlVariablesExpr,
9543
9836
  signal: paginationSignal,
9544
9837
  foldMergeLines: paginatedFoldMergeLines,
9838
+ valueConstraints,
9839
+ fetchCall: gql
9840
+ ? {
9841
+ kind: "gql",
9842
+ gqlOperationNameExpr,
9843
+ queryConstName: `${pascal.toUpperCase()}_QUERY`,
9844
+ }
9845
+ : { kind: "rest", endpointPath },
9545
9846
  })
9546
9847
  : gql
9547
9848
  ? ` const data = await getGql(context.baseUrl)(${gqlOperationNameExpr}, ${pascal.toUpperCase()}_QUERY, ${gqlVariablesExpr});
@@ -9658,7 +9959,7 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
9658
9959
  // since the multipart wire format is what makes that field's
9659
9960
  // JSON-stringified encoding parseable.
9660
9961
  `
9661
- : ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}
9962
+ : ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || usesApplicantContactSchema || hasMultipartStep ? "\n multipart: true," : ""}${browserFallbackGateLiteral}
9662
9963
  },
9663
9964
  ${executeHttpMethodBlock}
9664
9965
  /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
@@ -10198,6 +10499,21 @@ async function resolveFormSchema(specifier) {
10198
10499
  logger.info(`form-schema: ${specifier === load_form_schema_1.FORM_SCHEMA_NONE ? "none (no ATS form recovery)" : `custom keys from ${specifier}`}`);
10199
10500
  return formSchema;
10200
10501
  }
10502
+ /**
10503
+ * Resolves the consumer-declared value constraints for this run.
10504
+ *
10505
+ * Absent `--value-constraints`, no override happens: every field's emitted
10506
+ * Zod expression stays whatever its discovery mechanism (Phase E/F options,
10507
+ * form-schema fields, structured keys) already inferred — the same "absence
10508
+ * means none" discipline `--vocabulary`/`--form-schema` use.
10509
+ */
10510
+ async function resolveValueConstraints(specifier) {
10511
+ if (!specifier)
10512
+ return value_constraints_1.EMPTY_VALUE_CONSTRAINTS;
10513
+ const valueConstraints = await (0, load_value_constraints_1.loadReconValueConstraints)(specifier, process.cwd());
10514
+ logger.info(`value-constraints: ${specifier === load_value_constraints_1.VALUE_CONSTRAINTS_NONE ? "none (no field overrides)" : `custom constraints from ${specifier}`}`);
10515
+ return valueConstraints;
10516
+ }
10201
10517
  /**
10202
10518
  * The capture whose own response `assertRequiredUrlFieldsReferenced`'s
10203
10519
  * scan actually describes: a submission flow returns
@@ -10362,6 +10678,7 @@ async function main() {
10362
10678
  let emit = "ts";
10363
10679
  let vocabularySpecifier = "";
10364
10680
  let formSchemaSpecifier = "";
10681
+ let valueConstraintsSpecifier = "";
10365
10682
  let runDir;
10366
10683
  let allowEmptyCapture = false;
10367
10684
  for (let i = 0; i < args.length; i++) {
@@ -10371,6 +10688,8 @@ async function main() {
10371
10688
  vocabularySpecifier = args[++i];
10372
10689
  else if (args[i] === "--form-schema" && args[i + 1])
10373
10690
  formSchemaSpecifier = args[++i];
10691
+ else if (args[i] === "--value-constraints" && args[i + 1])
10692
+ valueConstraintsSpecifier = args[++i];
10374
10693
  else if (args[i] === "--run-dir" && args[i + 1])
10375
10694
  runDir = args[++i];
10376
10695
  else if (args[i] === "--force")
@@ -10440,7 +10759,7 @@ async function main() {
10440
10759
  return [];
10441
10760
  }
10442
10761
  })();
10443
- const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern, requireSubmitEndpointMatch, displayName, foldReturnSpec, } = (() => {
10762
+ const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern, requireSubmitEndpointMatch, displayName, foldReturnSpec, fallbackGateSpec, } = (() => {
10444
10763
  const flowFileContents = (() => {
10445
10764
  try {
10446
10765
  return (0, node_fs_1.readFileSync)(flowFile, "utf8");
@@ -10453,6 +10772,10 @@ async function main() {
10453
10772
  // valid `foldReturn` still resolves when the rest of the flow file is
10454
10773
  // degenerate — the two declarations fail independently.
10455
10774
  const foldReturnSpec = flowFileContents === null ? null : parseFoldReturnSpec(flowFileContents);
10775
+ // Same independence rule as `foldReturnSpec` above: an empty spec
10776
+ // (missing/malformed declaration) is indistinguishable from "not
10777
+ // declared" and preserves today's byte-identical output.
10778
+ const fallbackGateSpec = flowFileContents === null ? {} : parseFallbackGateSpec(flowFileContents);
10456
10779
  try {
10457
10780
  const raw = flowFileContents === null ? null : JSON.parse(flowFileContents);
10458
10781
  if (Array.isArray(raw))
@@ -10464,6 +10787,7 @@ async function main() {
10464
10787
  requireSubmitEndpointMatch: false,
10465
10788
  displayName: undefined,
10466
10789
  foldReturnSpec,
10790
+ fallbackGateSpec,
10467
10791
  };
10468
10792
  if (raw !== null &&
10469
10793
  typeof raw === "object" &&
@@ -10478,6 +10802,7 @@ async function main() {
10478
10802
  requireSubmitEndpointMatch: obj.requireSubmitEndpointMatch ?? false,
10479
10803
  displayName: obj.displayName,
10480
10804
  foldReturnSpec,
10805
+ fallbackGateSpec,
10481
10806
  };
10482
10807
  }
10483
10808
  return {
@@ -10488,6 +10813,7 @@ async function main() {
10488
10813
  requireSubmitEndpointMatch: false,
10489
10814
  displayName: undefined,
10490
10815
  foldReturnSpec,
10816
+ fallbackGateSpec,
10491
10817
  };
10492
10818
  }
10493
10819
  catch {
@@ -10499,6 +10825,7 @@ async function main() {
10499
10825
  requireSubmitEndpointMatch: false,
10500
10826
  displayName: undefined,
10501
10827
  foldReturnSpec,
10828
+ fallbackGateSpec,
10502
10829
  };
10503
10830
  }
10504
10831
  })();
@@ -10516,6 +10843,10 @@ async function main() {
10516
10843
  // Consumer-supplied wire keys for ATS form-schema recovery, or null. When
10517
10844
  // null the recovery functions no-op — the engine hardcodes no vendor format.
10518
10845
  const formSchema = await resolveFormSchema(formSchemaSpecifier);
10846
+ // Consumer-declared domain facts (enum/min/max) that no captured response
10847
+ // leaf exposes a shape for, or EMPTY_VALUE_CONSTRAINTS when absent — see
10848
+ // {@link ReconValueConstraints}.
10849
+ const valueConstraints = await resolveValueConstraints(valueConstraintsSpecifier);
10519
10850
  const pascal = toPascalCase(siteId);
10520
10851
  // Read the flow's declared own-backend hosts BEFORE deriving baseUrl, so
10521
10852
  // deriveBaseUrl itself can gate on them: a third-party host that happens
@@ -10937,7 +11268,12 @@ async function main() {
10937
11268
  parsedOperationName(primaryGraphQLOperation.capture.query ?? ""))
10938
11269
  : (fallbackGraphQLCapture?.operationName ??
10939
11270
  parsedOperationName(fallbackGraphQLCapture?.query ?? "")),
10940
- gqlVariables: primaryGraphQLOperation?.capture.variables ?? null,
11271
+ // For a REST primary (no primaryGraphQLOperation), the winning
11272
+ // capture's own `variables`/`decodedParams` is the REST equivalent of
11273
+ // a GraphQL operation's captured variables — the object
11274
+ // detectPaginationSignal walks to find a skip/count container.
11275
+ gqlVariables: primaryGraphQLOperation?.capture.variables ??
11276
+ (!gql ? (winningCapture?.variables ?? winningCapture?.decodedParams ?? null) : null),
10941
11277
  allCaptures: activeCaptures,
10942
11278
  auxFiles,
10943
11279
  multiStepBody,
@@ -10953,10 +11289,12 @@ async function main() {
10953
11289
  discoveredRawOptionFields,
10954
11290
  discoveredAdditionalBodyKeys,
10955
11291
  discoveredStructuredKeys,
11292
+ valueConstraints,
10956
11293
  payloadFieldNames: browserFlow.payloadFieldNames,
10957
11294
  optionalPayloadFieldNames: browserFlow.optionalPayloadFieldNames,
10958
11295
  headerBindings,
10959
11296
  unpopulatedDeclaredVariables: primaryGraphQLOperation?.unpopulatedDeclaredVariables ?? [],
11297
+ fallbackGateSpec,
10960
11298
  };
10961
11299
  const contractCode = emitContractTs(contractOpts);
10962
11300
  return {