@enricai/barnacle 1.12.77 → 1.12.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/plugins/config-plugin.d.ts.map +1 -1
  2. package/dist/plugins/config-plugin.js +2 -1
  3. package/dist/plugins/config-plugin.js.map +1 -1
  4. package/dist/scraper/actionable-visibility-expr.d.ts +12 -0
  5. package/dist/scraper/actionable-visibility-expr.d.ts.map +1 -0
  6. package/dist/scraper/actionable-visibility-expr.js +22 -0
  7. package/dist/scraper/actionable-visibility-expr.js.map +1 -0
  8. package/dist/scraper/browser-click-expr.d.ts +1 -1
  9. package/dist/scraper/browser-click-expr.d.ts.map +1 -1
  10. package/dist/scraper/browser-click-expr.js +2 -6
  11. package/dist/scraper/browser-click-expr.js.map +1 -1
  12. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  13. package/dist/scraper/deep-locator-candidates.js +35 -11
  14. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  15. package/dist/scraper/deep-locator-scan.d.ts +12 -0
  16. package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
  17. package/dist/scraper/deep-locator-scan.js +42 -13
  18. package/dist/scraper/deep-locator-scan.js.map +1 -1
  19. package/dist/scraper/errors.d.ts +10 -0
  20. package/dist/scraper/errors.d.ts.map +1 -1
  21. package/dist/scraper/errors.js +19 -1
  22. package/dist/scraper/errors.js.map +1 -1
  23. package/dist/scraper/flow-runner.d.ts +47 -0
  24. package/dist/scraper/flow-runner.d.ts.map +1 -1
  25. package/dist/scraper/flow-runner.js +310 -61
  26. package/dist/scraper/flow-runner.js.map +1 -1
  27. package/dist/scraper/phantom-click.d.ts +3 -3
  28. package/dist/scraper/phantom-click.d.ts.map +1 -1
  29. package/dist/scraper/phantom-click.js +6 -14
  30. package/dist/scraper/phantom-click.js.map +1 -1
  31. package/dist/scraper/pool.d.ts.map +1 -1
  32. package/dist/scraper/pool.js +8 -1
  33. package/dist/scraper/pool.js.map +1 -1
  34. package/dist/scraper/retry.d.ts +1 -1
  35. package/dist/scraper/retry.d.ts.map +1 -1
  36. package/dist/scraper/retry.js +5 -1
  37. package/dist/scraper/retry.js.map +1 -1
  38. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  39. package/dist/scraper/session-browserbase.js +29 -17
  40. package/dist/scraper/session-browserbase.js.map +1 -1
  41. package/dist/scraper/session-shared.d.ts +5 -0
  42. package/dist/scraper/session-shared.d.ts.map +1 -1
  43. package/dist/scraper/session-shared.js.map +1 -1
  44. package/dist/scraper/session-teardown.d.ts +30 -1
  45. package/dist/scraper/session-teardown.d.ts.map +1 -1
  46. package/dist/scraper/session-teardown.js +48 -3
  47. package/dist/scraper/session-teardown.js.map +1 -1
  48. package/dist/scraper/submit-control.d.ts +2 -2
  49. package/dist/scraper/submit-control.d.ts.map +1 -1
  50. package/dist/scraper/submit-control.js +5 -21
  51. package/dist/scraper/submit-control.js.map +1 -1
  52. package/dist/scraper/target-resolution-diagnostic.d.ts.map +1 -1
  53. package/dist/scraper/target-resolution-diagnostic.js +6 -11
  54. package/dist/scraper/target-resolution-diagnostic.js.map +1 -1
  55. package/dist/scripts/recon-browser.d.ts +4 -1
  56. package/dist/scripts/recon-browser.d.ts.map +1 -1
  57. package/dist/scripts/recon-browser.js +25 -6
  58. package/dist/scripts/recon-browser.js.map +1 -1
  59. package/dist/scripts/recon-generate.d.ts +117 -2
  60. package/dist/scripts/recon-generate.d.ts.map +1 -1
  61. package/dist/scripts/recon-generate.js +801 -166
  62. package/dist/scripts/recon-generate.js.map +1 -1
  63. package/package.json +1 -1
@@ -62,13 +62,18 @@ 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;
69
71
  exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
72
+ exports.renderGqlVariablesExpr = renderGqlVariablesExpr;
70
73
  exports.findAllObjectArrayFields = findAllObjectArrayFields;
71
74
  exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
75
+ exports.collectRequestBodyValuesByKey = collectRequestBodyValuesByKey;
76
+ exports.findFrozenVaryingDrillParams = findFrozenVaryingDrillParams;
72
77
  exports.applyDrillParamBindings = applyDrillParamBindings;
73
78
  exports.collectResponseLeafValues = collectResponseLeafValues;
74
79
  exports.getScanPrimaryCandidateGroupsCallCountForTest = getScanPrimaryCandidateGroupsCallCountForTest;
@@ -76,6 +81,7 @@ exports.resetScanPrimaryCandidateGroupsCallCountForTest = resetScanPrimaryCandid
76
81
  exports.detectDrillDownFoldPlan = detectDrillDownFoldPlan;
77
82
  exports.parseFoldReturnSpec = parseFoldReturnSpec;
78
83
  exports.parseFallbackGateSpec = parseFallbackGateSpec;
84
+ exports.readComparableBodyLeaf = readComparableBodyLeaf;
79
85
  exports.collectRequestValuesIncludingHeaders = collectRequestValuesIncludingHeaders;
80
86
  exports.getFoldPlanResolutionCallCountForTests = getFoldPlanResolutionCallCountForTests;
81
87
  exports.resetFoldPlanResolutionCallCountForTests = resetFoldPlanResolutionCallCountForTests;
@@ -382,14 +388,53 @@ function extractNavigateToHashFragmentValue(url, previousHash) {
382
388
  if (hashIndex === -1)
383
389
  return null;
384
390
  const hash = url.slice(hashIndex + 1);
385
- if (previousHash && hash.length > previousHash.length && hash.startsWith(previousHash)) {
386
- const suffix = hash.slice(previousHash.length);
387
- const delta = /^[,/]/.test(suffix) ? suffix.slice(1) : suffix;
388
- if (delta.length > 0)
389
- return delta;
391
+ if (previousHash) {
392
+ // A growing hash can add more than one token per step (a "label/value"
393
+ // pair, or a "key=value" pair), at either end, joined by any hash
394
+ // separator. The facet's actual VALUE is the last token that is new
395
+ // relative to the previous hash, with any "key=" prefix removed.
396
+ const previousTokens = new Set(splitHashTokens(previousHash));
397
+ const added = splitHashTokens(hash).filter((token) => !previousTokens.has(token));
398
+ const delta = added[added.length - 1];
399
+ if (delta !== undefined)
400
+ return stripHashKeyPrefix(delta);
401
+ }
402
+ const tokens = splitHashTokens(hash);
403
+ return tokens.length > 0 ? stripHashKeyPrefix(tokens[tokens.length - 1]) : null;
404
+ }
405
+ /**
406
+ * Single walk over navigateTo+payloadField steps shared by
407
+ * {@link harvestPersonaBindings} and {@link extractNavigateToFacetOrder}, so
408
+ * both derive identical literals. The delta baseline is the most recent
409
+ * hash-bearing navigateTo; a hashless reset navigation must not erase it, or
410
+ * the next cumulative hash would be taken whole and never recur in a request.
411
+ */
412
+ function walkNavigateToFacets(flowSteps) {
413
+ const facets = [];
414
+ let baselineHash;
415
+ for (const step of flowSteps) {
416
+ if (typeof step === "string" || step.navigateTo === undefined)
417
+ continue;
418
+ const hashIndex = step.navigateTo.indexOf("#");
419
+ const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
420
+ if (step.payloadField) {
421
+ facets.push({
422
+ value: extractNavigateToHashFragmentValue(step.navigateTo, baselineHash),
423
+ field: step.payloadField,
424
+ optional: step.optional === true,
425
+ });
426
+ }
427
+ if (currentHash !== undefined)
428
+ baselineHash = currentHash;
390
429
  }
391
- const segments = hash.split("/").filter((s) => s.length > 0);
392
- return segments.length > 0 ? segments[segments.length - 1] : null;
430
+ return facets;
431
+ }
432
+ function splitHashTokens(hash) {
433
+ return hash.split(/[,/;&]/).filter((token) => token.length > 0);
434
+ }
435
+ function stripHashKeyPrefix(token) {
436
+ const stripped = token.slice(token.indexOf("=") + 1);
437
+ return stripped.length > 0 ? stripped : token;
393
438
  }
394
439
  /**
395
440
  * Derives a payload field name from the field LABEL in a fill/enter/type
@@ -437,7 +482,11 @@ function deriveFillLabelField(instruction) {
437
482
  function harvestPersonaBindings(flowSteps, vocabulary, env) {
438
483
  const bindings = new Map();
439
484
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
440
- let previousNavigateToHash;
485
+ for (const facet of walkNavigateToFacets(flowSteps)) {
486
+ if (facet.value !== null && !bindings.has(facet.value)) {
487
+ bindings.set(facet.value, `payload.${facet.field}`);
488
+ }
489
+ }
441
490
  for (const step of flowSteps) {
442
491
  const isObj = typeof step !== "string";
443
492
  const instruction = isObj ? step.step : step;
@@ -448,21 +497,8 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
448
497
  // `.../#/jobs/category-widgets`), so the value to correlate against
449
498
  // downstream header/body templates is the URL's own hash-derived
450
499
  // fragment, not anything extractStepPersonaValue could find in `step.step`.
451
- if (isObj && step.navigateTo !== undefined) {
452
- const hashIndex = step.navigateTo.indexOf("#");
453
- const currentHash = hashIndex === -1 ? undefined : step.navigateTo.slice(hashIndex + 1);
454
- if (!step.payloadField) {
455
- previousNavigateToHash = currentHash;
456
- continue;
457
- }
458
- const value = extractNavigateToHashFragmentValue(step.navigateTo, previousNavigateToHash);
459
- previousNavigateToHash = currentHash;
460
- if (value === null)
461
- continue;
462
- if (!bindings.has(value))
463
- bindings.set(value, `payload.${step.payloadField}`);
500
+ if (isObj && step.navigateTo !== undefined)
464
501
  continue;
465
- }
466
502
  const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
467
503
  // Vocabulary wins outright. Only on a miss do we fall back to deriving the
468
504
  // field from the instruction's own label — and never when the author opted
@@ -492,26 +528,18 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
492
528
  * which DOES see the captures, re-derive the same (value, field) pairs and
493
529
  * try the causally-adjacent-action fallback (see `emitMultiStepExecuteHttp`)
494
530
  * for whichever ones the recurrence-anchored path left unbound.
531
+ *
532
+ * A step whose URL carries no hash literal still holds its slot (empty
533
+ * `value`) so positional correlation against captured transitions stays
534
+ * aligned with the declared facets; {@link resolveRecurringNavigateToFacets}
535
+ * drops the empty literal itself and keeps only a correlated value.
495
536
  */
496
537
  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;
538
+ return walkNavigateToFacets(flowSteps).map((facet) => ({
539
+ value: facet.value ?? "",
540
+ field: facet.field,
541
+ optional: facet.optional,
542
+ }));
515
543
  }
