@enricai/barnacle 1.12.75 → 1.12.77

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.
@@ -27,6 +27,7 @@ exports.extractStepPersonaValue = extractStepPersonaValue;
27
27
  exports.extractNavigateToHashFragmentValue = extractNavigateToHashFragmentValue;
28
28
  exports.deriveFillLabelField = deriveFillLabelField;
29
29
  exports.harvestPersonaBindings = harvestPersonaBindings;
30
+ exports.extractNavigateToFacetOrder = extractNavigateToFacetOrder;
30
31
  exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
31
32
  exports.collectConditionalGraphQLFieldNames = collectConditionalGraphQLFieldNames;
32
33
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
@@ -65,6 +66,7 @@ exports.collectUnboundLiterals = collectUnboundLiterals;
65
66
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
66
67
  exports.countSpliceableFacets = countSpliceableFacets;
67
68
  exports.spliceFacetsIntoStringVariable = spliceFacetsIntoStringVariable;
69
+ exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
68
70
  exports.findAllObjectArrayFields = findAllObjectArrayFields;
69
71
  exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
70
72
  exports.applyDrillParamBindings = applyDrillParamBindings;
@@ -481,6 +483,36 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
481
483
  }
482
484
  return bindings;
483
485
  }
486
+ /**
487
+ * Re-walks the flow for navigateTo+payloadField steps in isolation, in flow
488
+ * order, independent of whether {@link harvestPersonaBindings} could anchor
489
+ * each one's literal anywhere in the capture. `harvestPersonaBindings` only
490
+ * sees flow steps (not captures), so it cannot tell which of its bindings
491
+ * will turn out to have zero recurrence downstream — this lets the emitter,
492
+ * which DOES see the captures, re-derive the same (value, field) pairs and
493
+ * try the causally-adjacent-action fallback (see `emitMultiStepExecuteHttp`)
494
+ * for whichever ones the recurrence-anchored path left unbound.
495
+ */
496
+ function extractNavigateToFacetOrder(flowSteps) {
497
+ const order = [];
498
+ let previousNavigateToHash;
499
+ for (const step of flowSteps) {
500
+ if (typeof step === "string" || step.navigateTo === undefined)
501
+ continue;
502
+ const hashIndex = step.navigateTo.indexOf("#");
503
+ const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
504
+ if (!step.payloadField) {
505
+ previousNavigateToHash = currentHash;
506
+ continue;
507
+ }
508
+ const value = extractNavigateToHashFragmentValue(step.navigateTo, previousNavigateToHash);
509
+ previousNavigateToHash = currentHash;
510
+ if (value === null || value.length === 0)
511
+ continue;
512
+ order.push({ value, field: step.payloadField, optional: step.optional === true });
513
+ }
514
+ return order;
515
+ }
484
516
  /**
485
517
  * Detects a flow step whose quoted VALUE is the space-joined concatenation of
486
518
  * two already-known field values (either order), e.g. a signature step whose
@@ -3940,7 +3972,12 @@ function locateFormEnvelopePath(parsedBody) {
3940
3972
  * and swap for `${item.<field>}`. Freezing either into an opaque
3941
3973
  * `${JSON.stringify(payload.tokens)}` blob here would silently drop the
3942
3974
  * value the request depends on, breaking the fold at exactly the case an
3943
- * ARRAY/OBJECT-wrapped join field represents.
3975
+ * ARRAY/OBJECT-wrapped join field represents. A candidate whose leaves
3976
+ * include a value already registered as a payload-accessor literal (e.g. a
3977
+ * facet value a prior step derived and already wired for splicing elsewhere,
3978
+ * such as a `departmentCode` recurring inside a `tags` array) is excluded on
3979
+ * the same unconditional basis — it is already-threaded text waiting on the
3980
+ * substring-splice pass, not caller-supplied history data.
3944
3981
  *
3945
3982
  * A body with no detected form envelope worth swallowing into (a facet/
3946
3983
  * search body — all scalars, if any, sit directly at the root) still needs
@@ -3951,7 +3988,7 @@ function locateFormEnvelopePath(parsedBody) {
3951
3988
  *
3952
3989
  * Site-agnostic: operates only on the recon body's own shape.
3953
3990
  *
3954
- * A top-level-ARRAY-shaped body (e.g. a cruise-line multi-room search that
3991
+ * A top-level-ARRAY-shaped body (e.g. a travel-brand multi-room search that
3955
3992
  * batches per-room criteria as `[{...}, {...}]`) is walked element by
3956
3993
  * element, applying this same envelope logic to each object element in
3957
3994
  * textual order — otherwise a structured field living inside an array
@@ -3966,7 +4003,35 @@ function locateFormEnvelopePath(parsedBody) {
3966
4003
  * search there stops an earlier element's already-rewritten (or still-frozen)
3967
4004
  * span from swallowing a later element's identically-named key.
3968
4005
  */
