@enricai/barnacle 1.12.76 → 1.12.78

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.
@@ -62,10 +62,14 @@ exports.ancestorScopedAccessor = ancestorScopedAccessor;
62
62
  exports.compileActionSteps = compileActionSteps;
63
63
  exports.collectHeaderBindings = collectHeaderBindings;
64
64
  exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
65
+ exports.applyPayloadKeyValueSubstitutions = applyPayloadKeyValueSubstitutions;
65
66
  exports.collectUnboundLiterals = collectUnboundLiterals;
67
+ exports.resolveRecurringNavigateToFacets = resolveRecurringNavigateToFacets;
66
68
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
67
69
  exports.countSpliceableFacets = countSpliceableFacets;
68
70
  exports.spliceFacetsIntoStringVariable = spliceFacetsIntoStringVariable;
71
+ exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
72
+ exports.renderGqlVariablesExpr = renderGqlVariablesExpr;
69
73
  exports.findAllObjectArrayFields = findAllObjectArrayFields;
70
74
  exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
71
75
  exports.applyDrillParamBindings = applyDrillParamBindings;
@@ -381,14 +385,53 @@ function extractNavigateToHashFragmentValue(url, previousHash) {
381
385
  if (hashIndex === -1)
382
386
  return null;
383
387
  const hash = url.slice(hashIndex + 1);
384
- if (previousHash && hash.length > previousHash.length && hash.startsWith(previousHash)) {
385
- const suffix = hash.slice(previousHash.length);
386
- const delta = /^[,/]/.test(suffix) ? suffix.slice(1) : suffix;
387
- if (delta.length > 0)
388
- return delta;
388
+ if (previousHash) {
389
+ // A growing hash can add more than one token per step (a "label/value"
390
+ // pair, or a "key=value" pair), at either end, joined by any hash
391
+ // separator. The facet's actual VALUE is the last token that is new
392
+ // relative to the previous hash, with any "key=" prefix removed.
393
+ const previousTokens = new Set(splitHashTokens(previousHash));
394
+ const added = splitHashTokens(hash).filter((token) => !previousTokens.has(token));
395
+ const delta = added[added.length - 1];
396
+ if (delta !== undefined)
397
+ return stripHashKeyPrefix(delta);
398
+ }
399
+ const tokens = splitHashTokens(hash);
400
+ return tokens.length > 0 ? stripHashKeyPrefix(tokens[tokens.length - 1]) : null;
401
+ }
402
+ /**
403
+ * Single walk over navigateTo+payloadField steps shared by
404
+ * {@link harvestPersonaBindings} and {@link extractNavigateToFacetOrder}, so
405
+ * both derive identical literals. The delta baseline is the most recent
406
+ * hash-bearing navigateTo; a hashless reset navigation must not erase it, or
407
+ * the next cumulative hash would be taken whole and never recur in a request.
408
+ */
409
+ function walkNavigateToFacets(flowSteps) {
410
+ const facets = [];
411
+ let baselineHash;
412
+ for (const step of flowSteps) {
413
+ if (typeof step === "string" || step.navigateTo === undefined)
414
+ continue;
415
+ const hashIndex = step.navigateTo.indexOf("#");
416
+ const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
417
+ if (step.payloadField) {
418
+ facets.push({
419
+ value: extractNavigateToHashFragmentValue(step.navigateTo, baselineHash),
420
+ field: step.payloadField,
421
+ optional: step.optional === true,
422
+ });
423
+ }
424
+ if (currentHash !== undefined)
425
+ baselineHash = currentHash;
389
426
  }
390
- const segments = hash.split("/").filter((s) => s.length > 0);
391
- return segments.length > 0 ? segments[segments.length - 1] : null;
427
+ return facets;
428
+ }
429
+ function splitHashTokens(hash) {
430
+ return hash.split(/[,/;&]/).filter((token) => token.length > 0);
431
+ }
432
+ function stripHashKeyPrefix(token) {
433
+ const stripped = token.slice(token.indexOf("=") + 1);
434
+ return stripped.length > 0 ? stripped : token;
392
435
  }
393
436
  /**
394
437
  * Derives a payload field name from the field LABEL in a fill/enter/type
@@ -436,7 +479,11 @@ function deriveFillLabelField(instruction) {
436
479
  function harvestPersonaBindings(flowSteps, vocabulary, env) {
437
480
  const bindings = new Map();
438
481
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
439
- let previousNavigateToHash;
482
+ for (const facet of walkNavigateToFacets(flowSteps)) {
483
+ if (facet.value !== null && !bindings.has(facet.value)) {
484
+ bindings.set(facet.value, `payload.${facet.field}`);
485
+ }
486
+ }
440
487
  for (const step of flowSteps) {
441
488
  const isObj = typeof step !== "string";
442
489
  const instruction = isObj ? step.step : step;
@@ -447,21 +494,8 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
447
494
  // `.../#/jobs/category-widgets`), so the value to correlate against
448
495
  // downstream header/body templates is the URL's own hash-derived
449
496
  // fragment, not anything extractStepPersonaValue could find in `step.step`.
450
- if (isObj && step.navigateTo !== undefined) {
451
- const hashIndex = step.navigateTo.indexOf("#");
452
- const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
453
- if (!step.payloadField) {
454
- previousNavigateToHash = currentHash;
455
- continue;
456
- }
457
- const value = extractNavigateToHashFragmentValue(step.navigateTo, previousNavigateToHash);
458
- previousNavigateToHash = currentHash;
459
- if (value === null)
460
- continue;
461
- if (!bindings.has(value))
462
- bindings.set(value, `payload.${step.payloadField}`);
497
+ if (isObj && step.navigateTo !== undefined)
463
498
  continue;
464
- }
465
499
  const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
466
500
  // Vocabulary wins outright. Only on a miss do we fall back to deriving the
467
501
  // field from the instruction's own label — and never when the author opted
@@ -491,26 +525,18 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
491
525
  * which DOES see the captures, re-derive the same (value, field) pairs and
492
526
  * try the causally-adjacent-action fallback (see `emitMultiStepExecuteHttp`)
493
527
  * for whichever ones the recurrence-anchored path left unbound.
528
+ *
529
+ * A step whose URL carries no hash literal still holds its slot (empty
530
+ * `value`) so positional correlation against captured transitions stays
531
+ * aligned with the declared facets; {@link resolveRecurringNavigateToFacets}
532
+ * drops the empty literal itself and keeps only a correlated value.
494
533
  */
495
534
  function extractNavigateToFacetOrder(flowSteps) {
496
- const order = [];
497
- let previousNavigateToHash;
498
- for (const step of flowSteps) {
499
- if (typeof step === "string" || step.navigateTo === undefined)
500
- continue;
501
- const hashIndex = step.navigateTo.indexOf("#");
502
- const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
503
- if (!step.payloadField) {
504
- previousNavigateToHash = currentHash;
505
- continue;
506
- }
507
- const value = extractNavigateToHashFragmentValue(step.navigateTo, previousNavigateToHash);
508
- previousNavigateToHash = currentHash;
509
- if (value === null || value.length === 0)
510
- continue;
511
- order.push({ value, field: step.payloadField });
512
- }
513
- return order;
535
+ return walkNavigateToFacets(flowSteps).map((facet) => ({
536
+ value: facet.value ?? "",
537
+ field: facet.field,
538
+ optional: facet.optional,
539
+ }));
514
540
  }
515
541
  /**
516
542
  * Detects a flow step whose quoted VALUE is the space-joined concatenation of
@@ -2823,6 +2849,17 @@ function jsonBodyLeafValues(requestPostData) {
2823
2849
  }
2824
2850
  return values;
2825
2851
  }
2852
+ /** Parses a captured request body as JSON; null for an absent or non-JSON body (multipart raw bytes). */
2853
+ function parseJsonBodyOrNull(requestPostData) {
2854
+ if (typeof requestPostData !== "string" || requestPostData.length === 0)
2855
+ return null;
2856
+ try {
2857
+ return JSON.parse(requestPostData);
2858
+ }
2859
+ catch {
2860
+ return null;
2861
+ }
2862
+ }
2826
2863
  /**
2827
2864
  * Same JSON body walk as {@link jsonBodyLeafValues}, but grouped by the JSON
2828
2865
  * key/array-index that carries each leaf value — the by-name correlation
@@ -3971,7 +4008,12 @@ function locateFormEnvelopePath(parsedBody) {
3971
4008
  * and swap for `${item.<field>}`. Freezing either into an opaque
3972
4009
  * `${JSON.stringify(payload.tokens)}` blob here would silently drop the
3973
4010
  * value the request depends on, breaking the fold at exactly the case an
3974
- * ARRAY/OBJECT-wrapped join field represents.
4011
+ * ARRAY/OBJECT-wrapped join field represents. A candidate whose leaves
4012
+ * include a value already registered as a payload-accessor literal (e.g. a
4013
+ * facet value a prior step derived and already wired for splicing elsewhere,
4014
+ * such as a `departmentCode` recurring inside a `tags` array) is excluded on
4015
+ * the same unconditional basis — it is already-threaded text waiting on the
4016
+ * substring-splice pass, not caller-supplied history data.
3975
4017
  *
3976
4018
  * A body with no detected form envelope worth swallowing into (a facet/
3977
4019
  * search body — all scalars, if any, sit directly at the root) still needs
@@ -3982,7 +4024,7 @@ function locateFormEnvelopePath(parsedBody) {
3982
4024
  *
3983
4025
  * Site-agnostic: operates only on the recon body's own shape.
3984
4026
  *
3985
- * A top-level-ARRAY-shaped body (e.g. a cruise-line multi-room search that
4027
+ * A top-level-ARRAY-shaped body (e.g. a travel-brand multi-room search that
3986
4028
  * batches per-room criteria as `[{...}, {...}]`) is walked element by
3987
4029
  * element, applying this same envelope logic to each object element in
3988
4030
  * textual order — otherwise a structured field living inside an array
@@ -3997,7 +4039,56 @@ function locateFormEnvelopePath(parsedBody) {
3997
4039
  * search there stops an earlier element's already-rewritten (or still-frozen)
3998
4040
  * span from swallowing a later element's identically-named key.
3999
4041
  */
4000
- function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody) {
4042
+ function isTokenBoundaryChar(ch) {
4043
+ return ch === undefined || !/[A-Za-z0-9]/.test(ch);
4044
+ }
4045
+ /**
4046
+ * True when `needle` occurs in `haystack` at a token boundary (flanked only
4047
+ * by non-alphanumeric characters or the string's own edges) — e.g. a
4048
+ * navigateTo facet's bare literal recurring inside a captured array element
4049
+ * that suffixes it with a constant delimiter string (`"<literal>;filterId=
4050
+ * urlFriendlyId"`). Exact equality alone misses this recurrence shape since
4051
+ * the leaf carries the delimiter-joined text, not the bare literal. Mirrors
4052
+ * the same boundary discipline `bindsWithoutCollision` already applies when
4053
+ * registering a value for substring splicing in the first place.
4054
+ */
4055
+ function containsValueAtTokenBoundary(haystack, needle) {
4056
+ if (needle.length === 0 || needle === haystack)
4057
+ return false;
4058
+ let from = 0;
4059
+ while (true) {
4060
+ const at = haystack.indexOf(needle, from);
4061
+ if (at === -1)
4062
+ return false;
4063
+ if (isTokenBoundaryChar(haystack[at - 1]) &&
4064
+ isTokenBoundaryChar(haystack[at + needle.length])) {
4065
+ return true;
4066
+ }
4067
+ from = at + needle.length;
4068
+ }
4069
+ }
4070
+ const structuredFieldSamples = new WeakMap();
4071
+ /**
4072
+ * Single registration point for a structured (array/object) payload field, so
4073
+ * the declared schema type is inferred from every captured body carrying the
4074
+ * field rather than from whichever body or visiting order reached it first.
4075
+ * Both the by-index accessor loop and the whole-field substitution pass go
4076
+ * through here, so an indexed accessor can never coexist with a string schema.
4077
+ */
4078
+ function registerStructuredPayloadField(outStructuredKeys, field, value) {
4079
+ if (value === undefined || value === null)
4080
+ return;
4081
+ const samplesByField = structuredFieldSamples.get(outStructuredKeys) ?? new Map();
4082
+ structuredFieldSamples.set(outStructuredKeys, samplesByField);
4083
+ const samples = samplesByField.get(field) ?? [];
4084
+ samplesByField.set(field, samples);
4085
+ const serialized = JSON.stringify(value);
4086
+ if (samples.some((sample) => JSON.stringify(sample) === serialized))
4087
+ return;
4088
+ samples.push(value);
4089
+ outStructuredKeys.set(field, inferZodSchemaFromSamples(samples));
4090
+ }
4091
+ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody, payloadAccessorExcludePattern, ownedArrayPathKeys) {
4001
4092
  let result = template;
4002
4093
  let cursor = searchFrom;
4003
4094
  // The body root's own entries are eligible too, whenever the located
@@ -4007,11 +4098,11 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4007
4098
  // pagination/sort block) outranks the root as the "form envelope". Root
4008
4099
  // keys already present on the envelope are skipped so nothing is visited
4009
4100
  // (or registered) twice.
4010
- const envelopeEntries = Object.entries(envelope);
4011
- const rootEntries = envelopePath.length === 0
4101
+ const envelopeEntries = Object.entries(envelope).map(([key, value]) => [key, value, [...envelopePath, key]]);
4102
+ const rootEntries = (envelopePath.length === 0
4012
4103
  ? []
4013
- : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope));
4014
- for (const [key, value] of [...envelopeEntries, ...rootEntries]) {
4104
+ : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope))).map(([key, value]) => [key, value, [key]]);
4105
+ for (const [key, value, valuePath] of [...envelopeEntries, ...rootEntries]) {
4015
4106
  const isNonEmptyArray = Array.isArray(value) && value.length > 0;
4016
4107
  const isNestedObject = value !== null &&
4017
4108
  typeof value === "object" &&
@@ -4019,7 +4110,25 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4019
4110
  Object.keys(value).length > 0;
4020
4111
  if (!isNonEmptyArray && !isNestedObject)
4021
4112
  continue;
4022
- if (unconditionalExcludeValues.size > 0 || restrictedExcludeSourceByValue.size > 0) {
4113
+ // An array path the facet pass owns is rendered element by element at every
4114
+ // call site, so it is neither a payload field of its own nor swallowable.
4115
+ if (isNonEmptyArray && ownedArrayPathKeys.has(arrayPathKey(valuePath)))
4116
+ continue;
4117
+ // Schema registration and the wholesale text-swallow below are
4118
+ // independent concerns: this field's inferred Zod shape is correct
4119
+ // regardless of whether any of the exclusion/guard checks below skip
4120
+ // freezing its literal into the template. Registering it here (before
4121
+ // those checks, and before even locating the span) keeps the generated
4122
+ // contract's declared type in sync with payloadAccessorForPath's
4123
+ // array/object accessors even when the wholesale swallow is skipped.
4124
+ registerStructuredPayloadField(outStructuredKeys, key, value);
4125
+ // A call site whose array carries a facet was already rewritten by the
4126
+ // facet pass (the span-text guard below leaves it intact); a site whose
4127
+ // array carries none falls through to the same opaque `payload.<key>`
4128
+ // passthrough every non-facet array gets, so the declared field is used.
4129
+ if (unconditionalExcludeValues.size > 0 ||
4130
+ restrictedExcludeSourceByValue.size > 0 ||
4131
+ payloadAccessorExcludePattern !== null) {
4023
4132
  // A name-free join/chain value (unconditionalExcludeValues) excludes the
4024
4133
  // whole field on ANY leaf coincidence, same as before. A RESTRICTED prior-
4025
4134
  // step value (one with a real source field name) additionally requires
@@ -4030,12 +4139,39 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4030
4139
  // count/id) merely equalling SOME other step's produced value gets the
4031
4140
  // ENTIRE array/object excluded from threading, even though nothing about
4032
4141
  // that leaf's key names the same concept as the matched value's source.
4142
+ // A registered payload-accessor literal (payloadAccessorExcludePattern)
4143
+ // excludes unconditionally too, but on a DELIMITED-SUBSTRING match, not
4144
+ // exact leaf equality — the reported shape is a facet value recurring
4145
+ // inside a captured array element with a constant suffix still attached
4146
+ // (e.g. a hash-derived facet embedded in a `tags` entry alongside a
4147
+ // trailing marker), so requiring the whole leaf to equal the bare facet
4148
+ // literal would miss exactly the case this exclusion exists for. Reuses
4149
+ // {@link buildValueAlternationPattern}'s own token-boundary rule so a
4150
+ // facet value that merely happens to be a substring of an unrelated,
4151
+ // dash/dot-concatenated compound token is still correctly left alone.
4152
+ //
4153
+ // Beyond exact equality, a leaf that carries an excluded value as a
4154
+ // token-bounded SUBSTRING (e.g. a navigateTo facet literal recurring
4155
+ // inside an array element suffixed by a constant delimiter string, like
4156
+ // `"<literal>;filterId=urlFriendlyId"`) must exclude the field too —
4157
+ // otherwise the field gets frozen wholesale before the later
4158
+ // literal-value interpolation pass (which operates on text, not parsed
4159
+ // values) ever gets a chance to splice the facet out of that element.
4033
4160
  const carriesThreadedValue = [...walkAllPrimitiveLeaves(value)].some(({ value: leaf, path: leafPath }) => {
4034
4161
  if (leaf === null)
4035
4162
  return false;
4036
4163
  const leafValue = String(leaf);
4037
4164
  if (unconditionalExcludeValues.has(leafValue))
4038
4165
  return true;
4166
+ if (payloadAccessorExcludePattern !== null) {
4167
+ payloadAccessorExcludePattern.lastIndex = 0;
4168
+ if (payloadAccessorExcludePattern.test(leafValue))
4169
+ return true;
4170
+ }
4171
+ for (const excluded of unconditionalExcludeValues) {
4172
+ if (containsValueAtTokenBoundary(leafValue, excluded))
4173
+ return true;
4174
+ }
4039
4175
  const sourceName = restrictedExcludeSourceByValue.get(leafValue);
4040
4176
  if (sourceName === undefined)
4041
4177
  return false;
@@ -4083,12 +4219,22 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4083
4219
  }
4084
4220
  if (spanEnd === -1)
4085
4221
  continue;
4222
+ // An earlier pass in this same pipeline run (e.g. the optional-facet
4223
+ // array-element splice) may have already spliced a `${payload.<field>}`
4224
+ // accessor into this exact span — e.g. turning one element of a
4225
+ // search/filter array into `...(payload.sortOrder ? [...] : [])`. The
4226
+ // exclusion-source checks above only ever see the ORIGINAL parsed leaf
4227
+ // values, so they can't detect this; inspecting the located span's
4228
+ // CURRENT text catches it and leaves the pre-spliced accessor intact
4229
+ // instead of clobbering it with a wholesale replacement. Matching on the
4230
+ // literal template-placeholder prefix `${payload.` (rather than the bare
4231
+ // substring `payload.`) avoids false-positiving on scraped leaf data that
4232
+ // merely happens to contain the word "payload." as ordinary text.
4233
+ if (result.slice(spanStart, spanEnd).includes("${payload."))
4234
+ continue;
4086
4235
  const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
4087
4236
  result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
4088
4237
  cursor = Math.max(cursor, spanStart + replacement.length);
4089
- if (!outStructuredKeys.has(key)) {
4090
- outStructuredKeys.set(key, inferZodSchema(value));
4091
- }
4092
4238
  }
4093
4239
  return { result, nextSearchFrom: cursor };
4094
4240
  }
@@ -4098,7 +4244,7 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4098
4244
  * pass to it. Shared by the top-level-object and top-level-array branches of
4099
4245
  * {@link applyStructuredValuePayloadSubstitutions} below.
4100
4246
  */
4101
- function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom) {
4247
+ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern, ownedArrayPathKeys) {
4102
4248
  const envelopePath = locateFormEnvelopePath(objectBody);
4103
4249
  let envelope = objectBody;
4104
4250
  for (const seg of envelopePath) {
@@ -4109,7 +4255,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4109
4255
  if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
4110
4256
  return { result: template, nextSearchFrom: searchFrom };
4111
4257
  }
4112
- return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody);
4258
+ return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern, ownedArrayPathKeys);
4113
4259
  }
