@enricai/barnacle 1.5.0 → 1.6.1

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 (71) hide show
  1. package/README.md +86 -0
  2. package/dist/api/errors.d.ts +2 -1
  3. package/dist/api/errors.d.ts.map +1 -1
  4. package/dist/api/errors.js +3 -2
  5. package/dist/api/errors.js.map +1 -1
  6. package/dist/lib/llm/judge.d.ts +1 -1
  7. package/dist/lib/llm/judge.js +1 -1
  8. package/dist/recon/capture-filters.d.ts +40 -0
  9. package/dist/recon/capture-filters.d.ts.map +1 -0
  10. package/dist/recon/capture-filters.js +106 -0
  11. package/dist/recon/capture-filters.js.map +1 -0
  12. package/dist/recon/form-schema.d.ts +51 -0
  13. package/dist/recon/form-schema.d.ts.map +1 -0
  14. package/dist/recon/form-schema.js +20 -0
  15. package/dist/recon/form-schema.js.map +1 -0
  16. package/dist/recon/load-form-schema.d.ts +24 -0
  17. package/dist/recon/load-form-schema.d.ts.map +1 -0
  18. package/dist/recon/load-form-schema.js +66 -0
  19. package/dist/recon/load-form-schema.js.map +1 -0
  20. package/dist/scraper/cookie-jar.d.ts +19 -0
  21. package/dist/scraper/cookie-jar.d.ts.map +1 -0
  22. package/dist/scraper/cookie-jar.js +43 -0
  23. package/dist/scraper/cookie-jar.js.map +1 -0
  24. package/dist/scraper/errors.d.ts +14 -14
  25. package/dist/scraper/errors.d.ts.map +1 -1
  26. package/dist/scraper/errors.js +15 -15
  27. package/dist/scraper/errors.js.map +1 -1
  28. package/dist/scraper/flow-runner.d.ts.map +1 -1
  29. package/dist/scraper/flow-runner.js +28 -8
  30. package/dist/scraper/flow-runner.js.map +1 -1
  31. package/dist/scraper/http-client.d.ts +12 -0
  32. package/dist/scraper/http-client.d.ts.map +1 -1
  33. package/dist/scraper/http-client.js +11 -15
  34. package/dist/scraper/http-client.js.map +1 -1
  35. package/dist/scraper/parse-json-response.d.ts +7 -5
  36. package/dist/scraper/parse-json-response.d.ts.map +1 -1
  37. package/dist/scraper/parse-json-response.js +9 -8
  38. package/dist/scraper/parse-json-response.js.map +1 -1
  39. package/dist/scraper/rate-limited-json-client.d.ts +8 -1
  40. package/dist/scraper/rate-limited-json-client.d.ts.map +1 -1
  41. package/dist/scraper/rate-limited-json-client.js +6 -1
  42. package/dist/scraper/rate-limited-json-client.js.map +1 -1
  43. package/dist/scraper/session-browserbase.d.ts +6 -0
  44. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  45. package/dist/scraper/session-browserbase.js +6 -0
  46. package/dist/scraper/session-browserbase.js.map +1 -1
  47. package/dist/scripts/recon-browser.d.ts +19 -2
  48. package/dist/scripts/recon-browser.d.ts.map +1 -1
  49. package/dist/scripts/recon-browser.js +40 -5
  50. package/dist/scripts/recon-browser.js.map +1 -1
  51. package/dist/scripts/recon-generate.d.ts +50 -21
  52. package/dist/scripts/recon-generate.d.ts.map +1 -1
  53. package/dist/scripts/recon-generate.js +190 -182
  54. package/dist/scripts/recon-generate.js.map +1 -1
  55. package/dist/scripts/recon-http.d.ts +5 -2
  56. package/dist/scripts/recon-http.d.ts.map +1 -1
  57. package/dist/scripts/recon-http.js +16 -4
  58. package/dist/scripts/recon-http.js.map +1 -1
  59. package/dist/scripts/recon-shared.d.ts +33 -0
  60. package/dist/scripts/recon-shared.d.ts.map +1 -1
  61. package/dist/scripts/recon-shared.js +2 -1
  62. package/dist/scripts/recon-shared.js.map +1 -1
  63. package/dist/testing/persona-fixture.d.ts +2 -2
  64. package/dist/testing/persona-fixture.d.ts.map +1 -1
  65. package/dist/testing/persona-fixture.js +2 -2
  66. package/dist/testing/persona-fixture.js.map +1 -1
  67. package/package.json +13 -1
  68. package/dist/scraper/oracle-sentinels.d.ts +0 -22
  69. package/dist/scraper/oracle-sentinels.d.ts.map +0 -1
  70. package/dist/scraper/oracle-sentinels.js +0 -40
  71. package/dist/scraper/oracle-sentinels.js.map +0 -1
@@ -21,6 +21,7 @@ exports.resolveStepPayloadField = resolveStepPayloadField;
21
21
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
22
22
  exports.selectPayloadAction = selectPayloadAction;
23
23
  exports.extractActionSequence = extractActionSequence;
24
+ exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
24
25
  exports.indexStateValues = indexStateValues;
25
26
  exports.compileActionSteps = compileActionSteps;
26
27
  exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
@@ -35,6 +36,8 @@ const errors_1 = require("../lib/errors");
35
36
  const logging_1 = require("../lib/logging");
36
37
  const plugin_api_version_1 = require("../plugins/plugin-api-version");
37
38
  const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
39
+ const capture_filters_1 = require("../recon/capture-filters");
40
+ const load_form_schema_1 = require("../recon/load-form-schema");
38
41
  const load_vocabulary_1 = require("../recon/load-vocabulary");
39
42
  const vocabulary_1 = require("../recon/vocabulary");
40
43
  const recon_shared_1 = require("../scripts/recon-shared");