3969
- function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody) {
4006
+ function isTokenBoundaryChar(ch) {
4007
+ return ch === undefined || !/[A-Za-z0-9]/.test(ch);
4008
+ }
4009
+ /**
4010
+ * True when `needle` occurs in `haystack` at a token boundary (flanked only
4011
+ * by non-alphanumeric characters or the string's own edges) — e.g. a
4012
+ * navigateTo facet's bare literal recurring inside a captured array element
4013
+ * that suffixes it with a constant delimiter string (`"<literal>;filterId=
4014
+ * urlFriendlyId"`). Exact equality alone misses this recurrence shape since
4015
+ * the leaf carries the delimiter-joined text, not the bare literal. Mirrors
4016
+ * the same boundary discipline `bindsWithoutCollision` already applies when
4017
+ * registering a value for substring splicing in the first place.
4018
+ */
4019
+ function containsValueAtTokenBoundary(haystack, needle) {
4020
+ if (needle.length === 0 || needle === haystack)
4021
+ return false;
4022
+ let from = 0;
4023
+ while (true) {
4024
+ const at = haystack.indexOf(needle, from);
4025
+ if (at === -1)
4026
+ return false;
4027
+ if (isTokenBoundaryChar(haystack[at - 1]) &&
4028
+ isTokenBoundaryChar(haystack[at + needle.length])) {
4029
+ return true;
4030
+ }
4031
+ from = at + needle.length;
4032
+ }
4033
+ }
4034
+ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody, payloadAccessorExcludePattern) {
3970
4035
  let result = template;
3971
4036
  let cursor = searchFrom;
3972
4037
  // The body root's own entries are eligible too, whenever the located
@@ -3988,7 +4053,9 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
3988
4053
  Object.keys(value).length > 0;
3989
4054
  if (!isNonEmptyArray && !isNestedObject)
3990
4055
  continue;
3991
- if (unconditionalExcludeValues.size > 0 || restrictedExcludeSourceByValue.size > 0) {
4056
+ if (unconditionalExcludeValues.size > 0 ||
4057
+ restrictedExcludeSourceByValue.size > 0 ||
4058
+ payloadAccessorExcludePattern !== null) {
3992
4059
  // A name-free join/chain value (unconditionalExcludeValues) excludes the
3993
4060
  // whole field on ANY leaf coincidence, same as before. A RESTRICTED prior-
3994
4061
  // step value (one with a real source field name) additionally requires
@@ -3999,12 +4066,39 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
3999
4066
  // count/id) merely equalling SOME other step's produced value gets the
4000
4067
  // ENTIRE array/object excluded from threading, even though nothing about
4001
4068
  // that leaf's key names the same concept as the matched value's source.
4069
+ // A registered payload-accessor literal (payloadAccessorExcludePattern)
4070
+ // excludes unconditionally too, but on a DELIMITED-SUBSTRING match, not
4071
+ // exact leaf equality — the reported shape is a facet value recurring
4072
+ // inside a captured array element with a constant suffix still attached
4073
+ // (e.g. a hash-derived facet embedded in a `tags` entry alongside a
4074
+ // trailing marker), so requiring the whole leaf to equal the bare facet
4075
+ // literal would miss exactly the case this exclusion exists for. Reuses
4076
+ // {@link buildValueAlternationPattern}'s own token-boundary rule so a
4077
+ // facet value that merely happens to be a substring of an unrelated,
4078
+ // dash/dot-concatenated compound token is still correctly left alone.
4079
+ //
4080
+ // Beyond exact equality, a leaf that carries an excluded value as a
4081
+ // token-bounded SUBSTRING (e.g. a navigateTo facet literal recurring
4082
+ // inside an array element suffixed by a constant delimiter string, like
4083
+ // `"<literal>;filterId=urlFriendlyId"`) must exclude the field too —
4084
+ // otherwise the field gets frozen wholesale before the later
4085
+ // literal-value interpolation pass (which operates on text, not parsed
4086
+ // values) ever gets a chance to splice the facet out of that element.
4002
4087
  const carriesThreadedValue = [...walkAllPrimitiveLeaves(value)].some(({ value: leaf, path: leafPath }) => {
4003
4088
  if (leaf === null)
4004
4089
  return false;
4005
4090
  const leafValue = String(leaf);
4006
4091
  if (unconditionalExcludeValues.has(leafValue))
4007
4092
  return true;
4093
+ if (payloadAccessorExcludePattern !== null) {
4094
+ payloadAccessorExcludePattern.lastIndex = 0;
4095
+ if (payloadAccessorExcludePattern.test(leafValue))
4096
+ return true;
4097
+ }
4098
+ for (const excluded of unconditionalExcludeValues) {
4099
+ if (containsValueAtTokenBoundary(leafValue, excluded))
4100
+ return true;
4101
+ }
4008
4102
  const sourceName = restrictedExcludeSourceByValue.get(leafValue);
4009
4103
  if (sourceName === undefined)
4010
4104
  return false;
@@ -4052,6 +4146,19 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4052
4146
  }
4053
4147
  if (spanEnd === -1)
4054
4148
  continue;
4149
+ // An earlier pass in this same pipeline run (e.g. the optional-facet
4150
+ // array-element splice) may have already spliced a `${payload.<field>}`
4151
+ // accessor into this exact span — e.g. turning one element of a
4152
+ // search/filter array into `...(payload.sortOrder ? [...] : [])`. The
4153
+ // exclusion-source checks above only ever see the ORIGINAL parsed leaf
4154
+ // values, so they can't detect this; inspecting the located span's
4155
+ // CURRENT text catches it and leaves the pre-spliced accessor intact
4156
+ // instead of clobbering it with a wholesale replacement. Matching on the
4157
+ // literal template-placeholder prefix `${payload.` (rather than the bare
4158
+ // substring `payload.`) avoids false-positiving on scraped leaf data that
4159
+ // merely happens to contain the word "payload." as ordinary text.
4160
+ if (result.slice(spanStart, spanEnd).includes("${payload."))
4161
+ continue;
4055
4162
  const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
4056
4163
  result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
4057
4164
  cursor = Math.max(cursor, spanStart + replacement.length);
@@ -4067,7 +4174,7 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4067
4174
  * pass to it. Shared by the top-level-object and top-level-array branches of
4068
4175
  * {@link applyStructuredValuePayloadSubstitutions} below.
4069
4176
  */
4070
- function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom) {
4177
+ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern) {
4071
4178
  const envelopePath = locateFormEnvelopePath(objectBody);
4072
4179
  let envelope = objectBody;
4073
4180
  for (const seg of envelopePath) {
@@ -4078,7 +4185,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4078
4185
  if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
4079
4186
  return { result: template, nextSearchFrom: searchFrom };
4080
4187
  }
4081
- return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody);
4188
+ return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern);
4082
4189
  }
4083
4190
  /**
4084
4191
  * `priorStepStateBindings` carries the SAME value→binding map
@@ -4087,13 +4194,23 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4087
4194
  * field when its own leaf's key {@link keyNamesCorrelate}s with that source
4088
4195
  * name — an unrestricted binding (name-free, e.g. a bare array-index chain
4089
4196
  * hop) or a `joinFieldValues` entry excludes unconditionally on any leaf
4090
- * match, same as before. Without the correlation requirement, an array/
4197
+ * match, same as before. A `payloadAccessorExcludeValues` key excludes
4198
+ * unconditionally too, but on a delimited-SUBSTRING match of the leaf (see
4199
+ * {@link applyStructuredValuePayloadSubstitutionsForEnvelope}), not exact
4200
+ * equality — the reported shape is a facet value recurring inside a captured
4201
+ * array element with a constant suffix still attached (e.g. a hash-derived
4202
+ * facet embedded in a `tags` entry alongside a trailing marker), so a
4203
+ * candidate field whose leaf merely CONTAINS one of those literals at a token
4204
+ * boundary is left alone here too, instead of being wholesale-swallowed into
4205
+ * an opaque `${JSON.stringify(payload.<key>)}` blob before the substring-
4206
+ * splice pass ({@link interpolateStateValues}) gets a chance to thread it.
4207
+ * Without the correlation requirement on the restricted source, an array/
4091
4208
  * object field whose own unrelated short scalar leaf (a count/id) merely
4092
4209
  * EQUALS some other step's produced value gets the whole field wrongly
4093
4210
  * excluded from threading — the same value-coincidence class of bug
4094
4211
  * {@link keyNamesCorrelate}'s other call sites already guard against.
4095
4212
  */
4096
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set()) {
4213
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map()) {
4097
4214
  if (parsedBody === null || typeof parsedBody !== "object") {
4098
4215
  return template;
4099
4216
  }
@@ -4105,7 +4222,10 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4105
4222
  else
4106
4223
  unconditionalExcludeValues.add(value);
4107
4224
  }