4114
4260
  /**
4115
4261
  * `priorStepStateBindings` carries the SAME value→binding map
@@ -4118,13 +4264,23 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4118
4264
  * field when its own leaf's key {@link keyNamesCorrelate}s with that source
4119
4265
  * name — an unrestricted binding (name-free, e.g. a bare array-index chain
4120
4266
  * hop) or a `joinFieldValues` entry excludes unconditionally on any leaf
4121
- * match, same as before. Without the correlation requirement, an array/
4267
+ * match, same as before. A `payloadAccessorExcludeValues` key excludes
4268
+ * unconditionally too, but on a delimited-SUBSTRING match of the leaf (see
4269
+ * {@link applyStructuredValuePayloadSubstitutionsForEnvelope}), not exact
4270
+ * equality — the reported shape is a facet value recurring inside a captured
4271
+ * array element with a constant suffix still attached (e.g. a hash-derived
4272
+ * facet embedded in a `tags` entry alongside a trailing marker), so a
4273
+ * candidate field whose leaf merely CONTAINS one of those literals at a token
4274
+ * boundary is left alone here too, instead of being wholesale-swallowed into
4275
+ * an opaque `${JSON.stringify(payload.<key>)}` blob before the substring-
4276
+ * splice pass ({@link interpolateStateValues}) gets a chance to thread it.
4277
+ * Without the correlation requirement on the restricted source, an array/
4122
4278
  * object field whose own unrelated short scalar leaf (a count/id) merely
4123
4279
  * EQUALS some other step's produced value gets the whole field wrongly
4124
4280
  * excluded from threading — the same value-coincidence class of bug
4125
4281
  * {@link keyNamesCorrelate}'s other call sites already guard against.
4126
4282
  */