516
544
  /**
517
545
  * Detects a flow step whose quoted VALUE is the space-joined concatenation of
@@ -2824,6 +2852,17 @@ function jsonBodyLeafValues(requestPostData) {
2824
2852
  }
2825
2853
  return values;
2826
2854
  }
2855
+ /** Parses a captured request body as JSON; null for an absent or non-JSON body (multipart raw bytes). */
2856
+ function parseJsonBodyOrNull(requestPostData) {
2857
+ if (typeof requestPostData !== "string" || requestPostData.length === 0)
2858
+ return null;
2859
+ try {
2860
+ return JSON.parse(requestPostData);
2861
+ }
2862
+ catch {
2863
+ return null;
2864
+ }
2865
+ }
2827
2866
  /**
2828
2867
  * Same JSON body walk as {@link jsonBodyLeafValues}, but grouped by the JSON
2829
2868
  * key/array-index that carries each leaf value — the by-name correlation
@@ -4031,7 +4070,28 @@ function containsValueAtTokenBoundary(haystack, needle) {
4031
4070
  from = at + needle.length;
4032
4071
  }
4033
4072
  }
4034
- function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody, payloadAccessorExcludePattern) {
4073
+ const structuredFieldSamples = new WeakMap();
4074
+ /**
4075
+ * Single registration point for a structured (array/object) payload field, so
4076
+ * the declared schema type is inferred from every captured body carrying the
4077
+ * field rather than from whichever body or visiting order reached it first.
4078
+ * Both the by-index accessor loop and the whole-field substitution pass go
4079
+ * through here, so an indexed accessor can never coexist with a string schema.
4080
+ */
4081
+ function registerStructuredPayloadField(outStructuredKeys, field, value) {
4082
+ if (value === undefined || value === null)
4083
+ return;
4084
+ const samplesByField = structuredFieldSamples.get(outStructuredKeys) ?? new Map();
4085
+ structuredFieldSamples.set(outStructuredKeys, samplesByField);
4086
+ const samples = samplesByField.get(field) ?? [];
4087
+ samplesByField.set(field, samples);
4088
+ const serialized = JSON.stringify(value);
4089
+ if (samples.some((sample) => JSON.stringify(sample) === serialized))
4090
+ return;
4091
+ samples.push(value);
4092
+ outStructuredKeys.set(field, inferZodSchemaFromSamples(samples));
4093
+ }
4094
+ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody, payloadAccessorExcludePattern, ownedArrayPathKeys) {
4035
4095
  let result = template;
4036
4096
  let cursor = searchFrom;
4037
4097
  // The body root's own entries are eligible too, whenever the located
@@ -4041,11 +4101,11 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4041
4101
  // pagination/sort block) outranks the root as the "form envelope". Root
4042
4102
  // keys already present on the envelope are skipped so nothing is visited
4043
4103
  // (or registered) twice.
4044
- const envelopeEntries = Object.entries(envelope);
4045
- const rootEntries = envelopePath.length === 0
4104
+ const envelopeEntries = Object.entries(envelope).map(([key, value]) => [key, value, [...envelopePath, key]]);
4105
+ const rootEntries = (envelopePath.length === 0
4046
4106
  ? []
4047
- : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope));
4048
- for (const [key, value] of [...envelopeEntries, ...rootEntries]) {
4107
+ : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope))).map(([key, value]) => [key, value, [key]]);
4108
+ for (const [key, value, valuePath] of [...envelopeEntries, ...rootEntries]) {
4049
4109
  const isNonEmptyArray = Array.isArray(value) && value.length > 0;
4050
4110
  const isNestedObject = value !== null &&
4051
4111
  typeof value === "object" &&
@@ -4053,6 +4113,22 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4053
4113
  Object.keys(value).length > 0;
4054
4114
  if (!isNonEmptyArray && !isNestedObject)
4055
4115
  continue;
4116
+ // An array path the facet pass owns is rendered element by element at every
4117
+ // call site, so it is neither a payload field of its own nor swallowable.
4118
+ if (isNonEmptyArray && ownedArrayPathKeys.has(arrayPathKey(valuePath)))
4119
+ continue;
4120
+ // Schema registration and the wholesale text-swallow below are
4121
+ // independent concerns: this field's inferred Zod shape is correct
4122
+ // regardless of whether any of the exclusion/guard checks below skip
4123
+ // freezing its literal into the template. Registering it here (before
4124
+ // those checks, and before even locating the span) keeps the generated
4125
+ // contract's declared type in sync with payloadAccessorForPath's
4126
+ // array/object accessors even when the wholesale swallow is skipped.
4127
+ registerStructuredPayloadField(outStructuredKeys, key, value);
4128
+ // A call site whose array carries a facet was already rewritten by the
4129
+ // facet pass (the span-text guard below leaves it intact); a site whose
4130
+ // array carries none falls through to the same opaque `payload.<key>`
4131
+ // passthrough every non-facet array gets, so the declared field is used.
4056
4132
  if (unconditionalExcludeValues.size > 0 ||
4057
4133
  restrictedExcludeSourceByValue.size > 0 ||
4058
4134
  payloadAccessorExcludePattern !== null) {
@@ -4162,9 +4238,6 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4162
4238
  const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
4163
4239
  result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
4164
4240
  cursor = Math.max(cursor, spanStart + replacement.length);
4165
- if (!outStructuredKeys.has(key)) {
4166
- outStructuredKeys.set(key, inferZodSchema(value));
4167
- }
4168
4241
  }
4169
4242
  return { result, nextSearchFrom: cursor };
4170
4243
  }
@@ -4174,7 +4247,7 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4174
4247
  * pass to it. Shared by the top-level-object and top-level-array branches of
4175
4248
  * {@link applyStructuredValuePayloadSubstitutions} below.
4176
4249
  */
4177
- function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern) {
4250
+ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern, ownedArrayPathKeys) {
4178
4251
  const envelopePath = locateFormEnvelopePath(objectBody);
4179
4252
  let envelope = objectBody;
4180
4253
  for (const seg of envelopePath) {
@@ -4185,7 +4258,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4185
4258
  if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
4186
4259
  return { result: template, nextSearchFrom: searchFrom };
4187
4260
  }
4188
- return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern);
4261
+ return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern, ownedArrayPathKeys);
4189
4262
  }
4190
4263
  /**
4191
4264
  * `priorStepStateBindings` carries the SAME value→binding map
@@ -4210,7 +4283,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4210
4283
  * excluded from threading — the same value-coincidence class of bug
4211
4284
  * {@link keyNamesCorrelate}'s other call sites already guard against.
4212
4285
  */
4213
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map()) {
4286
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map(), ownedArrayPathKeys = new Set()) {
4214
4287
  if (parsedBody === null || typeof parsedBody !== "object") {
4215
4288
  return template;
4216
4289
  }
@@ -4237,13 +4310,13 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4237
4310
  for (const element of parsedBody) {
4238
4311
  if (element === null || typeof element !== "object" || Array.isArray(element))
4239
4312
  continue;
4240
- const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern);
4313
+ const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern, ownedArrayPathKeys);
4241
4314
  result = nextResult;