4108
- // A cruise-line-style multi-room/multi-guest search body commonly batches
4225
+ const payloadAccessorExcludePattern = payloadAccessorExcludeValues.size > 0
4226
+ ? buildValueAlternationPattern([...payloadAccessorExcludeValues.keys()].sort((a, b) => b.length - a.length))
4227
+ : null;
4228
+ // A travel-brand-style multi-room/multi-guest search body commonly batches
4109
4229
  // per-element criteria as a top-level JSON ARRAY rather than a single
4110
4230
  // object. Walk each element in textual order (via the growing searchFrom
4111
4231
  // cursor) instead of bailing out here — otherwise any structured field
@@ -4117,13 +4237,13 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4117
4237
  for (const element of parsedBody) {
4118
4238
  if (element === null || typeof element !== "object" || Array.isArray(element))
4119
4239
  continue;
4120
- const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor);
4240
+ const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern);
4121
4241
  result = nextResult;
4122
4242
  cursor = nextSearchFrom;
4123
4243
  }
4124
4244
  return result;
4125
4245
  }
4126
- const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0);
4246
+ const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern);
4127
4247
  return result;
4128
4248
  }
4129
4249
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
@@ -5750,6 +5870,63 @@ responseBodiesForCapacitySignal = []) {
5750
5870
  * steps declared any field to correlate against) or when a leaf's value has