4127
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set()) {
4283
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map(), ownedArrayPathKeys = new Set()) {
4128
4284
  if (parsedBody === null || typeof parsedBody !== "object") {
4129
4285
  return template;
4130
4286
  }
@@ -4136,7 +4292,10 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4136
4292
  else
4137
4293
  unconditionalExcludeValues.add(value);
4138
4294
  }
4139
- // A cruise-line-style multi-room/multi-guest search body commonly batches
4295
+ const payloadAccessorExcludePattern = payloadAccessorExcludeValues.size > 0
4296
+ ? buildValueAlternationPattern([...payloadAccessorExcludeValues.keys()].sort((a, b) => b.length - a.length))
4297
+ : null;
4298
+ // A travel-brand-style multi-room/multi-guest search body commonly batches
4140
4299
  // per-element criteria as a top-level JSON ARRAY rather than a single
4141
4300
  // object. Walk each element in textual order (via the growing searchFrom
4142
4301
  // cursor) instead of bailing out here — otherwise any structured field
@@ -4148,13 +4307,13 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4148
4307
  for (const element of parsedBody) {
4149
4308
  if (element === null || typeof element !== "object" || Array.isArray(element))
4150
4309
  continue;
4151
- const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor);
4310
+ const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern, ownedArrayPathKeys);
4152
4311
  result = nextResult;
4153
4312
  cursor = nextSearchFrom;
4154
4313
  }
4155
4314
  return result;
4156
4315
  }
4157
- const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0);
4316
+ const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern, ownedArrayPathKeys);
4158
4317
  return result;
4159
4318
  }
4160
4319
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
@@ -4419,12 +4578,22 @@ function payloadAccessorForPath(path) {
4419
4578
  const arrayIndexPos = path.findIndex((segment) => /^\d+$/.test(segment));
4420
4579
  if (arrayIndexPos === -1) {
4421
4580
  const field = pathToPayloadFieldName(path);
4422
- return { accessor: `payload.${field}`, field };
4581
+ return { accessor: `payload.${field}`, field, structuredRootPath: null };
4423
4582
  }
4424
4583
  const objectPath = path.slice(0, arrayIndexPos);
4425
4584
  const field = objectPath.length > 0 ? pathToPayloadFieldName(objectPath) : (path[0] ?? "");
4426
4585
  const suffix = pathToAccessor(path.slice(arrayIndexPos), { assertNonNull: true });
4427
- return { accessor: `payload.${field}${suffix}`, field };
4586
+ return {
4587
+ accessor: `payload.${field}${suffix}`,
4588
+ field,
4589
+ structuredRootPath: objectPath.length > 0 ? objectPath : null,
4590
+ };
4591
+ }
4592
+ /** Reads the value at `path` inside a parsed JSON body, or undefined when any hop is missing. */
4593
+ function valueAtJsonPath(root, path) {
4594
+ return path.reduce((node, segment) => node !== null && typeof node === "object"
4595
+ ? node[segment]
4596
+ : undefined, root);
4428
4597
  }
4429
4598
  /** Derives a valid camelCase identifier from a fixture filename (e.g.
4430
4599
  * "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
@@ -5597,12 +5766,29 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
5597
5766
  /** Every capture's response body from the same run, scanned only for
5598
5767
  * {@link CAPACITY_FIELD_NAME_PATTERN}-shaped numeric leaves — the
5599
5768
  * evidence source for a discovered "number" key's `capacityMax`. */
5600
- responseBodiesForCapacitySignal = []) {
5769
+ responseBodiesForCapacitySignal = [], outStructuredKeys = new Map()) {
5601
5770
  const merged = [];
5602
5771
  const seenPairs = new Set();
5603
5772
  const seenValueByKey = new Map();
5604
5773
  const distinctValuesByKey = new Map();
5605
5774
  const allBodies = [inputBody, ...additionalBodies];
5775
+ // Structure is registered from every body before any scalar is considered, so
5776
+ // a name that is an array/object in one body and a primitive in another gets
5777
+ // the structured schema regardless of which body is visited first.
5778
+ for (const body of allBodies) {
5779
+ if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
5780
+ continue;
5781
+ }
5782
+ for (const [key, value] of Object.entries(body)) {
5783
+ if (!isValidJsIdentifier(key))
5784
+ continue;
5785
+ if (value === null || typeof value !== "object")
5786
+ continue;
5787
+ if (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0)
5788
+ continue;
5789
+ registerStructuredPayloadField(outStructuredKeys, key, value);
5790
+ }
5791
+ }
5606
5792
  for (const body of allBodies) {
5607
5793
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
5608
5794
  continue;
@@ -5613,6 +5799,8 @@ responseBodiesForCapacitySignal = []) {
5613
5799
  const key = path[0];
5614
5800
  if (!isValidJsIdentifier(key))
5615
5801
  continue;
5802
+ if (outStructuredKeys.has(key))
5803
+ continue;
5616
5804
  if (value === null)
5617
5805
  continue;
5618
5806
  // Dedupe identical (key, value) pairs only — a repeated occurrence of
@@ -5781,8 +5969,219 @@ responseBodiesForCapacitySignal = []) {
5781
5969
  * steps declared any field to correlate against) or when a leaf's value has