4242
4315
  cursor = nextSearchFrom;
4243
4316
  }
4244
4317
  return result;
4245
4318
  }
4246
- const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern);
4319
+ const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern, ownedArrayPathKeys);
4247
4320
  return result;
4248
4321
  }
4249
4322
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
@@ -4508,12 +4581,22 @@ function payloadAccessorForPath(path) {
4508
4581
  const arrayIndexPos = path.findIndex((segment) => /^\d+$/.test(segment));
4509
4582
  if (arrayIndexPos === -1) {
4510
4583
  const field = pathToPayloadFieldName(path);
4511
- return { accessor: `payload.${field}`, field };
4584
+ return { accessor: `payload.${field}`, field, structuredRootPath: null };
4512
4585
  }
4513
4586
  const objectPath = path.slice(0, arrayIndexPos);
4514
4587
  const field = objectPath.length > 0 ? pathToPayloadFieldName(objectPath) : (path[0] ?? "");
4515
4588
  const suffix = pathToAccessor(path.slice(arrayIndexPos), { assertNonNull: true });
4516
- return { accessor: `payload.${field}${suffix}`, field };
4589
+ return {
4590
+ accessor: `payload.${field}${suffix}`,
4591
+ field,
4592
+ structuredRootPath: objectPath.length > 0 ? objectPath : null,
4593
+ };
4594
+ }
4595
+ /** Reads the value at `path` inside a parsed JSON body, or undefined when any hop is missing. */
4596
+ function valueAtJsonPath(root, path) {
4597
+ return path.reduce((node, segment) => node !== null && typeof node === "object"
4598
+ ? node[segment]
4599
+ : undefined, root);
4517
4600
  }
4518
4601
  /** Derives a valid camelCase identifier from a fixture filename (e.g.
4519
4602
  * "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
@@ -5686,12 +5769,29 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
5686
5769
  /** Every capture's response body from the same run, scanned only for
5687
5770
  * {@link CAPACITY_FIELD_NAME_PATTERN}-shaped numeric leaves — the
5688
5771
  * evidence source for a discovered "number" key's `capacityMax`. */
5689
- responseBodiesForCapacitySignal = []) {
5772
+ responseBodiesForCapacitySignal = [], outStructuredKeys = new Map()) {
5690
5773
  const merged = [];
5691
5774
  const seenPairs = new Set();
5692
5775
  const seenValueByKey = new Map();
5693
5776
  const distinctValuesByKey = new Map();
5694
5777
  const allBodies = [inputBody, ...additionalBodies];
5778
+ // Structure is registered from every body before any scalar is considered, so
5779
+ // a name that is an array/object in one body and a primitive in another gets
5780
+ // the structured schema regardless of which body is visited first.
5781
+ for (const body of allBodies) {
5782
+ if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
5783
+ continue;
5784
+ }
5785
+ for (const [key, value] of Object.entries(body)) {
5786
+ if (!isValidJsIdentifier(key))
5787
+ continue;
5788
+ if (value === null || typeof value !== "object")
5789
+ continue;
5790
+ if (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0)
5791
+ continue;
5792
+ registerStructuredPayloadField(outStructuredKeys, key, value);
5793
+ }
5794
+ }
5695
5795
  for (const body of allBodies) {
5696
5796
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
5697
5797
  continue;
@@ -5702,6 +5802,8 @@ responseBodiesForCapacitySignal = []) {
5702
5802
  const key = path[0];
5703
5803
  if (!isValidJsIdentifier(key))
5704
5804
  continue;
5805
+ if (outStructuredKeys.has(key))
5806
+ continue;
5705
5807
  if (value === null)
5706
5808
  continue;
5707
5809
  // Dedupe identical (key, value) pairs only — a repeated occurrence of
@@ -5870,17 +5972,57 @@ responseBodiesForCapacitySignal = []) {
5870
5972
  * steps declared any field to correlate against) or when a leaf's value has
5871
5973
  * no `key:value` segment matching one of them.