5751
5871
  * no `key:value` segment matching one of them.
5752
5872
  */
5873
+ /** Yields every array-valued leaf in `value` (at any object nesting depth) —
5874
+ * `{@link applyOptionalArrayFacetSplicePayloadSubstitutions}`'s raw-array
5875
+ * search surface. Does not recurse INTO an array's own elements (a facet
5876
+ * array is always a flat array of primitives in every observed recon shape),
5877
+ * matching {@link walkAllPrimitiveLeaves}'s depth-first walk of everything
5878
+ * else. */
5879
+ function* walkArrayLeaves(value, path = []) {
5880
+ if (value === null || typeof value !== "object")
5881
+ return;
5882
+ if (Array.isArray(value)) {
5883
+ yield { value, path };
5884
+ return;
5885
+ }
5886
+ for (const [key, inner] of Object.entries(value)) {
5887
+ yield* walkArrayLeaves(inner, [...path, key]);
5888
+ }
5889
+ }
5890
+ /**
5891
+ * REST-body counterpart to {@link spliceFacetsIntoArrayVariable}'s GraphQL
5892
+ * use: a navigateTo facet's literal recurring as one array element (suffixed
5893
+ * by a constant delimiter) inside a REST JSON body gets its element text
5894
+ * swapped for `${payload.<field>}` by the plain `interpolateStateValues`
5895
+ * splice later in the pipeline — but that pass only rewrites literal text
5896
+ * IN PLACE, so when the matched facet is optional it would leave the array's
5897
+ * frozen `undefined`-shaped element (and its JSON delimiter/comma) in the
5898
+ * output whenever the caller omits the field, instead of omitting the
5899
+ * element entirely.
5900
+ *
5901
+ * This pass runs first and, for any array that matches at least one OPTIONAL
5902
+ * facet, rewrites the array's own frozen `JSON.stringify(...)` occurrence in
5903
+ * the body template to a single `${JSON.stringify([...])}` splice point
5904
+ * built from {@link spliceFacetsIntoArrayVariable}'s per-element conditional-
5905
+ * emission shape — the same `...(payload.<field> ? [...] : [])` convention
5906
+ * {@link spliceFacetsIntoStringVariable} already uses for the `key:value`
5907
+ * grammar. A required-only match (no optional facet in that array) is left
5908
+ * for the later plain splice to handle exactly as before — this pass is a
5909
+ * no-op whenever nothing it rewrites would differ, so the existing
5910
+ * required-only REST array-element shape (recon-generate-navigateto-array-
5911
+ * element-facet-splice-e2e.test.ts) never changes.
5912
+ */
5913
+ function applyOptionalArrayFacetSplicePayloadSubstitutions(template, inputBody, navigateToFacets) {
5914
+ if (!navigateToFacets.some((facet) => facet.optional))
5915
+ return template;
5916
+ if (inputBody === null || typeof inputBody !== "object")
5917
+ return template;
5918
+ let result = template;
5919
+ for (const { value } of walkArrayLeaves(inputBody)) {
5920
+ const spliced = spliceFacetsIntoArrayVariable(value, navigateToFacets);
5921
+ if (spliced === null || !spliced.includes("...(payload."))
5922
+ continue;
5923
+ const target = JSON.stringify(value);
5924
+ if (!result.includes(target))
5925
+ continue;
5926
+ result = result.split(target).join(`\${JSON.stringify(${spliced})}`);
5927
+ }
5928
+ return result;
5929
+ }
5753
5930
  function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames) {
5754
5931
  if (payloadFieldNames.size === 0)
5755
5932
  return template;
@@ -6012,6 +6189,109 @@ function emitFoldMatchAndMergeLines(terminalStep, target, itemVar, suffix, joinA
6012
6189
  ` Object.assign(${itemVar}, Object.fromEntries(Object.entries(foldMatch${suffix} ?? {}).filter(([k]) => !(k in ${itemVar}))));`,
6013
6190
  ];
6014
6191
  }