5782
5970
  * no `key:value` segment matching one of them.
5783
5971
  */
5784
- function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames) {
5785
- if (payloadFieldNames.size === 0)
5972
+ /**
5973
+ * Binds a scalar navigateTo facet at every call site: each object-property
5974
+ * string leaf, at any depth, whose whole value equals a declared facet literal
5975
+ * becomes `${payload.<field>}`. Matching is per whole leaf, so the same
5976
+ * literal flanked by alphanumerics inside an unrelated token is never touched
5977
+ * and no global collision guard can drop the facet. An optional facet falls
5978
+ * back to the captured literal when the caller omits it, never `undefined`.
5979
+ */
5980
+ function applyScalarFacetSplicePayloadSubstitutions(template, inputBody, navigateToFacets) {
5981
+ if (navigateToFacets.length === 0)
5982
+ return template;
5983
+ if (inputBody === null || typeof inputBody !== "object")
5984
+ return template;
5985
+ let result = template;
5986
+ for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5987
+ if (typeof value !== "string" && typeof value !== "number")
5988
+ continue;
5989
+ if (/^\d+$/.test(path[path.length - 1] ?? ""))
5990
+ continue;
5991
+ const facet = matchFacetRecurrence(String(value), navigateToFacets, {
5992
+ allowDelimiterSuffix: false,
5993
+ });
5994
+ if (facet === undefined)
5995
+ continue;
5996
+ const literal = JSON.stringify(value);
5997
+ const fallback = facet.optional ? ` ?? ${literal}` : "";
5998
+ const replacement = typeof value === "number" || facet.optional
5999
+ ? `\${JSON.stringify(payload.${facet.field}${fallback})}`
6000
+ : `"\${payload.${facet.field}}"`;
6001
+ for (const sep of [":", ": "]) {
6002
+ if (typeof value === "number") {
6003
+ const re = new RegExp(`${sep}${literal}(?![\\w.])`, "g");
6004
+ result = result.replace(re, () => `${sep}${replacement}`);
6005
+ continue;
6006
+ }
6007
+ result = result.split(`${sep}${literal}`).join(`${sep}${replacement}`);
6008
+ }
6009
+ }
6010
+ return result;
6011
+ }
6012
+ /** Yields every array in `value` (outermost first, at any nesting depth,
6013
+ * including arrays inside array elements) — `{@link
6014
+ * applyArrayFacetSplicePayloadSubstitutions}`'s raw-array search surface. */
6015
+ function* walkArrayLeaves(value, path = []) {
6016
+ if (value === null || typeof value !== "object")
6017
+ return;
6018
+ if (Array.isArray(value)) {
6019
+ yield { value, path };
6020
+ for (const [index, inner] of value.entries()) {
6021
+ yield* walkArrayLeaves(inner, [...path, String(index)]);
6022
+ }
6023
+ return;
6024
+ }
6025
+ for (const [key, inner] of Object.entries(value)) {
6026
+ yield* walkArrayLeaves(inner, [...path, key]);
6027
+ }
6028
+ }
6029
+ /**
6030
+ * REST-body counterpart to {@link spliceFacetsIntoArrayVariable}'s GraphQL
6031
+ * use: a navigateTo facet's literal recurring as one array element (suffixed
6032
+ * by a constant delimiter) inside a REST JSON body gets its element swapped
6033
+ * for `${payload.<field>}` — for EVERY declared facet, required or optional,
6034
+ * at EVERY call site's own array. Matching is structural (see
6035
+ * {@link replaceJsonArrayOccurrences}) and token-boundary guarded per element
6036
+ * ({@link matchArrayElementFacet}), so the splice never depends on the
6037
+ * global, all-or-nothing persona collision guard, on which call site carried
6038
+ * the array first, on element order, or on a facet being optional.
6039
+ *
6040
+ * Every facet-carrying array, whether its facets are required or optional, is
6041
+ * rewritten to the same single `${JSON.stringify([...])}` splice point built
6042
+ * from {@link spliceFacetsIntoArrayVariable}'s per-element conditional
6043
+ * emission, so an absent optional facet drops its element instead of freezing
6044
+ * an `undefined` and no call site renders the array through a different
6045
+ * construction. Non-facet elements stay literal.
6046
+ */
6047
+ function applyArrayFacetSplicePayloadSubstitutions(template, inputBody, navigateToFacets, ownedArrayPathKeys) {
6048
+ if (navigateToFacets.length === 0)
6049
+ return template;
6050
+ if (inputBody === null || typeof inputBody !== "object")
6051
+ return template;
6052
+ let result = template;
6053
+ for (const { value, path } of walkArrayLeaves(inputBody)) {
6054
+ if (value.length === 0)
6055
+ continue;
6056
+ if (!ownedArrayPathKeys.has(arrayPathKey(path)) &&
6057
+ !containsFacetArray(value, navigateToFacets)) {
6058
+ continue;
6059
+ }
6060
+ const expression = renderFacetValueExpression(value, navigateToFacets);
6061
+ result = replaceJsonArrayOccurrences(result, value, `\${JSON.stringify(${expression})}`);
6062
+ }
6063
+ return result;
6064
+ }
6065
+ /** Whether `value` is, or contains at any depth, an array with a
6066
+ * facet-bearing string element. */
6067
+ function containsFacetArray(value, facets) {
6068
+ for (const { value: array } of walkArrayLeaves(value)) {
6069
+ const hasFacet = array.some((element) => typeof element === "string" && matchArrayElementFacet(element, facets));
6070
+ if (hasFacet)
6071
+ return true;
6072
+ }
6073
+ return false;
6074
+ }
6075
+ /** JS expression for `value` with every facet array at any depth spliced, so
6076
+ * one construction covers arrays nested inside array elements. */
6077
+ function renderFacetValueExpression(value, facets) {
6078
+ if (value === null || typeof value !== "object" || !containsFacetArray(value, facets)) {
6079
+ return JSON.stringify(value);
6080
+ }
6081
+ if (Array.isArray(value)) {
6082
+ return (spliceFacetsIntoArrayVariable(value, facets, (element) => renderFacetValueExpression(element, facets)) ?? `[${value.map((element) => renderFacetValueExpression(element, facets)).join(", ")}]`);
6083
+ }
6084
+ const entries = Object.entries(value).map(([key, inner]) => `${JSON.stringify(key)}: ${renderFacetValueExpression(inner, facets)}`);
6085
+ return `{ ${entries.join(", ")} }`;
6086
+ }
6087
+ /** Full index-free path of an array: identifies the array across call sites
6088
+ * whatever index it was captured at, without conflating same-named fields
6089
+ * that live at different paths. */
6090
+ function arrayPathKey(path) {
6091
+ return JSON.stringify(path.filter((segment) => !/^\d+$/.test(segment)));
6092
+ }
6093
+ /** True when `path` addresses anything inside an array in `ownedKeys`, at any
6094
+ * depth (string or object element alike). */
6095
+ function isInsideOwnedArray(path, ownedKeys) {
6096
+ return path.some((segment, index) => /^\d+$/.test(segment) && ownedKeys.has(arrayPathKey(path.slice(0, index))));
6097
+ }
6098
+ /**
6099
+ * Path keys ({@link arrayPathKey}) of every array, in ANY captured body,
6100
+ * that carries at least one facet-bearing element. The facet pass owns those
6101
+ * arrays wholesale, so none of their elements may also be frozen as a
6102
+ * by-index `payload.<field>["N"]` accessor, whichever body was parsed first
6103
+ * and wherever the array sits in it.
6104
+ */
6105
+ function collectFacetArrayPathKeys(bodies, facets) {
6106
+ const keys = new Set();
6107
+ if (facets.length === 0)
6108
+ return keys;
6109
+ for (const body of bodies) {
6110
+ if (body === null || typeof body !== "object")
6111
+ continue;
6112
+ for (const { value, path } of walkArrayLeaves(body)) {
6113
+ const hasFacet = value.some((element) => typeof element === "string" && matchArrayElementFacet(element, facets) !== undefined);
6114
+ if (hasFacet)
6115
+ keys.add(arrayPathKey(path));
6116
+ }
6117
+ }
6118
+ return keys;
6119
+ }
6120
+ /**
6121
+ * Rewrites every occurrence of `target` inside `template` by JSON structure
6122
+ * (bracket-balanced, string-aware, whitespace-insensitive) rather than by
6123
+ * exact `JSON.stringify` text, so a pretty-printed or re-spaced body array
6124
+ * is still replaced.
6125
+ */
6126
+ function replaceJsonArrayOccurrences(template, target, replacement) {
6127
+ const targetKey = JSON.stringify(target);
6128
+ let out = "";
6129
+ let from = 0;
6130
+ while (from < template.length) {
6131
+ const open = template.indexOf("[", from);
6132
+ if (open === -1)
6133
+ break;
6134
+ const close = findMatchingBracket(template, open);
6135
+ const candidate = close === -1 ? undefined : template.slice(open, close + 1);
6136
+ const matches = candidate !== undefined && jsonTextEquals(candidate, targetKey);
6137
+ if (!matches) {
6138
+ out += template.slice(from, open + 1);
6139
+ from = open + 1;
6140
+ continue;
6141
+ }
6142
+ out += template.slice(from, open) + replacement;
6143
+ from = close + 1;
6144
+ }
6145
+ return out + template.slice(from);
6146
+ }
6147
+ function findMatchingBracket(text, open) {
6148
+ let depth = 0;
6149
+ let inString = false;
6150
+ for (let i = open; i < text.length; i++) {
6151
+ const ch = text[i];
6152
+ if (inString) {
6153
+ if (ch === "\\")
6154
+ i++;
6155
+ else if (ch === '"')
6156
+ inString = false;
6157
+ continue;
6158
+ }
6159
+ if (ch === '"')
6160
+ inString = true;
6161
+ else if (ch === "[")
6162
+ depth++;
6163
+ else if (ch === "]" && --depth === 0)
6164
+ return i;
6165
+ }
6166
+ return -1;
6167
+ }
6168
+ function jsonTextEquals(candidate, targetKey) {
6169
+ try {
6170
+ return JSON.stringify(JSON.parse(candidate)) === targetKey;
6171
+ }
6172
+ catch {
6173
+ return false;
6174
+ }
6175
+ }
6176
+ /** A facet literal recurring inside a packed string under a key that does not
6177
+ * name its payload field. A leaf that IS the bare literal is left to the scalar
6178
+ * pass, which owns the optional fallback for it. */
6179
+ function packedFacetRecurrence(value, facets) {
6180
+ const spliced = spliceFacetRecurrenceIntoScalarVariable(value, facets);
6181
+ return spliced !== null && !spliced.startsWith("payload.") ? spliced : null;
6182
+ }
6183
+ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames, navigateToFacets = []) {
6184
+ if (payloadFieldNames.size === 0 && navigateToFacets.length === 0)
5786
6185
  return template;
5787
6186
  if (inputBody === undefined ||
5788
6187
  inputBody === null ||
@@ -5793,18 +6192,23 @@ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldN
5793
6192
  const fields = [...payloadFieldNames];
5794
6193
  let result = template;
5795
6194
  for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5796
- if (path.length !== 1)
5797
- continue;
5798
- const key = path[0];
5799
- if (!isValidJsIdentifier(key))
6195
+ const key = path[path.length - 1];
6196
+ if (key === undefined || !isValidJsIdentifier(key))
5800
6197
  continue;
5801
6198
  if (typeof value !== "string")
5802
6199
  continue;
5803
- const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6200
+ const kv = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6201
+ const spliced = kv ?? packedFacetRecurrence(value, navigateToFacets);
5804
6202
  if (spliced === null)
5805
6203
  continue;
5806
6204
  const target = `"${key}":${JSON.stringify(value)}`;
5807
- const replacement = `"${key}":"\${${spliced}}"`;
6205
+ // A required-only packed value with nothing JSON-escaped splices in place,
6206
+ // so the accessor sits directly in the string instead of nesting a template.
6207
+ const inline = kv === null &&
6208
+ spliced.startsWith("`") &&
6209
+ !spliced.startsWith("`${") &&
6210
+ JSON.stringify(value).slice(1, -1) === value;
6211
+ const replacement = inline ? `"${key}":"${spliced.slice(1, -1)}"` : `"${key}":"\${${spliced}}"`;
5808
6212
  result = result.split(target).join(replacement);
5809
6213
  }
5810
6214
  return result;
@@ -6085,8 +6489,12 @@ function parseBodyLeafValues(requestPostData) {
6085
6489
  * affect (the already-working recurrence-anchored path exists precisely
6086
6490
  * because a real facet DOES recur in a later request; nothing here changes
6087
6491
  * that — this only supplies values for facets that are not found at all).
6492
+ *
6493
+ * Values that carry a literal of an already-reachable facet are explained by
6494
+ * that facet and never enter the pool, so reachable facets' own transitions
6495
+ * cannot skew the pool size away from the count of facets awaiting a value.
6088
6496
  */
6089
- function collectNewlyAppearingRequestValueTransitions(actions) {
6497
+ function collectNewlyAppearingRequestValueTransitions(actions, explainedLiterals = []) {
6090
6498
  const seenGlobally = new Set();
6091
6499
  const transitions = [];
6092
6500
  for (let i = 1; i < actions.length; i++) {
@@ -6097,6 +6505,8 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6097
6505
  const record = (value) => {
6098
6506
  if (value.length === 0 || seenGlobally.has(value) || seenThisTransition.has(value))
6099
6507
  return;
6508
+ if (explainedLiterals.some((literal) => occursAtTokenBoundary(value, literal)))
6509
+ return;
6100
6510
  seenThisTransition.add(value);
6101
6511
  values.push(value);
6102
6512
  };
@@ -6117,6 +6527,79 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6117
6527
  }
6118
6528
  return transitions;
6119
6529
  }
6530
+ /**
6531
+ * Whether `value` occurs in every request body only at token boundaries (never
6532
+ * flanked by an alphanumeric), so a global rewrite of it cannot mangle a longer
6533
+ * token. Shared by the persona-binding guard and facet identity resolution.
6534
+ */
6535
+ function bindsWithoutCollisionIn(bodies, value) {
6536
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
6537
+ for (const body of bodies) {
6538
+ let from = 0;
6539
+ while (true) {
6540
+ const at = body.indexOf(value, from);
6541
+ if (at === -1)
6542
+ break;
6543
+ if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
6544
+ return false;
6545
+ from = at + value.length;
6546
+ }
6547
+ }
6548
+ return true;
6549
+ }
6550
+ /**
6551
+ * Whether `value` occurs in `text` at least once flanked by non-alphanumerics,
6552
+ * so facet reachability agrees with the token-boundary {@link matchFacetRecurrence}
6553
+ * the splice passes use instead of counting a mere substring of a longer token.
6554
+ */
6555
+ function occursAtTokenBoundary(text, value) {
6556
+ if (value.length === 0)
6557
+ return false;
6558
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
6559
+ let from = 0;
6560
+ while (true) {
6561
+ const at = text.indexOf(value, from);
6562
+ if (at === -1)
6563
+ return false;
6564
+ if (!isAlnum(text[at - 1]) && !isAlnum(text[at + value.length]))
6565
+ return true;
6566
+ from = at + 1;
6567
+ }
6568
+ }
6569
+ /**
6570
+ * Every literal the flow's declared navigateTo facets take in the captured
6571
+ * traffic: the hash-derived literal plus, for a facet whose hash literal never
6572
+ * recurs, the body/query value {@link correlateUnreachableNavigateToFacets}
6573
+ * pairs it with. Every per-site splice pass matches against this one list, so
6574
+ * a facet is judged to recur identically by all of them instead of each pass
6575
+ * deciding from the hash-derived literal alone.
6576
+ */
6577
+ function resolveRecurringNavigateToFacets(declared, actions) {
6578
+ const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value) ||
6579
+ (capture.requestPostData != null &&
6580
+ occursAtTokenBoundary(capture.requestPostData, value)) ||
6581
+ Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value)));
6582
+ const reachable = declared.filter(({ value }) => value.length > 0 && appearsAnywhere(value));
6583
+ const resolvedDeclared = declared.filter(({ value }) => value.length > 0);
6584
+ const unreachable = declared.filter((facet) => !reachable.includes(facet));
6585
+ if (unreachable.length === 0)
6586
+ return resolvedDeclared;
6587
+ const correlated = correlateUnreachableNavigateToFacets(unreachable, actions, reachable.map(({ value }) => value));
6588
+ if (correlated === null)
6589
+ return resolvedDeclared;
6590
+ const bodies = actions
6591
+ .map(({ capture }) => capture.requestPostData)
6592
+ .filter((body) => typeof body === "string" && body.length > 0);
6593
+ const extras = unreachable.flatMap((facet) => {
6594
+ const diffed = correlated.get(facet.field);
6595
+ if (diffed === undefined || diffed.length === 0)
6596
+ return [];
6597
+ if (declared.some(({ value }) => value === diffed))
6598
+ return [];
6599
+ return bindsWithoutCollisionIn(bodies, diffed) ? [{ ...facet, value: diffed }] : [];
6600
+ });
6601
+ return [...resolvedDeclared, ...extras];
6602
+ }
6120
6603
  /**
6121
6604
  * Resolves each zero-recurrence navigateTo facet (in flow order) to the real
6122
6605
  * request value it causally affects, or returns `null` when the correlation
@@ -6133,14 +6616,27 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6133
6616
  * no longer guaranteed to line facets up with the RIGHT transition, so
6134
6617
  * nothing is bound — leaving a facet declared-but-unbound is preferable to
6135
6618
  * silently wiring it to another facet's (or an unrelated transition's) value.
6619
+ *
6620
+ * When the TOTAL transition count (ambiguous ones included) equals the facet
6621
+ * count, each ambiguous transition still holds its facet's slot, so only that
6622
+ * facet stays unbound while the others bind by position.
6136
6623
  */