5872
5974
  */
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. */
5975
+ /**
5976
+ * Binds a scalar navigateTo facet at every call site: each object-property
5977
+ * string leaf, at any depth, whose whole value equals a declared facet literal
5978
+ * becomes `${payload.<field>}`. Matching is per whole leaf, so the same
5979
+ * literal flanked by alphanumerics inside an unrelated token is never touched
5980
+ * and no global collision guard can drop the facet. An optional facet falls
5981
+ * back to the captured literal when the caller omits it, never `undefined`.
5982
+ */
5983
+ function applyScalarFacetSplicePayloadSubstitutions(template, inputBody, navigateToFacets) {
5984
+ if (navigateToFacets.length === 0)
5985
+ return template;
5986
+ if (inputBody === null || typeof inputBody !== "object")
5987
+ return template;
5988
+ let result = template;
5989
+ for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5990
+ if (typeof value !== "string" && typeof value !== "number")
5991
+ continue;
5992
+ if (/^\d+$/.test(path[path.length - 1] ?? ""))
5993
+ continue;
5994
+ const facet = matchFacetRecurrence(String(value), navigateToFacets, {
5995
+ allowDelimiterSuffix: false,
5996
+ });
5997
+ if (facet === undefined)
5998
+ continue;
5999
+ const literal = JSON.stringify(value);
6000
+ const fallback = facet.optional ? ` ?? ${literal}` : "";
6001
+ const replacement = typeof value === "number" || facet.optional
6002
+ ? `\${JSON.stringify(payload.${facet.field}${fallback})}`
6003
+ : `"\${payload.${facet.field}}"`;
6004
+ for (const sep of [":", ": "]) {
6005
+ if (typeof value === "number") {
6006
+ const re = new RegExp(`${sep}${literal}(?![\\w.])`, "g");
6007
+ result = result.replace(re, () => `${sep}${replacement}`);
6008
+ continue;
6009
+ }
6010
+ result = result.split(`${sep}${literal}`).join(`${sep}${replacement}`);
6011
+ }
6012
+ }
6013
+ return result;
6014
+ }
6015
+ /** Yields every array in `value` (outermost first, at any nesting depth,
6016
+ * including arrays inside array elements) — `{@link
6017
+ * applyArrayFacetSplicePayloadSubstitutions}`'s raw-array search surface. */
5879
6018
  function* walkArrayLeaves(value, path = []) {
5880
6019
  if (value === null || typeof value !== "object")
5881
6020
  return;
5882
6021
  if (Array.isArray(value)) {
5883
6022
  yield { value, path };
6023
+ for (const [index, inner] of value.entries()) {
6024
+ yield* walkArrayLeaves(inner, [...path, String(index)]);
6025
+ }
5884
6026
  return;
5885
6027
  }
5886
6028
  for (const [key, inner] of Object.entries(value)) {
@@ -5890,45 +6032,191 @@ function* walkArrayLeaves(value, path = []) {
5890
6032
  /**
5891
6033
  * REST-body counterpart to {@link spliceFacetsIntoArrayVariable}'s GraphQL
5892
6034
  * 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.
6035
+ * by a constant delimiter) inside a REST JSON body gets its element swapped
6036
+ * for `${payload.<field>}` — for EVERY declared facet, required or optional,
6037
+ * at EVERY call site's own array. Matching is structural (see
6038
+ * {@link replaceJsonArrayOccurrences}) and token-boundary guarded per element
6039
+ * ({@link matchArrayElementFacet}), so the splice never depends on the
6040
+ * global, all-or-nothing persona collision guard, on which call site carried
6041
+ * the array first, on element order, or on a facet being optional.
5900
6042
  *
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))
6043
+ * Every facet-carrying array, whether its facets are required or optional, is
6044
+ * rewritten to the same single `${JSON.stringify([...])}` splice point built
6045
+ * from {@link spliceFacetsIntoArrayVariable}'s per-element conditional
6046
+ * emission, so an absent optional facet drops its element instead of freezing
6047
+ * an `undefined` and no call site renders the array through a different
6048
+ * construction. Non-facet elements stay literal.
6049
+ */
6050
+ function applyArrayFacetSplicePayloadSubstitutions(template, inputBody, navigateToFacets, ownedArrayPathKeys, siblingBodies = []) {
6051
+ if (navigateToFacets.length === 0)
5915
6052
  return template;
5916
6053
  if (inputBody === null || typeof inputBody !== "object")
5917
6054
  return template;
5918
6055
  let result = template;
5919
- for (const { value } of walkArrayLeaves(inputBody)) {
5920
- const spliced = spliceFacetsIntoArrayVariable(value, navigateToFacets);
5921
- if (spliced === null || !spliced.includes("...(payload."))
6056
+ for (const { value, path } of walkArrayLeaves(inputBody)) {
6057
+ if (value.length === 0)
5922
6058
  continue;
5923
- const target = JSON.stringify(value);
5924
- if (!result.includes(target))
6059
+ if (!ownedArrayPathKeys.has(arrayPathKey(path)) &&
6060
+ !containsFacetArray(value, navigateToFacets)) {
5925
6061
  continue;
5926
- result = result.split(target).join(`\${JSON.stringify(${spliced})}`);
6062
+ }
6063
+ const absentFacets = collectSiblingArrayFacets(value, path, siblingBodies, navigateToFacets);
6064
+ const absentFields = new Set(absentFacets.map(({ facet }) => facet.field));
6065
+ const expression = renderFacetValueExpression([...value, ...absentFacets.map(({ element }) => element)], navigateToFacets.map((facet) => absentFields.has(facet.field) ? { ...facet, optional: true } : facet));
6066
+ result = replaceJsonArrayOccurrences(result, value, `\${JSON.stringify(${expression})}`);
5927
6067
  }
5928
6068
  return result;
5929
6069
  }
5930
- function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames) {
5931
- if (payloadFieldNames.size === 0)
6070
+ /**
6071
+ * Facet-bearing elements that same-endpoint sibling captures carry at the
6072
+ * array `path` but `own` does not. A fold drill re-issues ONE representative
6073
+ * capture per item, so a facet only a dropped sibling's differently shaped
6074
+ * array carried would otherwise never reach the emitted body. Each facet is
6075
+ * taken once, at its first occurrence, and only for facets `own` leaves out.
6076
+ */
6077
+ function collectSiblingArrayFacets(own, path, siblingBodies, facets) {
6078
+ const present = new Set(own.flatMap((element) => typeof element === "string" ? [matchArrayElementFacet(element, facets)?.field] : []));
6079
+ const key = arrayPathKey(path);
6080
+ const absent = [];
6081
+ for (const body of siblingBodies) {
6082
+ if (body === null || typeof body !== "object")
6083
+ continue;
6084
+ for (const { value, path: siblingPath } of walkArrayLeaves(body)) {
6085
+ if (arrayPathKey(siblingPath) !== key)
6086
+ continue;
6087
+ for (const element of value) {
6088
+ if (typeof element !== "string")
6089
+ continue;
6090
+ const facet = matchArrayElementFacet(element, facets);
6091
+ if (facet === undefined || present.has(facet.field))
6092
+ continue;
6093
+ present.add(facet.field);
6094
+ absent.push({ element, facet });
6095
+ }
6096
+ }
6097
+ }
6098
+ return absent;
6099
+ }
6100
+ /** Whether `value` is, or contains at any depth, an array with a
6101
+ * facet-bearing string element. */
6102
+ function containsFacetArray(value, facets) {
6103
+ for (const { value: array } of walkArrayLeaves(value)) {
6104
+ const hasFacet = array.some((element) => typeof element === "string" && matchArrayElementFacet(element, facets));
6105
+ if (hasFacet)
6106
+ return true;
6107
+ }
6108
+ return false;
6109
+ }
6110
+ /** JS expression for `value` with every facet array at any depth spliced, so
6111
+ * one construction covers arrays nested inside array elements. */
6112
+ function renderFacetValueExpression(value, facets) {
6113
+ if (value === null || typeof value !== "object" || !containsFacetArray(value, facets)) {
6114
+ return JSON.stringify(value);
6115
+ }
6116
+ if (Array.isArray(value)) {
6117
+ return (spliceFacetsIntoArrayVariable(value, facets, (element) => renderFacetValueExpression(element, facets)) ?? `[${value.map((element) => renderFacetValueExpression(element, facets)).join(", ")}]`);
6118
+ }
6119
+ const entries = Object.entries(value).map(([key, inner]) => `${JSON.stringify(key)}: ${renderFacetValueExpression(inner, facets)}`);
6120
+ return `{ ${entries.join(", ")} }`;
6121
+ }
6122
+ /** Full index-free path of an array: identifies the array across call sites
6123
+ * whatever index it was captured at, without conflating same-named fields
6124
+ * that live at different paths. */
6125
+ function arrayPathKey(path) {
6126
+ return JSON.stringify(path.filter((segment) => !/^\d+$/.test(segment)));
6127
+ }
6128
+ /** True when `path` addresses anything inside an array in `ownedKeys`, at any
6129
+ * depth (string or object element alike). */
6130
+ function isInsideOwnedArray(path, ownedKeys) {
6131
+ return path.some((segment, index) => /^\d+$/.test(segment) && ownedKeys.has(arrayPathKey(path.slice(0, index))));
6132
+ }
6133
+ /**
6134
+ * Path keys ({@link arrayPathKey}) of every array, in ANY captured body,
6135
+ * that carries at least one facet-bearing element. The facet pass owns those
6136
+ * arrays wholesale, so none of their elements may also be frozen as a
6137
+ * by-index `payload.<field>["N"]` accessor, whichever body was parsed first
6138
+ * and wherever the array sits in it.
6139
+ */
6140
+ function collectFacetArrayPathKeys(bodies, facets) {
6141
+ const keys = new Set();
6142
+ if (facets.length === 0)
6143
+ return keys;
6144
+ for (const body of bodies) {
6145
+ if (body === null || typeof body !== "object")
6146
+ continue;
6147
+ for (const { value, path } of walkArrayLeaves(body)) {
6148
+ const hasFacet = value.some((element) => typeof element === "string" && matchArrayElementFacet(element, facets) !== undefined);
6149
+ if (hasFacet)
6150
+ keys.add(arrayPathKey(path));
6151
+ }
6152
+ }
6153
+ return keys;
6154
+ }
6155
+ /**
6156
+ * Rewrites every occurrence of `target` inside `template` by JSON structure
6157
+ * (bracket-balanced, string-aware, whitespace-insensitive) rather than by
6158
+ * exact `JSON.stringify` text, so a pretty-printed or re-spaced body array
6159
+ * is still replaced.
6160
+ */
6161
+ function replaceJsonArrayOccurrences(template, target, replacement) {
6162
+ const targetKey = JSON.stringify(target);
6163
+ let out = "";
6164
+ let from = 0;
6165
+ while (from < template.length) {
6166
+ const open = template.indexOf("[", from);
6167
+ if (open === -1)
6168
+ break;
6169
+ const close = findMatchingBracket(template, open);
6170
+ const candidate = close === -1 ? undefined : template.slice(open, close + 1);
6171
+ const matches = candidate !== undefined && jsonTextEquals(candidate, targetKey);
6172
+ if (!matches) {
6173
+ out += template.slice(from, open + 1);
6174
+ from = open + 1;
6175
+ continue;
6176
+ }
6177
+ out += template.slice(from, open) + replacement;
6178
+ from = close + 1;
6179
+ }
6180
+ return out + template.slice(from);
6181
+ }
6182
+ function findMatchingBracket(text, open) {
6183
+ let depth = 0;
6184
+ let inString = false;
6185
+ for (let i = open; i < text.length; i++) {
6186
+ const ch = text[i];
6187
+ if (inString) {
6188
+ if (ch === "\\")
6189
+ i++;
6190
+ else if (ch === '"')
6191
+ inString = false;
6192
+ continue;
6193
+ }
6194
+ if (ch === '"')
6195
+ inString = true;
6196
+ else if (ch === "[")
6197
+ depth++;
6198
+ else if (ch === "]" && --depth === 0)
6199
+ return i;
6200
+ }
6201
+ return -1;
6202
+ }
6203
+ function jsonTextEquals(candidate, targetKey) {
6204
+ try {
6205
+ return JSON.stringify(JSON.parse(candidate)) === targetKey;
6206
+ }
6207
+ catch {
6208
+ return false;
6209
+ }
6210
+ }
6211
+ /** A facet literal recurring inside a packed string under a key that does not
6212
+ * name its payload field. A leaf that IS the bare literal is left to the scalar
6213
+ * pass, which owns the optional fallback for it. */
6214
+ function packedFacetRecurrence(value, facets) {
6215
+ const spliced = spliceFacetRecurrenceIntoScalarVariable(value, facets);
6216
+ return spliced !== null && !spliced.startsWith("payload.") ? spliced : null;
6217
+ }
6218
+ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames, navigateToFacets = []) {
6219
+ if (payloadFieldNames.size === 0 && navigateToFacets.length === 0)
5932
6220
  return template;
5933
6221
  if (inputBody === undefined ||
5934
6222
  inputBody === null ||
@@ -5939,18 +6227,23 @@ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldN
5939
6227
  const fields = [...payloadFieldNames];
5940
6228
  let result = template;
5941
6229
  for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5942
- if (path.length !== 1)
5943
- continue;
5944
- const key = path[0];
5945
- if (!isValidJsIdentifier(key))
6230
+ const key = path[path.length - 1];
6231
+ if (key === undefined || !isValidJsIdentifier(key))
5946
6232
  continue;
5947
6233
  if (typeof value !== "string")
5948
6234
  continue;
5949
- const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6235
+ const kv = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6236
+ const spliced = kv ?? packedFacetRecurrence(value, navigateToFacets);
5950
6237
  if (spliced === null)
5951
6238
  continue;
5952
6239
  const target = `"${key}":${JSON.stringify(value)}`;
5953
- const replacement = `"${key}":"\${${spliced}}"`;
6240
+ // A required-only packed value with nothing JSON-escaped splices in place,
6241
+ // so the accessor sits directly in the string instead of nesting a template.
6242
+ const inline = kv === null &&
6243
+ spliced.startsWith("`") &&
6244
+ !spliced.startsWith("`${") &&
6245
+ JSON.stringify(value).slice(1, -1) === value;
6246
+ const replacement = inline ? `"${key}":"${spliced.slice(1, -1)}"` : `"${key}":"\${${spliced}}"`;
5954
6247
  result = result.split(target).join(replacement);
5955
6248
  }
5956
6249
  return result;
@@ -6231,8 +6524,12 @@ function parseBodyLeafValues(requestPostData) {
6231
6524
  * affect (the already-working recurrence-anchored path exists precisely
6232
6525
  * because a real facet DOES recur in a later request; nothing here changes
6233
6526
  * that — this only supplies values for facets that are not found at all).
6527
+ *
6528
+ * Values that carry a literal of an already-reachable facet are explained by
6529
+ * that facet and never enter the pool, so reachable facets' own transitions
6530
+ * cannot skew the pool size away from the count of facets awaiting a value.
6234
6531
  */
6235
- function collectNewlyAppearingRequestValueTransitions(actions) {
6532
+ function collectNewlyAppearingRequestValueTransitions(actions, explainedLiterals = []) {
6236
6533
  const seenGlobally = new Set();
6237
6534
  const transitions = [];
6238
6535
  for (let i = 1; i < actions.length; i++) {
@@ -6243,6 +6540,8 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6243
6540
  const record = (value) => {
6244
6541
  if (value.length === 0 || seenGlobally.has(value) || seenThisTransition.has(value))
6245
6542
  return;
6543
+ if (explainedLiterals.some((literal) => occursAtTokenBoundary(value, literal)))
6544
+ return;
6246
6545
  seenThisTransition.add(value);
6247
6546
  values.push(value);
6248
6547
  };
@@ -6263,6 +6562,79 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6263
6562
  }
6264
6563
  return transitions;
6265
6564
  }
6565
+ /**
6566
+ * Whether `value` occurs in every request body only at token boundaries (never
6567
+ * flanked by an alphanumeric), so a global rewrite of it cannot mangle a longer
6568
+ * token. Shared by the persona-binding guard and facet identity resolution.
6569
+ */
6570
+ function bindsWithoutCollisionIn(bodies, value) {
6571
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
6572
+ for (const body of bodies) {
6573
+ let from = 0;
6574
+ while (true) {
6575
+ const at = body.indexOf(value, from);
6576
+ if (at === -1)
6577
+ break;
6578
+ if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
6579
+ return false;
6580
+ from = at + value.length;
6581
+ }
6582
+ }
6583
+ return true;
6584
+ }
6585
+ /**
6586
+ * Whether `value` occurs in `text` at least once flanked by non-alphanumerics,
6587
+ * so facet reachability agrees with the token-boundary {@link matchFacetRecurrence}
6588
+ * the splice passes use instead of counting a mere substring of a longer token.
6589
+ */
6590
+ function occursAtTokenBoundary(text, value) {
6591
+ if (value.length === 0)
6592
+ return false;
6593
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
6594
+ let from = 0;
6595
+ while (true) {
6596
+ const at = text.indexOf(value, from);
6597
+ if (at === -1)
6598
+ return false;
6599
+ if (!isAlnum(text[at - 1]) && !isAlnum(text[at + value.length]))
6600
+ return true;
6601
+ from = at + 1;
6602
+ }
6603
+ }
6604
+ /**
6605
+ * Every literal the flow's declared navigateTo facets take in the captured
6606
+ * traffic: the hash-derived literal plus, for a facet whose hash literal never
6607
+ * recurs, the body/query value {@link correlateUnreachableNavigateToFacets}
6608
+ * pairs it with. Every per-site splice pass matches against this one list, so
6609
+ * a facet is judged to recur identically by all of them instead of each pass
6610
+ * deciding from the hash-derived literal alone.
6611
+ */
6612
+ function resolveRecurringNavigateToFacets(declared, actions) {
6613
+ const appearsAnywhere = (value) => actions.some(({ capture }) => occursAtTokenBoundary(capture.url, value) ||
6614
+ (capture.requestPostData != null &&
6615
+ occursAtTokenBoundary(capture.requestPostData, value)) ||
6616
+ Object.values(capture.requestHeaders).some((h) => occursAtTokenBoundary(h, value)));
6617
+ const reachable = declared.filter(({ value }) => value.length > 0 && appearsAnywhere(value));
6618
+ const resolvedDeclared = declared.filter(({ value }) => value.length > 0);
6619
+ const unreachable = declared.filter((facet) => !reachable.includes(facet));
6620
+ if (unreachable.length === 0)
6621
+ return resolvedDeclared;
6622
+ const correlated = correlateUnreachableNavigateToFacets(unreachable, actions, reachable.map(({ value }) => value));
6623
+ if (correlated === null)
6624
+ return resolvedDeclared;
6625
+ const bodies = actions
6626
+ .map(({ capture }) => capture.requestPostData)
6627
+ .filter((body) => typeof body === "string" && body.length > 0);
6628
+ const extras = unreachable.flatMap((facet) => {
6629
+ const diffed = correlated.get(facet.field);
6630
+ if (diffed === undefined || diffed.length === 0)
6631
+ return [];
6632
+ if (declared.some(({ value }) => value === diffed))
6633
+ return [];
6634
+ return bindsWithoutCollisionIn(bodies, diffed) ? [{ ...facet, value: diffed }] : [];
6635
+ });
6636
+ return [...resolvedDeclared, ...extras];
6637
+ }
6266
6638
  /**
6267
6639
  * Resolves each zero-recurrence navigateTo facet (in flow order) to the real
6268
6640
  * request value it causally affects, or returns `null` when the correlation
@@ -6279,14 +6651,27 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6279
6651
  * no longer guaranteed to line facets up with the RIGHT transition, so
6280
6652
  * nothing is bound — leaving a facet declared-but-unbound is preferable to
6281
6653
  * silently wiring it to another facet's (or an unrelated transition's) value.
6654
+ *
6655
+ * When the TOTAL transition count (ambiguous ones included) equals the facet
6656
+ * count, each ambiguous transition still holds its facet's slot, so only that
6657
+ * facet stays unbound while the others bind by position.
6282
6658
  */
6283
- function correlateUnreachableNavigateToFacets(facets, actions) {
6284
- const unambiguousValues = collectNewlyAppearingRequestValueTransitions(actions)
6659
+ function correlateUnreachableNavigateToFacets(facets, actions, explainedLiterals) {
6660
+ const transitions = collectNewlyAppearingRequestValueTransitions(actions, explainedLiterals);
6661
+ const byField = new Map();
6662
+ if (transitions.length === facets.length) {
6663
+ facets.forEach(({ field }, index) => {
6664
+ const transition = transitions[index];
6665
+ if (transition.length === 1)
6666
+ byField.set(field, transition[0]);
6667
+ });
6668
+ return byField.size > 0 ? byField : null;
6669
+ }
6670
+ const unambiguousValues = transitions
6285
6671
  .filter((transition) => transition.length === 1)
6286
6672
  .map((transition) => transition[0]);
6287
6673
  if (unambiguousValues.length !== facets.length)
6288
6674
  return null;
6289
- const byField = new Map();
6290
6675
  facets.forEach(({ field }, index) => {
6291
6676
  byField.set(field, unambiguousValues[index]);
6292
6677
  });
@@ -6333,12 +6718,30 @@ flowSteps = [], vocabulary = undefined) {
6333
6718
  // below can correlate a REST body's facet-packed string leaves against it.
6334
6719
  const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
6335
6720
  const payloadAccessorByValue = new Map();
6721
+ const declaredNavigateToFacets = extractNavigateToFacetOrder(flowSteps);
6722
+ const navigateToFacetOrder = resolveRecurringNavigateToFacets(declaredNavigateToFacets, actions);
6723
+ const facetArrayPathKeys = collectFacetArrayPathKeys(actions.map((action) => parseJsonBodyOrNull(action.capture.requestPostData)), navigateToFacetOrder);
6336
6724
  if (inputBody !== undefined && inputBody !== null) {
6337
6725
  for (const { value, path } of walkStringLeaves(inputBody)) {
6338
6726
  if (value.length < MIN_STATE_VALUE_LENGTH)
6339
6727
  continue;
6340
- const { accessor, field: accessorField } = payloadAccessorForPath(path);
6728
+ // An element of an array any call site's facet pass owns belongs to the
6729
+ // facet's payloadField (or stays literal), not to a by-index accessor
6730
+ // that would freeze the sibling elements' shape.
6731
+ if (isInsideOwnedArray(path, facetArrayPathKeys))
6732
+ continue;
6733
+ // A leaf recurring as a declared facet literal binds to that facet's
6734
+ // payloadField via the persona pass, not to a by-index accessor.
6735
+ if (matchFacetRecurrence(value, navigateToFacetOrder, { allowDelimiterSuffix: true })) {
6736
+ continue;
6737
+ }
6738
+ const { accessor, field: accessorField, structuredRootPath } = payloadAccessorForPath(path);
6341
6739
  payloadAccessorByValue.set(value, accessor);
6740
+ // The accessor indexes into this field, so its declared type must be the
6741
+ // structured shape regardless of whether Mechanism B visited the key.
6742
+ if (structuredRootPath !== null) {
6743
+ registerStructuredPayloadField(outStructuredKeys, accessorField, valueAtJsonPath(inputBody, structuredRootPath));
6744
+ }
6342
6745
  if (isValidJsIdentifier(accessorField))
6343
6746
  outDiscoveredFields.add(accessorField);
6344
6747
  // Phase F: register a lowercase variant for UUID-shaped values so case-
@@ -6428,31 +6831,22 @@ flowSteps = [], vocabulary = undefined) {
6428
6831
  // or when the correlation is ambiguous (see
6429
6832
  // `correlateUnreachableNavigateToFacets`), there is nothing safe to splice
6430
6833
  // 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);
6834
+ // Every facet any splice pass binds comes from the one resolved list, so the
6835
+ // schema declaration and accessor table cannot disagree with the splices.
6836
+ const declaredFacetValues = new Set(declaredNavigateToFacets.map(({ value }) => value));
6837
+ for (const { value, field } of navigateToFacetOrder) {
6838
+ if (!isValidJsIdentifier(field))
6839
+ continue;
6840
+ if (declaredFacetValues.has(value)) {
6841
+ outDiscoveredFields.add(field);
6842
+ continue;
6455
6843
  }
6844
+ if (!bindsWithoutCollision(value))
6845
+ continue;
6846
+ if (payloadAccessorByValue.has(value))
6847
+ continue;
6848
+ payloadAccessorByValue.set(value, `payload.${field}`);
6849
+ outDiscoveredFields.add(field);
6456
6850
  }
6457
6851
  // Job coordinates from the recon entry URL's query string (e.g.
6458
6852
  // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
@@ -6600,6 +6994,21 @@ flowSteps = [], vocabulary = undefined) {
6600
6994
  // `foldReturnSpec`, not on Pass 1's render) so Pass 1 knows which values to
6601
6995
  // spare per step, mirroring the header-name collection above.
6602
6996
  const joinFieldValuesByStep = new Map();
6997
+ // Request bodies of the absorbed repeat captures of each fold target's
6998
+ // representative drill (see FoldPlan.absorbedIndices): never emitted
6999
+ // themselves, so a facet only their differently shaped arrays carry must be
7000
+ // threaded through the representative's body instead of being dropped.
7001
+ const absorbedSiblingBodiesByStep = new Map();
7002
+ for (const plan of earlyFoldPlans) {
7003
+ for (const target of plan.targets) {
7004
+ const drillEndpoint = endpointKey(actions[target.drillStepIndex].capture.url);
7005
+ const siblings = plan.absorbedIndices
7006
+ .map((index) => actions[index].capture)
7007
+ .filter((capture) => endpointKey(capture.url) === drillEndpoint)
7008
+ .map((capture) => parseJsonBodyOrNull(capture.requestPostData));
7009
+ absorbedSiblingBodiesByStep.set(target.drillStepIndex, siblings);
7010
+ }
7011
+ }
6603
7012
  for (const plan of earlyFoldPlans) {
6604
7013
  const primaryItems = objectItemsAtPath(actions[plan.primaryStepIndex].capture.responseBody, plan.primaryArrayPath);
6605
7014
  for (const target of plan.targets) {
@@ -6705,7 +7114,7 @@ flowSteps = [], vocabulary = undefined) {
6705
7114
  // same declared field set, just applied to REST body text instead of a
6706
7115
  // variables object literal.
6707
7116
  const rawBodyWithFacetSplices = parsedBody !== null
6708
- ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames)
7117
+ ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames, navigateToFacetOrder)
6709
7118
  : rawBodyWithFormSubs;
6710
7119
  // Optional-facet array-element splice — a navigateTo facet literal
6711
7120
  // recurring as one element of an array field (not the `key:value`
@@ -6713,10 +7122,13 @@ flowSteps = [], vocabulary = undefined) {
6713
7122
  // when its binding is optional and the caller omits it. Runs BEFORE
6714
7123
  // Mechanism B (below) can swallow the whole array wholesale as an
6715
7124
  // unparameterized structured value — see
6716
- // {@link applyOptionalArrayFacetSplicePayloadSubstitutions}.
7125
+ // {@link applyArrayFacetSplicePayloadSubstitutions}.
6717
7126
  const rawBodyWithOptionalArrayFacetSplices = parsedBody !== null
6718
- ? applyOptionalArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder)
7127
+ ? applyArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder, facetArrayPathKeys, absorbedSiblingBodiesByStep.get(i))
6719
7128
  : rawBodyWithFacetSplices;
7129
+ const rawBodyWithScalarFacetSplices = parsedBody !== null
7130
+ ? applyScalarFacetSplicePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, navigateToFacetOrder)
7131
+ : rawBodyWithOptionalArrayFacetSplices;
6720
7132
  // Mechanism B — parameterize whole nested caller structures
6721
7133
  // (experienceData/educationData history, opaque eventData) BEFORE value
6722
7134
  // substitution reaches inside them: swallowing the entire array/object first
@@ -6727,7 +7139,7 @@ flowSteps = [], vocabulary = undefined) {
6727
7139
  // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
6728
7140
  // must stay reachable for state threading, not get frozen as caller data.
6729
7141
  const rawBodyWithStructuredSubs = parsedBody !== null
6730
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue)
7142
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithScalarFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue, facetArrayPathKeys)
6731
7143
  : rawBodyWithFormSubs;
6732
7144
  // Whole-value caller coordinates bind here — after structured subs, BEFORE
6733
7145
  // state threading — so a composite coordinate (a jobLocation or jobSeqNo
@@ -6759,7 +7171,7 @@ flowSteps = [], vocabulary = undefined) {
6759
7171
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
6760
7172
  : rawBodyWithProducerBoundary;
6761
7173
  const bodyAfterStateAndKv = rawBodyWithUrlParams
6762
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues, joinFieldValuesByStep.get(i) ?? new Set()), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys, allResponseBodies)
7174
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithUrlParams, prior, cap, payloadAccessorByValue, true, producerBoundaryBindings, i, topLevelPayloadKvValues, joinFieldValuesByStep.get(i) ?? new Set()), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys, allResponseBodies, outStructuredKeys)
6763
7175
  : "";
6764
7176
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
6765
7177
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -7650,6 +8062,87 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7650
8062
  }