6192
+ /** Parses a URL's query string into a key→value map. Returns an empty map on
6193
+ * a malformed URL or when there is no query string. */
6194
+ function parseUrlQueryParams(url) {
6195
+ const params = new Map();
6196
+ try {
6197
+ for (const [key, value] of new URL(url).searchParams)
6198
+ params.set(key, value);
6199
+ }
6200
+ catch {
6201
+ // malformed URL — treat as having no query params
6202
+ }
6203
+ return params;
6204
+ }
6205
+ /** Parses a captured JSON request body into its string leaf values. Returns
6206
+ * an empty array when the body is absent or not valid JSON. */
6207
+ function parseBodyLeafValues(requestPostData) {
6208
+ if (!requestPostData)
6209
+ return [];
6210
+ try {
6211
+ return [...walkStringLeaves(JSON.parse(requestPostData))].map((leaf) => leaf.value);
6212
+ }
6213
+ catch {
6214
+ return [];
6215
+ }
6216
+ }
6217
+ /**
6218
+ * Walks `actions` in order and groups the query-string/body leaf values that
6219
+ * appear in an action's own capture but did NOT appear in the immediately-
6220
+ * preceding action's capture, PER TRANSITION (one entry per adjacent action
6221
+ * pair that introduced at least one such value). This is the generic
6222
+ * "what's new here" diff {@link correlateUnreachableNavigateToFacets} uses —
6223
+ * kept per-transition (rather than flattened) so a transition that
6224
+ * introduces more than one new value, or multiple transitions that fire
6225
+ * between two facets' real actions, can be told apart from the single clean
6226
+ * transition a lone facet causes.
6227
+ *
6228
+ * The first action never contributes: with no predecessor to diff against,
6229
+ * every one of its values would trivially count as "new", which is nearly
6230
+ * always the baseline/listing call a navigateTo-declared filter does NOT
6231
+ * affect (the already-working recurrence-anchored path exists precisely
6232
+ * because a real facet DOES recur in a later request; nothing here changes
6233
+ * that — this only supplies values for facets that are not found at all).
6234
+ */
6235
+ function collectNewlyAppearingRequestValueTransitions(actions) {
6236
+ const seenGlobally = new Set();
6237
+ const transitions = [];
6238
+ for (let i = 1; i < actions.length; i++) {
6239
+ const prior = actions[i - 1].capture;
6240
+ const current = actions[i].capture;
6241
+ const seenThisTransition = new Set();
6242
+ const values = [];
6243
+ const record = (value) => {
6244
+ if (value.length === 0 || seenGlobally.has(value) || seenThisTransition.has(value))
6245
+ return;
6246
+ seenThisTransition.add(value);
6247
+ values.push(value);
6248
+ };
6249
+ const priorParams = parseUrlQueryParams(prior.url);
6250
+ for (const [key, value] of parseUrlQueryParams(current.url)) {
6251
+ if (priorParams.get(key) !== value)
6252
+ record(value);
6253
+ }
6254
+ const priorLeaves = new Set(parseBodyLeafValues(prior.requestPostData));
6255
+ for (const value of parseBodyLeafValues(current.requestPostData)) {
6256
+ if (!priorLeaves.has(value))
6257
+ record(value);
6258
+ }
6259
+ for (const value of values)
6260
+ seenGlobally.add(value);
6261
+ if (values.length > 0)
6262
+ transitions.push(values);
6263
+ }
6264
+ return transitions;
6265
+ }
6266
+ /**
6267
+ * Resolves each zero-recurrence navigateTo facet (in flow order) to the real
6268
+ * request value it causally affects, or returns `null` when the correlation
6269
+ * cannot be trusted.
6270
+ *
6271
+ * Pairing is positional — facet N gets the Nth qualifying transition — which
6272
+ * is only safe when every transition in the pool is itself unambiguous (an
6273
+ * action introduced EXACTLY ONE new value, so there is no question which
6274
+ * value within it is "the" new one) AND the pool's size exactly matches the
6275
+ * number of facets awaiting a value. A transition that introduces more than
6276
+ * one new value is dropped from the pool entirely rather than guessing which
6277
+ * of its values is real; if that drops the pool below (or an unrelated
6278
+ * single-value transition pushes it above) the facet count, the pairing is
6279
+ * no longer guaranteed to line facets up with the RIGHT transition, so
6280
+ * nothing is bound — leaving a facet declared-but-unbound is preferable to
6281
+ * silently wiring it to another facet's (or an unrelated transition's) value.
6282
+ */
6283
+ function correlateUnreachableNavigateToFacets(facets, actions) {
6284
+ const unambiguousValues = collectNewlyAppearingRequestValueTransitions(actions)
6285
+ .filter((transition) => transition.length === 1)
6286
+ .map((transition) => transition[0]);
6287
+ if (unambiguousValues.length !== facets.length)
6288
+ return null;
6289
+ const byField = new Map();
6290
+ facets.forEach(({ field }, index) => {
6291
+ byField.set(field, unambiguousValues[index]);
6292
+ });
6293
+ return byField;
6294
+ }
6015
6295
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
6016
6296
  * without going through the full emitContractTs pipeline. */