6137
- function correlateUnreachableNavigateToFacets(facets, actions) {
6138
- const unambiguousValues = collectNewlyAppearingRequestValueTransitions(actions)
6624
+ function correlateUnreachableNavigateToFacets(facets, actions, explainedLiterals) {
6625
+ const transitions = collectNewlyAppearingRequestValueTransitions(actions, explainedLiterals);
6626
+ const byField = new Map();
6627
+ if (transitions.length === facets.length) {
6628
+ facets.forEach(({ field }, index) => {
6629
+ const transition = transitions[index];
6630
+ if (transition.length === 1)
6631
+ byField.set(field, transition[0]);
6632
+ });
6633
+ return byField.size > 0 ? byField : null;
6634
+ }
6635
+ const unambiguousValues = transitions
6139
6636
  .filter((transition) => transition.length === 1)
6140
6637
  .map((transition) => transition[0]);
6141
6638
  if (unambiguousValues.length !== facets.length)
6142
6639
  return null;
6143
- const byField = new Map();
6144
6640
  facets.forEach(({ field }, index) => {
6145
6641
  byField.set(field, unambiguousValues[index]);
6146
6642
  });
@@ -6187,12 +6683,30 @@ flowSteps = [], vocabulary = undefined) {
6187
6683
  // below can correlate a REST body's facet-packed string leaves against it.
6188
6684
  const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
6189
6685
  const payloadAccessorByValue = new Map();
6686
+ const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps);
6687
+ const navigateToFacetOrder = resolveRecurringNavigateToFacets(declaredNavigateToFacets, actions);
6688
+ const facetArrayPathKeys = collectFacetArrayPathKeys(actions.map((action) => parseJsonBodyOrNull(action.capture.requestPostData)), navigateToFacetOrder);
6190
6689
  if (inputBody !== undefined && inputBody !== null) {
6191
6690
  for (const { value, path } of walkStringLeaves(inputBody)) {
6192
6691
  if (value.length < MIN_STATE_VALUE_LENGTH)
6193
6692
  continue;
6194
- const { accessor, field: accessorField } = payloadAccessorForPath(path);
6693
+ // An element of an array any call site's facet pass owns belongs to the
6694
+ // facet's payloadField (or stays literal), not to a by-index accessor
6695
+ // that would freeze the sibling elements' shape.
6696
+ if (isInsideOwnedArray(path, facetArrayPathKeys))
6697
+ continue;
6698
+ // A leaf recurring as a declared facet literal binds to that facet's
6699
+ // payloadField via the persona pass, not to a by-index accessor.
6700
+ if (matchFacetRecurrence(value, navigateToFacetOrder, { allowDelimiterSuffix: true })) {
6701
+ continue;
6702
+ }
6703
+ const { accessor, field: accessorField, structuredRootPath } = payloadAccessorForPath(path);
6195
6704
  payloadAccessorByValue.set(value, accessor);
6705
+ // The accessor indexes into this field, so its declared type must be the
6706
+ // structured shape regardless of whether Mechanism B visited the key.
6707
+ if (structuredRootPath !== null) {
6708
+ registerStructuredPayloadField(outStructuredKeys, accessorField, valueAtJsonPath(inputBody, structuredRootPath));
6709
+ }
6196
6710
  if (isValidJsIdentifier(accessorField))
6197
6711
  outDiscoveredFields.add(accessorField);
6198
6712
  // Phase F: register a lowercase variant for UUID-shaped values so case-
@@ -6282,24 +6796,22 @@ flowSteps = [], vocabulary = undefined) {
6282
6796
  // or when the correlation is ambiguous (see
6283
6797
  // `correlateUnreachableNavigateToFacets`), there is nothing safe to splice
6284
6798
  // and the field(s) stay unbound rather than risk a wrong binding.
6285
- const appearsAnywhereInCapture = (value) => actions.some((action) => action.capture.url.includes(value) ||
6286
- (action.capture.requestPostData?.includes(value) ?? false) ||
6287
- Object.values(action.capture.requestHeaders).some((h) => h.includes(value)));
6288
- const unreachableNavigateToFacets = extractNavigateToFacetOrder(flowSteps).filter(({ value }) => !appearsAnywhereInCapture(value));
6289
- if (unreachableNavigateToFacets.length > 0) {
6290
- const correlated = correlateUnreachableNavigateToFacets(unreachableNavigateToFacets, actions);
6291
- for (const { field } of correlated === null ? [] : unreachableNavigateToFacets) {
6292
- const diffed = correlated?.get(field);
6293
- if (diffed === undefined)
6294
- continue;
6295
- if (!bindsWithoutCollision(diffed))
6296
- continue;
6297
- if (payloadAccessorByValue.has(diffed))
6298
- continue;
6299
- payloadAccessorByValue.set(diffed, `payload.${field}`);
6300
- if (isValidJsIdentifier(field))
6301
- outDiscoveredFields.add(field);
6799
+ // Every facet any splice pass binds comes from the one resolved list, so the
6800
+ // schema declaration and accessor table cannot disagree with the splices.
6801
+ const declaredFacetValues = new Set(declaredNavigateToFacets.map(({ value }) => value));
6802
+ for (const { value, field } of navigateToFacetOrder) {
6803
+ if (!isValidJsIdentifier(field))
6804
+ continue;
6805
+ if (declaredFacetValues.has(value)) {
6806
+ outDiscoveredFields.add(field);
6807
+ continue;
6302
6808
  }
6809
+ if (!bindsWithoutCollision(value))
6810
+ continue;
6811
+ if (payloadAccessorByValue.has(value))
6812
+ continue;
6813
+ payloadAccessorByValue.set(value, `payload.${field}`);
6814
+ outDiscoveredFields.add(field);
6303
6815
  }
6304
6816
  // Job coordinates from the recon entry URL's query string (e.g.
6305
6817
  // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
@@ -6552,8 +7064,21 @@ flowSteps = [], vocabulary = undefined) {
6552
7064
  // same declared field set, just applied to REST body text instead of a
6553
7065
  // variables object literal.
6554
7066
  const rawBodyWithFacetSplices = parsedBody !== null
6555
- ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames)
7067
+ ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames, navigateToFacetOrder)
6556
7068
  : rawBodyWithFormSubs;
7069
+ // Optional-facet array-element splice — a navigateTo facet literal
7070
+ // recurring as one element of an array field (not the `key:value`
7071
+ // grammar above) must drop its own element, not freeze an `undefined`,
7072
+ // when its binding is optional and the caller omits it. Runs BEFORE
7073
+ // Mechanism B (below) can swallow the whole array wholesale as an
7074
+ // unparameterized structured value — see
7075
+ // {@link applyArrayFacetSplicePayloadSubstitutions}.
7076
+ const rawBodyWithOptionalArrayFacetSplices = parsedBody !== null
7077
+ ? applyArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder, facetArrayPathKeys)
7078
+ : rawBodyWithFacetSplices;
7079
+ const rawBodyWithScalarFacetSplices = parsedBody !== null
7080
+ ? applyScalarFacetSplicePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, navigateToFacetOrder)
7081
+ : rawBodyWithOptionalArrayFacetSplices;
6557
7082
  // Mechanism B — parameterize whole nested caller structures
6558
7083
  // (experienceData/educationData history, opaque eventData) BEFORE value
6559
7084
  // substitution reaches inside them: swallowing the entire array/object first
@@ -6564,7 +7089,7 @@ flowSteps = [], vocabulary = undefined) {
6564
7089
  // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
6565
7090
  // must stay reachable for state threading, not get frozen as caller data.
6566
7091
  const rawBodyWithStructuredSubs = parsedBody !== null
6567
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set())
7092
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithScalarFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue, facetArrayPathKeys)
6568
7093
  : rawBodyWithFormSubs;
