@enricai/barnacle 1.12.77 → 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,11 +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;
69
71
  exports.spliceFacetsIntoArrayVariable = spliceFacetsIntoArrayVariable;
72
+ exports.renderGqlVariablesExpr = renderGqlVariablesExpr;
70
73
  exports.findAllObjectArrayFields = findAllObjectArrayFields;
71
74
  exports.findAllObjectArrayFieldsOrWholeObject = findAllObjectArrayFieldsOrWholeObject;
72
75
  exports.applyDrillParamBindings = applyDrillParamBindings;
@@ -382,14 +385,53 @@ function extractNavigateToHashFragmentValue(url, previousHash) {
382
385
  if (hashIndex === -1)
383
386
  return null;
384
387
  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;
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;
390
426
  }
391
- const segments = hash.split("/").filter((s) => s.length > 0);
392
- 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;
393
435
  }
394
436
  /**
395
437
  * Derives a payload field name from the field LABEL in a fill/enter/type
@@ -437,7 +479,11 @@ function deriveFillLabelField(instruction) {
437
479
  function harvestPersonaBindings(flowSteps, vocabulary, env) {
438
480
  const bindings = new Map();
439
481
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary, env);
440
- 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
+ }
441
487
  for (const step of flowSteps) {
442
488
  const isObj = typeof step !== "string";
443
489
  const instruction = isObj ? step.step : step;
@@ -448,21 +494,8 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
448
494
  // `.../#/jobs/category-widgets`), so the value to correlate against
449
495
  // downstream header/body templates is the URL's own hash-derived
450
496
  // 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}`);
497
+ if (isObj && step.navigateTo !== undefined)
464
498
  continue;
465
- }
466
499
  const vocabField = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
467
500
  // Vocabulary wins outright. Only on a miss do we fall back to deriving the
468
501
  // field from the instruction's own label — and never when the author opted
@@ -492,26 +525,18 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
492
525
  * which DOES see the captures, re-derive the same (value, field) pairs and
493
526
  * try the causally-adjacent-action fallback (see `emitMultiStepExecuteHttp`)
494
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.
495
533
  */
496
534
  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;
535
+ return walkNavigateToFacets(flowSteps).map((facet) => ({
536
+ value: facet.value ?? "",
537
+ field: facet.field,
538
+ optional: facet.optional,
539
+ }));
515
540
  }
516
541
  /**
517
542
  * Detects a flow step whose quoted VALUE is the space-joined concatenation of
@@ -2824,6 +2849,17 @@ function jsonBodyLeafValues(requestPostData) {
2824
2849
  }
2825
2850
  return values;
2826
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
+ }
2827
2863
  /**
2828
2864
  * Same JSON body walk as {@link jsonBodyLeafValues}, but grouped by the JSON
2829
2865
  * key/array-index that carries each leaf value — the by-name correlation
@@ -4031,7 +4067,28 @@ function containsValueAtTokenBoundary(haystack, needle) {
4031
4067
  from = at + needle.length;
4032
4068
  }
4033
4069
  }
4034
- function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, rootBody, payloadAccessorExcludePattern) {
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) {
4035
4092
  let result = template;
4036
4093
  let cursor = searchFrom;
4037
4094
  // The body root's own entries are eligible too, whenever the located
@@ -4041,11 +4098,11 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4041
4098
  // pagination/sort block) outranks the root as the "form envelope". Root
4042
4099
  // keys already present on the envelope are skipped so nothing is visited
4043
4100
  // (or registered) twice.
4044
- const envelopeEntries = Object.entries(envelope);
4045
- const rootEntries = envelopePath.length === 0
4101
+ const envelopeEntries = Object.entries(envelope).map(([key, value]) => [key, value, [...envelopePath, key]]);
4102
+ const rootEntries = (envelopePath.length === 0
4046
4103
  ? []
4047
- : Object.entries(rootBody).filter(([key]) => key !== envelopePath[0] && !(key in envelope));
4048
- 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]) {
4049
4106
  const isNonEmptyArray = Array.isArray(value) && value.length > 0;
4050
4107
  const isNestedObject = value !== null &&
4051
4108
  typeof value === "object" &&
@@ -4053,6 +4110,22 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4053
4110
  Object.keys(value).length > 0;
4054
4111
  if (!isNonEmptyArray && !isNestedObject)
4055
4112
  continue;
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.
4056
4129
  if (unconditionalExcludeValues.size > 0 ||
4057
4130
  restrictedExcludeSourceByValue.size > 0 ||
4058
4131
  payloadAccessorExcludePattern !== null) {
@@ -4162,9 +4235,6 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4162
4235
  const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
4163
4236
  result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
4164
4237
  cursor = Math.max(cursor, spanStart + replacement.length);
4165
- if (!outStructuredKeys.has(key)) {
4166
- outStructuredKeys.set(key, inferZodSchema(value));
4167
- }
4168
4238
  }
4169
4239
  return { result, nextSearchFrom: cursor };
4170
4240
  }
@@ -4174,7 +4244,7 @@ function applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope,
4174
4244
  * pass to it. Shared by the top-level-object and top-level-array branches of
4175
4245
  * {@link applyStructuredValuePayloadSubstitutions} below.
4176
4246
  */
