@enricai/barnacle 1.0.0

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 (448) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +958 -0
  3. package/dist/api/errors.d.ts +88 -0
  4. package/dist/api/errors.d.ts.map +1 -0
  5. package/dist/api/errors.js +164 -0
  6. package/dist/api/errors.js.map +1 -0
  7. package/dist/api/helpers/envelope.d.ts +13 -0
  8. package/dist/api/helpers/envelope.d.ts.map +1 -0
  9. package/dist/api/helpers/envelope.js +20 -0
  10. package/dist/api/helpers/envelope.js.map +1 -0
  11. package/dist/api/helpers/reply.d.ts +9 -0
  12. package/dist/api/helpers/reply.d.ts.map +1 -0
  13. package/dist/api/helpers/reply.js +14 -0
  14. package/dist/api/helpers/reply.js.map +1 -0
  15. package/dist/api/plugins/auth.d.ts +27 -0
  16. package/dist/api/plugins/auth.d.ts.map +1 -0
  17. package/dist/api/plugins/auth.js +111 -0
  18. package/dist/api/plugins/auth.js.map +1 -0
  19. package/dist/api/plugins/error-handler.d.ts +12 -0
  20. package/dist/api/plugins/error-handler.d.ts.map +1 -0
  21. package/dist/api/plugins/error-handler.js +72 -0
  22. package/dist/api/plugins/error-handler.js.map +1 -0
  23. package/dist/api/plugins/request-context.d.ts +17 -0
  24. package/dist/api/plugins/request-context.d.ts.map +1 -0
  25. package/dist/api/plugins/request-context.js +61 -0
  26. package/dist/api/plugins/request-context.js.map +1 -0
  27. package/dist/api/routes/health.d.ts +55 -0
  28. package/dist/api/routes/health.d.ts.map +1 -0
  29. package/dist/api/routes/health.js +137 -0
  30. package/dist/api/routes/health.js.map +1 -0
  31. package/dist/api/routes/plugins-introspection.d.ts +17 -0
  32. package/dist/api/routes/plugins-introspection.d.ts.map +1 -0
  33. package/dist/api/routes/plugins-introspection.js +13 -0
  34. package/dist/api/routes/plugins-introspection.js.map +1 -0
  35. package/dist/api/schemas/common.d.ts +114 -0
  36. package/dist/api/schemas/common.d.ts.map +1 -0
  37. package/dist/api/schemas/common.js +109 -0
  38. package/dist/api/schemas/common.js.map +1 -0
  39. package/dist/api/schemas/telemetry.d.ts +66 -0
  40. package/dist/api/schemas/telemetry.d.ts.map +1 -0
  41. package/dist/api/schemas/telemetry.js +52 -0
  42. package/dist/api/schemas/telemetry.js.map +1 -0
  43. package/dist/cache/keyed-ttl-cache.d.ts +45 -0
  44. package/dist/cache/keyed-ttl-cache.d.ts.map +1 -0
  45. package/dist/cache/keyed-ttl-cache.js +69 -0
  46. package/dist/cache/keyed-ttl-cache.js.map +1 -0
  47. package/dist/cache/response-cache.d.ts +37 -0
  48. package/dist/cache/response-cache.d.ts.map +1 -0
  49. package/dist/cache/response-cache.js +114 -0
  50. package/dist/cache/response-cache.js.map +1 -0
  51. package/dist/config.d.ts +258 -0
  52. package/dist/config.d.ts.map +1 -0
  53. package/dist/config.js +138 -0
  54. package/dist/config.js.map +1 -0
  55. package/dist/lib/applicant-payload.d.ts +28 -0
  56. package/dist/lib/applicant-payload.d.ts.map +1 -0
  57. package/dist/lib/applicant-payload.js +20 -0
  58. package/dist/lib/applicant-payload.js.map +1 -0
  59. package/dist/lib/application-address.d.ts +16 -0
  60. package/dist/lib/application-address.d.ts.map +1 -0
  61. package/dist/lib/application-address.js +20 -0
  62. package/dist/lib/application-address.js.map +1 -0
  63. package/dist/lib/application-answers.d.ts +151 -0
  64. package/dist/lib/application-answers.d.ts.map +1 -0
  65. package/dist/lib/application-answers.js +95 -0
  66. package/dist/lib/application-answers.js.map +1 -0
  67. package/dist/lib/application-identity.d.ts +18 -0
  68. package/dist/lib/application-identity.d.ts.map +1 -0
  69. package/dist/lib/application-identity.js +20 -0
  70. package/dist/lib/application-identity.js.map +1 -0
  71. package/dist/lib/application-resume.d.ts +21 -0
  72. package/dist/lib/application-resume.d.ts.map +1 -0
  73. package/dist/lib/application-resume.js +23 -0
  74. package/dist/lib/application-resume.js.map +1 -0
  75. package/dist/lib/bedrock.d.ts +22 -0
  76. package/dist/lib/bedrock.d.ts.map +1 -0
  77. package/dist/lib/bedrock.js +35 -0
  78. package/dist/lib/bedrock.js.map +1 -0
  79. package/dist/lib/case-insensitive-headers.d.ts +15 -0
  80. package/dist/lib/case-insensitive-headers.d.ts.map +1 -0
  81. package/dist/lib/case-insensitive-headers.js +29 -0
  82. package/dist/lib/case-insensitive-headers.js.map +1 -0
  83. package/dist/lib/chromium-client-hints.d.ts +11 -0
  84. package/dist/lib/chromium-client-hints.d.ts.map +1 -0
  85. package/dist/lib/chromium-client-hints.js +17 -0
  86. package/dist/lib/chromium-client-hints.js.map +1 -0
  87. package/dist/lib/cookie-value.d.ts +8 -0
  88. package/dist/lib/cookie-value.d.ts.map +1 -0
  89. package/dist/lib/cookie-value.js +14 -0
  90. package/dist/lib/cookie-value.js.map +1 -0
  91. package/dist/lib/datadog.d.ts +8 -0
  92. package/dist/lib/datadog.d.ts.map +1 -0
  93. package/dist/lib/datadog.js +27 -0
  94. package/dist/lib/datadog.js.map +1 -0
  95. package/dist/lib/dd-metrics.d.ts +28 -0
  96. package/dist/lib/dd-metrics.d.ts.map +1 -0
  97. package/dist/lib/dd-metrics.js +62 -0
  98. package/dist/lib/dd-metrics.js.map +1 -0
  99. package/dist/lib/dispatch-metrics.d.ts +19 -0
  100. package/dist/lib/dispatch-metrics.d.ts.map +1 -0
  101. package/dist/lib/dispatch-metrics.js +65 -0
  102. package/dist/lib/dispatch-metrics.js.map +1 -0
  103. package/dist/lib/env.d.ts +37 -0
  104. package/dist/lib/env.d.ts.map +1 -0
  105. package/dist/lib/env.js +70 -0
  106. package/dist/lib/env.js.map +1 -0
  107. package/dist/lib/errors.d.ts +15 -0
  108. package/dist/lib/errors.d.ts.map +1 -0
  109. package/dist/lib/errors.js +20 -0
  110. package/dist/lib/errors.js.map +1 -0
  111. package/dist/lib/http.d.ts +7 -0
  112. package/dist/lib/http.d.ts.map +1 -0
  113. package/dist/lib/http.js +14 -0
  114. package/dist/lib/http.js.map +1 -0
  115. package/dist/lib/job-tracking.d.ts +17 -0
  116. package/dist/lib/job-tracking.d.ts.map +1 -0
  117. package/dist/lib/job-tracking.js +19 -0
  118. package/dist/lib/job-tracking.js.map +1 -0
  119. package/dist/lib/llm/anthropic-client.d.ts +12 -0
  120. package/dist/lib/llm/anthropic-client.d.ts.map +1 -0
  121. package/dist/lib/llm/anthropic-client.js +23 -0
  122. package/dist/lib/llm/anthropic-client.js.map +1 -0
  123. package/dist/lib/llm/judge.d.ts +57 -0
  124. package/dist/lib/llm/judge.d.ts.map +1 -0
  125. package/dist/lib/llm/judge.js +123 -0
  126. package/dist/lib/llm/judge.js.map +1 -0
  127. package/dist/lib/llm/judges/error-messages.d.ts +38 -0
  128. package/dist/lib/llm/judges/error-messages.d.ts.map +1 -0
  129. package/dist/lib/llm/judges/error-messages.js +72 -0
  130. package/dist/lib/llm/judges/error-messages.js.map +1 -0
  131. package/dist/lib/llm/judges/invalid-fields.d.ts +43 -0
  132. package/dist/lib/llm/judges/invalid-fields.d.ts.map +1 -0
  133. package/dist/lib/llm/judges/invalid-fields.js +80 -0
  134. package/dist/lib/llm/judges/invalid-fields.js.map +1 -0
  135. package/dist/lib/llm/judges/modal-priority.d.ts +41 -0
  136. package/dist/lib/llm/judges/modal-priority.d.ts.map +1 -0
  137. package/dist/lib/llm/judges/modal-priority.js +74 -0
  138. package/dist/lib/llm/judges/modal-priority.js.map +1 -0
  139. package/dist/lib/llm/judges/select-option.d.ts +49 -0
  140. package/dist/lib/llm/judges/select-option.d.ts.map +1 -0
  141. package/dist/lib/llm/judges/select-option.js +90 -0
  142. package/dist/lib/llm/judges/select-option.js.map +1 -0
  143. package/dist/lib/llm/judges/verify-submit.d.ts +68 -0
  144. package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -0
  145. package/dist/lib/llm/judges/verify-submit.js +89 -0
  146. package/dist/lib/llm/judges/verify-submit.js.map +1 -0
  147. package/dist/lib/llm/schemas.d.ts +220 -0
  148. package/dist/lib/llm/schemas.d.ts.map +1 -0
  149. package/dist/lib/llm/schemas.js +239 -0
  150. package/dist/lib/llm/schemas.js.map +1 -0
  151. package/dist/lib/logging.d.ts +39 -0
  152. package/dist/lib/logging.d.ts.map +1 -0
  153. package/dist/lib/logging.js +219 -0
  154. package/dist/lib/logging.js.map +1 -0
  155. package/dist/lib/multipart.d.ts +34 -0
  156. package/dist/lib/multipart.d.ts.map +1 -0
  157. package/dist/lib/multipart.js +55 -0
  158. package/dist/lib/multipart.js.map +1 -0
  159. package/dist/lib/option-matcher.d.ts +15 -0
  160. package/dist/lib/option-matcher.d.ts.map +1 -0
  161. package/dist/lib/option-matcher.js +69 -0
  162. package/dist/lib/option-matcher.js.map +1 -0
  163. package/dist/lib/phone.d.ts +11 -0
  164. package/dist/lib/phone.d.ts.map +1 -0
  165. package/dist/lib/phone.js +17 -0
  166. package/dist/lib/phone.js.map +1 -0
  167. package/dist/lib/random.d.ts +22 -0
  168. package/dist/lib/random.d.ts.map +1 -0
  169. package/dist/lib/random.js +33 -0
  170. package/dist/lib/random.js.map +1 -0
  171. package/dist/lib/statsd.d.ts +11 -0
  172. package/dist/lib/statsd.d.ts.map +1 -0
  173. package/dist/lib/statsd.js +68 -0
  174. package/dist/lib/statsd.js.map +1 -0
  175. package/dist/lib/telemetry/call-capture.d.ts +89 -0
  176. package/dist/lib/telemetry/call-capture.d.ts.map +1 -0
  177. package/dist/lib/telemetry/call-capture.js +190 -0
  178. package/dist/lib/telemetry/call-capture.js.map +1 -0
  179. package/dist/lib/telemetry/call-types.d.ts +65 -0
  180. package/dist/lib/telemetry/call-types.d.ts.map +1 -0
  181. package/dist/lib/telemetry/call-types.js +68 -0
  182. package/dist/lib/telemetry/call-types.js.map +1 -0
  183. package/dist/lib/telemetry/run-state.d.ts +15 -0
  184. package/dist/lib/telemetry/run-state.d.ts.map +1 -0
  185. package/dist/lib/telemetry/run-state.js +23 -0
  186. package/dist/lib/telemetry/run-state.js.map +1 -0
  187. package/dist/lib/telemetry/s3-sink.d.ts +48 -0
  188. package/dist/lib/telemetry/s3-sink.d.ts.map +1 -0
  189. package/dist/lib/telemetry/s3-sink.js +229 -0
  190. package/dist/lib/telemetry/s3-sink.js.map +1 -0
  191. package/dist/lib/telemetry/submission-capture.d.ts +45 -0
  192. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -0
  193. package/dist/lib/telemetry/submission-capture.js +57 -0
  194. package/dist/lib/telemetry/submission-capture.js.map +1 -0
  195. package/dist/lib/telemetry/telemetry-paths.d.ts +59 -0
  196. package/dist/lib/telemetry/telemetry-paths.d.ts.map +1 -0
  197. package/dist/lib/telemetry/telemetry-paths.js +103 -0
  198. package/dist/lib/telemetry/telemetry-paths.js.map +1 -0
  199. package/dist/lib/tracking-click.d.ts +20 -0
  200. package/dist/lib/tracking-click.d.ts.map +1 -0
  201. package/dist/lib/tracking-click.js +75 -0
  202. package/dist/lib/tracking-click.js.map +1 -0
  203. package/dist/lib/url-capture.d.ts +8 -0
  204. package/dist/lib/url-capture.d.ts.map +1 -0
  205. package/dist/lib/url-capture.js +17 -0
  206. package/dist/lib/url-capture.js.map +1 -0
  207. package/dist/lib/us-states.d.ts +20 -0
  208. package/dist/lib/us-states.d.ts.map +1 -0
  209. package/dist/lib/us-states.js +86 -0
  210. package/dist/lib/us-states.js.map +1 -0
  211. package/dist/lib/zod-multipart.d.ts +18 -0
  212. package/dist/lib/zod-multipart.d.ts.map +1 -0
  213. package/dist/lib/zod-multipart.js +35 -0
  214. package/dist/lib/zod-multipart.js.map +1 -0
  215. package/dist/plugins/config-plugin.d.ts +77 -0
  216. package/dist/plugins/config-plugin.d.ts.map +1 -0
  217. package/dist/plugins/config-plugin.js +216 -0
  218. package/dist/plugins/config-plugin.js.map +1 -0
  219. package/dist/plugins/discover.d.ts +72 -0
  220. package/dist/plugins/discover.d.ts.map +1 -0
  221. package/dist/plugins/discover.js +265 -0
  222. package/dist/plugins/discover.js.map +1 -0
  223. package/dist/plugins/json-schema-to-zod.d.ts +47 -0
  224. package/dist/plugins/json-schema-to-zod.d.ts.map +1 -0
  225. package/dist/plugins/json-schema-to-zod.js +85 -0
  226. package/dist/plugins/json-schema-to-zod.js.map +1 -0
  227. package/dist/plugins/loader.d.ts +34 -0
  228. package/dist/plugins/loader.d.ts.map +1 -0
  229. package/dist/plugins/loader.js +348 -0
  230. package/dist/plugins/loader.js.map +1 -0
  231. package/dist/plugins/plugin-api-version.d.ts +8 -0
  232. package/dist/plugins/plugin-api-version.d.ts.map +1 -0
  233. package/dist/plugins/plugin-api-version.js +11 -0
  234. package/dist/plugins/plugin-api-version.js.map +1 -0
  235. package/dist/plugins/plugin-manifest-envelope.d.ts +16 -0
  236. package/dist/plugins/plugin-manifest-envelope.d.ts.map +1 -0
  237. package/dist/plugins/plugin-manifest-envelope.js +19 -0
  238. package/dist/plugins/plugin-manifest-envelope.js.map +1 -0
  239. package/dist/scraper/behavioral-signals.d.ts +16 -0
  240. package/dist/scraper/behavioral-signals.d.ts.map +1 -0
  241. package/dist/scraper/behavioral-signals.js +30 -0
  242. package/dist/scraper/behavioral-signals.js.map +1 -0
  243. package/dist/scraper/errors.d.ts +193 -0
  244. package/dist/scraper/errors.d.ts.map +1 -0
  245. package/dist/scraper/errors.js +226 -0
  246. package/dist/scraper/errors.js.map +1 -0
  247. package/dist/scraper/fixtures.d.ts +23 -0
  248. package/dist/scraper/fixtures.d.ts.map +1 -0
  249. package/dist/scraper/fixtures.js +48 -0
  250. package/dist/scraper/fixtures.js.map +1 -0
  251. package/dist/scraper/flow-runner.d.ts +1319 -0
  252. package/dist/scraper/flow-runner.d.ts.map +1 -0
  253. package/dist/scraper/flow-runner.js +5637 -0
  254. package/dist/scraper/flow-runner.js.map +1 -0
  255. package/dist/scraper/graphql-client.d.ts +30 -0
  256. package/dist/scraper/graphql-client.d.ts.map +1 -0
  257. package/dist/scraper/graphql-client.js +14 -0
  258. package/dist/scraper/graphql-client.js.map +1 -0
  259. package/dist/scraper/http-client.d.ts +75 -0
  260. package/dist/scraper/http-client.d.ts.map +1 -0
  261. package/dist/scraper/http-client.js +189 -0
  262. package/dist/scraper/http-client.js.map +1 -0
  263. package/dist/scraper/http-status-classifier.d.ts +12 -0
  264. package/dist/scraper/http-status-classifier.d.ts.map +1 -0
  265. package/dist/scraper/http-status-classifier.js +29 -0
  266. package/dist/scraper/http-status-classifier.js.map +1 -0
  267. package/dist/scraper/metrics.d.ts +50 -0
  268. package/dist/scraper/metrics.d.ts.map +1 -0
  269. package/dist/scraper/metrics.js +85 -0
  270. package/dist/scraper/metrics.js.map +1 -0
  271. package/dist/scraper/navigate.d.ts +20 -0
  272. package/dist/scraper/navigate.d.ts.map +1 -0
  273. package/dist/scraper/navigate.js +30 -0
  274. package/dist/scraper/navigate.js.map +1 -0
  275. package/dist/scraper/oracle-sentinels.d.ts +22 -0
  276. package/dist/scraper/oracle-sentinels.d.ts.map +1 -0
  277. package/dist/scraper/oracle-sentinels.js +40 -0
  278. package/dist/scraper/oracle-sentinels.js.map +1 -0
  279. package/dist/scraper/parse-json-response.d.ts +16 -0
  280. package/dist/scraper/parse-json-response.d.ts.map +1 -0
  281. package/dist/scraper/parse-json-response.js +37 -0
  282. package/dist/scraper/parse-json-response.js.map +1 -0
  283. package/dist/scraper/pool.d.ts +37 -0
  284. package/dist/scraper/pool.d.ts.map +1 -0
  285. package/dist/scraper/pool.js +105 -0
  286. package/dist/scraper/pool.js.map +1 -0
  287. package/dist/scraper/rate-limited-json-client.d.ts +35 -0
  288. package/dist/scraper/rate-limited-json-client.d.ts.map +1 -0
  289. package/dist/scraper/rate-limited-json-client.js +29 -0
  290. package/dist/scraper/rate-limited-json-client.js.map +1 -0
  291. package/dist/scraper/raw-fetch.d.ts +49 -0
  292. package/dist/scraper/raw-fetch.d.ts.map +1 -0
  293. package/dist/scraper/raw-fetch.js +40 -0
  294. package/dist/scraper/raw-fetch.js.map +1 -0
  295. package/dist/scraper/require-response-field.d.ts +23 -0
  296. package/dist/scraper/require-response-field.d.ts.map +1 -0
  297. package/dist/scraper/require-response-field.js +42 -0
  298. package/dist/scraper/require-response-field.js.map +1 -0
  299. package/dist/scraper/retry.d.ts +33 -0
  300. package/dist/scraper/retry.d.ts.map +1 -0
  301. package/dist/scraper/retry.js +122 -0
  302. package/dist/scraper/retry.js.map +1 -0
  303. package/dist/scraper/session-browserbase.d.ts +35 -0
  304. package/dist/scraper/session-browserbase.d.ts.map +1 -0
  305. package/dist/scraper/session-browserbase.js +198 -0
  306. package/dist/scraper/session-browserbase.js.map +1 -0
  307. package/dist/scraper/session-shared.d.ts +29 -0
  308. package/dist/scraper/session-shared.d.ts.map +1 -0
  309. package/dist/scraper/session-shared.js +35 -0
  310. package/dist/scraper/session-shared.js.map +1 -0
  311. package/dist/scraper/session-steel.d.ts +31 -0
  312. package/dist/scraper/session-steel.d.ts.map +1 -0
  313. package/dist/scraper/session-steel.js +140 -0
  314. package/dist/scraper/session-steel.js.map +1 -0
  315. package/dist/scraper/session-warmup.d.ts +31 -0
  316. package/dist/scraper/session-warmup.d.ts.map +1 -0
  317. package/dist/scraper/session-warmup.js +46 -0
  318. package/dist/scraper/session-warmup.js.map +1 -0
  319. package/dist/scraper/session.d.ts +21 -0
  320. package/dist/scraper/session.d.ts.map +1 -0
  321. package/dist/scraper/session.js +28 -0
  322. package/dist/scraper/session.js.map +1 -0
  323. package/dist/scraper/stagehand-guard.d.ts +128 -0
  324. package/dist/scraper/stagehand-guard.d.ts.map +1 -0
  325. package/dist/scraper/stagehand-guard.js +337 -0
  326. package/dist/scraper/stagehand-guard.js.map +1 -0
  327. package/dist/scraper/throttle.d.ts +21 -0
  328. package/dist/scraper/throttle.d.ts.map +1 -0
  329. package/dist/scraper/throttle.js +45 -0
  330. package/dist/scraper/throttle.js.map +1 -0
  331. package/dist/scripts/hca-inbox-poll.d.ts +19 -0
  332. package/dist/scripts/hca-inbox-poll.d.ts.map +1 -0
  333. package/dist/scripts/hca-inbox-poll.js +151 -0
  334. package/dist/scripts/hca-inbox-poll.js.map +1 -0
  335. package/dist/scripts/judge-llm-batch.d.ts +98 -0
  336. package/dist/scripts/judge-llm-batch.d.ts.map +1 -0
  337. package/dist/scripts/judge-llm-batch.js +350 -0
  338. package/dist/scripts/judge-llm-batch.js.map +1 -0
  339. package/dist/scripts/llm-heal.d.ts +165 -0
  340. package/dist/scripts/llm-heal.d.ts.map +1 -0
  341. package/dist/scripts/llm-heal.js +595 -0
  342. package/dist/scripts/llm-heal.js.map +1 -0
  343. package/dist/scripts/migrate-telemetry-dir-names.d.ts +57 -0
  344. package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -0
  345. package/dist/scripts/migrate-telemetry-dir-names.js +156 -0
  346. package/dist/scripts/migrate-telemetry-dir-names.js.map +1 -0
  347. package/dist/scripts/probe-setinputfiles.d.ts +28 -0
  348. package/dist/scripts/probe-setinputfiles.d.ts.map +1 -0
  349. package/dist/scripts/probe-setinputfiles.js +95 -0
  350. package/dist/scripts/probe-setinputfiles.js.map +1 -0
  351. package/dist/scripts/recon-browser.d.ts +304 -0
  352. package/dist/scripts/recon-browser.d.ts.map +1 -0
  353. package/dist/scripts/recon-browser.js +1833 -0
  354. package/dist/scripts/recon-browser.js.map +1 -0
  355. package/dist/scripts/recon-generate.d.ts +182 -0
  356. package/dist/scripts/recon-generate.d.ts.map +1 -0
  357. package/dist/scripts/recon-generate.js +2572 -0
  358. package/dist/scripts/recon-generate.js.map +1 -0
  359. package/dist/scripts/recon-heal.d.ts +176 -0
  360. package/dist/scripts/recon-heal.d.ts.map +1 -0
  361. package/dist/scripts/recon-heal.js +584 -0
  362. package/dist/scripts/recon-heal.js.map +1 -0
  363. package/dist/scripts/recon-http.d.ts +19 -0
  364. package/dist/scripts/recon-http.d.ts.map +1 -0
  365. package/dist/scripts/recon-http.js +377 -0
  366. package/dist/scripts/recon-http.js.map +1 -0
  367. package/dist/scripts/recon-replay-jobs.d.ts +21 -0
  368. package/dist/scripts/recon-replay-jobs.d.ts.map +1 -0
  369. package/dist/scripts/recon-replay-jobs.js +221 -0
  370. package/dist/scripts/recon-replay-jobs.js.map +1 -0
  371. package/dist/scripts/recon-shared.d.ts +52 -0
  372. package/dist/scripts/recon-shared.d.ts.map +1 -0
  373. package/dist/scripts/recon-shared.js +68 -0
  374. package/dist/scripts/recon-shared.js.map +1 -0
  375. package/dist/scripts/recon-summarize.d.ts +15 -0
  376. package/dist/scripts/recon-summarize.d.ts.map +1 -0
  377. package/dist/scripts/recon-summarize.js +233 -0
  378. package/dist/scripts/recon-summarize.js.map +1 -0
  379. package/dist/scripts/smoke-test.d.ts +22 -0
  380. package/dist/scripts/smoke-test.d.ts.map +1 -0
  381. package/dist/scripts/smoke-test.js +236 -0
  382. package/dist/scripts/smoke-test.js.map +1 -0
  383. package/dist/server.d.ts +10 -0
  384. package/dist/server.d.ts.map +1 -0
  385. package/dist/server.js +189 -0
  386. package/dist/server.js.map +1 -0
  387. package/dist/site-plugin.d.ts +226 -0
  388. package/dist/site-plugin.d.ts.map +1 -0
  389. package/dist/site-plugin.js +13 -0
  390. package/dist/site-plugin.js.map +1 -0
  391. package/dist/testing/answers-fixture.d.ts +13 -0
  392. package/dist/testing/answers-fixture.d.ts.map +1 -0
  393. package/dist/testing/answers-fixture.js +37 -0
  394. package/dist/testing/answers-fixture.js.map +1 -0
  395. package/dist/testing/batch-email-confirmation.d.ts +58 -0
  396. package/dist/testing/batch-email-confirmation.d.ts.map +1 -0
  397. package/dist/testing/batch-email-confirmation.js +62 -0
  398. package/dist/testing/batch-email-confirmation.js.map +1 -0
  399. package/dist/testing/batch-report.d.ts +33 -0
  400. package/dist/testing/batch-report.d.ts.map +1 -0
  401. package/dist/testing/batch-report.js +30 -0
  402. package/dist/testing/batch-report.js.map +1 -0
  403. package/dist/testing/contract-parity-suite.d.ts +55 -0
  404. package/dist/testing/contract-parity-suite.d.ts.map +1 -0
  405. package/dist/testing/contract-parity-suite.js +39 -0
  406. package/dist/testing/contract-parity-suite.js.map +1 -0
  407. package/dist/testing/coverage-guard-suite.d.ts +46 -0
  408. package/dist/testing/coverage-guard-suite.d.ts.map +1 -0
  409. package/dist/testing/coverage-guard-suite.js +39 -0
  410. package/dist/testing/coverage-guard-suite.js.map +1 -0
  411. package/dist/testing/fixtures/resume.pdf +54 -0
  412. package/dist/testing/integration-runner.d.ts +62 -0
  413. package/dist/testing/integration-runner.d.ts.map +1 -0
  414. package/dist/testing/integration-runner.js +46 -0
  415. package/dist/testing/integration-runner.js.map +1 -0
  416. package/dist/testing/mock-fetch-response.d.ts +7 -0
  417. package/dist/testing/mock-fetch-response.d.ts.map +1 -0
  418. package/dist/testing/mock-fetch-response.js +16 -0
  419. package/dist/testing/mock-fetch-response.js.map +1 -0
  420. package/dist/testing/persona-fixture.d.ts +77 -0
  421. package/dist/testing/persona-fixture.d.ts.map +1 -0
  422. package/dist/testing/persona-fixture.js +83 -0
  423. package/dist/testing/persona-fixture.js.map +1 -0
  424. package/dist/testing/replay-integration-suite.d.ts +50 -0
  425. package/dist/testing/replay-integration-suite.d.ts.map +1 -0
  426. package/dist/testing/replay-integration-suite.js +45 -0
  427. package/dist/testing/replay-integration-suite.js.map +1 -0
  428. package/dist/testing/resume-fixture.d.ts +39 -0
  429. package/dist/testing/resume-fixture.d.ts.map +1 -0
  430. package/dist/testing/resume-fixture.js +38 -0
  431. package/dist/testing/resume-fixture.js.map +1 -0
  432. package/dist/testmail/client.d.ts +61 -0
  433. package/dist/testmail/client.d.ts.map +1 -0
  434. package/dist/testmail/client.js +190 -0
  435. package/dist/testmail/client.js.map +1 -0
  436. package/dist/testmail/errors.d.ts +18 -0
  437. package/dist/testmail/errors.d.ts.map +1 -0
  438. package/dist/testmail/errors.js +29 -0
  439. package/dist/testmail/errors.js.map +1 -0
  440. package/dist/types/dispatch-metrics.d.ts +46 -0
  441. package/dist/types/dispatch-metrics.d.ts.map +1 -0
  442. package/dist/types/dispatch-metrics.js +25 -0
  443. package/dist/types/dispatch-metrics.js.map +1 -0
  444. package/dist/types/logging.d.ts +11 -0
  445. package/dist/types/logging.d.ts.map +1 -0
  446. package/dist/types/logging.js +3 -0
  447. package/dist/types/logging.js.map +1 -0
  448. package/package.json +283 -0