6569
7094
  // Whole-value caller coordinates bind here — after structured subs, BEFORE
6570
7095
  // state threading — so a composite coordinate (a jobLocation or jobSeqNo
@@ -6596,7 +7121,7 @@ flowSteps = [], vocabulary = undefined) {
6596
7121
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
6597
7122
  : rawBodyWithProducerBoundary;
6598
7123
  const bodyAfterStateAndKv = rawBodyWithUrlParams
6599
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues, joinFieldValuesByStep.get(i) ?? new Set()), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys, allResponseBodies)
7124
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues, joinFieldValuesByStep.get(i) ?? new Set()), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys, allResponseBodies, outStructuredKeys)
6600
7125
  : "";
6601
7126
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
6602
7127
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -7487,6 +8012,123 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7487
8012
  }
7488
8013
  return `[${elements.join(", ")}].join("")`;
7489
8014
  }
8015
+ /**
8016
+ * Token-boundary match of one array element against the declared navigateTo
8017
+ * facets — the single place elements are matched so the GraphQL and REST
8018
+ * paths (and the REST accessor registration) never disagree on what a
8019
+ * facet-bearing element is.
8020
+ */
8021
+ function matchArrayElementFacet(element, facets) {
8022
+ return matchFacetRecurrence(element, facets, { allowDelimiterSuffix: true });
8023
+ }
8024
+ /**
8025
+ * The one facet-recurrence predicate every grammar (array element, scalar
8026
+ * leaf) consults, so a declared facet literal is judged to recur identically
8027
+ * everywhere. A number leaf is compared by its string form, so a numeric body
8028
+ * value matches the string literal a navigateTo hash yields. With
8029
+ * `allowDelimiterSuffix`, a literal followed by a non-alphanumeric delimiter
8030
+ * suffix also matches. The longest literal wins.
8031
+ */
8032
+ function matchFacetRecurrence(text, facets, options) {
8033
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
8034
+ return facets.reduce((best, facet) => {
8035
+ const literal = facet.value;
8036
+ const matches = text === literal ||
8037
+ (options.allowDelimiterSuffix &&
8038
+ literal.length > 0 &&
8039
+ text.startsWith(literal) &&
8040
+ !isAlnum(text[literal.length]));
8041
+ if (!matches)
8042
+ return best;
8043
+ return best === undefined || literal.length > best.value.length ? facet : best;
8044
+ }, undefined);
8045
+ }
8046
+ /**
8047
+ * Binds a top-level GraphQL variable whose scalar value (or, for a string, any
8048
+ * `key:value` packed segment's value) recurs as a declared navigateTo facet
8049
+ * literal, via the shared {@link matchFacetRecurrence} predicate so GraphQL
8050
+ * judges recurrence exactly as the array and REST scalar passes do.
8051
+ */
8052
+ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
8053
+ if (facets.length === 0)
8054
+ return null;
8055
+ if (typeof value === "string" || typeof value === "number") {
8056
+ const facet = matchFacetRecurrence(String(value), facets, { allowDelimiterSuffix: false });
8057
+ if (facet !== undefined)
8058
+ return `payload.${facet.field}`;
8059
+ }
8060
+ if (typeof value !== "string")
8061
+ return null;
8062
+ const segments = value.split(/([|,;])/);
8063
+ let bound = false;
8064
+ let anyOptional = false;
8065
+ const units = [];
8066
+ for (let index = 0; index < segments.length; index += 2) {
8067
+ const segment = segments[index];
8068
+ const trailingDelimiter = segments[index + 1] ?? "";
8069
+ const colonIndex = segment.indexOf(":");
8070
+ const facet = colonIndex < 0
8071
+ ? undefined
8072
+ : matchFacetRecurrence(segment.slice(colonIndex + 1), facets, {
8073
+ allowDelimiterSuffix: false,
8074
+ });
8075
+ if (facet === undefined) {
8076
+ units.push(`\`${escapeForTemplateLiteral(segment + trailingDelimiter)}\``);
8077
+ continue;
8078
+ }
8079
+ bound = true;
8080
+ const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}\${payload.${facet.field}}${escapeForTemplateLiteral(trailingDelimiter)}\``;
8081
+ // An absent optional facet drops its own segment (and delimiter) rather
8082
+ // than rendering the string `undefined` into the packed value.
8083
+ if (facet.optional) {
8084
+ anyOptional = true;
8085
+ units.push(`...(payload.${facet.field} ? [${unit}] : [])`);
8086
+ continue;
8087
+ }
8088
+ units.push(unit);
8089
+ }
8090
+ if (!bound)
8091
+ return null;
8092
+ if (anyOptional)
8093
+ return `[${units.join(", ")}].join("")`;
8094
+ return `\`${units.map((unit) => unit.slice(1, -1)).join("")}\``;
8095
+ }
8096
+ /**
8097
+ * Splices a captured array-valued GraphQL variable's elements against
8098
+ * navigateTo facet bindings instead of the `key:value` segment grammar
8099
+ * {@link spliceFacetsIntoStringVariable} matches — an array element like
8100
+ * `widget-x;filterId=urlFriendlyId` carries a bare literal plus a constant
8101
+ * trailing delimiter, with no `key:` prefix to key off of. Matching is
8102
+ * token-boundary guarded the same way {@link bindsWithoutCollision} (recon-
8103
+ * generate.ts ~7223) guards persona bindings elsewhere in this file, so a
8104
+ * facet literal that's merely a substring of an unrelated element never
8105
+ * misfires. A matched binding whose `optional` flag is set is emitted as a
8106
+ * conditionally-included array element (`...(payload.<field> ? [...] : [])`)
8107
+ * instead of inline, the same optional-splice convention
8108
+ * {@link spliceFacetsIntoStringVariable} uses, so an absent optional facet
8109
+ * drops its element rather than emitting `undefined`.
8110
+ */
8111
+ function spliceFacetsIntoArrayVariable(value, facets, renderElement = (element) => JSON.stringify(element)) {
8112
+ if (!Array.isArray(value))
8113
+ return null;
8114
+ let matchCount = 0;
8115
+ const elements = value.map((element) => {
8116
+ if (typeof element !== "string")
8117
+ return renderElement(element);
8118
+ const matched = matchArrayElementFacet(element, facets);
8119
+ if (!matched)
8120
+ return renderElement(element);
8121
+ matchCount++;
8122
+ const suffix = element.slice(matched.value.length);
8123
+ const elementLiteral = `\`\${payload.${matched.field}}${escapeForTemplateLiteral(suffix)}\``;
8124
+ return matched.optional
8125
+ ? `...(payload.${matched.field} ? [${elementLiteral}] : [])`
8126
+ : elementLiteral;
8127
+ });
8128
+ if (matchCount === 0)
8129
+ return null;
8130
+ return `[${elements.join(", ")}]`;
8131
+ }
7490
8132
  /**
7491
8133
  * Renders the variables literal for the primary-operation getGql() call —
7492
8134
  * each key from the selected capture's own recorded variables is bound to
@@ -7498,23 +8140,35 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7498
8140
  * other value is emitted verbatim via JSON.stringify. `optionalFieldNames`
7499
8141
  * marks which of `payloadFieldNames` a facet-string splice should treat as
7500
8142
  * optional (see {@link spliceFacetsIntoStringVariable}); it has no effect on
7501
- * top-level key correlation.
8143
+ * top-level key correlation. When a value is an array (not itself a string
8144
+ * key correlation candidate), its elements are matched against
8145
+ * `navigateToFacets` instead — see {@link spliceFacetsIntoArrayVariable} for
8146
+ * why that's a distinct grammar from the `key:value` one above.
7502
8147
  */