4177
- function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern) {
4247
+ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, payloadAccessorExcludePattern, ownedArrayPathKeys) {
4178
4248
  const envelopePath = locateFormEnvelopePath(objectBody);
4179
4249
  let envelope = objectBody;
4180
4250
  for (const seg of envelopePath) {
@@ -4185,7 +4255,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4185
4255
  if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
4186
4256
  return { result: template, nextSearchFrom: searchFrom };
4187
4257
  }
4188
- return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern);
4258
+ return applyStructuredValuePayloadSubstitutionsForEnvelope(template, envelope, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, searchFrom, envelopePath, objectBody, payloadAccessorExcludePattern, ownedArrayPathKeys);
4189
4259
  }
4190
4260
  /**
4191
4261
  * `priorStepStateBindings` carries the SAME value→binding map
@@ -4210,7 +4280,7 @@ function applyStructuredValuePayloadSubstitutionsForObjectBody(template, objectB
4210
4280
  * excluded from threading — the same value-coincidence class of bug
4211
4281
  * {@link keyNamesCorrelate}'s other call sites already guard against.
4212
4282
  */
4213
- function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map()) {
4283
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys, priorStepStateBindings = new Map(), joinFieldValues = new Set(), payloadAccessorExcludeValues = new Map(), ownedArrayPathKeys = new Set()) {
4214
4284
  if (parsedBody === null || typeof parsedBody !== "object") {
4215
4285
  return template;
4216
4286
  }
@@ -4237,13 +4307,13 @@ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStruc
4237
4307
  for (const element of parsedBody) {
4238
4308
  if (element === null || typeof element !== "object" || Array.isArray(element))
4239
4309
  continue;
4240
- const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern);
4310
+ const { result: nextResult, nextSearchFrom } = applyStructuredValuePayloadSubstitutionsForObjectBody(result, element, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, cursor, payloadAccessorExcludePattern, ownedArrayPathKeys);
4241
4311
  result = nextResult;
4242
4312
  cursor = nextSearchFrom;
4243
4313
  }
4244
4314
  return result;
4245
4315
  }
4246
- const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern);
4316
+ const { result } = applyStructuredValuePayloadSubstitutionsForObjectBody(template, parsedBody, outStructuredKeys, unconditionalExcludeValues, restrictedExcludeSourceByValue, 0, payloadAccessorExcludePattern, ownedArrayPathKeys);
4247
4317
  return result;
4248
4318
  }
4249
4319
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
@@ -4508,12 +4578,22 @@ function payloadAccessorForPath(path) {
4508
4578
  const arrayIndexPos = path.findIndex((segment) => /^\d+$/.test(segment));
4509
4579
  if (arrayIndexPos === -1) {
4510
4580
  const field = pathToPayloadFieldName(path);
4511
- return { accessor: `payload.${field}`, field };
4581
+ return { accessor: `payload.${field}`, field, structuredRootPath: null };
4512
4582
  }
4513
4583
  const objectPath = path.slice(0, arrayIndexPos);
4514
4584
  const field = objectPath.length > 0 ? pathToPayloadFieldName(objectPath) : (path[0] ?? "");
4515
4585
  const suffix = pathToAccessor(path.slice(arrayIndexPos), { assertNonNull: true });
4516
- 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);
4517
4597
  }
4518
4598
  /** Derives a valid camelCase identifier from a fixture filename (e.g.
4519
4599
  * "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
@@ -5686,12 +5766,29 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
5686
5766
  /** Every capture's response body from the same run, scanned only for
5687
5767
  * {@link CAPACITY_FIELD_NAME_PATTERN}-shaped numeric leaves — the
5688
5768
  * evidence source for a discovered "number" key's `capacityMax`. */