6017
6297
  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,
@@ -6127,6 +6407,53 @@ flowSteps = [], vocabulary = undefined) {
6127
6407
  if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
6128
6408
  outDiscoveredFields.add(field);
6129
6409
  }
6410
+ // Fallback for a navigateTo step's declared `payloadField` whose extracted
6411
+ // hash literal has ZERO recurrence anywhere in the capture — not merely
6412
+ // blocked by the collision guard above, but genuinely absent from every
6413
+ // action's URL, body, AND headers. `harvestPersonaBindings` can only ever
6414
+ // register the one raw literal it extracted; when the site never replays
6415
+ // that literal verbatim (it's transformed, mapped to a backend code, or
6416
+ // otherwise re-shaped before the real request), the field is structurally
6417
+ // unreachable by the recurrence-anchored path above, exactly like a
6418
+ // declared-but-never-bound schema field.
6419
+ //
6420
+ // Site-agnostic, position/causality-based correlation: a navigateTo step
6421
+ // always precedes the real request its facet affects, so each such
6422
+ // unreachable facet (in flow order) is paired with the next "new" value —
6423
+ // a query-string or body leaf that a later action's capture carries but
6424
+ // its immediately-preceding action's capture does not — found by walking
6425
+ // the actions in order. This is a best-effort structural guarantee, not a
6426
+ // completeness proof: when the adjacent action's own capture carries no
6427
+ // new value at all (the facet's effect is purely server-side / implicit),
6428
+ // or when the correlation is ambiguous (see
6429
+ // `correlateUnreachableNavigateToFacets`), there is nothing safe to splice
6430
+ // and the field(s) stay unbound rather than risk a wrong binding.
6431
+ const appearsAnywhereInCapture = (value) => actions.some((action) => action.capture.url.includes(value) ||
6432
+ (action.capture.requestPostData?.includes(value) ?? false) ||
6433
+ Object.values(action.capture.requestHeaders).some((h) => h.includes(value)));
6434
+ // Hoisted once so both the unreachable-facet fallback above and the
6435
+ // REST array-element optional splice below (applyOptionalArrayFacetSplice
6436
+ // PayloadSubstitutions) share the exact same (value, field, optional)
6437
+ // triples — the array splice must see each binding's `optional` flag
6438
+ // precisely as the flow declared it, same as the already-fixed GraphQL
6439
+ // array-variable path ({@link spliceFacetsIntoArrayVariable}).
6440
+ const navigateToFacetOrder = extractNavigateToFacetOrder(flowSteps);
6441
+ const unreachableNavigateToFacets = navigateToFacetOrder.filter(({ value }) => !appearsAnywhereInCapture(value));
6442
+ if (unreachableNavigateToFacets.length > 0) {
6443
+ const correlated = correlateUnreachableNavigateToFacets(unreachableNavigateToFacets, actions);
6444
+ for (const { field } of correlated === null ? [] : unreachableNavigateToFacets) {
6445
+ const diffed = correlated?.get(field);
6446
+ if (diffed === undefined)
6447
+ continue;
6448
+ if (!bindsWithoutCollision(diffed))
6449
+ continue;
6450
+ if (payloadAccessorByValue.has(diffed))
6451
+ continue;
6452
+ payloadAccessorByValue.set(diffed, `payload.${field}`);
6453
+ if (isValidJsIdentifier(field))
6454
+ outDiscoveredFields.add(field);
6455
+ }
6456
+ }
6130
6457
  // Job coordinates from the recon entry URL's query string (e.g.
6131
6458
  // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
6132
6459
  // occurrence — and, via length-descending order, embedded substrings like a
@@ -6380,6 +6707,16 @@ flowSteps = [], vocabulary = undefined) {
6380
6707
  const rawBodyWithFacetSplices = parsedBody !== null
6381
6708
  ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames)
6382
6709
  : rawBodyWithFormSubs;