7503
- function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames = new Set()) {
8148
+ function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames = new Set(), navigateToFacets = []) {
7504
8149
  if (variables === null || typeof variables !== "object" || Array.isArray(variables))
7505
8150
  return "{}";
7506
8151
  const fields = payloadFieldNames ? [...payloadFieldNames] : [];
7507
- const entries = Object.entries(variables).map(([key, value]) => {
7508
- const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
7509
- const facetSpliceExpr = matchedField
7510
- ? null
7511
- : spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
7512
- const valueExpr = matchedField
7513
- ? `payload.${matchedField}`
7514
- : (facetSpliceExpr ?? JSON.stringify(value));
7515
- return `${key}: ${valueExpr}`;
7516
- });
7517
- return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
8152
+ const renderValue = (value) => {
8153
+ const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames) ??
8154
+ spliceFacetsIntoArrayVariable(value, navigateToFacets, renderValue) ??
8155
+ spliceFacetRecurrenceIntoScalarVariable(value, navigateToFacets);
8156
+ if (spliced !== null)
8157
+ return spliced;
8158
+ if (Array.isArray(value))
8159
+ return `[${value.map(renderValue).join(", ")}]`;
8160
+ if (value !== null && typeof value === "object")
8161
+ return renderObject(value);
8162
+ return JSON.stringify(value);
8163
+ };
8164
+ const renderObject = (obj) => {
8165
+ const entries = Object.entries(obj).map(([key, value]) => {
8166
+ const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
8167
+ return `${isValidJsIdentifier(key) ? key : JSON.stringify(key)}: ${matchedField ? `payload.${matchedField}` : renderValue(value)}`;
8168
+ });
8169
+ return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
8170
+ };
8171
+ return renderObject(variables);
7518
8172
  }