5689
- responseBodiesForCapacitySignal = []) {
5769
+ responseBodiesForCapacitySignal = [], outStructuredKeys = new Map()) {
5690
5770
  const merged = [];
5691
5771
  const seenPairs = new Set();
5692
5772
  const seenValueByKey = new Map();
5693
5773
  const distinctValuesByKey = new Map();
5694
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
+ }
5695
5792
  for (const body of allBodies) {
5696
5793
  if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
5697
5794
  continue;
@@ -5702,6 +5799,8 @@ responseBodiesForCapacitySignal = []) {
5702
5799
  const key = path[0];
5703
5800
  if (!isValidJsIdentifier(key))
5704
5801
  continue;
5802
+ if (outStructuredKeys.has(key))
5803
+ continue;
5705
5804
  if (value === null)
5706
5805
  continue;
5707
5806
  // Dedupe identical (key, value) pairs only — a repeated occurrence of
@@ -5870,17 +5969,57 @@ responseBodiesForCapacitySignal = []) {
5870
5969
  * steps declared any field to correlate against) or when a leaf's value has
5871
5970
  * no `key:value` segment matching one of them.
5872
5971
  */
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. */
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. */
5879
6015
  function* walkArrayLeaves(value, path = []) {
5880
6016
  if (value === null || typeof value !== "object")
5881
6017
  return;
5882
6018
  if (Array.isArray(value)) {
5883
6019
  yield { value, path };
6020
+ for (const [index, inner] of value.entries()) {
6021
+ yield* walkArrayLeaves(inner, [...path, String(index)]);
6022
+ }
5884
6023
  return;
5885
6024
  }
5886
6025
  for (const [key, inner] of Object.entries(value)) {
@@ -5890,45 +6029,159 @@ function* walkArrayLeaves(value, path = []) {
5890
6029
  /**
5891
6030
  * REST-body counterpart to {@link spliceFacetsIntoArrayVariable}'s GraphQL
5892
6031
  * 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.
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.
5900
6039
  *
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))
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)
5915
6049
  return template;
5916
6050
  if (inputBody === null || typeof inputBody !== "object")
5917
6051
  return template;
5918
6052
  let result = template;
5919
- for (const { value } of walkArrayLeaves(inputBody)) {
5920
- const spliced = spliceFacetsIntoArrayVariable(value, navigateToFacets);
5921
- if (spliced === null || !spliced.includes("...(payload."))
6053
+ for (const { value, path } of walkArrayLeaves(inputBody)) {
6054
+ if (value.length === 0)
5922
6055
  continue;
5923
- const target = JSON.stringify(value);
5924
- if (!result.includes(target))
6056
+ if (!ownedArrayPathKeys.has(arrayPathKey(path)) &&
6057
+ !containsFacetArray(value, navigateToFacets)) {
5925
6058
  continue;
5926
- result = result.split(target).join(`\${JSON.stringify(${spliced})}`);
6059
+ }
6060
+ const expression = renderFacetValueExpression(value, navigateToFacets);
6061
+ result = replaceJsonArrayOccurrences(result, value, `\${JSON.stringify(${expression})}`);
5927
6062
  }
5928
6063
  return result;
5929
6064
  }
5930
- function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldNames, optionalFieldNames) {
5931
- if (payloadFieldNames.size === 0)
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)
5932
6185
  return template;
5933
6186
  if (inputBody === undefined ||
5934
6187
  inputBody === null ||
@@ -5939,18 +6192,23 @@ function applyFacetSplicePayloadSubstitutions(template, inputBody, payloadFieldN
5939
6192
  const fields = [...payloadFieldNames];
5940
6193
  let result = template;
5941
6194
  for (const { value, path } of walkAllPrimitiveLeaves(inputBody)) {
5942
- if (path.length !== 1)
5943
- continue;
5944
- const key = path[0];
5945
- if (!isValidJsIdentifier(key))
6195
+ const key = path[path.length - 1];
6196
+ if (key === undefined || !isValidJsIdentifier(key))
5946
6197
  continue;
5947
6198
  if (typeof value !== "string")
5948
6199
  continue;
5949
- const spliced = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6200
+ const kv = spliceFacetsIntoStringVariable(value, fields, optionalFieldNames);
6201
+ const spliced = kv ?? packedFacetRecurrence(value, navigateToFacets);
5950
6202
  if (spliced === null)
5951
6203
  continue;
5952
6204
  const target = `"${key}":${JSON.stringify(value)}`;
5953
- 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}}"`;
5954
6212
  result = result.split(target).join(replacement);
5955
6213
  }
5956
6214
  return result;
@@ -6231,8 +6489,12 @@ function parseBodyLeafValues(requestPostData) {
6231
6489
  * affect (the already-working recurrence-anchored path exists precisely
6232
6490
  * because a real facet DOES recur in a later request; nothing here changes
6233
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.
6234
6496
  */
6235
- function collectNewlyAppearingRequestValueTransitions(actions) {
6497
+ function collectNewlyAppearingRequestValueTransitions(actions, explainedLiterals = []) {
6236
6498
  const seenGlobally = new Set();
6237
6499
  const transitions = [];
6238
6500
  for (let i = 1; i < actions.length; i++) {
@@ -6243,6 +6505,8 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6243
6505
  const record = (value) => {
6244
6506
  if (value.length === 0 || seenGlobally.has(value) || seenThisTransition.has(value))
6245
6507
  return;
6508
+ if (explainedLiterals.some((literal) => occursAtTokenBoundary(value, literal)))
6509
+ return;
6246
6510
  seenThisTransition.add(value);
6247
6511
  values.push(value);
6248
6512
  };
@@ -6263,6 +6527,79 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6263
6527
  }
6264
6528
  return transitions;
6265
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
+ }
6266
6603
  /**
6267
6604
  * Resolves each zero-recurrence navigateTo facet (in flow order) to the real
6268
6605
  * request value it causally affects, or returns `null` when the correlation
@@ -6279,14 +6616,27 @@ function collectNewlyAppearingRequestValueTransitions(actions) {
6279
6616
  * no longer guaranteed to line facets up with the RIGHT transition, so
6280
6617
  * nothing is bound — leaving a facet declared-but-unbound is preferable to
6281
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.
6282
6623
  */
6283
- function correlateUnreachableNavigateToFacets(facets, actions) {
6284
- 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
6285
6636
  .filter((transition) => transition.length === 1)
6286
6637
  .map((transition) => transition[0]);
6287
6638
  if (unambiguousValues.length !== facets.length)
6288
6639
  return null;
6289
- const byField = new Map();
6290
6640
  facets.forEach(({ field }, index) => {
6291
6641
  byField.set(field, unambiguousValues[index]);
6292
6642
  });
@@ -6333,12 +6683,30 @@ flowSteps = [], vocabulary = undefined) {
6333
6683
  // below can correlate a REST body's facet-packed string leaves against it.
6334
6684
  const { payloadFieldNames: restPayloadFieldNames, optionalPayloadFieldNames: restOptionalFieldNames, } = computeFlowPayloadFieldNames(flowSteps, vocabulary, process.env);
6335
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);
6336
6689
  if (inputBody !== undefined && inputBody !== null) {
6337
6690
  for (const { value, path } of walkStringLeaves(inputBody)) {
6338
6691
  if (value.length < MIN_STATE_VALUE_LENGTH)
6339
6692
  continue;
6340
- 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);
6341
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
+ }
6342
6710
  if (isValidJsIdentifier(accessorField))
6343
6711
  outDiscoveredFields.add(accessorField);
6344
6712
  // Phase F: register a lowercase variant for UUID-shaped values so case-
@@ -6428,31 +6796,22 @@ flowSteps = [], vocabulary = undefined) {
6428
6796
  // or when the correlation is ambiguous (see
6429
6797
  // `correlateUnreachableNavigateToFacets`), there is nothing safe to splice
6430
6798
  // 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);
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;
6455
6808
  }
6809
+ if (!bindsWithoutCollision(value))
6810
+ continue;
6811
+ if (payloadAccessorByValue.has(value))
6812
+ continue;
6813
+ payloadAccessorByValue.set(value, `payload.${field}`);
6814
+ outDiscoveredFields.add(field);
6456
6815
  }
6457
6816
  // Job coordinates from the recon entry URL's query string (e.g.
6458
6817
  // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
@@ -6705,7 +7064,7 @@ flowSteps = [], vocabulary = undefined) {
6705
7064
  // same declared field set, just applied to REST body text instead of a
6706
7065
  // variables object literal.
6707
7066
  const rawBodyWithFacetSplices = parsedBody !== null
6708
- ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames)
7067
+ ? applyFacetSplicePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, restPayloadFieldNames, restOptionalFieldNames, navigateToFacetOrder)
6709
7068
  : rawBodyWithFormSubs;