6710
+ // Optional-facet array-element splice — a navigateTo facet literal
6711
+ // recurring as one element of an array field (not the `key:value`
6712
+ // grammar above) must drop its own element, not freeze an `undefined`,
6713
+ // when its binding is optional and the caller omits it. Runs BEFORE
6714
+ // Mechanism B (below) can swallow the whole array wholesale as an
6715
+ // unparameterized structured value — see
6716
+ // {@link applyOptionalArrayFacetSplicePayloadSubstitutions}.
6717
+ const rawBodyWithOptionalArrayFacetSplices = parsedBody !== null
6718
+ ? applyOptionalArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder)
6719
+ : rawBodyWithFacetSplices;
6383
6720
  // Mechanism B — parameterize whole nested caller structures
6384
6721
  // (experienceData/educationData history, opaque eventData) BEFORE value
6385
6722
  // substitution reaches inside them: swallowing the entire array/object first
@@ -6390,7 +6727,7 @@ flowSteps = [], vocabulary = undefined) {
6390
6727
  // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
6391
6728
  // must stay reachable for state threading, not get frozen as caller data.
6392
6729
  const rawBodyWithStructuredSubs = parsedBody !== null
6393
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set())
6730
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue)
6394
6731
  : rawBodyWithFormSubs;
6395
6732
  // Whole-value caller coordinates bind here — after structured subs, BEFORE
6396
6733
  // state threading — so a composite coordinate (a jobLocation or jobSeqNo
@@ -7313,6 +7650,50 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7313
7650
  }
7314
7651
  return `[${elements.join(", ")}].join("")`;
7315
7652
  }
7653
+ /**
7654
+ * Splices a captured array-valued GraphQL variable's elements against
7655
+ * navigateTo facet bindings instead of the `key:value` segment grammar
7656
+ * {@link spliceFacetsIntoStringVariable} matches — an array element like
7657
+ * `widget-x;filterId=urlFriendlyId` carries a bare literal plus a constant
7658
+ * trailing delimiter, with no `key:` prefix to key off of. Matching is
7659
+ * token-boundary guarded the same way {@link bindsWithoutCollision} (recon-
7660
+ * generate.ts ~7223) guards persona bindings elsewhere in this file, so a
7661
+ * facet literal that's merely a substring of an unrelated element never
7662
+ * misfires. A matched binding whose `optional` flag is set is emitted as a
7663
+ * conditionally-included array element (`...(payload.<field> ? [...] : [])`)
7664
+ * instead of inline, the same optional-splice convention
7665
+ * {@link spliceFacetsIntoStringVariable} uses, so an absent optional facet
7666
+ * drops its element rather than emitting `undefined`.
7667
+ */
7668
+ function spliceFacetsIntoArrayVariable(value, facets) {
7669
+ if (!Array.isArray(value))
7670
+ return null;
7671
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
7672
+ const matchFacet = (element) => facets.find(({ value: literal }) => {
7673
+ if (element === literal)
7674
+ return true;
7675
+ if (!element.startsWith(literal))
7676
+ return false;
7677
+ return !isAlnum(element[literal.length]);
7678
+ });
7679
+ let matchCount = 0;
7680
+ const elements = value.map((element) => {
7681
+ if (typeof element !== "string")
7682
+ return JSON.stringify(element);
7683
+ const matched = matchFacet(element);
7684
+ if (!matched)
7685
+ return JSON.stringify(element);
7686
+ matchCount++;
7687
+ const suffix = element.slice(matched.value.length);
7688
+ const elementLiteral = `\`\${payload.${matched.field}}${escapeForTemplateLiteral(suffix)}\``;
7689
+ return matched.optional
7690
+ ? `...(payload.${matched.field} ? [${elementLiteral}] : [])`
7691
+ : elementLiteral;
7692
+ });
7693
+ if (matchCount === 0)
7694
+ return null;
7695
+ return `[${elements.join(", ")}]`;
7696
+ }
7316
7697
  /**
7317
7698
  * Renders the variables literal for the primary-operation getGql() call —
7318
7699
  * each key from the selected capture's own recorded variables is bound to
@@ -7324,9 +7705,12 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7324
7705
  * other value is emitted verbatim via JSON.stringify. `optionalFieldNames`
7325
7706
  * marks which of `payloadFieldNames` a facet-string splice should treat as
7326
7707
  * optional (see {@link spliceFacetsIntoStringVariable}); it has no effect on
7327
- * top-level key correlation.
7708
+ * top-level key correlation. When a value is an array (not itself a string
7709
+ * key correlation candidate), its elements are matched against
7710
+ * `navigateToFacets` instead — see {@link spliceFacetsIntoArrayVariable} for
7711
+ * why that's a distinct grammar from the `key:value` one above.
7328
7712
  */