7651
8063
  return `[${elements.join(", ")}].join("")`;
7652
8064
  }
8065
+ /**
8066
+ * Token-boundary match of one array element against the declared navigateTo
8067
+ * facets — the single place elements are matched so the GraphQL and REST
8068
+ * paths (and the REST accessor registration) never disagree on what a
8069
+ * facet-bearing element is.
8070
+ */
8071
+ function matchArrayElementFacet(element, facets) {
8072
+ return matchFacetRecurrence(element, facets, { allowDelimiterSuffix: true });
8073
+ }
8074
+ /**
8075
+ * The one facet-recurrence predicate every grammar (array element, scalar
8076
+ * leaf) consults, so a declared facet literal is judged to recur identically
8077
+ * everywhere. A number leaf is compared by its string form, so a numeric body
8078
+ * value matches the string literal a navigateTo hash yields. With
8079
+ * `allowDelimiterSuffix`, a literal followed by a non-alphanumeric delimiter
8080
+ * suffix also matches. The longest literal wins.
8081
+ */
8082
+ function matchFacetRecurrence(text, facets, options) {
8083
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
8084
+ return facets.reduce((best, facet) => {
8085
+ const literal = facet.value;
8086
+ const matches = text === literal ||
8087
+ (options.allowDelimiterSuffix &&
8088
+ literal.length > 0 &&
8089
+ text.startsWith(literal) &&
8090
+ !isAlnum(text[literal.length]));
8091
+ if (!matches)
8092
+ return best;
8093
+ return best === undefined || literal.length > best.value.length ? facet : best;
8094
+ }, undefined);
8095
+ }
8096
+ /**
8097
+ * Binds a top-level GraphQL variable whose scalar value (or, for a string, any
8098
+ * `key:value` packed segment's value) recurs as a declared navigateTo facet
8099
+ * literal, via the shared {@link matchFacetRecurrence} predicate so GraphQL
8100
+ * judges recurrence exactly as the array and REST scalar passes do.
8101
+ */
8102
+ function spliceFacetRecurrenceIntoScalarVariable(value, facets) {
8103
+ if (facets.length === 0)
8104
+ return null;
8105
+ if (typeof value === "string" || typeof value === "number") {
8106
+ const facet = matchFacetRecurrence(String(value), facets, { allowDelimiterSuffix: false });
8107
+ if (facet !== undefined)
8108
+ return `payload.${facet.field}`;
8109
+ }
8110
+ if (typeof value !== "string")
8111
+ return null;
8112
+ const segments = value.split(/([|,;])/);
8113
+ let bound = false;
8114
+ let anyOptional = false;
8115
+ const units = [];
8116
+ for (let index = 0; index < segments.length; index += 2) {
8117
+ const segment = segments[index];
8118
+ const trailingDelimiter = segments[index + 1] ?? "";
8119
+ const colonIndex = segment.indexOf(":");
8120
+ const facet = colonIndex < 0
8121
+ ? undefined
8122
+ : matchFacetRecurrence(segment.slice(colonIndex + 1), facets, {
8123
+ allowDelimiterSuffix: false,
8124
+ });
8125
+ if (facet === undefined) {
8126
+ units.push(`\`${escapeForTemplateLiteral(segment + trailingDelimiter)}\``);
8127
+ continue;
8128
+ }
8129
+ bound = true;
8130
+ const unit = `\`${escapeForTemplateLiteral(segment.slice(0, colonIndex + 1))}\${payload.${facet.field}}${escapeForTemplateLiteral(trailingDelimiter)}\``;
8131
+ // An absent optional facet drops its own segment (and delimiter) rather
8132
+ // than rendering the string `undefined` into the packed value.
8133
+ if (facet.optional) {
8134
+ anyOptional = true;
8135
+ units.push(`...(payload.${facet.field} ? [${unit}] : [])`);
8136
+ continue;
8137
+ }
8138
+ units.push(unit);
8139
+ }
8140
+ if (!bound)
8141
+ return null;
8142
+ if (anyOptional)
8143
+ return `[${units.join(", ")}].join("")`;
8144
+ return `\`${units.map((unit) => unit.slice(1, -1)).join("")}\``;
8145
+ }
7653
8146
  /**
7654
8147
  * Splices a captured array-valued GraphQL variable's elements against
7655
8148
  * navigateTo facet bindings instead of the `key:value` segment grammar
@@ -7665,24 +8158,16 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7665
8158
  * {@link spliceFacetsIntoStringVariable} uses, so an absent optional facet
7666
8159
  * drops its element rather than emitting `undefined`.
7667
8160
  */