6710
7069
  // Optional-facet array-element splice — a navigateTo facet literal
6711
7070
  // recurring as one element of an array field (not the `key:value`
@@ -6713,10 +7072,13 @@ flowSteps = [], vocabulary = undefined) {
6713
7072
  // when its binding is optional and the caller omits it. Runs BEFORE
6714
7073
  // Mechanism B (below) can swallow the whole array wholesale as an
6715
7074
  // unparameterized structured value — see
6716
- // {@link applyOptionalArrayFacetSplicePayloadSubstitutions}.
7075
+ // {@link applyArrayFacetSplicePayloadSubstitutions}.
6717
7076
  const rawBodyWithOptionalArrayFacetSplices = parsedBody !== null
6718
- ? applyOptionalArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder)
7077
+ ? applyArrayFacetSplicePayloadSubstitutions(rawBodyWithFacetSplices, parsedBody, navigateToFacetOrder, facetArrayPathKeys)
6719
7078
  : rawBodyWithFacetSplices;
7079
+ const rawBodyWithScalarFacetSplices = parsedBody !== null
7080
+ ? applyScalarFacetSplicePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, navigateToFacetOrder)
7081
+ : rawBodyWithOptionalArrayFacetSplices;
6720
7082
  // Mechanism B — parameterize whole nested caller structures