7329
- function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames = new Set()) {
7713
+ function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames = new Set(), navigateToFacets = []) {
7330
7714
  if (variables === null || typeof variables !== "object" || Array.isArray(variables))
7331
7715
  return "{}";
7332
7716
  const fields = payloadFieldNames ? [...payloadFieldNames] : [];
@@ -7334,7 +7718,8 @@ function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames
7334
7718
  const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
7335
7719
  const facetSpliceExpr = matchedField
7336
7720
  ? null
7337
- : spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
7721
+ : (spliceFacetsIntoStringVariable(value, fields, optionalFieldNames) ??
7722
+ spliceFacetsIntoArrayVariable(value, navigateToFacets));
7338
7723
  const valueExpr = matchedField
7339
7724
  ? `payload.${matchedField}`
7340
7725
  : (facetSpliceExpr ?? JSON.stringify(value));
@@ -7771,7 +8156,7 @@ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, sibl
7771
8156
  * iteration's request, exactly the defect described in
7772
8157
  * docs/recon-generate-nested-fold-flatmaps-away-the-parent-so-drill-params-freeze.md
7773
8158
  * (a `packageCode`/`groupId`/`sailDate` triple that provably varies per
7774
- * cruise, silently frozen because no threaded field explained it). Path
8159
+ * voyage, silently frozen because no threaded field explained it). Path
7775
8160
  * segments are deliberately not checked: {@link endpointKey} requires an
7776
8161
  * identical pathname to group two captures at all, so no path segment can
7777
8162
  * ever be observed to vary within a matched group.
@@ -10348,7 +10733,7 @@ function buildContractChecklist(opts) {
10348
10733
  /** Generates a complete contract.ts source string for a plugin — exported so
10349
10734
  * unit tests can drive the emitter directly without spawning the CLI. */
10350
10735
  function emitContractTs(opts) {
10351
- 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;
10736
+ 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(), flowSteps: contractFlowSteps = [], headerBindings = [], unpopulatedDeclaredVariables = [], fallbackGateSpec = {}, } = opts;
10352
10737
  const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
10353
10738
  /** Single source of truth for every GraphQL-emission decision below
10354
10739
  * (clientImport, gqlCacheBlock, queryConst, and both executeHttpBody
@@ -10858,10 +11243,11 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
10858
11243
  // the same captured request-variables object the signal was itself
10859
11244
  // detected from (see paginationOperationIdentity above) — the default
10860
11245
  // `{ q: payload.query }` REST body has no skip/count container to advance.
11246
+ const navigateToFacets = extractNavigateToFacetOrder(contractFlowSteps);
10861
11247
  const gqlVariablesExpr = gqlOperationName
10862
- ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
11248
+ ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
10863
11249
  : paginationSignal
10864
- ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
11250
+ ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
10865
11251
  : "{ q: payload.query }";
10866
11252
  /** Builds the nested `for` loop block(s) — see {@link pathToFoldLoopLines}
10867
11253
  * — that fold every resolved plan's drill-down data onto `dataVarName`'s
@@ -11610,7 +11996,10 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
11610
11996
  if (isObj && step.navigateTo !== undefined) {
11611
11997
  if (step.payloadField) {
11612
11998
  payloadFieldNames.add(step.payloadField);
11613
- registerFieldOptionality(step.payloadField, step.optional === true);
11999
+ // An explicitly declared payloadField is always required at the
12000
+ // schema level, independent of step.optional's execution-skip
12001
+ // semantics (which the browser-flow step record still honors as-is).
12002
+ registerFieldOptionality(step.payloadField, false);
11614
12003
  }
11615
12004
  continue;
11616
12005
  }
@@ -11623,9 +12012,13 @@ function computeFlowPayloadFieldNames(flowSteps, vocabulary, env) {
11623
12012
  ? resolveCompositePersonaFields(instruction, knownFieldValues)
11624
12013
  : null;
11625
12014
  const optional = isObj ? step.optional === true : false;
12015
+ const explicit = isObj ? step.payloadField : undefined;
11626
12016
  if (field !== null) {
11627
12017
  payloadFieldNames.add(field);
11628
- registerFieldOptionality(field, optional);
12018
+ // An explicit payloadField annotation is always required at the
12019
+ // schema level; only the vocabulary/composite-persona heuristic falls
12020
+ // back to step.optional's execution-skip semantics.
12021
+ registerFieldOptionality(field, explicit ? false : optional);
11629
12022
  }
11630
12023
  if (composite !== null) {
11631
12024
  payloadFieldNames.add(composite.fieldA);
@@ -12725,6 +13118,7 @@ async function main() {
12725
13118
  isSubmissionFlow,
12726
13119
  inputBody,
12727
13120
  hasMultipartStep,
13121
+ flowSteps,
12728
13122
  actionSteps,
12729
13123
  foldReturnSpec,
12730
13124
  discoveredFormFields,