7519
8173
  /** A count-style page-size key (e.g. `count`, `limit`, `first`) paired with a
7520
8174
  * skip-style offset key (e.g. `skip`, `offset`) in the same object is the
@@ -7945,7 +8599,7 @@ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, sibl
7945
8599
  * iteration's request, exactly the defect described in
7946
8600
  * docs/recon-generate-nested-fold-flatmaps-away-the-parent-so-drill-params-freeze.md
7947
8601
  * (a `packageCode`/`groupId`/`sailDate` triple that provably varies per
7948
- * cruise, silently frozen because no threaded field explained it). Path
8602
+ * voyage, silently frozen because no threaded field explained it). Path
7949
8603
  * segments are deliberately not checked: {@link endpointKey} requires an
7950
8604
  * identical pathname to group two captures at all, so no path segment can
7951
8605
  * ever be observed to vary within a matched group.
@@ -10519,10 +11173,68 @@ function buildContractChecklist(opts) {
10519
11173
  outOfTreeChecklistLine,
10520
11174
  ].filter((line) => line !== "");
10521
11175
  }
11176
+ const PAYLOAD_ACCESSOR_CHAIN = /\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)((?:!|\??\.[A-Za-z_$][A-Za-z0-9_$]*(?![A-Za-z0-9_$(])|\[(?:"[^"\]]*"|\d+)\])*)/g;
11177
+ const PAYLOAD_CHAIN_SEGMENT = /\??\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(?:"[^"\]]*"|\d+)\]/g;
11178
+ function newAccessorShape() {
11179
+ return { isArray: false, element: null, keys: new Map() };
11180
+ }
11181
+ function renderAccessorShape(shape) {
11182
+ if (shape.isArray) {
11183
+ return `z.array(${shape.element ? renderAccessorShape(shape.element) : "z.string()"})`;
11184
+ }
11185
+ if (shape.keys.size === 0)
11186
+ return "z.string()";
11187
+ const entries = [...shape.keys.entries()]
11188
+ .sort(([a], [b]) => a.localeCompare(b))
11189
+ .map(([key, child]) => `${isValidJsIdentifier(key) ? key : JSON.stringify(key)}: ${renderAccessorShape(child)}`);
11190
+ return `z.object({ ${entries.join(", ")} })`;
11191
+ }
11192
+ /**
11193
+ * Derives each `payload.<field>` Zod type from the full accessor chain the
11194
+ * emitted body text uses (`payload.f["0"]!.x` => array of objects with key
11195
+ * `x`), so the schema can never reject a property access the body performs.
11196
+ */
11197
+ function deriveBodyAccessorShapes(body) {
11198
+ const roots = new Map();
11199
+ for (const m of body.matchAll(PAYLOAD_ACCESSOR_CHAIN)) {
11200
+ const root = roots.get(m[1]) ?? newAccessorShape();
11201
+ roots.set(m[1], root);
11202
+ let node = root;
11203
+ for (const seg of m[2].matchAll(PAYLOAD_CHAIN_SEGMENT)) {
11204
+ if (seg[1] === undefined) {
11205
+ node.isArray = true;
11206
+ node.element ??= newAccessorShape();
11207
+ node = node.element;
11208
+ continue;
11209
+ }
11210
+ const child = node.keys.get(seg[1]) ?? newAccessorShape();
11211
+ node.keys.set(seg[1], child);
11212
+ node = child;
11213
+ }
11214
+ }
11215
+ return roots;
11216
+ }
11217
+ function isStructuredAccessorShape(shape) {
11218
+ return shape.isArray || shape.keys.size > 0;
11219
+ }
11220
+ /**
11221
+ * Whether a declared Zod expression already admits every access the shape
11222
+ * demands (array-ness and each dereferenced key), so a richer structured
11223
+ * declaration is kept instead of being narrowed to the accessor-derived one.
11224
+ */
11225
+ function declaredSchemaCoversAccessorShape(declared, shape) {
11226
+ if (shape.isArray && !/\bz\.array\(/.test(declared))
11227
+ return false;
11228
+ if (!shape.isArray && shape.keys.size > 0 && !/\bz\.(object|record)\(/.test(declared)) {
11229
+ return false;
11230
+ }
11231
+ const childShapes = [...(shape.element ? [shape.element] : []), ...shape.keys.values()];
11232
+ return ([...shape.keys.keys()].every((key) => new RegExp(`(?:^|[\\s{,])"?${key.replace(/[$]/g, "\\$&")}"?\\s*:`).test(declared)) && childShapes.every((child) => declaredSchemaCoversAccessorShape(declared, child)));
11233
+ }
10522
11234
  /** Generates a complete contract.ts source string for a plugin — exported so
10523
11235
  * unit tests can drive the emitter directly without spawning the CLI. */
10524
11236
  function emitContractTs(opts) {
10525
- 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;
11237
+ 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;
10526
11238
  const { browserFallbackGate, httpTimeoutMs } = fallbackGateSpec;
10527
11239
  /** Single source of truth for every GraphQL-emission decision below
10528
11240
  * (clientImport, gqlCacheBlock, queryConst, and both executeHttpBody
@@ -10680,7 +11392,10 @@ function emitContractTs(opts) {
10680
11392
  // override semantics a chain of `.extend()` calls used to have (each
10681
11393
  // subsequent `.extend` replaced an earlier field of the same name).
10682
11394
  const extendFields = new Map();
11395
+ const structuredFieldNames = new Set();
10683
11396
  const addExtendField = (name, line) => {
11397
+ if (structuredFieldNames.has(name))
11398
+ return;
10684
11399
  extendFields.set(name, line);
10685
11400
  };
10686
11401
  // A detected bounded-paging signal means buildPaginatedFetchLoopExecuteHttpBody
@@ -10838,7 +11553,8 @@ function emitContractTs(opts) {
10838
11553
  continue;
10839
11554
  const key = isValidJsIdentifier(name) ? name : JSON.stringify(name);
10840
11555
  const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
10841
- addExtendField(name, ` ${key}: ${value},`);
11556
+ extendFields.set(name, ` ${key}: ${value},`);
11557
+ structuredFieldNames.add(name);
10842
11558
  }
10843
11559
  // Closing-the-loop safety net: every discovered-field source above tracks
10844
11560
  // its own registration as it splices a `payload.<field>` accessor into the
@@ -10857,14 +11573,34 @@ function emitContractTs(opts) {
10857
11573
  // its structural root regardless of which upstream pass forgot to record a
10858
11574
  // field, instead of adding a fifth registration site that could itself be
10859
11575
  // forgotten by a future pass.
11576
+ const accessorStructuredFields = new Set();
10860
11577
  if (multiStepBody) {
10861
- const bodyReferencedFields = new Set([...multiStepBody.matchAll(/\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]));
10862
- for (const name of [...bodyReferencedFields].sort()) {
10863
- if (extendFields.has(name))
10864
- continue;
11578
+ const bodyReferencedFields = deriveBodyAccessorShapes(multiStepBody);
11579
+ for (const name of [...bodyReferencedFields.keys()].sort()) {
10865
11580
  if (isReservedByApplicantContactSchema(name))
10866
11581
  continue;
10867
- addExtendField(name, ` ${name}: z.string(),`);
11582
+ const shape = bodyReferencedFields.get(name);
11583
+ const structured = isStructuredAccessorShape(shape);
11584
+ // The body text is the ground truth for a field's shape: a scalar
11585
+ // registration from any source must not outlive an accessor that indexes
11586
+ // or dereferences the field, and a structured declaration is kept only
11587
+ // when it already admits every access the body performs.
11588
+ if (structured) {
11589
+ const declared = extendFields.get(name);
11590
+ if (structuredFieldNames.has(name) &&
11591
+ declared !== undefined &&
11592
+ declaredSchemaCoversAccessorShape(declared, shape)) {
11593
+ accessorStructuredFields.add(name);
11594
+ continue;
11595
+ }
11596
+ accessorStructuredFields.add(name);
11597
+ structuredFieldNames.delete(name);
11598
+ }
11599
+ else if (extendFields.has(name)) {
11600
+ continue;
11601
+ }
11602
+ const zod = renderAccessorShape(shape);
11603
+ addExtendField(name, ` ${name}: ${zod === "z.string()" || !payloadNeedsMultipart ? zod : `multipartJsonObject(${zod})`},`);
10868
11604
  }
10869
11605
  }
10870
11606
  // The structural walk over the captured request body that used to BE the
@@ -10912,6 +11648,8 @@ function emitContractTs(opts) {
10912
11648
  // capture this run and is a no-op, not an error: there is no schema line
10913
11649
  // for it to attach to.
10914
11650
  for (const [fieldName, constraint] of Object.entries(valueConstraints)) {
11651
+ if (accessorStructuredFields.has(fieldName))
11652
+ continue;
10915
11653
  const line = extendFields.get(fieldName);
10916
11654
  if (line === null || line === undefined)
10917
11655
  continue;
@@ -11032,10 +11770,11 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
11032
11770
  // the same captured request-variables object the signal was itself
11033
11771
  // detected from (see paginationOperationIdentity above) — the default
11034
11772
  // `{ q: payload.query }` REST body has no skip/count container to advance.
11773
+ const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps), actionSteps);
11035
11774
  const gqlVariablesExpr = gqlOperationName
11036
- ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
11775
+ ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
11037
11776
  : paginationSignal
11038
- ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames)
11777
+ ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
11039
11778
  : "{ q: payload.query }";
11040
11779
  /** Builds the nested `for` loop block(s) — see {@link pathToFoldLoopLines}
11041
11780
  * — that fold every resolved plan's drill-down data onto `dataVarName`'s
@@ -12906,6 +13645,7 @@ async function main() {
12906
13645
  isSubmissionFlow,
12907
13646
  inputBody,
12908
13647
  hasMultipartStep,
13648
+ flowSteps,
12909
13649
  actionSteps,
12910
13650
  foldReturnSpec,
12911
13651
  discoveredFormFields,