6721
7083
  // (experienceData/educationData history, opaque eventData) BEFORE value
6722
7084
  // substitution reaches inside them: swallowing the entire array/object first
@@ -6727,7 +7089,7 @@ flowSteps = [], vocabulary = undefined) {
6727
7089
  // dependent-drill-down join field (e.g. a bulk `{"tokens":[...]}` lookup)
6728
7090
  // must stay reachable for state threading, not get frozen as caller data.
6729
7091
  const rawBodyWithStructuredSubs = parsedBody !== null
6730
- ? applyStructuredValuePayloadSubstitutions(rawBodyWithOptionalArrayFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue)
7092
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithScalarFacetSplices, parsedBody, outStructuredKeys, deriveStateVarByValue(prior, cap), joinFieldValuesByStep.get(i) ?? new Set(), payloadAccessorByValue, facetArrayPathKeys)
6731
7093
  : rawBodyWithFormSubs;
6732
7094
  // Whole-value caller coordinates bind here — after structured subs, BEFORE
6733
7095
  // state threading — so a composite coordinate (a jobLocation or jobSeqNo
@@ -6759,7 +7121,7 @@ flowSteps = [], vocabulary = undefined) {
6759
7121
  ? applyUrlParamPayloadSubstitutions(rawBodyWithProducerBoundary, parsedBody, urlParamBindings)
6760
7122
  : rawBodyWithProducerBoundary;
6761
7123
  const bodyAfterStateAndKv = rawBodyWithUrlParams
6762
- ? 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)
6763
7125
  : "";
6764
7126
  // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
6765
7127
  // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
@@ -7650,6 +8012,87 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7650
8012
  }
7651
8013
  return `[${elements.join(", ")}].join("")`;
7652
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
+ }
7653
8096
  /**
7654
8097
  * Splices a captured array-valued GraphQL variable's elements against
7655
8098
  * navigateTo facet bindings instead of the `key:value` segment grammar
@@ -7665,24 +8108,16 @@ function spliceFacetsIntoStringVariable(value, fields, optionalFields = new Set(
7665
8108
  * {@link spliceFacetsIntoStringVariable} uses, so an absent optional facet
7666
8109
  * drops its element rather than emitting `undefined`.
7667
8110
  */