7668
- function spliceFacetsIntoArrayVariable(value, facets) {
8161
+ function spliceFacetsIntoArrayVariable(value, facets, renderElement = (element) => JSON.stringify(element)) {
7669
8162
  if (!Array.isArray(value))
7670
8163
  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
8164
  let matchCount = 0;
7680
8165
  const elements = value.map((element) => {
7681
8166
  if (typeof element !== "string")
7682
- return JSON.stringify(element);
7683
- const matched = matchFacet(element);
8167
+ return renderElement(element);
8168
+ const matched = matchArrayElementFacet(element, facets);
7684
8169
  if (!matched)
7685
- return JSON.stringify(element);
8170
+ return renderElement(element);
7686
8171
  matchCount++;
7687
8172
  const suffix = element.slice(matched.value.length);
7688
8173
  const elementLiteral = `\`\${payload.${matched.field}}${escapeForTemplateLiteral(suffix)}\``;
@@ -7714,18 +8199,26 @@ function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames
7714
8199
  if (variables === null || typeof variables !== "object" || Array.isArray(variables))
7715
8200
  return "{}";
7716
8201
  const fields = payloadFieldNames ? [...payloadFieldNames] : [];
7717
- const entries = Object.entries(variables).map(([key, value]) => {
7718
- const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
7719
- const facetSpliceExpr = matchedField
7720
- ? null
7721
- : (spliceFacetsIntoStringVariable(value, fields, optionalFieldNames) ??
7722
- spliceFacetsIntoArrayVariable(value, navigateToFacets));
7723
- const valueExpr = matchedField
7724
- ? `payload.${matchedField}`
7725
- : (facetSpliceExpr ?? JSON.stringify(value));
7726
- return `${key}: ${valueExpr}`;
7727
- });
7728
- return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
8202
+ const renderValue = (value) => {
8203
+ const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames) ??
8204
+ spliceFacetsIntoArrayVariable(value, navigateToFacets, renderValue) ??
8205
+ spliceFacetRecurrenceIntoScalarVariable(value, navigateToFacets);
8206
+ if (spliced !== null)
8207
+ return spliced;
8208
+ if (Array.isArray(value))
8209
+ return `[${value.map(renderValue).join(", ")}]`;
8210
+ if (value !== null && typeof value === "object")
8211
+ return renderObject(value);
8212
+ return JSON.stringify(value);
8213
+ };
8214
+ const renderObject = (obj) => {
8215
+ const entries = Object.entries(obj).map(([key, value]) => {
8216
+ const matchedField = fields.find((field) => field.toLowerCase() === key.toLowerCase());
8217
+ return `${isValidJsIdentifier(key) ? key : JSON.stringify(key)}: ${matchedField ? `payload.${matchedField}` : renderValue(value)}`;
8218
+ });
8219
+ return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
8220
+ };
8221
+ return renderObject(variables);
7729
8222
  }
7730
8223
  /** A count-style page-size key (e.g. `count`, `limit`, `first`) paired with a
7731
8224
  * skip-style offset key (e.g. `skip`, `offset`) in the same object is the
@@ -8063,7 +8556,7 @@ function collectRequestBodyValuesByKey(capture, allCaptures) {
8063
8556
  const otherBody = parsedRequestBodyFor(c);
8064
8557
  if (otherBody === undefined)
8065
8558
  return undefined;
8066
- const otherValue = readValueAtPath(otherBody, path);
8559
+ const otherValue = readComparableBodyLeaf(parsedBody, otherBody, path);
8067
8560
  return otherValue === undefined ? undefined : String(otherValue);
8068
8561
  }
8069
8562
  catch {
@@ -8114,10 +8607,10 @@ function isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, sibl
8114
8607
  const pairs = [];
8115
8608
  let sawUnexplainableField = false;
8116
8609
  for (const body of allBodies) {
8117
- const fieldRaw = readValueAtPath(body, path);
8610
+ const fieldRaw = readComparableBodyLeaf(parsedBody, body, path);
8118
8611
  if (fieldRaw === undefined)
8119
8612
  continue;
8120
- const siblingRaw = readValueAtPath(body, pathSegments);
8613
+ const siblingRaw = readComparableBodyLeaf(parsedBody, body, pathSegments);
8121
8614
  if (siblingRaw === undefined) {
8122
8615
  // This body has a value for the field we're trying to explain but no
8123
8616
  // value for the candidate sibling — the sibling can't vouch for it,
@@ -8228,7 +8721,7 @@ function findFrozenVaryingDrillParams(capture, renderedText, allCaptures) {
8228
8721
  continue;
8229
8722
  const fieldPath = path.join(".");
8230
8723
  const differing = otherBodies
8231
- .map((otherBody) => readValueAtPath(otherBody, path))
8724
+ .map((otherBody) => readComparableBodyLeaf(parsedBody, otherBody, path))
8232
8725
  .find((v) => v !== undefined && String(v) !== stringValue);
8233
8726
  if (differing !== undefined &&
8234
8727
  !isExplainedByCorrelatedSiblingField(path, parsedBody, otherBodies, new Set(allPaths.filter((p) => p !== fieldPath)))) {
@@ -9206,6 +9699,64 @@ function readValueAtPath(body, path) {
9206
9699
  return node[segment];
9207
9700
  }, body);
9208
9701
  }
9702
+ const ARRAY_ELEMENT_IDENTITY = /^[\s\S]*([^A-Za-z0-9=]\w+=[\s\S]*)$/;
9703
+ /** The `<delimiter><name>=<anything>` suffix of a self-describing array element
9704
+ * (the grammar {@link matchArrayElementFacet} binds on), or `undefined`. The
9705
+ * suffix is open-ended because slugs and names carry hyphens, dots and colons
9706
+ * — a stricter shape would silently fall back to index comparison for them. */
9707
+ function arrayElementIdentity(element) {
9708
+ if (typeof element !== "string")
9709
+ return undefined;
9710
+ return ARRAY_ELEMENT_IDENTITY.exec(element)?.[1];
9711
+ }
9712
+ /**
9713
+ * Index of the innermost path segment that indexes into an array (the array
9714
+ * holding the leaf, or one holding an object element that carries it), or -1.
9715
+ */
9716
+ function innermostArrayIndexSegment(body, path) {
9717
+ for (let index = path.length - 1; index >= 0; index--) {
9718
+ if (!/^\d+$/.test(path[index]))
9719
+ continue;
9720
+ if (Array.isArray(readValueAtPath(body, path.slice(0, index))))
9721
+ return index;
9722
+ }
9723
+ return -1;
9724
+ }
9725
+ /**
9726
+ * Counterpart of the primitive leaf at `path` in `body`, read from `otherBody`.
9727
+ * A string leaf inside an array of self-describing elements (directly, or as a
9728
+ * field of an object element) is matched by its identity suffix within the same
9729
+ * array, never by index, so differently shaped arrays are not mistaken for one
9730
+ * array with a varying slot; an identity the other body lacks yields
9731
+ * `undefined`. A plain element of such an array is matched by value, since its
9732
+ * slot says nothing about it once the arrays' shapes differ. Every other path
9733
+ * defers to {@link readValueAtPath}.
9734
+ */
9735
+ function readComparableBodyLeaf(body, otherBody, path) {
9736
+ const leaf = readValueAtPath(body, path);
9737
+ const arrayEnd = typeof leaf === "string" ? innermostArrayIndexSegment(body, path) : -1;
9738
+ if (arrayEnd === -1)
9739
+ return readValueAtPath(otherBody, path);
9740
+ const arrayPath = path.slice(0, arrayEnd);
9741
+ const elementSubpath = path.slice(arrayEnd + 1);
9742
+ const ownArray = readValueAtPath(body, arrayPath);
9743
+ const otherArray = readValueAtPath(otherBody, arrayPath);
9744
+ const identity = arrayElementIdentity(leaf);
9745
+ const isSelfDescribing = (array) => array.some((element) => arrayElementIdentity(readValueAtPath(element, elementSubpath)));
9746
+ if (identity === undefined &&
9747
+ !isSelfDescribing(ownArray) &&
9748
+ !(Array.isArray(otherArray) && isSelfDescribing(otherArray))) {
9749
+ return readValueAtPath(otherBody, path);
9750
+ }
9751
+ if (!Array.isArray(otherArray))
9752
+ return undefined;
9753
+ const counterparts = otherArray.map((element) => readValueAtPath(element, elementSubpath));
9754
+ if (counterparts.includes(leaf))
9755
+ return leaf;
9756
+ if (identity === undefined)
9757
+ return undefined;
9758
+ return counterparts.find((candidate) => arrayElementIdentity(candidate) === identity);
9759
+ }
9209
9760
  /**
9210
9761
  * Turns a flow author's plain dot-separated {@link FoldReturnSpec.resultsPath}
9211
9762
  * / `drillResultsPath` into the SAME {@link ARRAY_WILDCARD_SEGMENT}-bearing
@@ -10730,6 +11281,64 @@ function buildContractChecklist(opts) {
10730
11281
  outOfTreeChecklistLine,
10731
11282
  ].filter((line) => line !== "");
10732
11283
  }
11284
+ 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;
11285
+ const PAYLOAD_CHAIN_SEGMENT = /\??\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(?:"[^"\]]*"|\d+)\]/g;
11286
+ function newAccessorShape() {
11287
+ return { isArray: false, element: null, keys: new Map() };
11288
+ }
11289
+ function renderAccessorShape(shape) {
11290
+ if (shape.isArray) {
11291
+ return `z.array(${shape.element ? renderAccessorShape(shape.element) : "z.string()"})`;
11292
+ }
11293
+ if (shape.keys.size === 0)
11294
+ return "z.string()";
11295
+ const entries = [...shape.keys.entries()]
11296
+ .sort(([a], [b]) => a.localeCompare(b))
11297
+ .map(([key, child]) => `${isValidJsIdentifier(key) ? key : JSON.stringify(key)}: ${renderAccessorShape(child)}`);
11298
+ return `z.object({ ${entries.join(", ")} })`;
11299
+ }
11300
+ /**
11301
+ * Derives each `payload.<field>` Zod type from the full accessor chain the
11302
+ * emitted body text uses (`payload.f["0"]!.x` => array of objects with key
11303
+ * `x`), so the schema can never reject a property access the body performs.
11304
+ */
11305
+ function deriveBodyAccessorShapes(body) {
11306
+ const roots = new Map();
11307
+ for (const m of body.matchAll(PAYLOAD_ACCESSOR_CHAIN)) {
11308
+ const root = roots.get(m[1]) ?? newAccessorShape();
11309
+ roots.set(m[1], root);
11310
+ let node = root;
11311
+ for (const seg of m[2].matchAll(PAYLOAD_CHAIN_SEGMENT)) {
11312
+ if (seg[1] === undefined) {
11313
+ node.isArray = true;
11314
+ node.element ??= newAccessorShape();
11315
+ node = node.element;
11316
+ continue;
11317
+ }
11318
+ const child = node.keys.get(seg[1]) ?? newAccessorShape();
11319
+ node.keys.set(seg[1], child);
11320
+ node = child;
11321
+ }
11322
+ }
11323
+ return roots;
11324
+ }
11325
+ function isStructuredAccessorShape(shape) {
11326
+ return shape.isArray || shape.keys.size > 0;
11327
+ }
11328
+ /**
11329
+ * Whether a declared Zod expression already admits every access the shape
11330
+ * demands (array-ness and each dereferenced key), so a richer structured
11331
+ * declaration is kept instead of being narrowed to the accessor-derived one.
11332
+ */
11333
+ function declaredSchemaCoversAccessorShape(declared, shape) {
11334
+ if (shape.isArray && !/\bz\.array\(/.test(declared))
11335
+ return false;
11336
+ if (!shape.isArray && shape.keys.size > 0 && !/\bz\.(object|record)\(/.test(declared)) {
11337
+ return false;
11338
+ }
11339
+ const childShapes = [...(shape.element ? [shape.element] : []), ...shape.keys.values()];
11340
+ return ([...shape.keys.keys()].every((key) => new RegExp(`(?:^|[\\s{,])"?${key.replace(/[$]/g, "\\$&")}"?\\s*:`).test(declared)) && childShapes.every((child) => declaredSchemaCoversAccessorShape(declared, child)));
11341
+ }
10733
11342
  /** Generates a complete contract.ts source string for a plugin — exported so
10734
11343
  * unit tests can drive the emitter directly without spawning the CLI. */
10735
11344
  function emitContractTs(opts) {
@@ -10891,7 +11500,10 @@ function emitContractTs(opts) {
10891
11500
  // override semantics a chain of `.extend()` calls used to have (each
10892
11501
  // subsequent `.extend` replaced an earlier field of the same name).
10893
11502
  const extendFields = new Map();
11503
+ const structuredFieldNames = new Set();
10894
11504
  const addExtendField = (name, line) => {
11505
+ if (structuredFieldNames.has(name))
11506
+ return;
10895
11507
  extendFields.set(name, line);
10896
11508
  };
10897
11509
  // A detected bounded-paging signal means buildPaginatedFetchLoopExecuteHttpBody
@@ -11049,7 +11661,8 @@ function emitContractTs(opts) {
11049
11661
  continue;
11050
11662
  const key = isValidJsIdentifier(name) ? name : JSON.stringify(name);
11051
11663
  const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
11052
- addExtendField(name, ` ${key}: ${value},`);
11664
+ extendFields.set(name, ` ${key}: ${value},`);
11665
+ structuredFieldNames.add(name);
11053
11666
  }
11054
11667
  // Closing-the-loop safety net: every discovered-field source above tracks
11055
11668
  // its own registration as it splices a `payload.<field>` accessor into the
@@ -11068,14 +11681,34 @@ function emitContractTs(opts) {
11068
11681
  // its structural root regardless of which upstream pass forgot to record a
11069
11682
  // field, instead of adding a fifth registration site that could itself be
11070
11683
  // forgotten by a future pass.
11684
+ const accessorStructuredFields = new Set();
11071
11685
  if (multiStepBody) {
11072
- const bodyReferencedFields = new Set([...multiStepBody.matchAll(/\bpayload\.([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((m) => m[1]));
11073
- for (const name of [...bodyReferencedFields].sort()) {
11074
- if (extendFields.has(name))
11075
- continue;
11686
+ const bodyReferencedFields = deriveBodyAccessorShapes(multiStepBody);
11687
+ for (const name of [...bodyReferencedFields.keys()].sort()) {
11076
11688
  if (isReservedByApplicantContactSchema(name))
11077
11689
  continue;
11078
- addExtendField(name, ` ${name}: z.string(),`);
11690
+ const shape = bodyReferencedFields.get(name);
11691
+ const structured = isStructuredAccessorShape(shape);
11692
+ // The body text is the ground truth for a field's shape: a scalar
11693
+ // registration from any source must not outlive an accessor that indexes
11694
+ // or dereferences the field, and a structured declaration is kept only
11695
+ // when it already admits every access the body performs.
11696
+ if (structured) {
11697
+ const declared = extendFields.get(name);
11698
+ if (structuredFieldNames.has(name) &&
11699
+ declared !== undefined &&
11700
+ declaredSchemaCoversAccessorShape(declared, shape)) {
11701
+ accessorStructuredFields.add(name);
11702
+ continue;
11703
+ }
11704
+ accessorStructuredFields.add(name);
11705
+ structuredFieldNames.delete(name);
11706
+ }
11707
+ else if (extendFields.has(name)) {
11708
+ continue;
11709
+ }
11710
+ const zod = renderAccessorShape(shape);
11711
+ addExtendField(name, ` ${name}: ${zod === "z.string()" || !payloadNeedsMultipart ? zod : `multipartJsonObject(${zod})`},`);
11079
11712
  }
11080
11713
  }
11081
11714
  // The structural walk over the captured request body that used to BE the
@@ -11123,6 +11756,8 @@ function emitContractTs(opts) {
11123
11756
  // capture this run and is a no-op, not an error: there is no schema line
11124
11757
  // for it to attach to.
11125
11758
  for (const [fieldName, constraint] of Object.entries(valueConstraints)) {
11759
+ if (accessorStructuredFields.has(fieldName))
11760
+ continue;
11126
11761
  const line = extendFields.get(fieldName);
11127
11762
  if (line === null || line === undefined)
11128
11763
  continue;
@@ -11243,7 +11878,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
11243
11878
  // the same captured request-variables object the signal was itself
11244
11879
  // detected from (see paginationOperationIdentity above) — the default
11245
11880
  // `{ q: payload.query }` REST body has no skip/count container to advance.
11246
- const navigateToFacets = extractNavigateToFacetOrder(contractFlowSteps);
11881
+ const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps), actionSteps);
11247
11882
  const gqlVariablesExpr = gqlOperationName
11248
11883
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
11249
11884
  : paginationSignal