@@ -0,0 +1,2572 @@
1
+ "use strict";
2
+ /**
3
+ * Phase 4f: reads recon artifacts and generates a complete plugin skeleton —
4
+ * contract.ts, flows/browser-flow.ts, index.ts, and fixtures/ — so no manual
5
+ * coding is required between running recon and registering the plugin.
6
+ *
7
+ * Usage:
8
+ * pnpm run recon:generate -- --site-id my-site [--force]
9
+ *
10
+ * --force overwrites an existing src/sites/<siteId>/ directory.
11
+ *
12
+ * Reads from:
13
+ * /tmp/recon/graphql/*.json — Capture[] from recon-browser.ts
14
+ * /tmp/recon/replays/*.json — ReplayResult[] from recon-http.ts
15
+ * /tmp/recon/replays/rate-limit.json
16
+ * /tmp/recon/aux/*.json — static fixture files
17
+ * src/sites/<siteId>/recon-flow.json — plain-English flow steps
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.resolveStepPayloadField = resolveStepPayloadField;
21
+ exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
22
+ exports.emitContractTs = emitContractTs;
23
+ exports.emitConfigManifest = emitConfigManifest;
24
+ exports.emitBrowserFlowTs = emitBrowserFlowTs;
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = require("node:path");
27
+ const errors_1 = require("../lib/errors");
28
+ const logging_1 = require("../lib/logging");
29
+ const plugin_manifest_envelope_1 = require("../plugins/plugin-manifest-envelope");
30
+ const recon_shared_1 = require("../scripts/recon-shared");
31
+ const logger = (0, logging_1.getScriptLogger)("recon-generate");
32
+ // ── helpers ──────────────────────────────────────────────────────────────────
33
+ function toPascalCase(siteId) {
34
+ return siteId
35
+ .split(/[-_]/)
36
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
37
+ .join("");
38
+ }
39
+ /**
40
+ * Instruction labels that name a NON-candidate field — a reference-contact, an
41
+ * employment-history row, the applicant's signature, etc. When any of these
42
+ * matches we leave the step literal so the generator never splices the primary
43
+ * applicant's PII into a field that must hold someone/something else. Matched
44
+ * against the English label in the instruction, never the recon constant value.
45
+ */
46
+ const PAYLOAD_FIELD_EXCLUSIONS = [
47
+ /reference\s*#?\s*\d/i,
48
+ /employment history/i,
49
+ /\bcompany (name|phone)\b/i,
50
+ /\bemployer\b/i,
51
+ /signature/i,
52
+ /\bfull name\b/i,
53
+ /today'?s date/i,
54
+ /school|institution|degree|major|education/i,
55
+ // Screening-question shapes: "For '<question>' select '<answer>'" and any
56
+ // step framed around a 'question'. Here the first quoted string is the
57
+ // question label, not a candidate value — a label word inside the question
58
+ // (e.g. "...licensed in this state?") must NOT trigger a splice that would
59
+ // overwrite the question quote. Candidate-fill steps say "fill in the X
60
+ // field with '...'" / "Select '...' from the X dropdown" instead.
61
+ /^\s*for\s+'/i,
62
+ /\bquestion\b/i,
63
+ ];
64
+ /**
65
+ * Ordered candidate-field label table. Each entry maps an English-label regex
66
+ * to the PascalCase payload field the generator should splice. Order matters:
67
+ * the first matching row wins, so more-specific labels precede broader ones.
68
+ */
69
+ const PAYLOAD_FIELD_TABLE = [
70
+ [/\bfirst name\b/i, "FirstName"],
71
+ [/\blast name\b/i, "LastName"],
72
+ [/\b(e-?mail|email address)\b/i, "Email"],
73
+ [/\b(mobile phone|primary phone|phone number|mobile)\b/i, "MobilePhone"],
74
+ [/\b(street address|address line 1)\b/i, "AddressLine1"],
75
+ [/\bcity\b/i, "City"],
76
+ [/\b(state|province|state\/region)\b/i, "State"],
77
+ [/\b(zip|postal)\b/i, "PostalCode"],
78
+ [/\bcountry\b/i, "Country"],
79
+ ];
80
+ /**
81
+ * Decide whether a flow step should splice a runtime `payload.<field>` value in
82
+ * place of the frozen recon constant baked into its instruction. Exists so
83
+ * generated browser-flows use the caller's real applicant identity instead of
84
+ * recon's captured identity, while operational-default steps (decline self-ID,
85
+ * legal yes/no answers) stay literal. Matching on the English LABEL — not the
86
+ * drifting constant value — keeps the decision stable when recon re-captures
87
+ * with a different identity.
88
+ *
89
+ * @param instruction the flow step's plain-English instruction
90
+ * @param explicit an optional flow-authored `payloadField` override (wins outright)
91
+ * @param forceNone when true, force a literal step (the `payloadFieldNone` opt-out)
92
+ * @returns the PascalCase payload field name to splice, or null to keep literal
93
+ */
94
+ function resolveStepPayloadField(instruction, explicit, forceNone) {
95
+ if (forceNone)
96
+ return null;
97
+ if (explicit)
98
+ return explicit;
99
+ // Nothing to splice unless the instruction actually carries a recon constant:
100
+ // a quoted literal, the ${RECON_EMAIL} token, or a dropdown/select phrasing.
101
+ const hasSpliceable = /'[^']*'/.test(instruction) ||
102
+ /\$\{RECON_EMAIL\}/.test(instruction) ||
103
+ /\bdropdown\b/i.test(instruction) ||
104
+ /\bselect\b[^.]*\bfrom\b/i.test(instruction);
105
+ if (!hasSpliceable)
106
+ return null;
107
+ if (PAYLOAD_FIELD_EXCLUSIONS.some((rx) => rx.test(instruction)))
108
+ return null;
109
+ // "Secondary Phone Number" must not fill the primary MobilePhone field.
110
+ if (/\bsecondary\b/i.test(instruction) && /\bphone\b/i.test(instruction))
111
+ return null;
112
+ for (const [rx, field] of PAYLOAD_FIELD_TABLE) {
113
+ if (rx.test(instruction))
114
+ return field;
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Recursively infers a Zod schema expression string from a JSON value.
120
+ * Caps recursion at 4 levels to avoid generating unwieldy output for deeply
121
+ * nested API responses — deeper fields collapse to z.unknown().
122
+ */
123
+ function inferZodSchema(value, depth = 0, indent = "", opts = {}) {
124
+ if (depth > 4)
125
+ return "z.unknown()";
126
+ if (value === null)
127
+ return "z.null()";
128
+ if (typeof value === "string")
129
+ return "z.string()";
130
+ if (typeof value === "number")
131
+ return opts.multipartCoerce ? "z.coerce.number()" : "z.number()";
132
+ if (typeof value === "boolean") {
133
+ // multipart/form-data encodes booleans as "true"/"false". The contract
134
+ // emitter imports the shared multipartBoolean() helper from @/lib/zod-multipart
135
+ // when any field needs this coercion; we call it here to keep field declarations short.
136
+ return opts.multipartCoerce ? "multipartBoolean()" : "z.boolean()";
137
+ }
138
+ if (Array.isArray(value)) {
139
+ const item = value.length > 0 ? inferZodSchema(value[0], depth + 1, indent, opts) : "z.unknown()";
140
+ return `z.array(${item})`;
141
+ }
142
+ if (typeof value === "object") {
143
+ const entries = Object.entries(value);
144
+ if (entries.length === 0)
145
+ return "z.record(z.string(), z.unknown())";
146
+ const inner = `${indent} `;
147
+ // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
148
+ // the generated file on first lint:fix.
149
+ const fields = entries
150
+ .map(([k, v]) => `${inner}${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${inferZodSchema(v, depth + 1, inner, opts)}`)
151
+ .join(",\n");
152
+ return `z.object({\n${fields},\n${indent}})`;
153
+ }
154
+ return "z.unknown()";
155
+ }
156
+ function deriveMinTime(rateLimits) {
157
+ const first = rateLimits.find((f) => f.safeRps !== null);
158
+ return first?.safeRps ? Math.floor(1000 / first.safeRps) : 200;
159
+ }
160
+ function deriveBaseUrl(captures) {
161
+ for (const c of captures) {
162
+ try {
163
+ const u = new URL(c.url);
164
+ return `${u.protocol}//${u.host}`;
165
+ }
166
+ catch {
167
+ // try next
168
+ }
169
+ }
170
+ return "https://example.com";
171
+ }
172
+ const IGNORE_REQUEST_HEADERS = new Set([
173
+ "host",
174
+ "content-length",
175
+ "connection",
176
+ "accept-encoding",
177
+ "cookie",
178
+ ":method",
179
+ ":path",
180
+ ":authority",
181
+ ":scheme",
182
+ ]);
183
+ /**
184
+ * Derives BASE_HEADERS from the request headers the browser actually sent
185
+ * during recon, filtered to those present in every capture whose endpoint
186
+ * replayed successfully. Always includes the standard Content-Type / Accept /
187
+ * Origin / Referer / User-Agent baseline regardless of presence count.
188
+ *
189
+ * Fallback: when no replays succeeded (typical for auth-gated multi-step
190
+ * flows where every request after `/user/create` requires a token the
191
+ * stateless replay phase can't thread), derive headers from the meaningful
192
+ * action POSTs instead — same `extractActionSequence` definition used by
193
+ * the submission-flow detector. This catches load-bearing site-specific
194
+ * headers (Workday's `X-CSRF-Token`, Greenhouse's `Job-Boards-API-Token`,
195
+ * ClearCompany's `API-ShortName`, etc.) without the generator needing to
196
+ * know about any particular site.
197
+ */
198
+ function deriveRequestHeaders(captures, replays, baseUrl) {
199
+ const successfulUrls = new Set(replays
200
+ .filter((r) => r.success)
201
+ .map((r) => {
202
+ try {
203
+ const u = new URL(r.url);
204
+ return `${u.origin}${u.pathname}`;
205
+ }
206
+ catch {
207
+ return r.url;
208
+ }
209
+ }));
210
+ // Prefer ACTION captures (non-GET 2xx to baseUrl host, non-telemetry) as
211
+ // the authoritative header source. Replay-matched static-asset GETs lack
212
+ // the API-specific headers that REST endpoints require, so falling back
213
+ // to those produces a degenerate baseline-only header set. When action
214
+ // captures exist (multi-step submission flows), use them. For sites
215
+ // where the flow is a single REST call (no detectable action sequence),
216
+ // fall back to the replay-matched captures.
217
+ const actionCaptures = extractActionSequence(captures, baseUrl).map((a) => a.capture);
218
+ const replayMatchedCaptures = captures.filter((c) => {
219
+ try {
220
+ const u = new URL(c.url);
221
+ return successfulUrls.has(`${u.origin}${u.pathname}`);
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ });
227
+ const relevantCaptures = actionCaptures.length > 0 ? actionCaptures : replayMatchedCaptures;
228
+ const counts = new Map();
229
+ for (const c of relevantCaptures) {
230
+ for (const header of Object.keys(c.requestHeaders)) {
231
+ const lower = header.toLowerCase();
232
+ if (IGNORE_REQUEST_HEADERS.has(lower))
233
+ continue;
234
+ counts.set(lower, (counts.get(lower) ?? 0) + 1);
235
+ }
236
+ }
237
+ const baseline = {
238
+ "Content-Type": "application/json",
239
+ Accept: "application/json, */*",
240
+ Origin: baseUrl,
241
+ Referer: `${baseUrl}/`,
242
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
243
+ };
244
+ // Add any request header present in all relevant captures, preserving original casing.
245
+ for (const [lower, count] of counts) {
246
+ if (count < relevantCaptures.length)
247
+ continue;
248
+ if (Object.keys(baseline).some((k) => k.toLowerCase() === lower))
249
+ continue;
250
+ for (const c of relevantCaptures) {
251
+ const original = Object.keys(c.requestHeaders).find((h) => h.toLowerCase() === lower);
252
+ if (original) {
253
+ baseline[original] = c.requestHeaders[original];
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ return baseline;
259
+ }
260
+ function isGraphQL(captures) {
261
+ return captures.some((c) => c.operationName !== null);
262
+ }
263
+ function firstSuccessfulReplayBody(replays) {
264
+ return replays.find((r) => r.success)?.replayBody ?? null;
265
+ }
266
+ function firstGraphQLQuery(captures) {
267
+ return captures.find((c) => c.query)?.query ?? null;
268
+ }
269
+ function firstEndpointPath(captures) {
270
+ for (const c of captures) {
271
+ try {
272
+ const u = new URL(c.url);
273
+ return u.pathname;
274
+ }
275
+ catch {
276
+ // skip
277
+ }
278
+ }
279
+ return "/api/search";
280
+ }
281
+ // ── multi-step "submission flow" detection ────────────────────────────────────
282
+ //
283
+ // For transactional sites (apply forms, multi-step checkout, etc.) the captures
284
+ // form an ordered sequence of POSTs that thread state values through subsequent
285
+ // requests (auth tokens, candidate IDs, application IDs). Single-endpoint
286
+ // sites (job search, pricing APIs) have one action capture and skip this path.
287
+ /** Path elements we always treat as noise (analytics, logging). */
288
+ const TELEMETRY_URL_PATTERNS = [
289
+ "/util/logging/vweb/message",
290
+ "/blank/page",
291
+ "stats.g.doubleclick.net",
292
+ "google-analytics.com",
293
+ "click.appcast.io",
294
+ ];
295
+ /**
296
+ * Extracts the ordered sequence of meaningful POSTs that represent the
297
+ * transactional flow. Filters out GETs, telemetry, asset hits, and non-2xx.
298
+ */
299
+ function extractActionSequence(captures, baseUrl) {
300
+ let host;
301
+ try {
302
+ host = new URL(baseUrl).host;
303
+ }
304
+ catch {
305
+ host = "";
306
+ }
307
+ return captures
308
+ .map((capture, index) => ({ capture, index }))
309
+ .filter(({ capture }) => {
310
+ if (capture.method === "GET")
311
+ return false;
312
+ if (capture.status < 200 || capture.status >= 300)
313
+ return false;
314
+ let captureHost;
315
+ try {
316
+ captureHost = new URL(capture.url).host;
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ if (captureHost !== host)
322
+ return false;
323
+ if (TELEMETRY_URL_PATTERNS.some((p) => capture.url.includes(p)))
324
+ return false;
325
+ return true;
326
+ });
327
+ }
328
+ /**
329
+ * Collapses redundant PATCH calls to the same endpoint path, keeping only the
330
+ * last occurrence. SPA auto-save patterns produce one PATCH per field change,
331
+ * but the API accepts a single full-state PATCH. Reduces the generated hot
332
+ * path from dozens of calls to the essential sequence.
333
+ */
334
+ function collapseRedundantPatches(actions) {
335
+ const lastPatchByPath = new Map();
336
+ for (let i = 0; i < actions.length; i++) {
337
+ const a = actions[i];
338
+ if (a.capture.method === "PATCH") {
339
+ const path = a.capture.url.split("?")[0] ?? a.capture.url;
340
+ lastPatchByPath.set(path, i);
341
+ }
342
+ }
343
+ return actions.filter((a, i) => {
344
+ if (a.capture.method !== "PATCH")
345
+ return true;
346
+ const path = a.capture.url.split("?")[0] ?? a.capture.url;
347
+ return lastPatchByPath.get(path) === i;
348
+ });
349
+ }
350
+ /**
351
+ * Recursively walks a JSON value and yields every string leaf, paired with its
352
+ * JSON path. Numbers/booleans/nulls are skipped — only string leaves are
353
+ * candidates for state values (auth tokens, UUIDs, IDs).
354
+ */
355
+ function* walkStringLeaves(value, path = []) {
356
+ if (typeof value === "string") {
357
+ yield { value, path };
358
+ return;
359
+ }
360
+ if (Array.isArray(value)) {
361
+ for (let i = 0; i < value.length; i++) {
362
+ yield* walkStringLeaves(value[i], [...path, String(i)]);
363
+ }
364
+ return;
365
+ }
366
+ if (value !== null && typeof value === "object") {
367
+ for (const [k, v] of Object.entries(value)) {
368
+ yield* walkStringLeaves(v, [...path, k]);
369
+ }
370
+ }
371
+ }
372
+ /**
373
+ * Yields every primitive leaf (string, number, boolean, null) in the JSON
374
+ * value with its path. Used by the body-literal substitution pass to find
375
+ * JSON-keyed values whose key matches a payload field name — for example,
376
+ * `"FutureConsideration":true` becomes `"FutureConsideration":${payload.FutureConsideration}`.
377
+ * Unlike walkStringLeaves this includes non-string primitives, so boolean
378
+ * and number payload fields get parameterized too.
379
+ */
380
+ function* walkAllPrimitiveLeaves(value, path = []) {
381
+ if (typeof value === "string" ||
382
+ typeof value === "number" ||
383
+ typeof value === "boolean" ||
384
+ value === null) {
385
+ yield { value, path };
386
+ return;
387
+ }
388
+ if (Array.isArray(value)) {
389
+ for (let i = 0; i < value.length; i++) {
390
+ yield* walkAllPrimitiveLeaves(value[i], [...path, String(i)]);
391
+ }
392
+ return;
393
+ }
394
+ if (typeof value === "object") {
395
+ for (const [k, v] of Object.entries(value)) {
396
+ yield* walkAllPrimitiveLeaves(v, [...path, k]);
397
+ }
398
+ }
399
+ }
400
+ /** Minimum length for a string leaf to be indexed as a potential state value.
401
+ * Shorter strings (1-7 chars) are rarely meaningful auth tokens / IDs and
402
+ * inflate the index without contributing to state threading. */
403
+ const MIN_STATE_VALUE_LENGTH = 8;
404
+ /**
405
+ * Documented enum of common HTTP-API error-reporting key names. Closed set
406
+ * (per the no-regex-open-sets feedback): when an API returns 200 with an
407
+ * error payload, it almost always uses one of these key names at the top of
408
+ * the body. Matched case-insensitively so `Message`/`message`/`Error`/`error`
409
+ * all detect.
410
+ */
411
+ const KNOWN_TOP_LEVEL_ERROR_KEYS = new Set(["message", "error", "errormessage"]);
412
+ /**
413
+ * Suffixes that mark a JSON key as carrying validation/data errors when its
414
+ * value is non-null. Case-sensitive because real APIs use mixed-case in the
415
+ * exact form they ship (e.g. ClearCompany's `ResponseValidationErrors`).
416
+ */
417
+ const NESTED_ERROR_KEY_SUFFIXES = ["ValidationErrors", "DataErrors", "ValidationError"];
418
+ /**
419
+ * Detects which error-reporting key names this site's recon uses. Scans
420
+ * successful action-step response bodies for the well-known key shapes; only
421
+ * emits guards for keys that NEVER appear as non-null values in success
422
+ * responses (so legitimate success-only fields like `Name` aren't false-
423
+ * flagged as errors).
424
+ *
425
+ * Site-agnostic: ClearCompany uses `Message`/`Sections.ResponseValidationErrors`
426
+ * /`Sections.DataValidationErrors`; a different ATS using `error`/`errors[]`
427
+ * would emit guards for those instead.
428
+ */
429
+ function detectErrorSignals(actions) {
430
+ const candidateTopLevelKeys = new Map();
431
+ const candidateNestedPaths = new Map();
432
+ for (const step of actions) {
433
+ const body = step.capture.responseBody;
434
+ if (body === null || typeof body !== "object" || Array.isArray(body))
435
+ continue;
436
+ for (const [k, v] of Object.entries(body)) {
437
+ if (KNOWN_TOP_LEVEL_ERROR_KEYS.has(k.toLowerCase())) {
438
+ const existing = candidateTopLevelKeys.get(k) ?? { presentInSuccess: false };
439
+ if (typeof v === "string" && v.length > 0)
440
+ existing.presentInSuccess = true;
441
+ candidateTopLevelKeys.set(k, existing);
442
+ }
443
+ }
444
+ walkForNestedErrorKeys(body, [], candidateNestedPaths);
445
+ }
446
+ const successKeys = new Set();
447
+ for (const [k, info] of candidateTopLevelKeys) {
448
+ if (info.presentInSuccess)
449
+ successKeys.add(k);
450
+ }
451
+ const stringMessageKey = [...candidateTopLevelKeys.keys()].find((k) => !successKeys.has(k)) ?? null;
452
+ const nestedErrorPaths = [];
453
+ for (const info of candidateNestedPaths.values()) {
454
+ if (!info.presentInSuccess) {
455
+ nestedErrorPaths.push({ parentPath: info.parentPath, errorKey: info.errorKey });
456
+ }
457
+ }
458
+ return { stringMessageKey, nestedErrorPaths };
459
+ }
460
+ function walkForNestedErrorKeys(value, path, candidates) {
461
+ if (value === null || typeof value !== "object" || Array.isArray(value))
462
+ return;
463
+ for (const [k, v] of Object.entries(value)) {
464
+ if (NESTED_ERROR_KEY_SUFFIXES.some((suffix) => k.endsWith(suffix))) {
465
+ const dedupeKey = `${path.join(".")}::${k}`;
466
+ const existing = candidates.get(dedupeKey) ?? {
467
+ parentPath: path,
468
+ errorKey: k,
469
+ presentInSuccess: false,
470
+ };
471
+ if (v !== null)
472
+ existing.presentInSuccess = true;
473
+ candidates.set(dedupeKey, existing);
474
+ }
475
+ walkForNestedErrorKeys(v, [...path, k], candidates);
476
+ }
477
+ }
478
+ /**
479
+ * Canonical UUID-shape test. Closed-form regex per the no-regex-open-sets
480
+ * feedback: matches the dash-delimited 8-4-4-4-12 hex format universally used
481
+ * by Microsoft/RFC4122 UUIDs. Used to distinguish schema identifiers (UUIDs
482
+ * that the API uses as stable structural keys) from semantic strings.
483
+ */
484
+ const UUID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
485
+ /**
486
+ * Converts a FieldSourceCode like "contact.first.name" or "address.country.subdivision"
487
+ * to PascalCase: "ContactFirstName", "AddressCountrySubdivision". Site-agnostic:
488
+ * operates only on the input string. Returns null for inputs that don't
489
+ * produce a valid JS identifier.
490
+ */
491
+ function sourceCodeToPascalCase(sourceCode) {
492
+ const parts = sourceCode.split(/[.\-_\s]+/).filter((p) => p.length > 0);
493
+ if (parts.length === 0)
494
+ return null;
495
+ const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join("");
496
+ return isValidJsIdentifier(pascal) ? pascal : null;
497
+ }
498
+ /**
499
+ * Converts a free-form FieldName like "Reference #1 First Name" or "Email" to
500
+ * PascalCase, stripping punctuation in a way that preserves position (so
501
+ * "Reference #1 First Name" → "Reference1FirstName" via a section-heading
502
+ * prefix). Site-agnostic: operates only on input strings.
503
+ */
504
+ /**
505
+ * Converts an HTTP header name (e.g. `API-ShortName`, `X-CSRF-Token`) into a
506
+ * PascalCase JS identifier suitable for a payload field name. Preserves
507
+ * internal casing of each header-name part (so `ShortName` stays `ShortName`)
508
+ * while normalizing UPPER-only parts (`API` → `Api`). Site-agnostic.
509
+ */
510
+ function headerNameToPayloadFieldName(headerName) {
511
+ return headerName
512
+ .split(/[^a-zA-Z0-9]+/)
513
+ .filter((p) => p.length > 0)
514
+ .map((p) => {
515
+ const isAllUpper = p === p.toUpperCase();
516
+ return isAllUpper
517
+ ? p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()
518
+ : p.charAt(0).toUpperCase() + p.slice(1);
519
+ })
520
+ .join("");
521
+ }
522
+ function fieldNameToPascalCase(fieldName, prefix) {
523
+ const cleaned = fieldName.replace(/[^a-zA-Z0-9\s]/g, " ").trim();
524
+ if (cleaned === "")
525
+ return null;
526
+ const parts = cleaned.split(/\s+/).filter((p) => p.length > 0);
527
+ if (parts.length === 0)
528
+ return null;
529
+ const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join("");
530
+ const withPrefix = prefix ? `${prefix}${pascal}` : pascal;
531
+ return isValidJsIdentifier(withPrefix) ? withPrefix : null;
532
+ }
533
+ /**
534
+ * Recon-driven detection of form-schema captures. Scans all response bodies
535
+ * for arrays whose objects look like SectionField (UUID-shaped FieldId plus
536
+ * FieldName or FieldSourceCode). Builds FieldId → PascalCase name map.
537
+ *
538
+ * Site-agnostic: identifies form-schema captures by structural fingerprint,
539
+ * not by URL or site name. Any ATS exposing a similar schema would match.
540
+ */
541
+ function detectFormSchemaFieldNames(captures) {
542
+ const fieldNameMap = new Map();
543
+ const fieldOptionsMap = new Map();
544
+ const allSchemaUuids = new Set();
545
+ for (const capture of captures) {
546
+ walkForSectionFieldsArrays(capture.responseBody, fieldNameMap, fieldOptionsMap);
547
+ walkForSchemaUuids(capture.responseBody, allSchemaUuids);
548
+ }
549
+ return { fieldNameMap, fieldOptionsMap, allSchemaUuids };
550
+ }
551
+ /**
552
+ * Walks a response body collecting every UUID-shaped string that appears at
553
+ * a `FieldId` or `OptionId` (and its lowercase variants) path leaf, OR as the
554
+ * `Id` of an object that has `OptionSourceCode`/`StringKey`/`FieldOptions`
555
+ * sibling — i.e. an OptionId in the form schema. These UUIDs are stable
556
+ * schema anchors that must be shielded from state-threading even when
557
+ * detectFormSchemaFieldNames doesn't emit a payload-mappable name for the
558
+ * field (e.g. when the field's FieldName is too long for our naming heuristic).
559
+ */
560
+ function walkForSchemaUuids(value, out) {
561
+ if (value === null || typeof value !== "object")
562
+ return;
563
+ if (Array.isArray(value)) {
564
+ for (const item of value)
565
+ walkForSchemaUuids(item, out);
566
+ return;
567
+ }
568
+ const obj = value;
569
+ const fieldIdRaw = obj.FieldId;
570
+ if (typeof fieldIdRaw === "string" && UUID_REGEX.test(fieldIdRaw)) {
571
+ out.add(fieldIdRaw);
572
+ }
573
+ const optionsRaw = obj.FieldOptions;
574
+ if (Array.isArray(optionsRaw)) {
575
+ for (const opt of optionsRaw) {
576
+ if (opt !== null && typeof opt === "object") {
577
+ const optId = opt.Id;
578
+ if (typeof optId === "string" && UUID_REGEX.test(optId))
579
+ out.add(optId);
580
+ }
581
+ }
582
+ }
583
+ // Recurse into nested objects/arrays so nested SectionFields get walked too.
584
+ for (const v of Object.values(obj))
585
+ walkForSchemaUuids(v, out);
586
+ }
587
+ /**
588
+ * Closed enum of well-known cache-buster query parameter names. Stripped
589
+ * from the schema-fetch URL template so the runtime emit doesn't carry the
590
+ * recon's stale timestamp. Per the no-regex-open-sets feedback this is a
591
+ * small enumerated set.
592
+ */
593
+ const CACHE_BUSTER_QUERY_KEYS = new Set(["_", "cb", "t", "_t", "nocache"]);
594
+ function stripCacheBusterParams(url) {
595
+ try {
596
+ const u = new URL(url);
597
+ for (const key of CACHE_BUSTER_QUERY_KEYS) {
598
+ u.searchParams.delete(key);
599
+ }
600
+ return u.toString();
601
+ }
602
+ catch {
603
+ return url;
604
+ }
605
+ }
606
+ /**
607
+ * Recon-driven detection of the form-schema fetch capture. Returns the first
608
+ * GET capture (in recon order) whose response body contains a SectionFields-
609
+ * shaped array. Sites without such a capture get `null` and Phase B/C/D
610
+ * become no-ops.
611
+ *
612
+ * Site-agnostic: identifies the fetch by structural fingerprint of the
613
+ * response body, not by URL or site name.
614
+ */
615
+ function detectFormSchemaFetchCapture(captures, baseUrl) {
616
+ let host;
617
+ try {
618
+ host = new URL(baseUrl).host;
619
+ }
620
+ catch {
621
+ host = "";
622
+ }
623
+ for (let i = 0; i < captures.length; i++) {
624
+ const capture = captures[i];
625
+ if (capture.method !== "GET")
626
+ continue;
627
+ if (capture.status < 200 || capture.status >= 300)
628
+ continue;
629
+ let captureHost;
630
+ try {
631
+ captureHost = new URL(capture.url).host;
632
+ }
633
+ catch {
634
+ continue;
635
+ }
636
+ if (captureHost !== host)
637
+ continue;
638
+ if (TELEMETRY_URL_PATTERNS.some((p) => capture.url.includes(p)))
639
+ continue;
640
+ if (responseContainsSectionFields(capture.responseBody)) {
641
+ return { capture, index: i };
642
+ }
643
+ }
644
+ return null;
645
+ }
646
+ function responseContainsSectionFields(value) {
647
+ if (value === null || typeof value !== "object")
648
+ return false;
649
+ if (Array.isArray(value)) {
650
+ if (looksLikeSectionFieldsArray(value))
651
+ return true;
652
+ for (const item of value) {
653
+ if (responseContainsSectionFields(item))
654
+ return true;
655
+ }
656
+ return false;
657
+ }
658
+ for (const v of Object.values(value)) {
659
+ if (responseContainsSectionFields(v))
660
+ return true;
661
+ }
662
+ return false;
663
+ }
664
+ function walkForSectionFieldsArrays(value, fieldNameMap, fieldOptionsMap) {
665
+ if (value === null || typeof value !== "object")
666
+ return;
667
+ if (Array.isArray(value)) {
668
+ if (looksLikeSectionFieldsArray(value)) {
669
+ assignFieldNamesFromArray(value, fieldNameMap, fieldOptionsMap);
670
+ }
671
+ for (const item of value)
672
+ walkForSectionFieldsArrays(item, fieldNameMap, fieldOptionsMap);
673
+ return;
674
+ }
675
+ for (const v of Object.values(value)) {
676
+ walkForSectionFieldsArrays(v, fieldNameMap, fieldOptionsMap);
677
+ }
678
+ }
679
+ /**
680
+ * Structural fingerprint: array of objects, at least half of which have a
681
+ * UUID-shaped FieldId AND at least one of FieldName/FieldSourceCode.
682
+ */
683
+ function looksLikeSectionFieldsArray(arr) {
684
+ if (arr.length === 0)
685
+ return false;
686
+ let matches = 0;
687
+ for (const item of arr) {
688
+ if (item === null || typeof item !== "object")
689
+ continue;
690
+ const obj = item;
691
+ const fieldIdRaw = obj.FieldId;
692
+ if (typeof fieldIdRaw !== "string")
693
+ continue;
694
+ if (!UUID_REGEX.test(fieldIdRaw))
695
+ continue;
696
+ if (typeof obj.FieldName === "string" || typeof obj.FieldSourceCode === "string") {
697
+ matches++;
698
+ }
699
+ }
700
+ return matches >= Math.max(1, Math.floor(arr.length * 0.5));
701
+ }
702
+ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap) {
703
+ let currentPrefix = null;
704
+ const usedNames = new Set([...fieldNameMap.values()]);
705
+ for (const obj of arr) {
706
+ const fieldId = obj.FieldId;
707
+ if (typeof fieldId !== "string")
708
+ continue;
709
+ const sourceCode = obj.FieldSourceCode;
710
+ const name = obj.FieldName;
711
+ let semantic = null;
712
+ if (typeof sourceCode === "string" && sourceCode.trim().length > 0) {
713
+ semantic = sourceCodeToPascalCase(sourceCode);
714
+ currentPrefix = null;
715
+ }
716
+ else if (typeof name === "string" && name.trim().length > 0 && name.length < 250) {
717
+ const hasNoSourceCode = typeof sourceCode !== "string" || sourceCode.trim().length === 0;
718
+ // Section-heading heuristic: short FieldName, no SourceCode, MOSTLY
719
+ // uppercase letters (>= 70% of alphabetic chars) OR contains '#'.
720
+ // Whole-name uppercase ratio avoids false positives like "MM/DD/YYYY"
721
+ // appearing as a format hint inside a normal field label.
722
+ const letters = name.replace(/[^a-zA-Z]/g, "");
723
+ const upperLetters = name.replace(/[^A-Z]/g, "");
724
+ const isMostlyUppercase = letters.length >= 3 && upperLetters.length / letters.length >= 0.7;
725
+ const isSectionHeading = hasNoSourceCode && (isMostlyUppercase || name.includes("#"));
726
+ if (isSectionHeading) {
727
+ const headingPrefix = fieldNameToPascalCase(name, null);
728
+ if (headingPrefix !== null) {
729
+ currentPrefix = headingPrefix;
730
+ }
731
+ continue;
732
+ }
733
+ semantic = fieldNameToPascalCase(name, currentPrefix);
734
+ }
735
+ if (semantic !== null && !fieldNameMap.has(fieldId)) {
736
+ let unique = semantic;
737
+ let suffix = 2;
738
+ while (usedNames.has(unique)) {
739
+ unique = `${semantic}${suffix}`;
740
+ suffix++;
741
+ }
742
+ fieldNameMap.set(fieldId, unique);
743
+ usedNames.add(unique);
744
+ // Capture FieldOptions when present and ALL options have non-empty
745
+ // semantic Values (SystemFieldOption-tagged). Custom options with empty
746
+ // Value are skipped — they have no semantic label, so we can't generate
747
+ // a meaningful enum and leave the field's OptionId baked.
748
+ const optionsRaw = obj.FieldOptions;
749
+ if (Array.isArray(optionsRaw) && optionsRaw.length > 0) {
750
+ const options = [];
751
+ let allSemantic = true;
752
+ for (const optRaw of optionsRaw) {
753
+ if (optRaw === null || typeof optRaw !== "object") {
754
+ allSemantic = false;
755
+ break;
756
+ }
757
+ const opt = optRaw;
758
+ const optId = opt.Id;
759
+ const optValue = opt.Value;
760
+ if (typeof optId !== "string" ||
761
+ typeof optValue !== "string" ||
762
+ optValue.trim().length === 0) {
763
+ allSemantic = false;
764
+ break;
765
+ }
766
+ options.push({ value: optValue, optionId: optId });
767
+ }
768
+ if (allSemantic && options.length > 0 && !fieldOptionsMap.has(fieldId)) {
769
+ fieldOptionsMap.set(fieldId, { semanticName: unique, options });
770
+ }
771
+ }
772
+ }
773
+ }
774
+ }
775
+ /**
776
+ * Substitutes Responses[].Value literals with payload accessors based on the
777
+ * field-name map from the form schema. Operates on the body string before
778
+ * state interpolation so already-substituted state values (e.g. ${firstName})
779
+ * are preserved.
780
+ *
781
+ * Closed-set substring matching: both FieldId and the literal Value come from
782
+ * the generator's own input (recon).
783
+ */
784
+ function applyFormSchemaSubstitutions(rawBody, fieldNameMap, outDiscoveredFields) {
785
+ if (fieldNameMap.size === 0)
786
+ return rawBody;
787
+ let result = rawBody;
788
+ for (const [fieldId, semanticName] of fieldNameMap) {
789
+ const fieldIdMarker = `"FieldId":"${fieldId}"`;
790
+ let cursor = 0;
791
+ while (true) {
792
+ const idx = result.indexOf(fieldIdMarker, cursor);
793
+ if (idx === -1)
794
+ break;
795
+ const objEnd = result.indexOf("}", idx);
796
+ if (objEnd === -1)
797
+ break;
798
+ const segment = result.slice(idx, objEnd);
799
+ const valueMarker = `"Value":"`;
800
+ const valueIdx = segment.indexOf(valueMarker);
801
+ if (valueIdx === -1) {
802
+ cursor = objEnd;
803
+ continue;
804
+ }
805
+ const valueStart = idx + valueIdx + valueMarker.length;
806
+ const valueEnd = result.indexOf(`"`, valueStart);
807
+ if (valueEnd === -1 || valueEnd > objEnd) {
808
+ cursor = objEnd;
809
+ continue;
810
+ }
811
+ const currentValue = result.slice(valueStart, valueEnd);
812
+ if (currentValue.includes("${")) {
813
+ cursor = objEnd;
814
+ continue;
815
+ }
816
+ const replacement = `\${payload.${semanticName}}`;
817
+ result = result.slice(0, valueStart) + replacement + result.slice(valueEnd);
818
+ outDiscoveredFields.add(semanticName);
819
+ cursor = valueStart + replacement.length;
820
+ }
821
+ }
822
+ return result;
823
+ }
824
+ /**
825
+ * Substitutes Responses[].OptionId literals with payload-driven enum lookups.
826
+ * Operates on the body string before state interpolation. For each FieldId
827
+ * with a captured FieldOptionsMapping, find `"FieldId":"<uuid>"` and rewrite
828
+ * the matching `"OptionId":"<uuid>"` to `"OptionId":"${OPT_<Name>[payload.<Name>]}"`.
829
+ *
830
+ * Order-insensitive: matches `"OptionId":"<uuid>"` anywhere within the same
831
+ * JSON object as the FieldId (which is between this FieldId marker and the
832
+ * closing `}`). Closed-set substring matching: both FieldId and OptionId
833
+ * come from the generator's own input.
834
+ */
835
+ function applyFormSchemaOptionIdSubstitutions(rawBody, fieldOptionsMap, outDiscoveredOptionFields) {
836
+ if (fieldOptionsMap.size === 0)
837
+ return rawBody;
838
+ let result = rawBody;
839
+ for (const [fieldId, mapping] of fieldOptionsMap) {
840
+ const fieldIdMarker = `"FieldId":"${fieldId}"`;
841
+ let cursor = 0;
842
+ while (true) {
843
+ const idx = result.indexOf(fieldIdMarker, cursor);
844
+ if (idx === -1)
845
+ break;
846
+ const objEnd = result.indexOf("}", idx);
847
+ if (objEnd === -1)
848
+ break;
849
+ const segment = result.slice(idx, objEnd);
850
+ const optionIdMarker = `"OptionId":"`;
851
+ const optionIdLocal = segment.indexOf(optionIdMarker);
852
+ if (optionIdLocal === -1) {
853
+ cursor = objEnd;
854
+ continue;
855
+ }
856
+ const optionStart = idx + optionIdLocal + optionIdMarker.length;
857
+ const optionEnd = result.indexOf(`"`, optionStart);
858
+ if (optionEnd === -1 || optionEnd > objEnd) {
859
+ cursor = objEnd;
860
+ continue;
861
+ }
862
+ const currentOptionId = result.slice(optionStart, optionEnd);
863
+ if (currentOptionId.includes("${")) {
864
+ cursor = objEnd;
865
+ continue;
866
+ }
867
+ const replacement = `\${OPT_${mapping.semanticName}[payload.${mapping.semanticName}]}`;
868
+ result = result.slice(0, optionStart) + replacement + result.slice(optionEnd);
869
+ outDiscoveredOptionFields.add(mapping.semanticName);
870
+ cursor = optionStart + replacement.length;
871
+ }
872
+ }
873
+ return result;
874
+ }
875
+ /**
876
+ * For fields whose FieldOptions have NO semantic values (CustomFieldOption
877
+ * options where the recon schema's `.Value` is empty), T3's OPT_* enum
878
+ * mapping can't be emitted. Instead, parameterize the OptionId slot as a
879
+ * caller-supplied `<FieldName>OptionId` payload field with the recon-observed
880
+ * UUID documented in a TSDoc comment.
881
+ *
882
+ * Operates on the same `"FieldId":"<uuid>"` anchored search as
883
+ * applyFormSchemaOptionIdSubstitutions, but only fires when the FieldId is
884
+ * in fieldNameMap (has a semantic name) AND NOT in fieldOptionsMap (the
885
+ * structured enum substitution didn't fire). Site-agnostic.
886
+ */
887
+ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields) {
888
+ if (fieldNameMap.size === 0)
889
+ return rawBody;
890
+ let result = rawBody;
891
+ for (const [fieldId, fieldName] of fieldNameMap) {
892
+ if (fieldOptionsMap.has(fieldId))
893
+ continue; // T3's OPT_* already handles this.
894
+ const fieldIdMarker = `"FieldId":"${fieldId}"`;
895
+ let cursor = 0;
896
+ while (true) {
897
+ const idx = result.indexOf(fieldIdMarker, cursor);
898
+ if (idx === -1)
899
+ break;
900
+ const objEnd = result.indexOf("}", idx);
901
+ if (objEnd === -1)
902
+ break;
903
+ const segment = result.slice(idx, objEnd);
904
+ const optionIdMarker = `"OptionId":"`;
905
+ const optionIdLocal = segment.indexOf(optionIdMarker);
906
+ if (optionIdLocal === -1) {
907
+ cursor = objEnd;
908
+ continue;
909
+ }
910
+ const optionStart = idx + optionIdLocal + optionIdMarker.length;
911
+ const optionEnd = result.indexOf(`"`, optionStart);
912
+ if (optionEnd === -1 || optionEnd > objEnd) {
913
+ cursor = objEnd;
914
+ continue;
915
+ }
916
+ const currentOptionId = result.slice(optionStart, optionEnd);
917
+ if (currentOptionId.includes("${")) {
918
+ cursor = objEnd;
919
+ continue;
920
+ }
921
+ const fieldNameOptionId = `${fieldName}OptionId`;
922
+ const replacement = `\${payload.${fieldNameOptionId}}`;
923
+ result = result.slice(0, optionStart) + replacement + result.slice(optionEnd);
924
+ // Record the recon-observed UUID so the contract can document it in
925
+ // a TSDoc comment as the caller's starting reference value.
926
+ if (!outDiscoveredRawOptionFields.has(fieldNameOptionId)) {
927
+ outDiscoveredRawOptionFields.set(fieldNameOptionId, currentOptionId);
928
+ }
929
+ cursor = optionStart + replacement.length;
930
+ }
931
+ }
932
+ return result;
933
+ }
934
+ /** Maximum length to guard against indexing massive blobs (HTML fragments,
935
+ * embedded base64 images, etc.) that aren't candidates for state threading. */
936
+ const MAX_STATE_VALUE_LENGTH = 256;
937
+ /** Canonical "uninitialized" sentinel values that some REST APIs return as
938
+ * placeholders before a downstream call populates the real identifier.
939
+ * ClearCompany's `/user/create` returns these for CandidateId/ApplicationId/
940
+ * ApplyProcessId, then `/user/start` returns the real values. Indexing the
941
+ * placeholder would lock the generated plugin's `${candidateId}` binding to
942
+ * the all-zero UUID — every downstream call would then 404 with "candidate
943
+ * does not exist". Closed set, literal-string match — never expand to
944
+ * pattern-based detection (would trip the no-regex-on-open-sets rule). */
945
+ const PLACEHOLDER_STATE_VALUES = new Set(["00000000-0000-0000-0000-000000000000"]);
946
+ /**
947
+ * Walks every capture's response (including GETs — formHistoryId-style values
948
+ * may originate in a state-load GET, not a POST). Indexes every string leaf
949
+ * whose length is in [MIN, MAX], recording the EARLIEST capture index that
950
+ * produced it. Later occurrences of the same value reuse the earliest origin.
951
+ *
952
+ * The index is intentionally permissive — it doesn't try to shape-match
953
+ * "what looks like a token" because token shapes are an open set across the
954
+ * web. Authoritative filtering happens downstream in `compileActionSteps`,
955
+ * which only emits produces[] entries for values that ALSO appear in some
956
+ * downstream URL/headers/body (i.e. real cross-step reuse).
957
+ *
958
+ * Exception: values in `PLACEHOLDER_STATE_VALUES` are skipped entirely so
959
+ * the LATER non-placeholder occurrence at the same JSON path becomes the
960
+ * canonical binding instead.
961
+ */
962
+ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndices = new Set()) {
963
+ const index = new Map();
964
+ // First pass: identify the earliest origin among ACTION captures for each
965
+ // value. Action-only earliest-origin tracking is what compileActionSteps'
966
+ // produces[] check needs — it ignores non-action captures (telemetry GETs,
967
+ // static-asset fetches) that may have surfaced the same UUID earlier in
968
+ // recon order. Without this, a UUID like FormId that appears in some pre-
969
+ // r0 GET response would never produce[] from r1 because originIndex points
970
+ // at the non-action GET that nobody emits as a step.
971
+ const haveActionFilter = actionCaptureIndices.size > 0;
972
+ for (let i = 0; i < captures.length; i++) {
973
+ const c = captures[i];
974
+ if (c.responseBody === undefined || c.responseBody === null)
975
+ continue;
976
+ if (haveActionFilter && !actionCaptureIndices.has(i))
977
+ continue;
978
+ // For GET captures, only index UUID-shaped strings. GET captures (today,
979
+ // only the form-schema fetch inserted as an action step) surface stable
980
+ // structural identifiers — UUIDs that downstream POSTs need to thread.
981
+ // Short non-UUID strings ("candidate", "unlocked") from GET responses are
982
+ // noise and create substring-collision bugs in length-descending replace:
983
+ // e.g. "candidate" as a state value gets substituted INSIDE an already-
984
+ // emitted ${candidateId} interpolation, producing ${${entityTypeCode}Id}.
985
+ const isGet = c.method === "GET";
986
+ for (const { value, path } of walkStringLeaves(c.responseBody)) {
987
+ if (value.length < MIN_STATE_VALUE_LENGTH)
988
+ continue;
989
+ if (value.length > MAX_STATE_VALUE_LENGTH)
990
+ continue;
991
+ if (PLACEHOLDER_STATE_VALUES.has(value))
992
+ continue;
993
+ // Schema-identifier UUIDs (FieldId, OptionId) are stable anchors that
994
+ // T2/T3 substitution depends on remaining literal in body templates.
995
+ // Indexing them would let state-threading rewrite the anchors and
996
+ // corrupt T2/T3's already-substituted Values.
997
+ if (shieldedUuids.has(value))
998
+ continue;
999
+ if (isGet && !UUID_REGEX.test(value))
1000
+ continue;
1001
+ if (!index.has(value)) {
1002
+ index.set(value, { value, originIndex: i, path });
1003
+ }
1004
+ }
1005
+ }
1006
+ return index;
1007
+ }
1008
+ /** Matches strings that are valid JavaScript identifiers (start with letter/$/_,
1009
+ * followed by letters/digits/$/_). Used by the code emitter to decide between
1010
+ * dot-access vs bracket-access and quoted vs unquoted object keys. */
1011
+ function isValidJsIdentifier(s) {
1012
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
1013
+ }
1014
+ /** Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token". */
1015
+ function pathToAccessor(path) {
1016
+ return path.map((p) => (isValidJsIdentifier(p) ? `.${p}` : `[${JSON.stringify(p)}]`)).join("");
1017
+ }
1018
+ /**
1019
+ * Builds a nested TypeScript assertion type matching a JSON path. e.g.
1020
+ * ["Auth","Token"] -> `{ Auth: { Token: string } }`
1021
+ * ["Sections","SectionIds","0"] -> `{ Sections: { SectionIds: { "0": string } } }`
1022
+ * The leaf is always `string` because produces[] entries are only emitted for
1023
+ * string leaves (see compileActionSteps + walkStringLeaves). Used to keep
1024
+ * emitted code free of `any` casts while still letting nested-path access
1025
+ * compile against `Record<string, unknown>`-typed response variables.
1026
+ */
1027
+ function pathToAssertionType(path) {
1028
+ if (path.length === 0)
1029
+ return "string";
1030
+ const segment = path[0];
1031
+ const key = isValidJsIdentifier(segment) ? segment : JSON.stringify(segment);
1032
+ return `{ ${key}: ${pathToAssertionType(path.slice(1))} }`;
1033
+ }
1034
+ /** Suggests a JS-camelCase variable name for a state value path. Falls back
1035
+ * up the path if the tail is numeric or not a valid JS identifier. */
1036
+ function pathToVarName(path) {
1037
+ for (let i = path.length - 1; i >= 0; i--) {
1038
+ const segment = path[i];
1039
+ if (isValidJsIdentifier(segment)) {
1040
+ return segment.charAt(0).toLowerCase() + segment.slice(1);
1041
+ }
1042
+ }
1043
+ return "value";
1044
+ }
1045
+ /**
1046
+ * Walks the action sequence and decorates each step with: a unique response
1047
+ * var name, the state values its response produces (used by downstream steps),
1048
+ * and a multipart flag (request body bytes not captured).
1049
+ */
1050
+ function compileActionSteps(actions, stateIndex) {
1051
+ const usedValues = new Set();
1052
+ // Pre-scan: collect all state values referenced by ANY action's URL/headers/body
1053
+ // so we only "produce" the values that are actually consumed downstream.
1054
+ for (const { capture } of actions) {
1055
+ const haystacks = [capture.url];
1056
+ for (const v of Object.values(capture.requestHeaders))
1057
+ haystacks.push(v);
1058
+ if (capture.requestPostData)
1059
+ haystacks.push(capture.requestPostData);
1060
+ for (const sv of stateIndex.values()) {
1061
+ if (haystacks.some((h) => h.includes(sv.value)))
1062
+ usedValues.add(sv.value);
1063
+ }
1064
+ }
1065
+ let lastHost = null;
1066
+ return actions.map(({ capture, index }, i) => {
1067
+ const varName = `r${i}`;
1068
+ const produces = [];
1069
+ const seenNames = new Set();
1070
+ if (capture.responseBody !== undefined && capture.responseBody !== null) {
1071
+ for (const { value, path } of walkStringLeaves(capture.responseBody)) {
1072
+ if (!usedValues.has(value))
1073
+ continue;
1074
+ const sv = stateIndex.get(value);
1075
+ // Only PRODUCE values whose earliest origin is this very capture.
1076
+ if (!sv || sv.originIndex !== index)
1077
+ continue;
1078
+ let name = pathToVarName(path);
1079
+ let suffix = 1;
1080
+ while (seenNames.has(name)) {
1081
+ suffix++;
1082
+ name = `${pathToVarName(path)}${suffix}`;
1083
+ }
1084
+ seenNames.add(name);
1085
+ produces.push({ name, pathExpr: `${varName}${pathToAccessor(path)}`, path });
1086
+ }
1087
+ }
1088
+ const ct = Object.entries(capture.requestHeaders).find(([k]) => k.toLowerCase() === "content-type");
1089
+ const isMultipart = (ct?.[1] ?? "").toLowerCase().includes("multipart/") && capture.requestPostData === null;
1090
+ let currentHost = null;
1091
+ try {
1092
+ currentHost = new URL(capture.url).host;
1093
+ }
1094
+ catch {
1095
+ currentHost = null;
1096
+ }
1097
+ const isCrossDomain = lastHost !== null && currentHost !== null && lastHost !== currentHost;
1098
+ lastHost = currentHost;
1099
+ return { capture, varName, produces, isMultipart, isCrossDomain };
1100
+ });
1101
+ }
1102
+ /**
1103
+ * Replaces occurrences of state values in `template` with `${varName}`
1104
+ * interpolations. Returns a JS template-literal string fragment (no backticks).
1105
+ *
1106
+ * Algorithm: walk the producing steps' response bodies in order, harvest each
1107
+ * produced value's concrete string, and map it to the produces[].name. Then
1108
+ * scan the template for those strings and replace with ${varName}. Length-
1109
+ * descending order avoids prefix conflicts (e.g. an 8-char prefix of a
1110
+ * 36-char UUID).
1111
+ */
1112
+ function interpolateStateValues(template, priorSteps, payloadAccessorByValue = new Map()) {
1113
+ const varNameByValue = new Map();
1114
+ for (const step of priorSteps) {
1115
+ for (const p of step.produces) {
1116
+ let cursor = step.capture.responseBody;
1117
+ for (const segment of p.path) {
1118
+ if (cursor !== null &&
1119
+ typeof cursor === "object" &&
1120
+ segment in cursor) {
1121
+ cursor = cursor[segment];
1122
+ }
1123
+ else {
1124
+ cursor = null;
1125
+ break;
1126
+ }
1127
+ }
1128
+ if (typeof cursor === "string")
1129
+ varNameByValue.set(cursor, p.name);
1130
+ }
1131
+ }
1132
+ let result = template;
1133
+ // Pass 1: substitute state values (length-descending to avoid prefix
1134
+ // conflicts). `\$` is a literal dollar sign (NOT an interpolation);
1135
+ // `${varName}` interpolates the binding name at code-generation time so
1136
+ // the resulting string contains a template-literal placeholder like
1137
+ // `${candidateId}`.
1138
+ const sortedState = [...varNameByValue.entries()].sort((a, b) => b[0].length - a[0].length);
1139
+ for (const [value, varName] of sortedState) {
1140
+ result = result.split(value).join(`\${${varName}}`);
1141
+ }
1142
+ // Pass 2: substitute payload values that survived the state pass. Same
1143
+ // length-descending order. The payload pass only fires on remaining
1144
+ // literal occurrences, so state substitutions win on collisions
1145
+ // (e.g., when an Auth.UserName response value contains the user's email).
1146
+ const sortedPayload = [...payloadAccessorByValue.entries()].sort((a, b) => b[0].length - a[0].length);
1147
+ for (const [value, accessor] of sortedPayload) {
1148
+ result = result.split(value).join(`\${${accessor}}`);
1149
+ }
1150
+ return result;
1151
+ }
1152
+ /**
1153
+ * Substitutes literal JSON key/value pairs in a body template with payload
1154
+ * interpolations. Catches short strings (e.g. Culture: "en"), booleans
1155
+ * (FutureConsideration: true), and numbers that interpolateStateValues skips
1156
+ * because they're below the state-value length threshold or non-string.
1157
+ *
1158
+ * Site-agnostic: only consults the recon's POST body shapes, doesn't
1159
+ * reference any site-specific key names.
1160
+ *
1161
+ * Substitution is **JSON-key-aware** — only fires on `"key":value` patterns
1162
+ * with the exact recon-captured value. Closed-set matching per the
1163
+ * no-regex-open-sets feedback: both the key and the value come from the
1164
+ * generator's own input. No risk of substring false positives because the
1165
+ * key-prefix anchors the match to a JSON object property.
1166
+ *
1167
+ * additionalBodies are merged after inputBody so subsequent POST bodies' new
1168
+ * top-level keys also become caller-supplied payload fields. Used in Phase F
1169
+ * to parameterize fields like SourceCode that appear in r1's body but not
1170
+ * r0's (inputBody).
1171
+ */
1172
+ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies = [], outAdditionalKeys = new Map()) {
1173
+ const merged = [];
1174
+ const seenKeys = new Set();
1175
+ // Track keys from inputBody (r0) separately so we know which ones are NEW.
1176
+ // Only NEW keys need to be added to discovered-form-fields (the payload
1177
+ // schema's inferZodSchema(inputBody) already covers inputBody's keys).
1178
+ if (inputBody !== null && typeof inputBody === "object" && !Array.isArray(inputBody)) {
1179
+ for (const { path } of walkAllPrimitiveLeaves(inputBody)) {
1180
+ if (path.length === 1)
1181
+ seenKeys.add(path[0]);
1182
+ }
1183
+ }
1184
+ const inputBodyKeys = new Set(seenKeys);
1185
+ const allBodies = [inputBody, ...additionalBodies];
1186
+ for (const body of allBodies) {
1187
+ if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) {
1188
+ continue;
1189
+ }
1190
+ for (const { value, path } of walkAllPrimitiveLeaves(body)) {
1191
+ if (path.length !== 1)
1192
+ continue;
1193
+ const key = path[0];
1194
+ if (!isValidJsIdentifier(key))
1195
+ continue;
1196
+ if (seenKeys.has(key) && body !== inputBody)
1197
+ continue;
1198
+ // For inputBody first pass: don't dedupe (we need all values).
1199
+ if (body === inputBody && !inputBodyKeys.has(key))
1200
+ continue;
1201
+ seenKeys.add(key);
1202
+ if (value === null)
1203
+ continue;
1204
+ merged.push([key, value]);
1205
+ // Record only the NEW keys (not in inputBody) so the contract emitter
1206
+ // can add them to the payload schema. inputBody's keys are already
1207
+ // emitted by inferZodSchema(inputBody).
1208
+ if (!inputBodyKeys.has(key)) {
1209
+ if (typeof value === "string")
1210
+ outAdditionalKeys.set(key, "string");
1211
+ else if (typeof value === "number")
1212
+ outAdditionalKeys.set(key, "number");
1213
+ else if (typeof value === "boolean")
1214
+ outAdditionalKeys.set(key, "boolean");
1215
+ }
1216
+ }
1217
+ }
1218
+ let result = template;
1219
+ for (const [key, value] of merged) {
1220
+ const accessor = `payload.${key}`;
1221
+ if (typeof value === "string") {
1222
+ const target = `"${key}":${JSON.stringify(value)}`;
1223
+ const replacement = `"${key}":"\${${accessor}}"`;
1224
+ result = result.split(target).join(replacement);
1225
+ }
1226
+ else if (typeof value === "boolean" || typeof value === "number") {
1227
+ const target = `"${key}":${JSON.stringify(value)}`;
1228
+ const replacement = `"${key}":\${${accessor}}`;
1229
+ result = result.split(target).join(replacement);
1230
+ }
1231
+ }
1232
+ return result;
1233
+ }
1234
+ // ── base64 Content parameterization ──────────────────────────────────────────
1235
+ /** Keyword map for matching ATS screening question prompts to payload.Answers field names. */
1236
+ const QUESTION_PROMPT_KEYWORDS = {
1237
+ RelatedToEmployee: ["related", "employee"],
1238
+ PreviouslyEmployedAtEncompass: ["previously", "worked"],
1239
+ EverSanctionedOrOnProbation: ["sanctions", "probation", "limitations"],
1240
+ EverTerminated: ["terminated", "resign"],
1241
+ EverExcludedFromFederalProgram: ["excluded", "federal", "program"],
1242
+ VisaSponsorship: ["visa", "sponsor"],
1243
+ LegallyEligibleToWorkUS: ["legal", "eligib", "work", "united states"],
1244
+ CanPerformJobFunctions: ["perform", "job functions", "duties"],
1245
+ };
1246
+ /**
1247
+ * Scans captures for a `recruitingCEQuestions` GET response and builds a
1248
+ * mapping from question prompts to payload.Answers field names using keyword
1249
+ * overlap scoring. Returns null if no questions capture is found.
1250
+ */
1251
+ function buildQuestionnaireMapping(captures) {
1252
+ const questionCapture = captures.find((c) => c.method === "GET" && c.url.includes("recruitingCEQuestions"));
1253
+ if (!questionCapture)
1254
+ return null;
1255
+ const resp = typeof questionCapture.responseBody === "string"
1256
+ ? JSON.parse(questionCapture.responseBody)
1257
+ : questionCapture.responseBody;
1258
+ if (!resp || !Array.isArray(resp.items))
1259
+ return null;
1260
+ const mappings = [];
1261
+ for (const item of resp.items) {
1262
+ const prompt = String(item.Prompt ?? "").toLowerCase();
1263
+ const qid = item.AttributeName;
1264
+ const uiType = String(item.UIDisplayType ?? "");
1265
+ if (!qid || uiType === "TextBox")
1266
+ continue;
1267
+ let bestField = null;
1268
+ let bestScore = 0;
1269
+ for (const [field, keywords] of Object.entries(QUESTION_PROMPT_KEYWORDS)) {
1270
+ const score = keywords.filter((kw) => prompt.includes(kw)).length;
1271
+ if (score > bestScore) {
1272
+ bestScore = score;
1273
+ bestField = field;
1274
+ }
1275
+ }
1276
+ if (!bestField || bestScore < 2)
1277
+ continue;
1278
+ if (mappings.some((m) => m.payloadField === bestField))
1279
+ continue;
1280
+ const answers = {};
1281
+ for (const a of (item.answers ?? [])) {
1282
+ const meaning = String(a.Meaning ?? "");
1283
+ const code = a.LookupCode;
1284
+ if (meaning && code)
1285
+ answers[meaning] = code;
1286
+ }
1287
+ mappings.push({ questionId: qid, payloadField: bestField, answers });
1288
+ }
1289
+ return mappings.length > 0 ? mappings : null;
1290
+ }
1291
+ /**
1292
+ * Builds the TypeScript source for a `buildBase64Content` function that
1293
+ * constructs the base64-encoded Content JSON from payload values and returns
1294
+ * it as a base64 string. The function replaces persona-specific values
1295
+ * with payload references and maps questionnaire answers via a static
1296
+ * lookup table derived from the recon captures.
1297
+ */
1298
+ function emitBuildBase64ContentFunction(base64, personaValues, questionMapping, pascal) {
1299
+ const decoded = Buffer.from(base64, "base64").toString("utf8");
1300
+ const content = JSON.parse(decoded);
1301
+ const candidate = content.candidate;
1302
+ const basic = candidate.basicInformation;
1303
+ const phone = basic.phone;
1304
+ const application = content.application;
1305
+ const esig = application.eSignature;
1306
+ basic.firstName = "__PAYLOAD_FirstName__";
1307
+ basic.lastName = "__PAYLOAD_LastName__";
1308
+ basic.email = "__PAYLOAD_Email__";
1309
+ if (esig)
1310
+ esig.fullName = "__PAYLOAD_SignatureFullName__";
1311
+ if (basic.displayName && typeof basic.displayName === "string")
1312
+ basic.displayName = "__PAYLOAD_DisplayName__";
1313
+ if (phone && typeof phone.number === "string" && phone.number)
1314
+ phone.number = "__PAYLOAD_Phone__";
1315
+ for (const [personaVal, _payloadRef] of personaValues) {
1316
+ if (typeof basic.email === "string" && basic.email === personaVal)
1317
+ basic.email = "__PAYLOAD_Email__";
1318
+ }
1319
+ const questionnaires = candidate.questionnaires;
1320
+ if (questionnaires && questionMapping) {
1321
+ for (const q of questionnaires) {
1322
+ q.questionnaireId = -1;
1323
+ for (const question of q.questions) {
1324
+ const mapping = questionMapping.find((m) => m.questionId === question.questionId);
1325
+ if (mapping) {
1326
+ question.answer = `__QMAP_${mapping.payloadField}__`;
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+ const attachments = candidate.attachments;
1332
+ if (attachments) {
1333
+ for (const att of attachments) {
1334
+ if (att.id && att.id !== "draft-json-undefined") {
1335
+ att.id = "__PAYLOAD_AttachmentId__";
1336
+ }
1337
+ }
1338
+ if (attachments[0]) {
1339
+ attachments[0].appDraftId = "__PAYLOAD_DraftId__";
1340
+ }
1341
+ }
1342
+ const jsonStr = JSON.stringify(content, null, 0);
1343
+ const contentObj = JSON.parse(jsonStr);
1344
+ const questionMapEntries = (questionMapping ?? []).map((m) => ` ${JSON.stringify(m.payloadField)}: { answers: ${JSON.stringify(m.answers)} as Record<string, number>, questionId: ${m.questionId} },`);
1345
+ const questionMapConst2 = questionMapEntries.length > 0
1346
+ ? `\nconst QUESTIONNAIRE_ANSWER_MAP = {\n${questionMapEntries.join("\n")}\n};\n`
1347
+ : "";
1348
+ const contentTemplate = JSON.stringify(contentObj, null, 2);
1349
+ const parameterized = contentTemplate
1350
+ .replace(/"__PAYLOAD_FirstName__"/g, "payload.FirstName")
1351
+ .replace(/"__PAYLOAD_LastName__"/g, "payload.LastName")
1352
+ .replace(/"__PAYLOAD_Email__"/g, "payload.Email")
1353
+ .replace(/"__PAYLOAD_Phone__"/g, "payload.Phone")
1354
+ .replace(/"__PAYLOAD_SignatureFullName__"/g, "payload.Answers.SignatureFullName")
1355
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
1356
+ .replace(/"__PAYLOAD_DisplayName__"/g, "`${payload.FirstName} ${payload.LastName}`")
1357
+ .replace(/"__PAYLOAD_AttachmentId__"/g, "attachmentId")
1358
+ .replace(/"__PAYLOAD_DraftId__"/g, "draftId")
1359
+ .replace(/-1(?=,\n\s*"questions")/g, "questionnaireId");
1360
+ for (const m of questionMapping ?? []) {
1361
+ parameterized.replace(`"__QMAP_${m.payloadField}__"`, `QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined"`);
1362
+ }
1363
+ let finalTemplate = parameterized.replace(/"__QMAP_[^"]*__"/g, '"draft-json-undefined"');
1364
+ for (const m of questionMapping ?? []) {
1365
+ finalTemplate = finalTemplate.replace(`"__QMAP_${m.payloadField}__"`, `(QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined")`);
1366
+ }
1367
+ return `${questionMapConst2}
1368
+ /** Builds the ATS Content payload as a base64-encoded JSON string. */
1369
+ function buildBase64Content(
1370
+ payload: ${pascal}Payload,
1371
+ questionnaireId: number,
1372
+ draftId: number,
1373
+ attachmentId: string
1374
+ ): string {
1375
+ const content = ${finalTemplate};
1376
+ return Buffer.from(JSON.stringify(content)).toString("base64");
1377
+ }
1378
+ `;
1379
+ }
1380
+ /** Builds the multi-step `executeHttp` body as a single template-literal string.
1381
+ *
1382
+ * Two-pass design avoids emitting unused bindings (which would trip Biome's
1383
+ * `noUnusedVariables`):
1384
+ * 1. Render URL / headers / body for each step and collect the set of
1385
+ * `${name}` substrings actually referenced by emitted text.
1386
+ * 2. Emit. Skip per-step response bindings whose response var isn't
1387
+ * referenced AND isn't the terminal var (needed for `return { data }`).
1388
+ * Skip produces[] entries whose name isn't referenced anywhere downstream.
1389
+ */
1390
+ /**
1391
+ * Emits per-step `throw new Error(...)` lines for each detected error signal.
1392
+ * Returns lines indented to sit inside the `if (typeof X === "object" && X !==
1393
+ * null)` wrapper that `emitMultiStepExecuteHttp` writes.
1394
+ */
1395
+ function emitErrorSignalGuards(varName, urlPath, signals) {
1396
+ const out = [];
1397
+ if (signals.stringMessageKey !== null) {
1398
+ const k = signals.stringMessageKey;
1399
+ out.push(` if (typeof (${varName} as { ${k}?: unknown }).${k} === "string") throw new Error(\`step ${varName} (${urlPath}) returned error: \${(${varName} as { ${k}: string }).${k}}\`);`);
1400
+ }
1401
+ const nestedByParent = new Map();
1402
+ for (const { parentPath, errorKey } of signals.nestedErrorPaths) {
1403
+ const key = parentPath.join(".");
1404
+ const existing = nestedByParent.get(key) ?? [];
1405
+ existing.push({ errorKey });
1406
+ nestedByParent.set(key, existing);
1407
+ }
1408
+ for (const [parentPathStr, errorKeys] of nestedByParent) {
1409
+ if (parentPathStr === "") {
1410
+ for (const { errorKey } of errorKeys) {
1411
+ out.push(` if ((${varName} as { ${errorKey}?: unknown }).${errorKey} != null) throw new Error(\`step ${varName} ${errorKey.toLowerCase()}: \${JSON.stringify((${varName} as { ${errorKey}: unknown }).${errorKey})}\`);`);
1412
+ }
1413
+ continue;
1414
+ }
1415
+ const parentSegments = parentPathStr.split(".");
1416
+ const parentVar = `${varName}_${parentSegments[parentSegments.length - 1].toLowerCase()}`;
1417
+ const parentAccessor = parentSegments.join("?.");
1418
+ const parentTypeAssertion = errorKeys.map(({ errorKey }) => `${errorKey}?: unknown`).join("; ");
1419
+ const parentObjType = parentSegments
1420
+ .reverse()
1421
+ .reduce((inner, seg) => `${seg}?: { ${inner} }`, parentTypeAssertion);
1422
+ parentSegments.reverse();
1423
+ out.push(` const ${parentVar} = (${varName} as { ${parentObjType} }).${parentAccessor};`);
1424
+ for (const { errorKey } of errorKeys) {
1425
+ const label = errorKey === "ResponseValidationErrors"
1426
+ ? "validation errors"
1427
+ : errorKey === "DataValidationErrors"
1428
+ ? "data errors"
1429
+ : errorKey
1430
+ .replace(/([A-Z])/g, " $1")
1431
+ .trim()
1432
+ .toLowerCase();
1433
+ out.push(` if (${parentVar} != null && ${parentVar}.${errorKey} != null) throw new Error(\`step ${varName} ${label}: \${JSON.stringify(${parentVar}.${errorKey})}\`);`);
1434
+ }
1435
+ }
1436
+ return out;
1437
+ }
1438
+ /** Exported for unit testing — lets tests drive the multipart-upload code path directly
1439
+ * without going through the full emitContractTs pipeline. */
1440
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map()) {
1441
+ // Walk the first action's request body to map each leaf string value to its
1442
+ // `payload.<accessor>` expression. The emit's second interpolation pass uses
1443
+ // this to substitute literal occurrences (e.g. "Reginald") with their
1444
+ // payload references (e.g. ${payload.FirstName}) — so the generated plugin
1445
+ // actually uses the runtime payload instead of the recon's frozen identity.
1446
+ //
1447
+ // Same MIN_STATE_VALUE_LENGTH threshold as state values: short values
1448
+ // (e.g. `"en"` for Culture, `"US"` for country) collide with arbitrary
1449
+ // substrings in URLs/bodies ("token", "entities", "Australia") and would
1450
+ // produce nonsense substitutions. Values below the threshold stay literal
1451
+ // in the emitted template — fine for short enum-like fields that rarely
1452
+ // need to vary at runtime.
1453
+ const payloadAccessorByValue = new Map();
1454
+ if (inputBody !== undefined && inputBody !== null) {
1455
+ for (const { value, path } of walkStringLeaves(inputBody)) {
1456
+ if (value.length < MIN_STATE_VALUE_LENGTH)
1457
+ continue;
1458
+ const accessor = `payload${pathToAccessor(path)}`;
1459
+ payloadAccessorByValue.set(value, accessor);
1460
+ // Phase F: register a lowercase variant for UUID-shaped values so case-
1461
+ // variant URL path segments (e.g. r9 echoes the requisition UUID in
1462
+ // lowercase even though r0's body had it uppercase) still get
1463
+ // substituted. Site-agnostic.
1464
+ if (UUID_REGEX.test(value) && value.toLowerCase() !== value) {
1465
+ payloadAccessorByValue.set(value.toLowerCase(), accessor);
1466
+ }
1467
+ }
1468
+ }
1469
+ // G1: register the recon's baseUrl so the existing payload-substitution pass
1470
+ // rewrites every URL occurrence to `${payload.BaseUrl}/...`. Same plugin
1471
+ // then works for any tenant on the same ATS just by passing a different
1472
+ // BaseUrl. Site-agnostic: just registers the recon's own baseUrl as a payload
1473
+ // accessor; no site-specific URL knowledge.
1474
+ if (baseUrl.length >= MIN_STATE_VALUE_LENGTH) {
1475
+ payloadAccessorByValue.set(baseUrl, "payload.BaseUrl");
1476
+ outDiscoveredFields.add("BaseUrl");
1477
+ }
1478
+ // G2: register any tenant-subdomain header values as payload-supplied fields
1479
+ // (e.g. ClearCompany's `API-ShortName: "addus"` becomes `payload.ApiShortName`).
1480
+ for (const [headerName, _value] of tenantSubdomainHeaders) {
1481
+ outDiscoveredFields.add(headerNameToPayloadFieldName(headerName));
1482
+ }
1483
+ // Phase F: gather all action POST bodies (parsed) so the T1 substitution
1484
+ // can catch top-level keys from EVERY POST, not just the first one. E.g.
1485
+ // r1's body has SourceCode/FormId/LocationIds/ReOpen — these become
1486
+ // caller-supplied payload fields (when non-null).
1487
+ const additionalBodies = [];
1488
+ for (let i = 1; i < actions.length; i++) {
1489
+ const cap = actions[i].capture;
1490
+ if (cap.method !== "POST" || !cap.requestPostData)
1491
+ continue;
1492
+ try {
1493
+ additionalBodies.push(JSON.parse(cap.requestPostData));
1494
+ }
1495
+ catch {
1496
+ // skip non-JSON bodies (e.g. multipart raw bytes)
1497
+ }
1498
+ }
1499
+ // Pass 1: render every step's emitted strings; collect referenced var names.
1500
+ const rendered = [];
1501
+ for (let i = 0; i < actions.length; i++) {
1502
+ const step = actions[i];
1503
+ const cap = step.capture;
1504
+ const prior = actions.slice(0, i);
1505
+ const url = interpolateStateValues(cap.url, prior, payloadAccessorByValue);
1506
+ // Form-schema substitution runs first on the raw recon body so its
1507
+ // FieldId-anchored matches see the original JSON. State-threading and
1508
+ // payload key-value passes then run on top. OptionId substitution runs
1509
+ // here too: same closed-set FieldId anchor; rewrites "OptionId":"<uuid>"
1510
+ // slots to "${OPT_X[payload.X]}" lookups.
1511
+ const rawBodyWithFormSubs = cap.requestPostData
1512
+ ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields), fieldOptionsMap, outDiscoveredOptionFields), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields)
1513
+ : "";
1514
+ let bodyTemplate = rawBodyWithFormSubs
1515
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithFormSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
1516
+ : "";
1517
+ const contentOverride = base64PatchOverride.get(step.varName);
1518
+ if (contentOverride && bodyTemplate) {
1519
+ bodyTemplate = bodyTemplate.replace(/"Content":"ey[A-Za-z0-9+/=]{100,}"/, contentOverride);
1520
+ }
1521
+ const perCallHeaders = {};
1522
+ for (const [k, v] of Object.entries(cap.requestHeaders)) {
1523
+ const lower = k.toLowerCase();
1524
+ if (lower === "api-token" || lower === "authorization") {
1525
+ perCallHeaders[k] = interpolateStateValues(v, prior, payloadAccessorByValue);
1526
+ }
1527
+ }
1528
+ // G1: emit baseUrl-derived headers (Origin, Referer) per-call from
1529
+ // payload.BaseUrl. interpolateStateValues already substituted the literal
1530
+ // baseUrl with `${payload.BaseUrl}`, so a simple sub of the recon's
1531
+ // observed baseUrl in each header value gives us `${payload.BaseUrl}/`.
1532
+ // Emit "${payload.BaseUrl}" as a template-literal placeholder into the
1533
+ // generated plugin code. Built via concatenation so Biome doesn't mistake
1534
+ // it for a placeholder in THIS file's source.
1535
+ const baseUrlPlaceholder = `$${"{"}payload.BaseUrl${"}"}`;
1536
+ for (const [headerName, observedValue] of baseUrlDerivedHeaders) {
1537
+ perCallHeaders[headerName] = observedValue.split(baseUrl).join(baseUrlPlaceholder);
1538
+ }
1539
+ // G2: emit tenant-subdomain headers per-call from caller payload field
1540
+ // (e.g. API-ShortName → ${payload.ApiShortName}). The discoveredFields
1541
+ // population above ensures the field is in the payload schema.
1542
+ for (const [headerName, _observedValue] of tenantSubdomainHeaders) {
1543
+ const fieldName = headerNameToPayloadFieldName(headerName);
1544
+ perCallHeaders[headerName] = `\${payload.${fieldName}}`;
1545
+ }
1546
+ const headersExpr = Object.keys(perCallHeaders).length
1547
+ ? `headers: { ${Object.entries(perCallHeaders)
1548
+ .map(([k, v]) => `${JSON.stringify(k)}: \`${v}\``)
1549
+ .join(", ")} },`
1550
+ : "";
1551
+ const bodyArg = bodyTemplate ? `body: \`${bodyTemplate}\`,` : "";
1552
+ rendered.push({ url, method: cap.method, headersExpr, bodyArg });
1553
+ }
1554
+ // Identifier scan against the rendered text — captures `${foo}`, `${foo.bar}`,
1555
+ // etc. The first segment (anchored at `${`) is the binding's name. Closed
1556
+ // grammar (template-literal syntax we generated ourselves).
1557
+ // For multipart steps the bodyArg isn't emitted (the body is a FormData
1558
+ // built inline), but the URL and headers ARE in executable code — scan
1559
+ // only those two haystacks for multipart.
1560
+ const referencedNames = new Set();
1561
+ for (let i = 0; i < rendered.length; i++) {
1562
+ const r = rendered[i];
1563
+ const haystacks = actions[i].isMultipart
1564
+ ? [r.url, r.headersExpr]
1565
+ : [r.url, r.headersExpr, r.bodyArg];
1566
+ for (const haystack of haystacks) {
1567
+ for (const match of haystack.matchAll(/\$\{([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
1568
+ referencedNames.add(match[1]);
1569
+ }
1570
+ }
1571
+ }
1572
+ // The last step's var is also referenced by the closing `return { data }`.
1573
+ if (actions.length > 0)
1574
+ referencedNames.add(actions[actions.length - 1].varName);
1575
+ // Base64 Content overrides reference variables inside function calls
1576
+ // (e.g. buildBase64Content(payload, questionnaireId, ...)) that the
1577
+ // ${name} regex above doesn't capture. Add them explicitly.
1578
+ for (const [key, override] of base64PatchOverride.entries()) {
1579
+ if (key === "__EXTRA_VARS__")
1580
+ continue;
1581
+ for (const m of override.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) {
1582
+ const name = m[1];
1583
+ if (/^r\d+$/.test(name))
1584
+ continue;
1585
+ referencedNames.add(name);
1586
+ }
1587
+ }
1588
+ // Pass 2: emit. Skip response bindings that aren't referenced; skip
1589
+ // produces[] entries whose name isn't referenced. A step's response var
1590
+ // is still needed when at least one of its produces[] entries IS
1591
+ // referenced — the produces line dereferences it.
1592
+ const lines = [];
1593
+ const declaredNames = new Set();
1594
+ for (let i = 0; i < actions.length; i++) {
1595
+ const step = actions[i];
1596
+ const cap = step.capture;
1597
+ const r = rendered[i];
1598
+ const hasReferencedProduce = step.produces.some((p) => referencedNames.has(p.name));
1599
+ const bindResponse = referencedNames.has(step.varName) || hasReferencedProduce;
1600
+ if (base64PatchOverride.has(step.varName) && base64PatchOverride.has("__EXTRA_VARS__")) {
1601
+ lines.push(base64PatchOverride.get("__EXTRA_VARS__"));
1602
+ lines.push("");
1603
+ }
1604
+ if (step.isCrossDomain) {
1605
+ lines.push(` // TODO: cross-domain redirect detected (${cap.url.split("/")[2]}) — likely needs browser fallback for this step.`);
1606
+ }
1607
+ if (step.isMultipart) {
1608
+ // Bypasses httpClient because its typed string-body interface can't
1609
+ // carry a FormData payload. We splice in BASE_HEADERS (minus
1610
+ // Content-Type, which FormData sets to multipart/form-data with the
1611
+ // boundary it generates) so site-required custom headers like
1612
+ // API-Realm/API-AppType/etc. are carried over. The next call goes
1613
+ // back through httpClient for rate-limit + Zod parsing.
1614
+ //
1615
+ // Binary asset (file Buffer + content-type + filename) is required on
1616
+ // the payload. The plugin route is registered with @fastify/multipart's
1617
+ // `attachFieldsToBody: 'keyValues'` so callers POST these fields as
1618
+ // standard multipart/form-data — no base64-in-JSON, no fixtures.
1619
+ const fdVar = `fd_${step.varName}`;
1620
+ const respVar = `resp_${step.varName}`;
1621
+ const headersVar = `headers_${step.varName}`;
1622
+ // Extract just the per-call header overrides (API-Token etc.) from the
1623
+ // rendered headers expression to merge with BASE_HEADERS.
1624
+ const perCallHeaderEntries = [];
1625
+ for (const [k, v] of Object.entries(cap.requestHeaders)) {
1626
+ const lower = k.toLowerCase();
1627
+ if (lower === "api-token" || lower === "authorization") {
1628
+ perCallHeaderEntries.push(`${JSON.stringify(k)}: \`${interpolateStateValues(v, actions.slice(0, i), payloadAccessorByValue)}\``);
1629
+ }
1630
+ }
1631
+ // G1+G2: include tenant-derived headers in the multipart fetch too.
1632
+ // Build the placeholder via concatenation so Biome doesn't mistake it
1633
+ // for a template-literal in THIS file's source.
1634
+ const baseUrlPlaceholder = `$${"{"}payload.BaseUrl${"}"}`;
1635
+ for (const [headerName, observedValue] of baseUrlDerivedHeaders) {
1636
+ const v = observedValue.split(baseUrl).join(baseUrlPlaceholder);
1637
+ perCallHeaderEntries.push(`${JSON.stringify(headerName)}: \`${v}\``);
1638
+ }
1639
+ for (const [headerName, _observedValue] of tenantSubdomainHeaders) {
1640
+ const fieldName = headerNameToPayloadFieldName(headerName);
1641
+ perCallHeaderEntries.push(`${JSON.stringify(headerName)}: \`\${payload.${fieldName}}\``);
1642
+ }
1643
+ const perCallHeadersLit = perCallHeaderEntries.length
1644
+ ? `, ${perCallHeaderEntries.join(", ")}`
1645
+ : "";
1646
+ // Buffer-to-Blob coercion in the emitted line below: Node's Buffer is a
1647
+ // Uint8Array subclass, but its TS type lists ArrayBufferLike (which
1648
+ // includes SharedArrayBuffer), so it isn't assignable to BlobPart
1649
+ // directly. Uint8Array.from copies the bytes into a fresh
1650
+ // ArrayBuffer-backed view that satisfies BlobPart.
1651
+ lines.push(` // Expected response shape: ${JSON.stringify(summariseResponseShape(cap.responseBody))}`, ` const ${fdVar} = new FormData();`, ` const ${fdVar}_bytes = Uint8Array.from(payload.Resume);`, ` ${fdVar}.append("files[]", new Blob([${fdVar}_bytes], { type: payload.ResumeContentType }), payload.ResumeFilename);`, ` const ${headersVar} = { ...omitHeaderCaseInsensitive(BASE_HEADERS, "Content-Type")${perCallHeadersLit} };`, ` const ${respVar} = await fetch(\`${r.url}\`, {`, ` method: "POST",`, ` headers: ${headersVar},`, ` body: ${fdVar},`, ` });`, ` if (!${respVar}.ok) throw new Error(\`step ${step.varName} (multipart upload) failed: HTTP \${${respVar}.status}\`);`);
1652
+ if (bindResponse) {
1653
+ lines.push(` const ${step.varName} = (await ${respVar}.json()) as Record<string, unknown>;`);
1654
+ }
1655
+ else {
1656
+ lines.push(` await ${respVar}.json();`);
1657
+ }
1658
+ }
1659
+ else {
1660
+ const urlPath = cap.url.split("/").slice(3).join("/").split("?")[0] ?? "";
1661
+ const guardLines = emitErrorSignalGuards(step.varName, urlPath, errorSignals);
1662
+ const needsBinding = bindResponse || guardLines.length > 0;
1663
+ if (needsBinding) {
1664
+ lines.push(` const ${step.varName} = (await httpClient(\`${r.url}\`, {`);
1665
+ }
1666
+ else {
1667
+ lines.push(` await httpClient(\`${r.url}\`, {`);
1668
+ }
1669
+ lines.push(` method: ${JSON.stringify(r.method)},`);
1670
+ const joined = [r.headersExpr, r.bodyArg].filter((s) => s !== "").join(" ");
1671
+ if (joined !== "") {
1672
+ lines.push(` ${joined}`);
1673
+ }
1674
+ if (needsBinding) {
1675
+ lines.push(` })) as Record<string, unknown>;`);
1676
+ }
1677
+ else {
1678
+ lines.push(` });`);
1679
+ }
1680
+ if (guardLines.length > 0) {
1681
+ lines.push(` if (typeof ${step.varName} === "object" && ${step.varName} !== null) {`);
1682
+ for (const line of guardLines)
1683
+ lines.push(line);
1684
+ lines.push(` }`);
1685
+ }
1686
+ }
1687
+ for (const p of step.produces) {
1688
+ if (declaredNames.has(p.name))
1689
+ continue;
1690
+ if (!referencedNames.has(p.name))
1691
+ continue;
1692
+ declaredNames.add(p.name);
1693
+ const assertion = pathToAssertionType(p.path);
1694
+ lines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path)};`);
1695
+ }
1696
+ lines.push("");
1697
+ }
1698
+ const lastVar = actions.length > 0 ? actions[actions.length - 1].varName : "undefined";
1699
+ lines.push(` return { data: ${lastVar} };`);
1700
+ return lines.join("\n");
1701
+ }
1702
+ function summariseResponseShape(value) {
1703
+ if (value === null || typeof value !== "object")
1704
+ return typeof value;
1705
+ if (Array.isArray(value))
1706
+ return `array(${value.length})`;
1707
+ const obj = {};
1708
+ for (const [k, v] of Object.entries(value)) {
1709
+ obj[k] = v === null ? "null" : typeof v;
1710
+ }
1711
+ return obj;
1712
+ }
1713
+ // ── code emitters ─────────────────────────────────────────────────────────────
1714
+ /** Generates a complete contract.ts source string for a plugin — exported so
1715
+ * unit tests can drive the emitter directly without spawning the CLI. */
1716
+ function emitContractTs(opts) {
1717
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", } = opts;
1718
+ // Multi-step plugins thread responses through many different shapes that a
1719
+ // single Zod schema can't cover — use z.unknown() so each per-step access
1720
+ // compiles cleanly. Single-endpoint plugins keep the inferred schema.
1721
+ const responseSchemaExpr = multiStepBody ? `z.unknown()` : inferZodSchema(responseBody);
1722
+ // Multi-step flows that include a multipart upload need the binary asset
1723
+ // on the payload. Add Resume/ResumeContentType/ResumeFilename as required
1724
+ // fields so the @fastify/multipart-populated request body has everything
1725
+ // the upload step needs. Site-agnostic: works for any flow with a multipart
1726
+ // step, regardless of which step in the sequence it is. The hasMultipartStep
1727
+ // flag is computed once at the call site from actionSteps.some(s.isMultipart).
1728
+ //
1729
+ // multipart/form-data wire format encodes every text field as a string, so
1730
+ // pass multipartCoerce so inferZodSchema emits multipartBoolean() calls for
1731
+ // booleans and z.coerce.number() for numbers at the source rather than via
1732
+ // brittle post-process string substitution. Site-agnostic: only flips on
1733
+ // when meta.multipart is true.
1734
+ const basePayloadSchemaExpr = inputBody
1735
+ ? inferZodSchema(inputBody, 0, "", { multipartCoerce: hasMultipartStep })
1736
+ : `z.object({\n query: z.string().min(1),\n})`;
1737
+ // Form-schema-discovered fields (e.g. AddressLine1, UserSsn, Reference1FirstName)
1738
+ // are added to the payload as required strings. Site-agnostic: the set is
1739
+ // populated by applyFormSchemaSubstitutions when the recon includes a
1740
+ // detectable form schema; empty for sites without one.
1741
+ const formFieldsExtension = discoveredFormFields && discoveredFormFields.size > 0
1742
+ ? `.extend({\n${[...discoveredFormFields]
1743
+ .sort()
1744
+ .map((name) => ` ${name}: z.string(),`)
1745
+ .join("\n")}\n})`
1746
+ : "";
1747
+ // Candidate-PII fields the browser flow splices as `payload.<field>`. Emitted
1748
+ // as required strings (z.email() for Email per the repo's z.string().email()→
1749
+ // z.email() migration) so those references typecheck in the generated flow.
1750
+ // Skip any field the form-schema pass already added to avoid a duplicate
1751
+ // `.extend` key.
1752
+ const splicedFieldNames = payloadFieldNames
1753
+ ? [...payloadFieldNames].filter((name) => !discoveredFormFields?.has(name)).sort()
1754
+ : [];
1755
+ const splicedFieldsExtension = splicedFieldNames.length > 0
1756
+ ? `.extend({\n${splicedFieldNames
1757
+ .map((name) => ` ${name}: ${name === "Email" ? "z.email()" : "z.string()"},`)
1758
+ .join("\n")}\n})`
1759
+ : "";
1760
+ // Build per-field OPT_<Name> constant declarations + payload-schema enum
1761
+ // entries from the form schema's FieldOptions. Only fields whose OptionId
1762
+ // slots were actually rewritten in the body (i.e. that appear in
1763
+ // discoveredOptionFields) get emitted; the rest leave their schema entries
1764
+ // unused. Computed BEFORE payloadSchemaExpr so the extension string is
1765
+ // available for the final schema concat.
1766
+ const emittedOptionMappings = [];
1767
+ if (fieldOptionsMap && discoveredOptionFields && discoveredOptionFields.size > 0) {
1768
+ for (const mapping of fieldOptionsMap.values()) {
1769
+ if (discoveredOptionFields.has(mapping.semanticName)) {
1770
+ emittedOptionMappings.push(mapping);
1771
+ }
1772
+ }
1773
+ emittedOptionMappings.sort((a, b) => a.semanticName.localeCompare(b.semanticName));
1774
+ }
1775
+ const optionDecls = emittedOptionMappings
1776
+ .map((mapping) => {
1777
+ const entries = mapping.options
1778
+ .map(({ value, optionId }) => ` ${isValidJsIdentifier(value) ? value : JSON.stringify(value)}: ${JSON.stringify(optionId)},`)
1779
+ .join("\n");
1780
+ return `\nconst OPT_${mapping.semanticName} = {\n${entries}\n} as const;\n`;
1781
+ })
1782
+ .join("");
1783
+ const optionSchemaExtension = emittedOptionMappings.length > 0
1784
+ ? `.extend({\n${emittedOptionMappings
1785
+ .map((m) => ` ${m.semanticName}: z.enum([${m.options.map((o) => JSON.stringify(o.value)).join(", ")}]),`)
1786
+ .join("\n")}\n})`
1787
+ : "";
1788
+ // Phase E raw-option payload fields: FieldOptions whose .Value strings are
1789
+ // empty in the schema (CustomFieldOption) — no semantic enum is possible,
1790
+ // so the caller supplies the OptionId UUID directly. The recon-observed
1791
+ // UUID is documented in a TSDoc comment so callers have a starting point.
1792
+ const sortedRawOptionEntries = discoveredRawOptionFields
1793
+ ? [...discoveredRawOptionFields.entries()].sort(([a], [b]) => a.localeCompare(b))
1794
+ : [];
1795
+ const rawOptionSchemaExtension = sortedRawOptionEntries.length > 0
1796
+ ? `.extend({\n${sortedRawOptionEntries
1797
+ .map(([name, reconUuid]) => ` /** Recon-observed: ${reconUuid}. Caller supplies the OptionId UUID for this field. */\n ${name}: z.string(),`)
1798
+ .join("\n")}\n})`
1799
+ : "";
1800
+ // Phase F: additional-body keys (from action POSTs beyond r0). Each gets a
1801
+ // payload field of the appropriate Zod type. Site-agnostic.
1802
+ const sortedAdditionalKeys = discoveredAdditionalBodyKeys
1803
+ ? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
1804
+ : [];
1805
+ const additionalBodyKeysExtension = sortedAdditionalKeys.length > 0
1806
+ ? `.extend({\n${sortedAdditionalKeys
1807
+ .map(([name, kind]) => {
1808
+ // Use multipartBoolean() for booleans when multipart is in play, so
1809
+ // multipart string-encoded "true"/"false" round-trip to native
1810
+ // booleans (matches the inputBody boolean handling for parity).
1811
+ const zod = kind === "string"
1812
+ ? "z.string()"
1813
+ : kind === "number"
1814
+ ? hasMultipartStep
1815
+ ? "z.coerce.number()"
1816
+ : "z.number()"
1817
+ : hasMultipartStep
1818
+ ? "multipartBoolean()"
1819
+ : "z.boolean()";
1820
+ return ` ${name}: ${zod},`;
1821
+ })
1822
+ .join("\n")}\n})`
1823
+ : "";
1824
+ // optionSchemaExtension is appended LAST so option enums show up at the
1825
+ // end of the payload type — the section ordering (base, multipart fields,
1826
+ // form-schema fields, option enums, raw-option fields) mirrors the body
1827
+ // emit order and keeps the generated payload type readable.
1828
+ const answersExtension = base64ContentHelper ? ".extend({ Answers: AnswersSchema })" : "";
1829
+ const payloadSchemaExpr = hasMultipartStep
1830
+ ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`
1831
+ : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`;
1832
+ // When the payload schema uses multipartBoolean(), import the shared helper
1833
+ // from @/lib/zod-multipart so the generated file resolves the reference and
1834
+ // doesn't re-inline the preprocess expression per boolean field.
1835
+ const multipartBoolImport = hasMultipartStep
1836
+ ? `import { multipartBoolean } from "../lib/zod-multipart";\n`
1837
+ : "";
1838
+ // Content-Type must be absent from multipart fetch calls so FormData can inject the boundary.
1839
+ const caseInsensitiveHeadersImport = hasMultipartStep
1840
+ ? `import { omitHeaderCaseInsensitive } from "../lib/case-insensitive-headers";\n`
1841
+ : "";
1842
+ // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
1843
+ // the generated file on first lint:fix.
1844
+ const headersLiteral = Object.entries(baseHeaders)
1845
+ .map(([k, v]) => ` ${isValidJsIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`)
1846
+ .join(",\n");
1847
+ const fixtureImport = auxFiles.length > 0 ? `// import { loadFixture } from "../scraper/fixtures";\n` : "";
1848
+ const clientImport = gql
1849
+ ? `import { createGraphqlClient } from "../scraper/graphql-client";`
1850
+ : `import { createHttpClient } from "../scraper/http-client";`;
1851
+ const queryConst = gql && gqlQuery
1852
+ ? `\n// Lifted verbatim from recon capture — trim UI-only fields before shipping.\nconst ${pascal.toUpperCase()}_QUERY = \`${gqlQuery.trim()}\`;\n`
1853
+ : "";
1854
+ const gqlCacheBlock = gql
1855
+ ? `
1856
+ type GqlFn = (operationName: string, query: string, variables: Record<string, unknown>) => Promise<${pascal}Response>;
1857
+
1858
+ const gqlCache = new Map<string, GqlFn>();
1859
+
1860
+ function getGql(baseUrl: string): GqlFn {
1861
+ let client = gqlCache.get(baseUrl);
1862
+ if (!client) {
1863
+ client = createGraphqlClient({
1864
+ schema: ${pascal}ResponseSchema,
1865
+ bottleneck: limiter,
1866
+ baseHeaders: BASE_HEADERS,
1867
+ endpoint: \`\${baseUrl}${endpointPath}\`,
1868
+ });
1869
+ gqlCache.set(baseUrl, client);
1870
+ }
1871
+ return client;
1872
+ }
1873
+ `
1874
+ : `
1875
+ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottleneck: limiter, baseHeaders: BASE_HEADERS });
1876
+ `;
1877
+ const executeHttpBody = multiStepBody
1878
+ ? multiStepBody
1879
+ : gql
1880
+ ? ` const data = await getGql(context.baseUrl)(${JSON.stringify(`${pascal}Search`)}, ${pascal.toUpperCase()}_QUERY, { q: payload.query });
1881
+ return { data };`
1882
+ : ` const data = await httpClient(\`\${context.baseUrl}${endpointPath}\`, {
1883
+ method: "POST",
1884
+ body: JSON.stringify({ query: payload.query }),
1885
+ });
1886
+ return { data };`;
1887
+ const fixtureComments = auxFiles.length > 0
1888
+ ? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
1889
+ auxFiles
1890
+ .map((f) => `// const ${f.replace(".json", "")} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
1891
+ .join("\n") +
1892
+ "\n"
1893
+ : "";
1894
+ const queryChecklistLine = gql
1895
+ ? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
1896
+ : "";
1897
+ return `/**
1898
+ * Generated by recon-generate.ts — review before shipping.
1899
+ *
1900
+ * Checklist:${queryChecklistLine}
1901
+ * [ ] Narrow ${pascal}ResponseSchema to match the real response shape
1902
+ * [ ] Adjust ${pascal}PayloadSchema to your actual request parameters
1903
+ * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing
1904
+ */
1905
+
1906
+ import Bottleneck from "bottleneck";
1907
+ import { z } from "zod/v4";
1908
+
1909
+ ${fixtureImport}${caseInsensitiveHeadersImport}${multipartBoolImport}${clientImport}
1910
+ import type { BrowserSession } from "../scraper/session";
1911
+ import type { SitePlugin, SitePluginContext, SitePluginResult } from "../site-plugin";
1912
+ import { run${pascal}BrowserFlow } from "@/sites/${siteId}/flows/browser-flow";
1913
+
1914
+ const BASE_HEADERS: Record<string, string> = {
1915
+ ${headersLiteral},
1916
+ };
1917
+
1918
+ // Safe ceiling: ${safeRps} rps — from recon rate-limit probe.
1919
+ const limiter = new Bottleneck({ minTime: ${minTime} });
1920
+
1921
+ const ${pascal}ResponseSchema = ${responseSchemaExpr};
1922
+
1923
+ export type ${pascal}Response = z.infer<typeof ${pascal}ResponseSchema>;
1924
+
1925
+ export default ${pascal}ResponseSchema;
1926
+ ${optionDecls}${base64ContentHelper}
1927
+ const ${pascal}PayloadSchema = ${payloadSchemaExpr};
1928
+
1929
+ export type ${pascal}Payload = z.infer<typeof ${pascal}PayloadSchema>;
1930
+ ${queryConst}${gqlCacheBlock}${fixtureComments}
1931
+ /**
1932
+ * Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
1933
+ * Stagehand automatically on schema drift or bot challenge.
1934
+ */
1935
+ export const ${siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
1936
+ meta: {
1937
+ siteId: ${JSON.stringify(siteId)},
1938
+ displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
1939
+ bodySchema: ${pascal}PayloadSchema,
1940
+ responseSchema: ${pascal}ResponseSchema,
1941
+ defaultBaseUrl: ${JSON.stringify(baseUrl)},${hasMultipartStep ? "\n multipart: true," : ""}
1942
+ },
1943
+
1944
+ /** Hot path: direct HTTP — no browser, no LLM tokens. */
1945
+ async executeHttp(
1946
+ payload: ${pascal}Payload,
1947
+ ${executeHttpBody.includes("context.") ? "context" : "_context"}: SitePluginContext
1948
+ ): Promise<SitePluginResult<${pascal}Response>> {
1949
+ ${executeHttpBody}
1950
+ },
1951
+
1952
+ /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
1953
+ async execute(
1954
+ payload: ${pascal}Payload,
1955
+ session: BrowserSession,
1956
+ context: SitePluginContext
1957
+ ): Promise<SitePluginResult<${pascal}Response>> {
1958
+ const raw = await run${pascal}BrowserFlow(session.stagehand, context.baseUrl, payload);
1959
+ return { data: raw as ${pascal}Response };
1960
+ },
1961
+ };
1962
+ `;
1963
+ }
1964
+ /**
1965
+ * Escape a literal string segment so it is safe INSIDE a JS backtick template
1966
+ * literal — backslashes, backticks, and `${` interpolation starts must all be
1967
+ * neutralized so the only interpolation the emitted flow performs is the
1968
+ * `${payload.X}` splice we insert deliberately.
1969
+ */
1970
+ function escapeForTemplateLiteral(segment) {
1971
+ return segment.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
1972
+ }
1973
+ /**
1974
+ * Build the emitted instruction expression for one step: a plain double-quoted
1975
+ * literal when nothing splices, or a backtick template literal with the recon
1976
+ * constant replaced by `${payload.<field>}` when the resolver picks a field.
1977
+ * The first `${RECON_EMAIL}` token (preferred) or the first single-quoted
1978
+ * literal in the instruction is the splice site.
1979
+ */
1980
+ function buildStepInstructionExpr(instruction, field) {
1981
+ if (field === null)
1982
+ return JSON.stringify(instruction);
1983
+ // Concatenated so Biome's noTemplateCurlyInString doesn't flag the literal
1984
+ // env-var token — it must stay `${RECON_EMAIL}` to match recon's flow files.
1985
+ const emailToken = `$${"{RECON_EMAIL}"}`;
1986
+ const emailIdx = instruction.indexOf(emailToken);
1987
+ const [before, matched, after] = emailIdx >= 0
1988
+ ? [
1989
+ instruction.slice(0, emailIdx),
1990
+ emailToken,
1991
+ instruction.slice(emailIdx + emailToken.length),
1992
+ ]
1993
+ : (() => {
1994
+ const m = /'[^']*'/.exec(instruction);
1995
+ if (m === null)
1996
+ return [instruction, "", ""];
1997
+ return [
1998
+ instruction.slice(0, m.index),
1999
+ m[0],
2000
+ instruction.slice(m.index + m[0].length),
2001
+ ];
2002
+ })();
2003
+ if (matched === "")
2004
+ return JSON.stringify(instruction);
2005
+ return `\`${escapeForTemplateLiteral(before)}\${payload.${field}}${escapeForTemplateLiteral(after)}\``;
2006
+ }
2007
+ /**
2008
+ * Rewrites one step instruction into the config-manifest templating form:
2009
+ * the recon splice site (a `${RECON_EMAIL}` token or the first single-quoted
2010
+ * literal) becomes `{{ .request.<field> }}`. Unlike {@link buildStepInstructionExpr}
2011
+ * this yields a plain manifest string, not a TS expression — the runtime
2012
+ * config-plugin resolver, not the code generator, performs the splice.
2013
+ */
2014
+ function buildManifestInstruction(instruction, field) {
2015
+ if (field === null)
2016
+ return instruction;
2017
+ const emailToken = `$${"{RECON_EMAIL}"}`;
2018
+ const emailIdx = instruction.indexOf(emailToken);
2019
+ if (emailIdx >= 0) {
2020
+ return (instruction.slice(0, emailIdx) +
2021
+ `{{ .request.${field} }}` +
2022
+ instruction.slice(emailIdx + emailToken.length));
2023
+ }
2024
+ const m = /'[^']*'/.exec(instruction);
2025
+ if (m === null)
2026
+ return instruction;
2027
+ return (instruction.slice(0, m.index) +
2028
+ `{{ .request.${field} }}` +
2029
+ instruction.slice(m.index + m[0].length));
2030
+ }
2031
+ /**
2032
+ * Emits a config-only plugin manifest (`<siteId>.plugin.json`) from the recon
2033
+ * flow, as an alternative to the `.ts` trio for browser-only sites. Reuses the
2034
+ * SAME `resolveStepPayloadField` splice logic as the browser-flow emitter, so
2035
+ * every `{{ .request.<field> }}` reference also lands in the manifest's request
2036
+ * schema — the two cannot drift. The direct-HTTP hot path is intentionally
2037
+ * omitted; a site that needs it keeps the `.ts` path or wires `spec.httpModule`.
2038
+ */
2039
+ function emitConfigManifest(opts) {
2040
+ const { siteId, displayName, baseUrl, flowSteps } = opts;
2041
+ const payloadFieldNames = new Set();
2042
+ const steps = flowSteps.map((step) => {
2043
+ const isObj = typeof step !== "string";
2044
+ const instruction = isObj ? step.step : step;
2045
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined);
2046
+ if (field !== null)
2047
+ payloadFieldNames.add(field);
2048
+ const rewritten = buildManifestInstruction(instruction, field);
2049
+ const optional = isObj ? step.optional === true : false;
2050
+ const upload = isObj ? step.upload === true : false;
2051
+ const submitStep = isObj ? step.submitStep === true : false;
2052
+ if (!optional && !upload && !submitStep)
2053
+ return rewritten;
2054
+ return { step: rewritten, optional, upload, submitStep };
2055
+ });
2056
+ const requestProperties = Object.fromEntries([...payloadFieldNames].sort().map((name) => [name, { type: "string" }]));
2057
+ const manifest = {
2058
+ apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
2059
+ kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
2060
+ metadata: { siteId, displayName },
2061
+ spec: {
2062
+ defaultBaseUrl: baseUrl,
2063
+ request: { type: "object", properties: requestProperties },
2064
+ response: {
2065
+ type: "object",
2066
+ description: "TODO: declare the fields this site returns (recon leaves this empty).",
2067
+ properties: {},
2068
+ },
2069
+ flow: { steps },
2070
+ extract: {
2071
+ instruction: `extract the confirmation id and status for ${siteId}`,
2072
+ schema: {
2073
+ type: "object",
2074
+ description: "TODO: declare the fields to extract (empty extracts nothing at runtime).",
2075
+ properties: {},
2076
+ },
2077
+ },
2078
+ },
2079
+ };
2080
+ return `${JSON.stringify(manifest, null, 2)}\n`;
2081
+ }
2082
+ /**
2083
+ * Emit the generated browser-flow module and return the accumulated set of
2084
+ * spliced payload-field names. Exported so the anti-drift unit test can assert
2085
+ * every `payload.<field>` the flow references also appears in the contract's
2086
+ * payload schema (both are driven by this same set).
2087
+ */
2088
+ function emitBrowserFlowTs(opts) {
2089
+ const { siteId, pascal, flowSteps, isSubmissionFlow, hasMultipartStep = false } = opts;
2090
+ const payloadFieldNames = new Set();
2091
+ const hasUploadStep = flowSteps.some((s) => typeof s !== "string" && s.upload === true);
2092
+ const stepLiterals = flowSteps.map((step) => {
2093
+ const isObj = typeof step !== "string";
2094
+ const instruction = isObj ? step.step : step;
2095
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined);
2096
+ if (field !== null)
2097
+ payloadFieldNames.add(field);
2098
+ const instructionExpr = buildStepInstructionExpr(instruction, field);
2099
+ const optional = isObj ? step.optional === true : false;
2100
+ const upload = isObj ? step.upload === true : false;
2101
+ const submitStep = isObj ? step.submitStep === true : false;
2102
+ return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
2103
+ });
2104
+ const flowStepsBlock = stepLiterals.length > 0
2105
+ ? stepLiterals.join("\n")
2106
+ : ` // TODO: add flow steps from src/sites/${siteId}/recon-flow.json`;
2107
+ // Wire a resumeFixture from the payload's Resume/ResumeFilename/
2108
+ // ResumeContentType fields ONLY when the contract actually carries them
2109
+ // (hasMultipartStep) AND the flow uploads. When a flow has an upload step but
2110
+ // the captures weren't detected as multipart (e.g. a GraphQL site where
2111
+ // multipart detection is dropped), those payload fields don't exist yet — emit
2112
+ // a null + TODO so the generated module still typechecks; the operator adds
2113
+ // the multipart contract fields and wires the fixture during hand-finish.
2114
+ const resumeFixtureExpr = hasUploadStep && hasMultipartStep
2115
+ ? `{
2116
+ buffer: Buffer.from(payload.Resume ?? "", "base64"),
2117
+ name: payload.ResumeFilename ?? "resume.pdf",
2118
+ mimeType: payload.ResumeContentType ?? "application/pdf",
2119
+ }`
2120
+ : hasUploadStep
2121
+ ? `null /* TODO: this flow uploads, but the contract has no Resume multipart\n fields yet. Add Resume/ResumeFilename/ResumeContentType to the payload\n schema (set meta.multipart:true) and build the fixture from payload here. */`
2122
+ : "null";
2123
+ const code = `/**
2124
+ * Generated by recon-generate.ts — Stagehand browser fallback for ${siteId}.
2125
+ * Core invokes this automatically when executeHttp throws HttpSchemaError or
2126
+ * HttpBotChallengeError. Update the flow steps and extract schema as needed.
2127
+ *
2128
+ * Steps whose instruction named a candidate PII label have their recon
2129
+ * constant spliced to \`payload.<field>\` so the caller's real applicant reaches
2130
+ * the page; operational-default steps stay literal. The steps run through the
2131
+ * self-heal cascade via runHealingFlow — the same engine the recon CLI uses,
2132
+ * minus its disk-dump/replan layer.
2133
+ */
2134
+
2135
+ import type { Stagehand } from "@browserbasehq/stagehand";
2136
+ import { z } from "zod/v4";
2137
+
2138
+ import { buildAnthropicClient } from "../lib/llm/anthropic-client";
2139
+ import { getLogger } from "../lib/logging";
2140
+ import { type HealingFlowStep, runHealingFlow, waitForSpaReady } from "../scraper/flow-runner";
2141
+ import { guardedExtract } from "../scraper/stagehand-guard";
2142
+ import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/contract";
2143
+
2144
+ const logger = getLogger({ name: "${siteId}-browser-flow" });
2145
+
2146
+ const ${pascal}BrowserSchema = z.object({
2147
+ // TODO: define the fields you need — align with ${pascal}Response
2148
+ extraction: z.string(),
2149
+ });
2150
+
2151
+ /**
2152
+ * Drives ${siteId} through the recon flow and extracts structured data. This is
2153
+ * the browser path; if contract.ts also defines executeHttp, that hot path runs
2154
+ * first and this is the fallback — otherwise this IS the production path.
2155
+ */
2156
+ export async function run${pascal}BrowserFlow(
2157
+ stagehand: Stagehand,
2158
+ baseUrl: string,
2159
+ payload: ${pascal}Payload
2160
+ ): Promise<${pascal}Response> {
2161
+ const page = await stagehand.context.awaitActivePage();
2162
+
2163
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
2164
+ // networkidle can resolve before a Cloudflare-fronted SPA hydrates; wait for
2165
+ // the real DOM so the first steps don't probe an empty shell page and skip.
2166
+ await waitForSpaReady(page, logger);
2167
+
2168
+ const FLOW_STEPS: HealingFlowStep[] = [
2169
+ ${flowStepsBlock}
2170
+ ];
2171
+
2172
+ // buildAnthropicClient() enables the cascade's attempt-5 rephrase + replan to
2173
+ // recover a stuck step; null on a Bedrock-only deployment, where the cascade
2174
+ // degrades to its deterministic DOM primitives.
2175
+ await runHealingFlow({
2176
+ stagehand,
2177
+ page,
2178
+ steps: FLOW_STEPS,
2179
+ logger,
2180
+ anthropic: buildAnthropicClient(),
2181
+ resumeFixture: ${resumeFixtureExpr},
2182
+ });
2183
+
2184
+ // Schema-enforced extract via guardedExtract: Stagehand 3.4.0 accepts
2185
+ // both Zod v3 and v4 schemas natively (StagehandZodSchema union since
2186
+ // 2.4.3 / PR #944), and the caller-side safeParse defends against SDK
2187
+ // contract drift. Widen ${pascal}BrowserSchema as needed to match the
2188
+ // fields the recon flow actually surfaces.
2189
+ const result = await guardedExtract(
2190
+ stagehand,
2191
+ ${isSubmissionFlow ? `\`drove the ${siteId} submission flow for payload \${JSON.stringify(payload)}\`` : `\`extract results matching query: \${payload.query}\``},
2192
+ ${pascal}BrowserSchema
2193
+ );
2194
+
2195
+ return result as unknown as ${pascal}Response;
2196
+ }
2197
+ `;
2198
+ return { code, payloadFieldNames };
2199
+ }
2200
+ function emitIndexTs(opts) {
2201
+ const { siteId } = opts;
2202
+ const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2203
+ return `/**
2204
+ * Generated by recon-generate.ts.
2205
+ * Register this built-in plugin by adding it to \`BUILTIN_SITE_PLUGINS\` in
2206
+ * src/plugins/discover.ts:
2207
+ *
2208
+ * import { ${camel}Plugin } from "@/sites/${siteId}";
2209
+ * BUILTIN_SITE_PLUGINS.push(${camel}Plugin as SitePlugin<unknown, unknown>);
2210
+ */
2211
+
2212
+ export { ${camel}Plugin } from "@/sites/${siteId}/contract";
2213
+ `;
2214
+ }
2215
+ // ── main ──────────────────────────────────────────────────────────────────────
2216
+ async function main() {
2217
+ const args = process.argv.slice(2);
2218
+ let siteId = "";
2219
+ let force = false;
2220
+ let emit = "ts";
2221
+ for (let i = 0; i < args.length; i++) {
2222
+ if (args[i] === "--site-id" && args[i + 1])
2223
+ siteId = args[++i];
2224
+ else if (args[i] === "--force")
2225
+ force = true;
2226
+ else if (args[i] === "--emit" && args[i + 1]) {
2227
+ const value = args[++i];
2228
+ if (value !== "ts" && value !== "config") {
2229
+ logger.error(`--emit must be "ts" or "config", got ${JSON.stringify(value)}`);
2230
+ process.exit(1);
2231
+ }
2232
+ emit = value;
2233
+ }
2234
+ }
2235
+ if (!siteId) {
2236
+ logger.error("--site-id <id> is required");
2237
+ process.exit(1);
2238
+ }
2239
+ const outDir = `src/sites/${siteId}`;
2240
+ const manifestPath = `src/sites/${siteId}/${siteId}.plugin.json`;
2241
+ if (emit === "ts" && (0, node_fs_1.existsSync)(outDir) && !force) {
2242
+ logger.error(`${outDir} already exists — pass --force to overwrite`);
2243
+ process.exit(1);
2244
+ }
2245
+ if (emit === "config" && (0, node_fs_1.existsSync)(manifestPath) && !force) {
2246
+ logger.error(`${manifestPath} already exists — pass --force to overwrite`);
2247
+ process.exit(1);
2248
+ }
2249
+ const captures = (0, recon_shared_1.readJsonDir)(recon_shared_1.CAPTURES_DIR);
2250
+ const replays = (0, recon_shared_1.readJsonDir)(recon_shared_1.REPLAYS_DIR, [
2251
+ "rate-limit.json",
2252
+ "introspection-schema.json",
2253
+ ]);
2254
+ const rateLimits = (() => {
2255
+ try {
2256
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(recon_shared_1.REPLAYS_DIR, "rate-limit.json"), "utf8"));
2257
+ }
2258
+ catch {
2259
+ return [];
2260
+ }
2261
+ })();
2262
+ const auxFiles = (() => {
2263
+ try {
2264
+ return (0, node_fs_1.readdirSync)(recon_shared_1.AUX_DIR)
2265
+ .filter((f) => f.endsWith(".json"))
2266
+ .sort();
2267
+ }
2268
+ catch {
2269
+ return [];
2270
+ }
2271
+ })();
2272
+ const flowSteps = (() => {
2273
+ const flowFile = `src/sites/${siteId}/recon-flow.json`;
2274
+ try {
2275
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
2276
+ if (Array.isArray(raw))
2277
+ return raw;
2278
+ if (raw !== null &&
2279
+ typeof raw === "object" &&
2280
+ "steps" in raw &&
2281
+ Array.isArray(raw.steps)) {
2282
+ return raw.steps;
2283
+ }
2284
+ return [];
2285
+ }
2286
+ catch {
2287
+ return [];
2288
+ }
2289
+ })();
2290
+ const pascal = toPascalCase(siteId);
2291
+ const baseUrl = deriveBaseUrl(captures);
2292
+ const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl);
2293
+ const minTime = deriveMinTime(rateLimits);
2294
+ const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
2295
+ const responseBody = firstSuccessfulReplayBody(replays);
2296
+ const gql = isGraphQL(captures);
2297
+ const gqlQuery = firstGraphQLQuery(captures);
2298
+ const endpointPath = firstEndpointPath(captures);
2299
+ // Detect a multi-step submission flow (transactional sites like apply forms,
2300
+ // checkout, etc.). When the action sequence has 2+ POSTs, switch the
2301
+ // contract template to emit a state-threaded executeHttp.
2302
+ const rawActionCaptures = gql
2303
+ ? []
2304
+ : collapseRedundantPatches(extractActionSequence(captures, baseUrl));
2305
+ // Form-schema detection runs BEFORE state-indexing so the FieldId/OptionId
2306
+ // UUIDs can be shielded from indexing — those UUIDs are stable schema
2307
+ // anchors that T2/T3 substitution depends on remaining literal in body
2308
+ // templates.
2309
+ const { fieldNameMap, fieldOptionsMap, allSchemaUuids } = detectFormSchemaFieldNames(captures);
2310
+ // Shield ALL FieldId/OptionId UUIDs that appear in any schema response, not
2311
+ // just the ones that detectFormSchemaFieldNames emits a payload name for.
2312
+ // Some fields have FieldNames too long for the naming heuristic (>80 chars)
2313
+ // and would be skipped by fieldNameMap; their FieldIds still need shielding
2314
+ // because they appear as anchors in the T2-substituted body templates.
2315
+ const shieldedUuids = new Set(allSchemaUuids);
2316
+ // T4 — Phase B+C: detect a form-schema GET capture and insert it into the
2317
+ // action sequence at the position observed during recon, so the existing
2318
+ // state-threading machinery can produce its FormHistoryId / section UUIDs /
2319
+ // etc. as state values for downstream POSTs. Strip cache-buster query
2320
+ // params (recon timestamps) from the captured URL so the emitted runtime
2321
+ // fetch uses a clean template. Sites without a schema-fetch capture
2322
+ // (rawSchemaFetch === null) get unchanged behavior.
2323
+ const rawSchemaFetch = gql ? null : detectFormSchemaFetchCapture(captures, baseUrl);
2324
+ const schemaFetchCleaned = rawSchemaFetch
2325
+ ? { ...rawSchemaFetch.capture, url: stripCacheBusterParams(rawSchemaFetch.capture.url) }
2326
+ : null;
2327
+ const actionCaptures = (() => {
2328
+ if (rawActionCaptures.length === 0 || schemaFetchCleaned === null || rawSchemaFetch === null) {
2329
+ return rawActionCaptures;
2330
+ }
2331
+ let insertAt = rawActionCaptures.length;
2332
+ for (let i = 0; i < rawActionCaptures.length; i++) {
2333
+ if (rawActionCaptures[i].index >= rawSchemaFetch.index) {
2334
+ insertAt = i;
2335
+ break;
2336
+ }
2337
+ }
2338
+ return [
2339
+ ...rawActionCaptures.slice(0, insertAt),
2340
+ { capture: schemaFetchCleaned, index: rawSchemaFetch.index },
2341
+ ...rawActionCaptures.slice(insertAt),
2342
+ ];
2343
+ })();
2344
+ const actionCaptureIndices = new Set(actionCaptures.map((a) => a.index));
2345
+ const stateIndex = actionCaptures.length > 1
2346
+ ? indexStateValues(captures, shieldedUuids, actionCaptureIndices)
2347
+ : new Map();
2348
+ const actionSteps = actionCaptures.length > 1 ? compileActionSteps(actionCaptures, stateIndex) : [];
2349
+ const isSubmissionFlow = actionSteps.length > 1;
2350
+ const inputBody = isSubmissionFlow
2351
+ ? (() => {
2352
+ try {
2353
+ return JSON.parse(actionSteps[0].capture.requestPostData ?? "null");
2354
+ }
2355
+ catch {
2356
+ return null;
2357
+ }
2358
+ })()
2359
+ : undefined;
2360
+ const errorSignals = detectErrorSignals(actionSteps);
2361
+ const discoveredFormFields = new Set();
2362
+ const discoveredOptionFields = new Set();
2363
+ // Phase E: maps FieldName-derived raw-option payload field name (e.g.
2364
+ // "AreYouOverTheAgeOf18OptionId") → recon-observed OptionId UUID. Used to
2365
+ // emit `<FieldName>OptionId: z.string()` payload fields with TSDoc docs.
2366
+ const discoveredRawOptionFields = new Map();
2367
+ // Phase F: keys from additional action POST bodies (beyond inputBody/r0)
2368
+ // that get parameterized. Recorded with their value type so the contract
2369
+ // emitter can add them to the payload schema with appropriate Zod types.
2370
+ const discoveredAdditionalBodyKeys = new Map();
2371
+ // G1+G2: partition baseHeaders into three buckets:
2372
+ // - static: values that don't reference baseUrl or tenant subdomain
2373
+ // - baseUrl-derived: values containing the recon's baseUrl as substring
2374
+ // (e.g. Origin, Referer) — emit per-call from payload.BaseUrl
2375
+ // - tenant-subdomain: values that EXACTLY equal the first subdomain
2376
+ // (e.g. API-ShortName: "addus") — emit per-call from a payload field
2377
+ const staticBaseHeaders = {};
2378
+ const baseUrlDerivedHeaders = new Map();
2379
+ const tenantSubdomainHeaders = new Map();
2380
+ const firstSubdomain = (() => {
2381
+ try {
2382
+ const host = new URL(baseUrl).hostname;
2383
+ const firstDot = host.indexOf(".");
2384
+ return firstDot === -1 ? host : host.slice(0, firstDot);
2385
+ }
2386
+ catch {
2387
+ return "";
2388
+ }
2389
+ })();
2390
+ for (const [k, v] of Object.entries(baseHeaders)) {
2391
+ if (firstSubdomain.length > 0 && v === firstSubdomain) {
2392
+ tenantSubdomainHeaders.set(k, v);
2393
+ }
2394
+ else if (baseUrl.length > 0 && v.includes(baseUrl)) {
2395
+ baseUrlDerivedHeaders.set(k, v);
2396
+ }
2397
+ else {
2398
+ staticBaseHeaders[k] = v;
2399
+ }
2400
+ }
2401
+ const multiStepBody = isSubmissionFlow
2402
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders)
2403
+ : undefined;
2404
+ let base64ContentHelper = "";
2405
+ const base64PatchOverride = new Map();
2406
+ if (isSubmissionFlow && actionSteps.length > 0) {
2407
+ const lastPatchWithContent = [...actionSteps]
2408
+ .reverse()
2409
+ .find((s) => s.capture.method === "PATCH" &&
2410
+ s.capture.requestPostData &&
2411
+ /"Content":"ey[A-Za-z0-9+/=]{100,}"/.test(s.capture.requestPostData));
2412
+ if (lastPatchWithContent) {
2413
+ const b64Match = lastPatchWithContent.capture.requestPostData.match(/"Content":"(ey[A-Za-z0-9+/=]{100,})"/);
2414
+ if (b64Match) {
2415
+ const b64 = b64Match[1];
2416
+ const qMapping = buildQuestionnaireMapping(captures);
2417
+ const personaValues = new Map();
2418
+ const firstPost = captures.find((c) => c.method === "POST" &&
2419
+ c.url.includes("recruitingCEJobApplicationDrafts") &&
2420
+ c.requestPostData);
2421
+ if (firstPost?.requestPostData) {
2422
+ try {
2423
+ const pb = JSON.parse(firstPost.requestPostData);
2424
+ if (typeof pb.EmailAddress === "string")
2425
+ personaValues.set(pb.EmailAddress, "payload.Email");
2426
+ }
2427
+ catch {
2428
+ /* skip */
2429
+ }
2430
+ }
2431
+ base64ContentHelper = emitBuildBase64ContentFunction(b64, personaValues, qMapping, pascal);
2432
+ base64PatchOverride.set(lastPatchWithContent.varName,
2433
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
2434
+ '"Content":"${buildBase64Content(payload, questionnaireId, Number(draftId), String(attachmentId))}"');
2435
+ const draftPostStep = actionSteps.find((s) => s.capture.method === "POST" &&
2436
+ s.capture.url.includes("recruitingCEJobApplicationDrafts"));
2437
+ if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
2438
+ draftPostStep.produces.push({
2439
+ name: "draftId",
2440
+ pathExpr: `${draftPostStep.varName}.APPDraftId`,
2441
+ path: ["APPDraftId"],
2442
+ });
2443
+ }
2444
+ const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
2445
+ if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
2446
+ attachPostStep.produces.push({
2447
+ name: "attachmentId",
2448
+ pathExpr: `${attachPostStep.varName}.Id`,
2449
+ path: ["Id"],
2450
+ });
2451
+ }
2452
+ const questionnaireCapture = captures.find((c) => c.method === "GET" &&
2453
+ c.url.includes("recruitingCEQuestions") &&
2454
+ c.url.includes("expand=answers"));
2455
+ let sampleQid;
2456
+ if (questionnaireCapture) {
2457
+ const qResp = typeof questionnaireCapture.responseBody === "string"
2458
+ ? JSON.parse(questionnaireCapture.responseBody)
2459
+ : questionnaireCapture.responseBody;
2460
+ sampleQid = qResp?.items?.[0]?.QuestionnaireId ?? undefined;
2461
+ }
2462
+ const overrideValue = base64PatchOverride.values().next().value;
2463
+ if (overrideValue) {
2464
+ const extraVarLines = [];
2465
+ if (sampleQid) {
2466
+ extraVarLines.push(` const questionnaireId = ${sampleQid};`);
2467
+ }
2468
+ if (!attachPostStep) {
2469
+ extraVarLines.push(` const attachmentId = "";`);
2470
+ }
2471
+ if (extraVarLines.length > 0) {
2472
+ base64PatchOverride.set("__EXTRA_VARS__", extraVarLines.join("\n"));
2473
+ }
2474
+ }
2475
+ const answersFields = (qMapping ?? []).map((m) => m.payloadField);
2476
+ answersFields.push("SignatureFullName");
2477
+ const answersSchemaFields = answersFields.map((f) => ` ${f}: z.string(),`).join("\n");
2478
+ base64ContentHelper = `\nconst AnswersSchema = z.object({\n${answersSchemaFields}\n});\n${base64ContentHelper}`;
2479
+ const inputKeys = new Set();
2480
+ if (inputBody && typeof inputBody === "object" && !Array.isArray(inputBody)) {
2481
+ for (const k of Object.keys(inputBody))
2482
+ inputKeys.add(k);
2483
+ }
2484
+ for (const fld of ["FirstName", "LastName", "Email", "Phone"]) {
2485
+ if (!inputKeys.has(fld))
2486
+ discoveredAdditionalBodyKeys.set(fld, "string");
2487
+ }
2488
+ }
2489
+ }
2490
+ }
2491
+ const processedMultiStepBody = isSubmissionFlow
2492
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride)
2493
+ : multiStepBody;
2494
+ const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
2495
+ // For submission flows the final action's response body is the most useful
2496
+ // shape inference target (it's the terminal success signal). Fall back to
2497
+ // the replay body for single-endpoint sites.
2498
+ const effectiveResponseBody = isSubmissionFlow
2499
+ ? (actionSteps[actionSteps.length - 1].capture.responseBody ?? responseBody)
2500
+ : responseBody;
2501
+ logger.info(`generating plugin for ${siteId} (${gql ? "GraphQL" : isSubmissionFlow ? `submission flow, ${actionSteps.length} steps` : "single-endpoint REST"}, baseUrl: ${baseUrl})`);
2502
+ if (emit === "config") {
2503
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
2504
+ (0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
2505
+ siteId,
2506
+ displayName: pascal,
2507
+ baseUrl,
2508
+ flowSteps,
2509
+ }));
2510
+ logger.info(`wrote ${manifestPath}`);
2511
+ logger.info(`done — review ${manifestPath}, fill in response/extract schemas, then load via BARNACLE_PLUGINS or BARNACLE_PLUGINS_CONFIG_DIR (no compile step)`);
2512
+ return;
2513
+ }
2514
+ (0, node_fs_1.mkdirSync)(`${outDir}/flows`, { recursive: true });
2515
+ // Emit the browser flow first so the SAME payloadFieldNames set that drives
2516
+ // its `payload.<field>` splices also extends the contract's payload schema —
2517
+ // the two artifacts can't drift because one accumulator feeds both.
2518
+ const browserFlow = emitBrowserFlowTs({
2519
+ siteId,
2520
+ pascal,
2521
+ baseUrl,
2522
+ flowSteps,
2523
+ isSubmissionFlow,
2524
+ hasMultipartStep,
2525
+ });
2526
+ (0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, emitContractTs({
2527
+ siteId,
2528
+ pascal,
2529
+ baseUrl,
2530
+ // G1+G2: only the static headers (no baseUrl/tenant-subdomain references)
2531
+ // get baked into BASE_HEADERS. The rest are emitted per-call from payload.
2532
+ baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
2533
+ minTime,
2534
+ safeRps,
2535
+ responseBody: effectiveResponseBody,
2536
+ gql,
2537
+ gqlQuery,
2538
+ endpointPath,
2539
+ auxFiles,
2540
+ multiStepBody: processedMultiStepBody,
2541
+ base64ContentHelper,
2542
+ inputBody,
2543
+ hasMultipartStep,
2544
+ discoveredFormFields,
2545
+ fieldOptionsMap,
2546
+ discoveredOptionFields,
2547
+ discoveredRawOptionFields,
2548
+ discoveredAdditionalBodyKeys,
2549
+ payloadFieldNames: browserFlow.payloadFieldNames,
2550
+ }));
2551
+ logger.info(`wrote ${outDir}/contract.ts`);
2552
+ (0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);
2553
+ logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
2554
+ (0, node_fs_1.writeFileSync)(`${outDir}/index.ts`, emitIndexTs({ siteId, pascal }));
2555
+ logger.info(`wrote ${outDir}/index.ts`);
2556
+ if (auxFiles.length > 0) {
2557
+ (0, node_fs_1.mkdirSync)(`${outDir}/fixtures`, { recursive: true });
2558
+ for (const f of auxFiles) {
2559
+ (0, node_fs_1.copyFileSync)((0, node_path_1.join)(recon_shared_1.AUX_DIR, f), `${outDir}/fixtures/${f}`);
2560
+ }
2561
+ logger.info(`copied ${auxFiles.length} fixture(s) to ${outDir}/fixtures/`);
2562
+ }
2563
+ logger.info(`done — review ${outDir}/, then register in src/plugins/loader.ts`);
2564
+ }
2565
+ if (process.argv[1] !== undefined &&
2566
+ (process.argv[1].endsWith("recon-generate.ts") || process.argv[1].endsWith("recon-generate.js"))) {
2567
+ main().catch((err) => {
2568
+ logger.error(`recon-generate failed: ${(0, errors_1.toErrorMessage)(err)}`);
2569
+ process.exit(1);
2570
+ });
2571
+ }
2572
+ //# sourceMappingURL=recon-generate.js.map