@@ -334,9 +337,8 @@ const IGNORE_REQUEST_HEADERS = new Set([
334
337
  * stateless replay phase can't thread), derive headers from the meaningful
335
338
  * action POSTs instead — same `extractActionSequence` definition used by
336
339
  * the submission-flow detector. This catches load-bearing site-specific
337
- * headers (Workday's `X-CSRF-Token`, Greenhouse's `Job-Boards-API-Token`,
338
- * ClearCompany's `API-ShortName`, etc.) without the generator needing to
339
- * know about any particular site.
340
+ * headers (a `X-CSRF-Token`, a `Job-Boards-API-Token`, an `API-ShortName`,
341
+ * etc.) without the generator needing to know about any particular site.
340
342
  */
341
343
  function deriveRequestHeaders(captures, replays, baseUrl) {
342
344
  const successfulUrls = new Set(replays.filter((r) => r.success).map((r) => endpointKey(r.url)));
@@ -403,42 +405,6 @@ function firstEndpointPath(captures) {
403
405
  }
404
406
  return "/api/search";
405
407
  }
406
- // ── multi-step "submission flow" detection ────────────────────────────────────
407
- //
408
- // For transactional sites (apply forms, multi-step checkout, etc.) the captures
409
- // form an ordered sequence of POSTs that thread state values through subsequent
410
- // requests (auth tokens, candidate IDs, application IDs). Single-endpoint
411
- // sites (job search, pricing APIs) have one action capture and skip this path.
412
- /**
413
- * Path elements we always treat as noise (analytics, logging). Site-specific
414
- * trackers belong in RECON_TELEMETRY_URL_PATTERNS (comma-separated), not here —
415
- * the engine must not carry any one site's ad-tech domains.
416
- */
417
- const TELEMETRY_URL_PATTERNS = [
418
- "/util/logging/vweb/message",
419
- "/blank/page",
420
- "stats.g.doubleclick.net",
421
- "google-analytics.com",
422
- ...(process.env.RECON_TELEMETRY_URL_PATTERNS ?? "")
423
- .split(",")
424
- .map((p) => p.trim())
425
- .filter(Boolean),
426
- ];
427
- /**
428
- * A POST to a path whose own segment is `error`/`errors` is a client-side
429
- * reporting sink, never a call a caller wants replayed.
430
- *
431
- * Emitting one is worse than noise. A browser's error reports are frozen at
432
- * recon time, so the generated plugin re-POSTs a crash that never happened —
433
- * a stack trace and timestamp from the recon run, sent to the site on every
434
- * invocation, describing a failure in a page the plugin never loaded.
435
- *
436
- * Matched on a whole path segment rather than by substring so `/error-codes`
437
- * and `/terrorism-screening` stay data endpoints, and kept out of
438
- * TELEMETRY_URL_PATTERNS because that list is literal substrings — a site's own
439
- * sink is structural, not an ad-tech domain the operator must enumerate.
440
- */
441
- const ERROR_SINK_PATH_SEGMENT = /(^|\/)errors?(\/|$)/i;
442
408
  /**
443
409
  * Extracts the ordered sequence of meaningful POSTs that represent the
444
410
  * transactional flow: same-host 2xx POSTs, minus telemetry and error-reporting
@@ -472,15 +438,8 @@ function extractActionSequence(captures, baseUrl) {
472
438
  }
473
439
  if (captureHost !== host)
474
440
  return false;
475
- if (TELEMETRY_URL_PATTERNS.some((p) => capture.url.includes(p)))
476
- return false;
477
- try {
478
- if (ERROR_SINK_PATH_SEGMENT.test(new URL(capture.url).pathname))
479
- return false;
480
- }
481
- catch {
441
+ if ((0, capture_filters_1.isNoiseUrl)(capture.url))
482
442
  return false;
483
- }
484
443
  return true;
485
444
  });
486
445
  }
@@ -571,7 +530,7 @@ const KNOWN_TOP_LEVEL_ERROR_KEYS = new Set(["message", "error", "errormessage"])
571
530
  /**
572
531
  * Suffixes that mark a JSON key as carrying validation/data errors when its
573
532
  * value is non-null. Case-sensitive because real APIs use mixed-case in the
574
- * exact form they ship (e.g. ClearCompany's `ResponseValidationErrors`).
533
+ * exact form they ship (e.g. a `ResponseValidationErrors` key).
575
534
  */
576
535
  const NESTED_ERROR_KEY_SUFFIXES = ["ValidationErrors", "DataErrors", "ValidationError"];
577
536
  /**
@@ -581,7 +540,7 @@ const NESTED_ERROR_KEY_SUFFIXES = ["ValidationErrors", "DataErrors", "Validation
581
540
  * responses (so legitimate success-only fields like `Name` aren't false-
582
541
  * flagged as errors).
583
542
  *
584
- * Site-agnostic: ClearCompany uses `Message`/`Sections.ResponseValidationErrors`
543
+ * Site-agnostic: one ATS may use `Message`/`Sections.ResponseValidationErrors`
585
544
  * /`Sections.DataValidationErrors`; a different ATS using `error`/`errors[]`
586
545
  * would emit guards for those instead.
587
546
  */
@@ -642,7 +601,7 @@ function walkForNestedErrorKeys(value, path, candidates) {
642
601
  */
643
602
  const UUID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
644
603
  /**
645
- * Converts a FieldSourceCode like "contact.first.name" or "address.country.subdivision"
604
+ * Converts a machine code like "contact.first.name" or "address.country.subdivision"
646
605
  * to PascalCase: "ContactFirstName", "AddressCountrySubdivision". Site-agnostic:
647
606
  * operates only on the input string. Returns null for inputs that don't
648
607
  * produce a valid JS identifier.
@@ -655,7 +614,7 @@ function sourceCodeToPascalCase(sourceCode) {
655
614
  return isValidJsIdentifier(pascal) ? pascal : null;
656
615
  }
657
616
  /**
658
- * Converts a free-form FieldName like "Reference #1 First Name" or "Email" to
617
+ * Converts a free-form field label like "Reference #1 First Name" or "Email" to
659
618
  * PascalCase, stripping punctuation in a way that preserves position (so
660
619
  * "Reference #1 First Name" → "Reference1FirstName" via a section-heading
661
620
  * prefix). Site-agnostic: operates only on input strings.
@@ -691,60 +650,70 @@ function fieldNameToPascalCase(fieldName, prefix) {
691
650
  }
692
651
  /**
693
652
  * Recon-driven detection of form-schema captures. Scans all response bodies
694
- * for arrays whose objects look like SectionField (UUID-shaped FieldId plus
695
- * FieldName or FieldSourceCode). Builds FieldId → PascalCase name map.
653
+ * for arrays whose objects look like form fields (a UUID-shaped field-id key
654
+ * plus at least one of the schema's name keys). Builds field-id → PascalCase
655
+ * name map.
656
+ *
657
+ * Site-agnostic: identifies form-schema captures by structural fingerprint
658
+ * against the consumer-supplied keys, not by URL or site name. Any ATS exposing
659
+ * a matching schema would match.
696
660
  *
697
- * Site-agnostic: identifies form-schema captures by structural fingerprint,
698
- * not by URL or site name. Any ATS exposing a similar schema would match.
661
+ * Exported for unit testing lets tests prove the rewiring: the same field is
662
+ * recovered whether the supplied keys are one vendor's names or a differing
663
+ * vendor's, and that a null schema recovers nothing.
699
664
  */
700
- function detectFormSchemaFieldNames(captures) {
665
+ function detectFormSchemaFieldNames(captures, formSchema) {
701
666
  const fieldNameMap = new Map();
702
667
  const fieldOptionsMap = new Map();
703
668
  const allSchemaUuids = new Set();
669
+ // No form-schema declared → recover nothing. The engine carries no vendor's
670
+ // wire keys, so there is nothing to fingerprint ATS responses against.
671
+ if (formSchema === null)
672
+ return { fieldNameMap, fieldOptionsMap, allSchemaUuids };
704
673
  for (const capture of captures) {
705
- walkForSectionFieldsArrays(capture.responseBody, fieldNameMap, fieldOptionsMap);
706
- walkForSchemaUuids(capture.responseBody, allSchemaUuids);
674
+ walkForSectionFieldsArrays(capture.responseBody, fieldNameMap, fieldOptionsMap, formSchema);
675
+ walkForSchemaUuids(capture.responseBody, allSchemaUuids, formSchema);
707
676
  }
708
677
  return { fieldNameMap, fieldOptionsMap, allSchemaUuids };
709
678
  }
710
679
  /**
711
- * Walks a response body collecting UUID-shaped strings under a `FieldId` key, or
712
- * under the `Id` of an entry in a sibling `FieldOptions` array. These are stable
713
- * schema anchors that must be shielded from state-threading even when
714
- * detectFormSchemaFieldNames emits no payload-mappable name for the field (e.g.
715
- * when the field's FieldName is too long for our naming heuristic).
680
+ * Walks a response body collecting UUID-shaped strings under the schema's
681
+ * field-id key, or under the option-id key of an entry in a sibling
682
+ * field-options array. These are stable schema anchors that must be shielded
683
+ * from state-threading even when detectFormSchemaFieldNames emits no
684
+ * payload-mappable name for the field (e.g. when the field name is too long for
685
+ * our naming heuristic).
716
686
  *
717
- * The key names are exact by design, not an oversight: a differing wire format
718
- * (a lowercase `fieldId`, another vendor's option key) is the consumer's to
719
- * declare, not the engine's to guess — see issue #57. Matching case variants
720
- * here would re-broaden the very fingerprint that issue exists to narrow.
687
+ * The wire keys come from the consumer-supplied {@link ReconFormSchema}, so a
688
+ * vendor declares its own keys with `--form-schema` rather than the engine
689
+ * hardcoding any the inversion issue #57 asked for.
721
690
  */
722
- function walkForSchemaUuids(value, out) {
691
+ function walkForSchemaUuids(value, out, formSchema) {
723
692
  if (value === null || typeof value !== "object")
724
693
  return;
725
694
  if (Array.isArray(value)) {
726
695
  for (const item of value)
727
- walkForSchemaUuids(item, out);
696
+ walkForSchemaUuids(item, out, formSchema);
728
697
  return;
729
698
  }
730
699
  const obj = value;
731
- const fieldIdRaw = obj.FieldId;
700
+ const fieldIdRaw = obj[formSchema.fieldIdKey];
732
701
  if (typeof fieldIdRaw === "string" && UUID_REGEX.test(fieldIdRaw)) {
733
702
  out.add(fieldIdRaw);
734
703
  }
735
- const optionsRaw = obj.FieldOptions;
704
+ const optionsRaw = obj[formSchema.fieldOptionsKey];
736
705
  if (Array.isArray(optionsRaw)) {
737
706
  for (const opt of optionsRaw) {
738
707
  if (opt !== null && typeof opt === "object") {
739
- const optId = opt.Id;
708
+ const optId = opt[formSchema.optionIdKey];
740
709
  if (typeof optId === "string" && UUID_REGEX.test(optId))
741
710
  out.add(optId);
742
711
  }
743
712
  }
744
713
  }
745
- // Recurse into nested objects/arrays so nested SectionFields get walked too.
714
+ // Recurse into nested objects/arrays so nested form fields get walked too.
746
715
  for (const v of Object.values(obj))
747
- walkForSchemaUuids(v, out);
716
+ walkForSchemaUuids(v, out, formSchema);
748
717
  }
749
718
  /**
750
719
  * Closed enum of well-known cache-buster query parameter names. Stripped
@@ -767,14 +736,14 @@ function stripCacheBusterParams(url) {
767
736
  }
768
737
  /**
769
738
  * Recon-driven detection of the form-schema fetch capture. Returns the first
770
- * GET capture (in recon order) whose response body contains a SectionFields-
771
- * shaped array. Sites without such a capture get `null` and Phase B/C/D
772
- * become no-ops.
739
+ * GET capture (in recon order) whose response body contains a form-fields-
740
+ * shaped array (per the supplied schema keys). Sites without such a capture get
741
+ * `null` and Phase B/C/D become no-ops.
773
742
  *
774
743
  * Site-agnostic: identifies the fetch by structural fingerprint of the
775
744
  * response body, not by URL or site name.
776
745
  */
777
- function detectFormSchemaFetchCapture(captures, baseUrl) {
746
+ function detectFormSchemaFetchCapture(captures, baseUrl, formSchema) {
778
747
  let host;
779
748
  try {
780
749
  host = new URL(baseUrl).host;
@@ -797,52 +766,52 @@ function detectFormSchemaFetchCapture(captures, baseUrl) {
797
766
  }
798
767
  if (captureHost !== host)
799
768
  continue;
800
- if (TELEMETRY_URL_PATTERNS.some((p) => capture.url.includes(p)))
769
+ if ((0, capture_filters_1.telemetryUrlPatterns)().some((p) => capture.url.includes(p)))
801
770
  continue;
802
- if (responseContainsSectionFields(capture.responseBody)) {
771
+ if (responseContainsSectionFields(capture.responseBody, formSchema)) {
803
772
  return { capture, index: i };
804
773
  }
805
774
  }
806
775
  return null;
807
776
  }
808
- function responseContainsSectionFields(value) {
777
+ function responseContainsSectionFields(value, formSchema) {
809
778
  if (value === null || typeof value !== "object")
810
779
  return false;
811
780
  if (Array.isArray(value)) {
812
- if (looksLikeSectionFieldsArray(value))
781
+ if (looksLikeSectionFieldsArray(value, formSchema))
813
782
  return true;
814
783
  for (const item of value) {
815
- if (responseContainsSectionFields(item))
784
+ if (responseContainsSectionFields(item, formSchema))
816
785
  return true;
817
786
  }
818
787
  return false;
819
788
  }
820
789
  for (const v of Object.values(value)) {
821
- if (responseContainsSectionFields(v))
790
+ if (responseContainsSectionFields(v, formSchema))
822
791
  return true;
823
792
  }
824
793
  return false;
825
794
  }
826
- function walkForSectionFieldsArrays(value, fieldNameMap, fieldOptionsMap) {
795
+ function walkForSectionFieldsArrays(value, fieldNameMap, fieldOptionsMap, formSchema) {
827
796
  if (value === null || typeof value !== "object")
828
797
  return;
829
798
  if (Array.isArray(value)) {
830
- if (looksLikeSectionFieldsArray(value)) {
831
- assignFieldNamesFromArray(value, fieldNameMap, fieldOptionsMap);
799
+ if (looksLikeSectionFieldsArray(value, formSchema)) {
800
+ assignFieldNamesFromArray(value, fieldNameMap, fieldOptionsMap, formSchema);
832
801
  }
833
802
  for (const item of value)
834
- walkForSectionFieldsArrays(item, fieldNameMap, fieldOptionsMap);
803
+ walkForSectionFieldsArrays(item, fieldNameMap, fieldOptionsMap, formSchema);
835
804
  return;
836
805
  }
837
806
  for (const v of Object.values(value)) {
838
- walkForSectionFieldsArrays(v, fieldNameMap, fieldOptionsMap);
807
+ walkForSectionFieldsArrays(v, fieldNameMap, fieldOptionsMap, formSchema);
839
808
  }
840
809
  }
841
810
  /**
842
811
  * Structural fingerprint: array of objects, at least half of which have a
843
- * UUID-shaped FieldId AND at least one of FieldName/FieldSourceCode.
812
+ * UUID-shaped field-id AND at least one of the schema's field-name keys.
844
813
  */
845
- function looksLikeSectionFieldsArray(arr) {
814
+ function looksLikeSectionFieldsArray(arr, formSchema) {
846
815
  if (arr.length === 0)
847
816
  return false;
848
817
  let matches = 0;
@@ -850,26 +819,31 @@ function looksLikeSectionFieldsArray(arr) {
850
819
  if (item === null || typeof item !== "object")
851
820
  continue;
852
821
  const obj = item;
853
- const fieldIdRaw = obj.FieldId;
822
+ const fieldIdRaw = obj[formSchema.fieldIdKey];
854
823
  if (typeof fieldIdRaw !== "string")
855
824
  continue;
856
825
  if (!UUID_REGEX.test(fieldIdRaw))
857
826
  continue;
858
- if (typeof obj.FieldName === "string" || typeof obj.FieldSourceCode === "string") {
827
+ if (formSchema.fieldNameKeys.some((key) => typeof obj[key] === "string")) {
859
828
  matches++;
860
829
  }
861
830
  }
862
831
  return matches >= Math.max(1, Math.floor(arr.length * 0.5));
863
832
  }
864
- function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
833
+ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchema) {
865
834
  let currentPrefix = null;
866
835
  const usedNames = new Set([...fieldNameMap.values()]);
836
+ // First field-name key is the machine code (preferred, PascalCased directly);
837
+ // any later key is a human label (subject to the section-heading heuristic).
838
+ // With one key both branches collapse to the label path.
839
+ const [codeKey, ...labelKeys] = formSchema.fieldNameKeys;
840
+ const labelKey = labelKeys[0];
867
841
  for (const obj of arr) {
868
- const fieldId = obj.FieldId;
842
+ const fieldId = obj[formSchema.fieldIdKey];
869
843
  if (typeof fieldId !== "string")
870
844
  continue;
871
- const sourceCode = obj.FieldSourceCode;
872
- const name = obj.FieldName;
845
+ const sourceCode = codeKey !== undefined && labelKey !== undefined ? obj[codeKey] : undefined;
846
+ const name = labelKey !== undefined ? obj[labelKey] : obj[codeKey ?? ""];
873
847
  let semantic = null;
874
848
  if (typeof sourceCode === "string" && sourceCode.trim().length > 0) {
875
849
  semantic = sourceCodeToPascalCase(sourceCode);
@@ -877,7 +851,7 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
877
851
  }
878
852
  else if (typeof name === "string" && name.trim().length > 0 && name.length < 250) {
879
853
  const hasNoSourceCode = typeof sourceCode !== "string" || sourceCode.trim().length === 0;
880
- // Section-heading heuristic: short FieldName, no SourceCode, MOSTLY
854
+ // Section-heading heuristic: short label, no machine code, MOSTLY
881
855
  // uppercase letters (>= 70% of alphabetic chars) OR contains '#'.
882
856
  // Whole-name uppercase ratio avoids false positives like "MM/DD/YYYY"
883
857
  // appearing as a format hint inside a normal field label.
@@ -903,11 +877,11 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
903
877
  }
904
878
  fieldNameMap.set(fieldId, unique);
905
879
  usedNames.add(unique);
906
- // Capture FieldOptions when present and ALL options have non-empty
907
- // semantic Values (SystemFieldOption-tagged). Custom options with empty
908
- // Value are skipped they have no semantic label, so we can't generate
909
- // a meaningful enum and leave the field's OptionId baked.
910
- const optionsRaw = obj.FieldOptions;
880
+ // Capture the field's options when present and ALL options have non-empty
881
+ // semantic labels. Options with an empty label are skipped — with no
882
+ // semantic value we can't generate a meaningful enum, so we leave the
883
+ // field's option-id baked.
884
+ const optionsRaw = obj[formSchema.fieldOptionsKey];
911
885
  if (Array.isArray(optionsRaw) && optionsRaw.length > 0) {
912
886
  const options = [];
913
887
  let allSemantic = true;
@@ -917,8 +891,8 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
917
891
  break;
918
892
  }
919
893
  const opt = optRaw;
920
- const optId = opt.Id;
921
- const optValue = opt.Value;
894
+ const optId = opt[formSchema.optionIdKey];
895
+ const optValue = opt[formSchema.optionValueKey];
922
896
  if (typeof optId !== "string" ||
923
897
  typeof optValue !== "string" ||
924
898
  optValue.trim().length === 0) {
@@ -935,20 +909,21 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
935
909
  }
936
910
  }
937
911
  /**
938
- * Substitutes Responses[].Value literals with payload accessors based on the
912
+ * Substitutes the submitted-value literals with payload accessors based on the
939
913
  * field-name map from the form schema. Operates on the body string before
940
914
  * state interpolation so already-substituted state values (e.g. ${firstName})
941
915
  * are preserved.
942
916
  *
943
- * Closed-set substring matching: both FieldId and the literal Value come from
944
- * the generator's own input (recon).
917
+ * Closed-set substring matching: both the field-id and the submitted value come
918
+ * from the generator's own input (recon).
945
919
  */
946
- function applyFormSchemaSubstitutions(rawBody, fieldNameMap, outDiscoveredFields) {
920
+ function applyFormSchemaSubstitutions(rawBody, fieldNameMap, outDiscoveredFields, formSchema) {
947
921
  if (fieldNameMap.size === 0)
948
922
  return rawBody;
949
923
  let result = rawBody;
924
+ const valueMarker = `"${formSchema.responseValueKey}":"`;
950
925
  for (const [fieldId, semanticName] of fieldNameMap) {
951
- const fieldIdMarker = `"FieldId":"${fieldId}"`;
926
+ const fieldIdMarker = `"${formSchema.fieldIdKey}":"${fieldId}"`;
952
927
  let cursor = 0;
953
928
  while (true) {
954
929
  const idx = result.indexOf(fieldIdMarker, cursor);
@@ -958,7 +933,6 @@ function applyFormSchemaSubstitutions(rawBody, fieldNameMap, outDiscoveredFields
958
933
  if (objEnd === -1)
959
934
  break;
960
935
  const segment = result.slice(idx, objEnd);
961
- const valueMarker = `"Value":"`;
962
936
  const valueIdx = segment.indexOf(valueMarker);
963
937
  if (valueIdx === -1) {
964
938
  cursor = objEnd;
@@ -984,22 +958,23 @@ function applyFormSchemaSubstitutions(rawBody, fieldNameMap, outDiscoveredFields
984
958
  return result;
985
959
  }
986
960
  /**
987
- * Substitutes Responses[].OptionId literals with payload-driven enum lookups.
988
- * Operates on the body string before state interpolation. For each FieldId
989
- * with a captured FieldOptionsMapping, find `"FieldId":"<uuid>"` and rewrite
990
- * the matching `"OptionId":"<uuid>"` to `"OptionId":"${OPT_<Name>[payload.<Name>]}"`.
961
+ * Substitutes submitted option-id literals with payload-driven enum lookups.
962
+ * Operates on the body string before state interpolation. For each field with a
963
+ * captured FieldOptionsMapping, find the schema's field-id marker and rewrite
964
+ * the matching option-id value to `${OPT_<Name>[payload.<Name>]}`.
991
965
  *
992
- * Order-insensitive: matches `"OptionId":"<uuid>"` anywhere within the same
993
- * JSON object as the FieldId (which is between this FieldId marker and the
994
- * closing `}`). Closed-set substring matching: both FieldId and OptionId
995
- * come from the generator's own input.
966
+ * Order-insensitive: matches the option-id marker anywhere within the same
967
+ * JSON object as the field-id marker (which is between it and the closing `}`).
968
+ * Closed-set substring matching: both marker values come from the generator's
969
+ * own input.
996
970
  */
997
- function applyFormSchemaOptionIdSubstitutions(rawBody, fieldOptionsMap, outDiscoveredOptionFields) {
971
+ function applyFormSchemaOptionIdSubstitutions(rawBody, fieldOptionsMap, outDiscoveredOptionFields, formSchema) {
998
972
  if (fieldOptionsMap.size === 0)
999
973
  return rawBody;
1000
974
  let result = rawBody;
975
+ const optionIdMarker = `"${formSchema.responseOptionIdKey}":"`;
1001
976
  for (const [fieldId, mapping] of fieldOptionsMap) {
1002
- const fieldIdMarker = `"FieldId":"${fieldId}"`;
977
+ const fieldIdMarker = `"${formSchema.fieldIdKey}":"${fieldId}"`;
1003
978
  let cursor = 0;
1004
979
  while (true) {
1005
980
  const idx = result.indexOf(fieldIdMarker, cursor);
@@ -1009,7 +984,6 @@ function applyFormSchemaOptionIdSubstitutions(rawBody, fieldOptionsMap, outDisco
1009
984
  if (objEnd === -1)
1010
985
  break;
1011
986
  const segment = result.slice(idx, objEnd);
1012
- const optionIdMarker = `"OptionId":"`;
1013
987
  const optionIdLocal = segment.indexOf(optionIdMarker);
1014
988
  if (optionIdLocal === -1) {
1015
989
  cursor = objEnd;
@@ -1035,25 +1009,26 @@ function applyFormSchemaOptionIdSubstitutions(rawBody, fieldOptionsMap, outDisco
1035
1009
  return result;
1036
1010
  }
1037
1011
  /**
1038
- * For fields whose FieldOptions have NO semantic values (CustomFieldOption
1039
- * options where the recon schema's `.Value` is empty), T3's OPT_* enum
1040
- * mapping can't be emitted. Instead, parameterize the OptionId slot as a
1041
- * caller-supplied `<FieldName>OptionId` payload field with the recon-observed
1042
- * UUID documented in a TSDoc comment.
1012
+ * For fields whose options have NO semantic labels (the schema's option-label
1013
+ * value is empty), T3's OPT_* enum mapping can't be emitted. Instead,
1014
+ * parameterize the submitted option-id slot as a caller-supplied
1015
+ * `<Name>OptionId` payload field with the recon-observed UUID documented in a
1016
+ * TSDoc comment.
1043
1017
  *
1044
- * Operates on the same `"FieldId":"<uuid>"` anchored search as
1045
- * applyFormSchemaOptionIdSubstitutions, but only fires when the FieldId is
1046
- * in fieldNameMap (has a semantic name) AND NOT in fieldOptionsMap (the
1047
- * structured enum substitution didn't fire). Site-agnostic.
1018
+ * Operates on the same field-id-anchored search as
1019
+ * applyFormSchemaOptionIdSubstitutions, but only fires when the field is in
1020
+ * fieldNameMap (has a semantic name) AND NOT in fieldOptionsMap (the structured
1021
+ * enum substitution didn't fire). Site-agnostic.
1048
1022
  */
1049
- function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields) {
1023
+ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema) {
1050
1024
  if (fieldNameMap.size === 0)
1051
1025
  return rawBody;
1052
1026
  let result = rawBody;
1027
+ const optionIdMarker = `"${formSchema.responseOptionIdKey}":"`;
1053
1028
  for (const [fieldId, fieldName] of fieldNameMap) {
1054
1029
  if (fieldOptionsMap.has(fieldId))
1055
1030
  continue; // T3's OPT_* already handles this.
1056
- const fieldIdMarker = `"FieldId":"${fieldId}"`;
1031
+ const fieldIdMarker = `"${formSchema.fieldIdKey}":"${fieldId}"`;
1057
1032
  let cursor = 0;
1058
1033
  while (true) {
1059
1034
  const idx = result.indexOf(fieldIdMarker, cursor);
@@ -1063,7 +1038,6 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
1063
1038
  if (objEnd === -1)
1064
1039
  break;
1065
1040
  const segment = result.slice(idx, objEnd);
1066
- const optionIdMarker = `"OptionId":"`;
1067
1041
  const optionIdLocal = segment.indexOf(optionIdMarker);
1068
1042
  if (optionIdLocal === -1) {
1069
1043
  cursor = objEnd;
@@ -1097,13 +1071,14 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
1097
1071
  * embedded base64 images, etc.) that aren't candidates for state threading. */
1098
1072
  const MAX_STATE_VALUE_LENGTH = 256;
1099
1073
  /** Canonical "uninitialized" sentinel values that some REST APIs return as
1100
- * placeholders before a downstream call populates the real identifier.
1101
- * ClearCompany's `/user/create` returns these for CandidateId/ApplicationId/
1102
- * ApplyProcessId, then `/user/start` returns the real values. Indexing the
1103
- * placeholder would lock the generated plugin's `${candidateId}` binding to
1104
- * the all-zero UUID — every downstream call would then 404 with "candidate
1105
- * does not exist". Closed set, literal-string match — never expand to
1106
- * pattern-based detection (would trip the no-regex-on-open-sets rule). */
1074
+ * placeholders before a downstream call populates the real identifier. An ATS
1075
+ * whose `/user/create` returns these for CandidateId/ApplicationId/
1076
+ * ApplyProcessId, then yields the real values on `/user/start`, is the case that
1077
+ * motivated this: indexing the placeholder would lock the generated plugin's
1078
+ * `${candidateId}` binding to the all-zero UUID — every downstream call would
1079
+ * then 404 with "candidate does not exist". Closed set, literal-string match —
1080
+ * never expand to pattern-based detection (would trip the no-regex-on-open-sets
1081
+ * rule). */
1107
1082
  const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"]);
1108
1083
  /**
1109
1084
  * Splits a raw `Set-Cookie` response-header string into `name`/`value` pairs.
@@ -1199,8 +1174,9 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
1199
1174
  continue;
1200
1175
  if (PLACEHOLDER_STATE_VALUES.has(value))
1201
1176
  continue;
1202
- // Schema-identifier UUIDs (FieldId, OptionId) are stable anchors that
1203
- // T2/T3 substitution depends on remaining literal in body templates.
1177
+ // Schema-identifier UUIDs (the field-id and option-id anchors) are stable
1178
+ // anchors that T2/T3 substitution depends on remaining literal in body
1179
+ // templates.
1204
1180
  // Indexing them would let state-threading rewrite the anchors and
1205
1181
  // corrupt T2/T3's already-substituted Values.
1206
1182
  if (shieldedUuids.has(value))
@@ -1528,7 +1504,7 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
1528
1504
  // ── base64 Content parameterization ──────────────────────────────────────────
1529
1505
  /**
1530
1506
  * Maps a site's screening-question prompts to the payload field that answers
1531
- * them, as `{ FieldName: [keyword, …] }`.
1507
+ * them, as `{ payloadField: [keyword, …] }`.
1532
1508
  *
1533
1509
  * Empty by default and supplied by the operator via `RECON_QUESTION_KEYWORDS`
1534
1510
  * (JSON) — the engine cannot know what any site asks or what a caller's payload
@@ -1737,14 +1713,12 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
1737
1713
  parentSegments.reverse();
1738
1714
  out.push(` const ${parentVar} = (${varName} as { ${parentObjType} }).${parentAccessor};`);
1739
1715
  for (const { errorKey } of errorKeys) {
1740
- const label = errorKey === "ResponseValidationErrors"
1741
- ? "validation errors"
1742
- : errorKey === "DataValidationErrors"
1743
- ? "data errors"
1744
- : errorKey
1745
- .replace(/([A-Z])/g, " $1")
1746
- .trim()
1747
- .toLowerCase();
1716
+ // Humanize the wire key generically (CamelCase → "camel case"); no
1717
+ // per-vendor special cases — the engine carries no vendor's key vocabulary.
1718
+ const label = errorKey
1719
+ .replace(/([A-Z])/g, " $1")
1720
+ .trim()
1721
+ .toLowerCase();
1748
1722
  out.push(` if (${parentVar} != null && ${parentVar}.${errorKey} != null) throw new Error(\`step ${varName} ${label}: \${JSON.stringify(${parentVar}.${errorKey})}\`);`);
1749
1723
  }
1750
1724
  }
@@ -1752,7 +1726,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
1752
1726
  }
1753
1727
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
1754
1728
  * without going through the full emitContractTs pipeline. */
1755
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map()) {
1729
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map(), formSchema = null) {
1756
1730
  // Walk the first action's request body to map each leaf string value to its
1757
1731
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
1758
1732
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -1791,7 +1765,7 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1791
1765
  outDiscoveredFields.add("BaseUrl");
1792
1766
  }
1793
1767
  // G2: register any tenant-subdomain header values as payload-supplied fields
1794
- // (e.g. ClearCompany's `API-ShortName: "addus"` becomes `payload.ApiShortName`).
1768
+ // (e.g. an `API-ShortName: "addus"` header becomes `payload.ApiShortName`).
1795
1769
  for (const [headerName, _value] of tenantSubdomainHeaders) {
1796
1770
  outDiscoveredFields.add(headerNameToPayloadFieldName(headerName));
1797
1771
  }
@@ -1819,13 +1793,16 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1819
1793
  const prior = actions.slice(0, i);
1820
1794
  const url = interpolateStateValues(cap.url, prior, payloadAccessorByValue);
1821
1795
  // Form-schema substitution runs first on the raw recon body so its
1822
- // FieldId-anchored matches see the original JSON. State-threading and
1823
- // payload key-value passes then run on top. OptionId substitution runs
1824
- // here too: same closed-set FieldId anchor; rewrites "OptionId":"<uuid>"
1825
- // slots to "${OPT_X[payload.X]}" lookups.
1826
- const rawBodyWithFormSubs = cap.requestPostData
1827
- ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields), fieldOptionsMap, outDiscoveredOptionFields), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields)
1828
- : "";
1796
+ // field-id-anchored matches see the original JSON. State-threading and
1797
+ // payload key-value passes then run on top. Option-id substitution runs
1798
+ // here too: same closed-set field-id anchor; rewrites the submitted
1799
+ // option-id slots to "${OPT_X[payload.X]}" lookups.
1800
+ // Form-schema passes only fire when a `--form-schema` was supplied (which is
1801
+ // also the only way the field maps are non-empty); without one they are
1802
+ // no-ops and the raw recon body flows straight through.
1803
+ const rawBodyWithFormSubs = cap.requestPostData && formSchema !== null
1804
+ ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields, formSchema), fieldOptionsMap, outDiscoveredOptionFields, formSchema), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema)
1805
+ : (cap.requestPostData ?? "");
1829
1806
  let bodyTemplate = rawBodyWithFormSubs
1830
1807
  ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithFormSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
1831
1808
  : "";
@@ -2104,7 +2081,7 @@ function emitContractTs(opts) {
2104
2081
  .join("\n")}\n})`
2105
2082
  : "";
2106
2083
  // Build per-field OPT_<Name> constant declarations + payload-schema enum
2107
- // entries from the form schema's FieldOptions. Only fields whose OptionId
2084
+ // entries from the form schema's options. Only fields whose option-id
2108
2085
  // slots were actually rewritten in the body (i.e. that appear in
2109
2086
  // discoveredOptionFields) get emitted; the rest leave their schema entries
2110
2087
  // unused. Computed BEFORE payloadSchemaExpr so the extension string is
@@ -2131,16 +2108,16 @@ function emitContractTs(opts) {
2131
2108
  .map((m) => ` ${m.semanticName}: z.enum([${m.options.map((o) => JSON.stringify(o.value)).join(", ")}]),`)
2132
2109
  .join("\n")}\n})`
2133
2110
  : "";
2134
- // Phase E raw-option payload fields: FieldOptions whose .Value strings are
2135
- // empty in the schema (CustomFieldOption) — no semantic enum is possible,
2136
- // so the caller supplies the OptionId UUID directly. The recon-observed
2137
- // UUID is documented in a TSDoc comment so callers have a starting point.
2111
+ // Phase E raw-option payload fields: options whose label strings are empty in
2112
+ // the schema — no semantic enum is possible, so the caller supplies the
2113
+ // option-id UUID directly. The recon-observed UUID is documented in a TSDoc
2114
+ // comment so callers have a starting point.
2138
2115
  const sortedRawOptionEntries = discoveredRawOptionFields
2139
2116
  ? [...discoveredRawOptionFields.entries()].sort(([a], [b]) => a.localeCompare(b))
2140
2117
  : [];
2141
2118
  const rawOptionSchemaExtension = sortedRawOptionEntries.length > 0
2142
2119
  ? `.extend({\n${sortedRawOptionEntries
2143
- .map(([name, reconUuid]) => ` /** Recon-observed: ${reconUuid}. Caller supplies the OptionId UUID for this field. */\n ${name}: z.string(),`)
2120
+ .map(([name, reconUuid]) => ` /** Recon-observed: ${reconUuid}. Caller supplies the option-id UUID for this field. */\n ${name}: z.string(),`)
2144
2121
  .join("\n")}\n})`
2145
2122
  : "";
2146
2123
  // Phase F: additional-body keys (from action POSTs beyond r0). Each gets a
@@ -2415,12 +2392,16 @@ function jsonSchemaTypeOf(value) {
2415
2392
  * `recovered` carries the request contract the `.ts` path infers from real
2416
2393
  * captures — the first POST body's fields plus form-schema discoveries — so
2417
2394
  * `--emit config` no longer throws that away and emit a request schema built
2418
- * only from the handful of flow-step splice hints. The direct-HTTP hot path is
2419
- * still omitted; a site that needs it keeps the `.ts` path or wires
2420
- * `spec.httpModule` by hand.
2395
+ * only from the handful of flow-step splice hints.
2396
+ *
2397
+ * When the site has a direct-HTTP path (a submission flow whose `.ts` emit would
2398
+ * carry an `executeHttp`), `httpModulePath` emits a `spec.httpModule` reference
2399
+ * to a compiled module the operator drops in — the config plugin's escape hatch
2400
+ * for the imperative hot path a JSON manifest cannot express. Absent that, the
2401
+ * browser `flow` is the only execution path, and the field is omitted.
2421
2402
  */
2422
2403
  function emitConfigManifest(opts) {
2423
- const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields } = opts;
2404
+ const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, } = opts;
2424
2405
  const payloadFieldNames = new Set();
2425
2406
  const steps = flowSteps.map((step) => {
2426
2407
  const isObj = typeof step !== "string";
@@ -2459,6 +2440,7 @@ function emitConfigManifest(opts) {
2459
2440
  metadata: { siteId, displayName },
2460
2441
  spec: {
2461
2442
  defaultBaseUrl: baseUrl,
2443
+ ...(httpModulePath ? { httpModule: httpModulePath } : {}),
2462
2444
  request: { type: "object", properties: sortedRequestProperties },
2463
2445
  response: {
2464
2446
  type: "object",
@@ -2650,17 +2632,36 @@ async function resolveVocabulary(specifier, flowSteps) {
2650
2632
  }
2651
2633
  return DEPRECATED_BUILTIN_ATS_VOCABULARY;
2652
2634
  }
2635
+ /**
2636
+ * Resolves the form-schema for this run and reports which one is in play.
2637
+ *
2638
+ * The engine carries no vendor's wire format (issue #57): a consumer whose ATS
2639
+ * exposes a form definition declares its keys with `--form-schema`. Absent one
2640
+ * (or `--form-schema none`), form-key recovery does not run and the generator
2641
+ * recovers nothing from ATS-shaped responses — the same "absence means none"
2642
+ * discipline `--vocabulary` uses.
2643
+ */
2644
+ async function resolveFormSchema(specifier) {
2645
+ if (!specifier)
2646
+ return null;
2647
+ const formSchema = await (0, load_form_schema_1.loadReconFormSchema)(specifier, process.cwd());
2648
+ logger.info(`form-schema: ${specifier === load_form_schema_1.FORM_SCHEMA_NONE ? "none (no ATS form recovery)" : `custom keys from ${specifier}`}`);
2649
+ return formSchema;
2650
+ }
2653
2651
  async function main() {
2654
2652
  const args = process.argv.slice(2);
2655
2653
  let siteId = "";
2656
2654
  let force = false;
2657
2655
  let emit = "ts";
2658
2656
  let vocabularySpecifier = "";
2657
+ let formSchemaSpecifier = "";
2659
2658
  for (let i = 0; i < args.length; i++) {
2660
2659
  if (args[i] === "--site-id" && args[i + 1])
2661
2660
  siteId = args[++i];
2662
2661
  else if (args[i] === "--vocabulary" && args[i + 1])
2663
2662
  vocabularySpecifier = args[++i];
2663
+ else if (args[i] === "--form-schema" && args[i + 1])
2664
+ formSchemaSpecifier = args[++i];
2664
2665
  else if (args[i] === "--force")
2665
2666
  force = true;
2666
2667
  else if (args[i] === "--emit" && args[i + 1]) {
@@ -2731,6 +2732,9 @@ async function main() {
2731
2732
  // module-level const would freeze at import time, which is the bug that makes
2732
2733
  // RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
2733
2734
  const vocabulary = await resolveVocabulary(vocabularySpecifier, flowSteps);
2735
+ // Consumer-supplied wire keys for ATS form-schema recovery, or null. When
2736
+ // null the recovery functions no-op — the engine hardcodes no vendor format.
2737
+ const formSchema = await resolveFormSchema(formSchemaSpecifier);
2734
2738
  const pascal = toPascalCase(siteId);
2735
2739
  const baseUrl = deriveBaseUrl(captures);
2736
2740
  const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl);
@@ -2746,15 +2750,15 @@ async function main() {
2746
2750
  const rawActionCaptures = gql
2747
2751
  ? []
2748
2752
  : collapseRedundantPatches(extractActionSequence(captures, baseUrl));
2749
- // Form-schema detection runs BEFORE state-indexing so the FieldId/OptionId
2753
+ // Form-schema detection runs BEFORE state-indexing so the field-id/option-id
2750
2754
  // UUIDs can be shielded from indexing — those UUIDs are stable schema
2751
2755
  // anchors that T2/T3 substitution depends on remaining literal in body
2752
2756
  // templates.
2753
- const { fieldNameMap, fieldOptionsMap, allSchemaUuids } = detectFormSchemaFieldNames(captures);
2754
- // Shield ALL FieldId/OptionId UUIDs that appear in any schema response, not
2757
+ const { fieldNameMap, fieldOptionsMap, allSchemaUuids } = detectFormSchemaFieldNames(captures, formSchema);
2758
+ // Shield ALL field-id/option-id UUIDs that appear in any schema response, not
2755
2759
  // just the ones that detectFormSchemaFieldNames emits a payload name for.
2756
- // Some fields have FieldNames too long for the naming heuristic (>80 chars)
2757
- // and would be skipped by fieldNameMap; their FieldIds still need shielding
2760
+ // Some fields have names too long for the naming heuristic (>80 chars) and
2761
+ // would be skipped by fieldNameMap; their field-ids still need shielding
2758
2762
  // because they appear as anchors in the T2-substituted body templates.
2759
2763
  const shieldedUuids = new Set(allSchemaUuids);
2760
2764
  // T4 — Phase B+C: detect a form-schema GET capture and insert it into the
@@ -2764,7 +2768,7 @@ async function main() {
2764
2768
  // params (recon timestamps) from the captured URL so the emitted runtime
2765
2769
  // fetch uses a clean template. Sites without a schema-fetch capture
2766
2770
  // (rawSchemaFetch === null) get unchanged behavior.
2767
- const rawSchemaFetch = gql ? null : detectFormSchemaFetchCapture(captures, baseUrl);
2771
+ const rawSchemaFetch = gql || formSchema === null ? null : detectFormSchemaFetchCapture(captures, baseUrl, formSchema);
2768
2772
  const schemaFetchCleaned = rawSchemaFetch
2769
2773
  ? { ...rawSchemaFetch.capture, url: stripCacheBusterParams(rawSchemaFetch.capture.url) }
2770
2774
  : null;
@@ -2805,9 +2809,9 @@ async function main() {
2805
2809
  const errorSignals = detectErrorSignals(actionSteps);
2806
2810
  const discoveredFormFields = new Set();
2807
2811
  const discoveredOptionFields = new Set();
2808
- // Phase E: maps FieldName-derived raw-option payload field name (e.g.
2809
- // "AreYouOverTheAgeOf18OptionId") → recon-observed OptionId UUID. Used to
2810
- // emit `<FieldName>OptionId: z.string()` payload fields with TSDoc docs.
2812
+ // Phase E: maps label-derived raw-option payload field name (e.g.
2813
+ // "AreYouOverTheAgeOf18OptionId") → recon-observed option-id UUID. Used to
2814
+ // emit `<Name>OptionId: z.string()` payload fields with TSDoc docs.
2811
2815
  const discoveredRawOptionFields = new Map();
2812
2816
  // Phase F: keys from additional action POST bodies (beyond inputBody/r0)
2813
2817
  // that get parameterized. Recorded with their value type so the contract
@@ -2844,7 +2848,7 @@ async function main() {
2844
2848
  }
2845
2849
  }
2846
2850
  const multiStepBody = isSubmissionFlow
2847
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders)
2851
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, new Map(), formSchema)
2848
2852
  : undefined;
2849
2853
  let base64ContentHelper = "";
2850
2854
  const base64PatchOverride = new Map();
@@ -2936,7 +2940,7 @@ async function main() {
2936
2940
  }
2937
2941
  }
2938
2942
  const processedMultiStepBody = isSubmissionFlow
2939
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride)
2943
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride, formSchema)
2940
2944
  : multiStepBody;
2941
2945
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
2942
2946
  const headerBindings = collectHeaderBindings(actionSteps);
@@ -2957,6 +2961,10 @@ async function main() {
2957
2961
  vocabulary,
2958
2962
  inputBody,
2959
2963
  recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
2964
+ // A submission flow is the case where the `.ts` emit carries an
2965
+ // executeHttp hot path; point the manifest at where the operator drops
2966
+ // the compiled module rather than silently dropping the direct path.
2967
+ httpModulePath: isSubmissionFlow ? `./${siteId}.http.js` : undefined,
2960
2968
  }));
2961
2969
  logger.info(`wrote ${manifestPath}`);
2962
2970
  logger.info(`done — review ${manifestPath}, fill in response/extract schemas, then load via BARNACLE_PLUGINS or BARNACLE_PLUGINS_CONFIG_DIR (no compile step)`);