7668
- function spliceFacetsIntoArrayVariable(value, facets) {
8111
+ function spliceFacetsIntoArrayVariable(value, facets, renderElement = (element) => JSON.stringify(element)) {
7669
8112
  if (!Array.isArray(value))
7670
8113
  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
8114
  let matchCount = 0;
7680
8115
  const elements = value.map((element) => {
7681
8116
  if (typeof element !== "string")
7682
- return JSON.stringify(element);
7683
- const matched = matchFacet(element);
8117
+ return renderElement(element);
8118
+ const matched = matchArrayElementFacet(element, facets);
7684
8119
  if (!matched)
7685
- return JSON.stringify(element);
8120
+ return renderElement(element);
7686
8121
  matchCount++;
7687
8122
  const suffix = element.slice(matched.value.length);
7688
8123
  const elementLiteral = `\`\${payload.${matched.field}}${escapeForTemplateLiteral(suffix)}\``;
@@ -7714,18 +8149,26 @@ function renderGqlVariablesExpr(variables, payloadFieldNames, optionalFieldNames
7714
8149
  if (variables === null || typeof variables !== "object" || Array.isArray(variables))
7715
8150
  return "{}";
7716
8151
  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(", ")} }` : "{}";
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);
7729
8172
  }
7730
8173
  /** A count-style page-size key (e.g. `count`, `limit`, `first`) paired with a
7731
8174
  * skip-style offset key (e.g. `skip`, `offset`) in the same object is the
@@ -10730,6 +11173,64 @@ function buildContractChecklist(opts) {
10730
11173
  outOfTreeChecklistLine,
10731
11174
  ].filter((line) => line !== "");
10732
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
+ }
10733
11234
  /** Generates a complete contract.ts source string for a plugin — exported so
10734
11235
  * unit tests can drive the emitter directly without spawning the CLI. */
10735
11236
  function emitContractTs(opts) {
@@ -10891,7 +11392,10 @@ function emitContractTs(opts) {
10891
11392
  // override semantics a chain of `.extend()` calls used to have (each
10892
11393
  // subsequent `.extend` replaced an earlier field of the same name).
10893
11394
  const extendFields = new Map();
11395
+ const structuredFieldNames = new Set();
10894
11396
  const addExtendField = (name, line) => {
11397
+ if (structuredFieldNames.has(name))
11398
+ return;
10895
11399
  extendFields.set(name, line);
10896
11400
  };
10897
11401
  // A detected bounded-paging signal means buildPaginatedFetchLoopExecuteHttpBody
@@ -11049,7 +11553,8 @@ function emitContractTs(opts) {
11049
11553
  continue;
11050
11554
  const key = isValidJsIdentifier(name) ? name : JSON.stringify(name);
11051
11555
  const value = payloadNeedsMultipart ? `multipartJsonObject(${schema})` : schema;
11052
- addExtendField(name, ` ${key}: ${value},`);
11556
+ extendFields.set(name, ` ${key}: ${value},`);
11557
+ structuredFieldNames.add(name);
11053
11558
  }
11054
11559
  // Closing-the-loop safety net: every discovered-field source above tracks
11055
11560
  // its own registration as it splices a `payload.<field>` accessor into the
@@ -11068,14 +11573,34 @@ function emitContractTs(opts) {
11068
11573
  // its structural root regardless of which upstream pass forgot to record a
11069
11574
  // field, instead of adding a fifth registration site that could itself be
11070
11575
  // forgotten by a future pass.
11576
+ const accessorStructuredFields = new Set();
11071
11577
  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;
11578
+ const bodyReferencedFields = deriveBodyAccessorShapes(multiStepBody);
11579
+ for (const name of [...bodyReferencedFields.keys()].sort()) {
11076
11580
  if (isReservedByApplicantContactSchema(name))
11077
11581
  continue;
11078
- 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})`},`);
11079
11604
  }
11080
11605
  }
11081
11606
  // The structural walk over the captured request body that used to BE the
@@ -11123,6 +11648,8 @@ function emitContractTs(opts) {
11123
11648
  // capture this run and is a no-op, not an error: there is no schema line
11124
11649
  // for it to attach to.
11125
11650
  for (const [fieldName, constraint] of Object.entries(valueConstraints)) {
11651
+ if (accessorStructuredFields.has(fieldName))
11652
+ continue;
11126
11653
  const line = extendFields.get(fieldName);
11127
11654
  if (line === null || line === undefined)
11128
11655
  continue;
@@ -11243,7 +11770,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
11243
11770
  // the same captured request-variables object the signal was itself
11244
11771
  // detected from (see paginationOperationIdentity above) — the default
11245
11772
  // `{ q: payload.query }` REST body has no skip/count container to advance.
11246
- const navigateToFacets = extractNavigateToFacetOrder(contractFlowSteps);
11773
+ const navigateToFacets = resolveRecurringNavigateToFacets(extractNavigateToFacetOrder(contractFlowSteps), actionSteps);
11247
11774
  const gqlVariablesExpr = gqlOperationName
11248
11775
  ? renderGqlVariablesExpr(gqlVariables, payloadFieldNames, optionalPayloadFieldNames, navigateToFacets)
11249
11776
  : paginationSignal