@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.
- package/LICENSE +21 -0
- package/README.md +958 -0
- package/dist/api/errors.d.ts +88 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +164 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/helpers/envelope.d.ts +13 -0
- package/dist/api/helpers/envelope.d.ts.map +1 -0
- package/dist/api/helpers/envelope.js +20 -0
- package/dist/api/helpers/envelope.js.map +1 -0
- package/dist/api/helpers/reply.d.ts +9 -0
- package/dist/api/helpers/reply.d.ts.map +1 -0
- package/dist/api/helpers/reply.js +14 -0
- package/dist/api/helpers/reply.js.map +1 -0
- package/dist/api/plugins/auth.d.ts +27 -0
- package/dist/api/plugins/auth.d.ts.map +1 -0
- package/dist/api/plugins/auth.js +111 -0
- package/dist/api/plugins/auth.js.map +1 -0
- package/dist/api/plugins/error-handler.d.ts +12 -0
- package/dist/api/plugins/error-handler.d.ts.map +1 -0
- package/dist/api/plugins/error-handler.js +72 -0
- package/dist/api/plugins/error-handler.js.map +1 -0
- package/dist/api/plugins/request-context.d.ts +17 -0
- package/dist/api/plugins/request-context.d.ts.map +1 -0
- package/dist/api/plugins/request-context.js +61 -0
- package/dist/api/plugins/request-context.js.map +1 -0
- package/dist/api/routes/health.d.ts +55 -0
- package/dist/api/routes/health.d.ts.map +1 -0
- package/dist/api/routes/health.js +137 -0
- package/dist/api/routes/health.js.map +1 -0
- package/dist/api/routes/plugins-introspection.d.ts +17 -0
- package/dist/api/routes/plugins-introspection.d.ts.map +1 -0
- package/dist/api/routes/plugins-introspection.js +13 -0
- package/dist/api/routes/plugins-introspection.js.map +1 -0
- package/dist/api/schemas/common.d.ts +114 -0
- package/dist/api/schemas/common.d.ts.map +1 -0
- package/dist/api/schemas/common.js +109 -0
- package/dist/api/schemas/common.js.map +1 -0
- package/dist/api/schemas/telemetry.d.ts +66 -0
- package/dist/api/schemas/telemetry.d.ts.map +1 -0
- package/dist/api/schemas/telemetry.js +52 -0
- package/dist/api/schemas/telemetry.js.map +1 -0
- package/dist/cache/keyed-ttl-cache.d.ts +45 -0
- package/dist/cache/keyed-ttl-cache.d.ts.map +1 -0
- package/dist/cache/keyed-ttl-cache.js +69 -0
- package/dist/cache/keyed-ttl-cache.js.map +1 -0
- package/dist/cache/response-cache.d.ts +37 -0
- package/dist/cache/response-cache.d.ts.map +1 -0
- package/dist/cache/response-cache.js +114 -0
- package/dist/cache/response-cache.js.map +1 -0
- package/dist/config.d.ts +258 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +138 -0
- package/dist/config.js.map +1 -0
- package/dist/lib/applicant-payload.d.ts +28 -0
- package/dist/lib/applicant-payload.d.ts.map +1 -0
- package/dist/lib/applicant-payload.js +20 -0
- package/dist/lib/applicant-payload.js.map +1 -0
- package/dist/lib/application-address.d.ts +16 -0
- package/dist/lib/application-address.d.ts.map +1 -0
- package/dist/lib/application-address.js +20 -0
- package/dist/lib/application-address.js.map +1 -0
- package/dist/lib/application-answers.d.ts +151 -0
- package/dist/lib/application-answers.d.ts.map +1 -0
- package/dist/lib/application-answers.js +95 -0
- package/dist/lib/application-answers.js.map +1 -0
- package/dist/lib/application-identity.d.ts +18 -0
- package/dist/lib/application-identity.d.ts.map +1 -0
- package/dist/lib/application-identity.js +20 -0
- package/dist/lib/application-identity.js.map +1 -0
- package/dist/lib/application-resume.d.ts +21 -0
- package/dist/lib/application-resume.d.ts.map +1 -0
- package/dist/lib/application-resume.js +23 -0
- package/dist/lib/application-resume.js.map +1 -0
- package/dist/lib/bedrock.d.ts +22 -0
- package/dist/lib/bedrock.d.ts.map +1 -0
- package/dist/lib/bedrock.js +35 -0
- package/dist/lib/bedrock.js.map +1 -0
- package/dist/lib/case-insensitive-headers.d.ts +15 -0
- package/dist/lib/case-insensitive-headers.d.ts.map +1 -0
- package/dist/lib/case-insensitive-headers.js +29 -0
- package/dist/lib/case-insensitive-headers.js.map +1 -0
- package/dist/lib/chromium-client-hints.d.ts +11 -0
- package/dist/lib/chromium-client-hints.d.ts.map +1 -0
- package/dist/lib/chromium-client-hints.js +17 -0
- package/dist/lib/chromium-client-hints.js.map +1 -0
- package/dist/lib/cookie-value.d.ts +8 -0
- package/dist/lib/cookie-value.d.ts.map +1 -0
- package/dist/lib/cookie-value.js +14 -0
- package/dist/lib/cookie-value.js.map +1 -0
- package/dist/lib/datadog.d.ts +8 -0
- package/dist/lib/datadog.d.ts.map +1 -0
- package/dist/lib/datadog.js +27 -0
- package/dist/lib/datadog.js.map +1 -0
- package/dist/lib/dd-metrics.d.ts +28 -0
- package/dist/lib/dd-metrics.d.ts.map +1 -0
- package/dist/lib/dd-metrics.js +62 -0
- package/dist/lib/dd-metrics.js.map +1 -0
- package/dist/lib/dispatch-metrics.d.ts +19 -0
- package/dist/lib/dispatch-metrics.d.ts.map +1 -0
- package/dist/lib/dispatch-metrics.js +65 -0
- package/dist/lib/dispatch-metrics.js.map +1 -0
- package/dist/lib/env.d.ts +37 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +70 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/errors.d.ts +15 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +20 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/http.d.ts +7 -0
- package/dist/lib/http.d.ts.map +1 -0
- package/dist/lib/http.js +14 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/job-tracking.d.ts +17 -0
- package/dist/lib/job-tracking.d.ts.map +1 -0
- package/dist/lib/job-tracking.js +19 -0
- package/dist/lib/job-tracking.js.map +1 -0
- package/dist/lib/llm/anthropic-client.d.ts +12 -0
- package/dist/lib/llm/anthropic-client.d.ts.map +1 -0
- package/dist/lib/llm/anthropic-client.js +23 -0
- package/dist/lib/llm/anthropic-client.js.map +1 -0
- package/dist/lib/llm/judge.d.ts +57 -0
- package/dist/lib/llm/judge.d.ts.map +1 -0
- package/dist/lib/llm/judge.js +123 -0
- package/dist/lib/llm/judge.js.map +1 -0
- package/dist/lib/llm/judges/error-messages.d.ts +38 -0
- package/dist/lib/llm/judges/error-messages.d.ts.map +1 -0
- package/dist/lib/llm/judges/error-messages.js +72 -0
- package/dist/lib/llm/judges/error-messages.js.map +1 -0
- package/dist/lib/llm/judges/invalid-fields.d.ts +43 -0
- package/dist/lib/llm/judges/invalid-fields.d.ts.map +1 -0
- package/dist/lib/llm/judges/invalid-fields.js +80 -0
- package/dist/lib/llm/judges/invalid-fields.js.map +1 -0
- package/dist/lib/llm/judges/modal-priority.d.ts +41 -0
- package/dist/lib/llm/judges/modal-priority.d.ts.map +1 -0
- package/dist/lib/llm/judges/modal-priority.js +74 -0
- package/dist/lib/llm/judges/modal-priority.js.map +1 -0
- package/dist/lib/llm/judges/select-option.d.ts +49 -0
- package/dist/lib/llm/judges/select-option.d.ts.map +1 -0
- package/dist/lib/llm/judges/select-option.js +90 -0
- package/dist/lib/llm/judges/select-option.js.map +1 -0
- package/dist/lib/llm/judges/verify-submit.d.ts +68 -0
- package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -0
- package/dist/lib/llm/judges/verify-submit.js +89 -0
- package/dist/lib/llm/judges/verify-submit.js.map +1 -0
- package/dist/lib/llm/schemas.d.ts +220 -0
- package/dist/lib/llm/schemas.d.ts.map +1 -0
- package/dist/lib/llm/schemas.js +239 -0
- package/dist/lib/llm/schemas.js.map +1 -0
- package/dist/lib/logging.d.ts +39 -0
- package/dist/lib/logging.d.ts.map +1 -0
- package/dist/lib/logging.js +219 -0
- package/dist/lib/logging.js.map +1 -0
- package/dist/lib/multipart.d.ts +34 -0
- package/dist/lib/multipart.d.ts.map +1 -0
- package/dist/lib/multipart.js +55 -0
- package/dist/lib/multipart.js.map +1 -0
- package/dist/lib/option-matcher.d.ts +15 -0
- package/dist/lib/option-matcher.d.ts.map +1 -0
- package/dist/lib/option-matcher.js +69 -0
- package/dist/lib/option-matcher.js.map +1 -0
- package/dist/lib/phone.d.ts +11 -0
- package/dist/lib/phone.d.ts.map +1 -0
- package/dist/lib/phone.js +17 -0
- package/dist/lib/phone.js.map +1 -0
- package/dist/lib/random.d.ts +22 -0
- package/dist/lib/random.d.ts.map +1 -0
- package/dist/lib/random.js +33 -0
- package/dist/lib/random.js.map +1 -0
- package/dist/lib/statsd.d.ts +11 -0
- package/dist/lib/statsd.d.ts.map +1 -0
- package/dist/lib/statsd.js +68 -0
- package/dist/lib/statsd.js.map +1 -0
- package/dist/lib/telemetry/call-capture.d.ts +89 -0
- package/dist/lib/telemetry/call-capture.d.ts.map +1 -0
- package/dist/lib/telemetry/call-capture.js +190 -0
- package/dist/lib/telemetry/call-capture.js.map +1 -0
- package/dist/lib/telemetry/call-types.d.ts +65 -0
- package/dist/lib/telemetry/call-types.d.ts.map +1 -0
- package/dist/lib/telemetry/call-types.js +68 -0
- package/dist/lib/telemetry/call-types.js.map +1 -0
- package/dist/lib/telemetry/run-state.d.ts +15 -0
- package/dist/lib/telemetry/run-state.d.ts.map +1 -0
- package/dist/lib/telemetry/run-state.js +23 -0
- package/dist/lib/telemetry/run-state.js.map +1 -0
- package/dist/lib/telemetry/s3-sink.d.ts +48 -0
- package/dist/lib/telemetry/s3-sink.d.ts.map +1 -0
- package/dist/lib/telemetry/s3-sink.js +229 -0
- package/dist/lib/telemetry/s3-sink.js.map +1 -0
- package/dist/lib/telemetry/submission-capture.d.ts +45 -0
- package/dist/lib/telemetry/submission-capture.d.ts.map +1 -0
- package/dist/lib/telemetry/submission-capture.js +57 -0
- package/dist/lib/telemetry/submission-capture.js.map +1 -0
- package/dist/lib/telemetry/telemetry-paths.d.ts +59 -0
- package/dist/lib/telemetry/telemetry-paths.d.ts.map +1 -0
- package/dist/lib/telemetry/telemetry-paths.js +103 -0
- package/dist/lib/telemetry/telemetry-paths.js.map +1 -0
- package/dist/lib/tracking-click.d.ts +20 -0
- package/dist/lib/tracking-click.d.ts.map +1 -0
- package/dist/lib/tracking-click.js +75 -0
- package/dist/lib/tracking-click.js.map +1 -0
- package/dist/lib/url-capture.d.ts +8 -0
- package/dist/lib/url-capture.d.ts.map +1 -0
- package/dist/lib/url-capture.js +17 -0
- package/dist/lib/url-capture.js.map +1 -0
- package/dist/lib/us-states.d.ts +20 -0
- package/dist/lib/us-states.d.ts.map +1 -0
- package/dist/lib/us-states.js +86 -0
- package/dist/lib/us-states.js.map +1 -0
- package/dist/lib/zod-multipart.d.ts +18 -0
- package/dist/lib/zod-multipart.d.ts.map +1 -0
- package/dist/lib/zod-multipart.js +35 -0
- package/dist/lib/zod-multipart.js.map +1 -0
- package/dist/plugins/config-plugin.d.ts +77 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -0
- package/dist/plugins/config-plugin.js +216 -0
- package/dist/plugins/config-plugin.js.map +1 -0
- package/dist/plugins/discover.d.ts +72 -0
- package/dist/plugins/discover.d.ts.map +1 -0
- package/dist/plugins/discover.js +265 -0
- package/dist/plugins/discover.js.map +1 -0
- package/dist/plugins/json-schema-to-zod.d.ts +47 -0
- package/dist/plugins/json-schema-to-zod.d.ts.map +1 -0
- package/dist/plugins/json-schema-to-zod.js +85 -0
- package/dist/plugins/json-schema-to-zod.js.map +1 -0
- package/dist/plugins/loader.d.ts +34 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +348 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/plugin-api-version.d.ts +8 -0
- package/dist/plugins/plugin-api-version.d.ts.map +1 -0
- package/dist/plugins/plugin-api-version.js +11 -0
- package/dist/plugins/plugin-api-version.js.map +1 -0
- package/dist/plugins/plugin-manifest-envelope.d.ts +16 -0
- package/dist/plugins/plugin-manifest-envelope.d.ts.map +1 -0
- package/dist/plugins/plugin-manifest-envelope.js +19 -0
- package/dist/plugins/plugin-manifest-envelope.js.map +1 -0
- package/dist/scraper/behavioral-signals.d.ts +16 -0
- package/dist/scraper/behavioral-signals.d.ts.map +1 -0
- package/dist/scraper/behavioral-signals.js +30 -0
- package/dist/scraper/behavioral-signals.js.map +1 -0
- package/dist/scraper/errors.d.ts +193 -0
- package/dist/scraper/errors.d.ts.map +1 -0
- package/dist/scraper/errors.js +226 -0
- package/dist/scraper/errors.js.map +1 -0
- package/dist/scraper/fixtures.d.ts +23 -0
- package/dist/scraper/fixtures.d.ts.map +1 -0
- package/dist/scraper/fixtures.js +48 -0
- package/dist/scraper/fixtures.js.map +1 -0
- package/dist/scraper/flow-runner.d.ts +1319 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -0
- package/dist/scraper/flow-runner.js +5637 -0
- package/dist/scraper/flow-runner.js.map +1 -0
- package/dist/scraper/graphql-client.d.ts +30 -0
- package/dist/scraper/graphql-client.d.ts.map +1 -0
- package/dist/scraper/graphql-client.js +14 -0
- package/dist/scraper/graphql-client.js.map +1 -0
- package/dist/scraper/http-client.d.ts +75 -0
- package/dist/scraper/http-client.d.ts.map +1 -0
- package/dist/scraper/http-client.js +189 -0
- package/dist/scraper/http-client.js.map +1 -0
- package/dist/scraper/http-status-classifier.d.ts +12 -0
- package/dist/scraper/http-status-classifier.d.ts.map +1 -0
- package/dist/scraper/http-status-classifier.js +29 -0
- package/dist/scraper/http-status-classifier.js.map +1 -0
- package/dist/scraper/metrics.d.ts +50 -0
- package/dist/scraper/metrics.d.ts.map +1 -0
- package/dist/scraper/metrics.js +85 -0
- package/dist/scraper/metrics.js.map +1 -0
- package/dist/scraper/navigate.d.ts +20 -0
- package/dist/scraper/navigate.d.ts.map +1 -0
- package/dist/scraper/navigate.js +30 -0
- package/dist/scraper/navigate.js.map +1 -0
- package/dist/scraper/oracle-sentinels.d.ts +22 -0
- package/dist/scraper/oracle-sentinels.d.ts.map +1 -0
- package/dist/scraper/oracle-sentinels.js +40 -0
- package/dist/scraper/oracle-sentinels.js.map +1 -0
- package/dist/scraper/parse-json-response.d.ts +16 -0
- package/dist/scraper/parse-json-response.d.ts.map +1 -0
- package/dist/scraper/parse-json-response.js +37 -0
- package/dist/scraper/parse-json-response.js.map +1 -0
- package/dist/scraper/pool.d.ts +37 -0
- package/dist/scraper/pool.d.ts.map +1 -0
- package/dist/scraper/pool.js +105 -0
- package/dist/scraper/pool.js.map +1 -0
- package/dist/scraper/rate-limited-json-client.d.ts +35 -0
- package/dist/scraper/rate-limited-json-client.d.ts.map +1 -0
- package/dist/scraper/rate-limited-json-client.js +29 -0
- package/dist/scraper/rate-limited-json-client.js.map +1 -0
- package/dist/scraper/raw-fetch.d.ts +49 -0
- package/dist/scraper/raw-fetch.d.ts.map +1 -0
- package/dist/scraper/raw-fetch.js +40 -0
- package/dist/scraper/raw-fetch.js.map +1 -0
- package/dist/scraper/require-response-field.d.ts +23 -0
- package/dist/scraper/require-response-field.d.ts.map +1 -0
- package/dist/scraper/require-response-field.js +42 -0
- package/dist/scraper/require-response-field.js.map +1 -0
- package/dist/scraper/retry.d.ts +33 -0
- package/dist/scraper/retry.d.ts.map +1 -0
- package/dist/scraper/retry.js +122 -0
- package/dist/scraper/retry.js.map +1 -0
- package/dist/scraper/session-browserbase.d.ts +35 -0
- package/dist/scraper/session-browserbase.d.ts.map +1 -0
- package/dist/scraper/session-browserbase.js +198 -0
- package/dist/scraper/session-browserbase.js.map +1 -0
- package/dist/scraper/session-shared.d.ts +29 -0
- package/dist/scraper/session-shared.d.ts.map +1 -0
- package/dist/scraper/session-shared.js +35 -0
- package/dist/scraper/session-shared.js.map +1 -0
- package/dist/scraper/session-steel.d.ts +31 -0
- package/dist/scraper/session-steel.d.ts.map +1 -0
- package/dist/scraper/session-steel.js +140 -0
- package/dist/scraper/session-steel.js.map +1 -0
- package/dist/scraper/session-warmup.d.ts +31 -0
- package/dist/scraper/session-warmup.d.ts.map +1 -0
- package/dist/scraper/session-warmup.js +46 -0
- package/dist/scraper/session-warmup.js.map +1 -0
- package/dist/scraper/session.d.ts +21 -0
- package/dist/scraper/session.d.ts.map +1 -0
- package/dist/scraper/session.js +28 -0
- package/dist/scraper/session.js.map +1 -0
- package/dist/scraper/stagehand-guard.d.ts +128 -0
- package/dist/scraper/stagehand-guard.d.ts.map +1 -0
- package/dist/scraper/stagehand-guard.js +337 -0
- package/dist/scraper/stagehand-guard.js.map +1 -0
- package/dist/scraper/throttle.d.ts +21 -0
- package/dist/scraper/throttle.d.ts.map +1 -0
- package/dist/scraper/throttle.js +45 -0
- package/dist/scraper/throttle.js.map +1 -0
- package/dist/scripts/hca-inbox-poll.d.ts +19 -0
- package/dist/scripts/hca-inbox-poll.d.ts.map +1 -0
- package/dist/scripts/hca-inbox-poll.js +151 -0
- package/dist/scripts/hca-inbox-poll.js.map +1 -0
- package/dist/scripts/judge-llm-batch.d.ts +98 -0
- package/dist/scripts/judge-llm-batch.d.ts.map +1 -0
- package/dist/scripts/judge-llm-batch.js +350 -0
- package/dist/scripts/judge-llm-batch.js.map +1 -0
- package/dist/scripts/llm-heal.d.ts +165 -0
- package/dist/scripts/llm-heal.d.ts.map +1 -0
- package/dist/scripts/llm-heal.js +595 -0
- package/dist/scripts/llm-heal.js.map +1 -0
- package/dist/scripts/migrate-telemetry-dir-names.d.ts +57 -0
- package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -0
- package/dist/scripts/migrate-telemetry-dir-names.js +156 -0
- package/dist/scripts/migrate-telemetry-dir-names.js.map +1 -0
- package/dist/scripts/probe-setinputfiles.d.ts +28 -0
- package/dist/scripts/probe-setinputfiles.d.ts.map +1 -0
- package/dist/scripts/probe-setinputfiles.js +95 -0
- package/dist/scripts/probe-setinputfiles.js.map +1 -0
- package/dist/scripts/recon-browser.d.ts +304 -0
- package/dist/scripts/recon-browser.d.ts.map +1 -0
- package/dist/scripts/recon-browser.js +1833 -0
- package/dist/scripts/recon-browser.js.map +1 -0
- package/dist/scripts/recon-generate.d.ts +182 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -0
- package/dist/scripts/recon-generate.js +2572 -0
- package/dist/scripts/recon-generate.js.map +1 -0
- package/dist/scripts/recon-heal.d.ts +176 -0
- package/dist/scripts/recon-heal.d.ts.map +1 -0
- package/dist/scripts/recon-heal.js +584 -0
- package/dist/scripts/recon-heal.js.map +1 -0
- package/dist/scripts/recon-http.d.ts +19 -0
- package/dist/scripts/recon-http.d.ts.map +1 -0
- package/dist/scripts/recon-http.js +377 -0
- package/dist/scripts/recon-http.js.map +1 -0
- package/dist/scripts/recon-replay-jobs.d.ts +21 -0
- package/dist/scripts/recon-replay-jobs.d.ts.map +1 -0
- package/dist/scripts/recon-replay-jobs.js +221 -0
- package/dist/scripts/recon-replay-jobs.js.map +1 -0
- package/dist/scripts/recon-shared.d.ts +52 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -0
- package/dist/scripts/recon-shared.js +68 -0
- package/dist/scripts/recon-shared.js.map +1 -0
- package/dist/scripts/recon-summarize.d.ts +15 -0
- package/dist/scripts/recon-summarize.d.ts.map +1 -0
- package/dist/scripts/recon-summarize.js +233 -0
- package/dist/scripts/recon-summarize.js.map +1 -0
- package/dist/scripts/smoke-test.d.ts +22 -0
- package/dist/scripts/smoke-test.d.ts.map +1 -0
- package/dist/scripts/smoke-test.js +236 -0
- package/dist/scripts/smoke-test.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +189 -0
- package/dist/server.js.map +1 -0
- package/dist/site-plugin.d.ts +226 -0
- package/dist/site-plugin.d.ts.map +1 -0
- package/dist/site-plugin.js +13 -0
- package/dist/site-plugin.js.map +1 -0
- package/dist/testing/answers-fixture.d.ts +13 -0
- package/dist/testing/answers-fixture.d.ts.map +1 -0
- package/dist/testing/answers-fixture.js +37 -0
- package/dist/testing/answers-fixture.js.map +1 -0
- package/dist/testing/batch-email-confirmation.d.ts +58 -0
- package/dist/testing/batch-email-confirmation.d.ts.map +1 -0
- package/dist/testing/batch-email-confirmation.js +62 -0
- package/dist/testing/batch-email-confirmation.js.map +1 -0
- package/dist/testing/batch-report.d.ts +33 -0
- package/dist/testing/batch-report.d.ts.map +1 -0
- package/dist/testing/batch-report.js +30 -0
- package/dist/testing/batch-report.js.map +1 -0
- package/dist/testing/contract-parity-suite.d.ts +55 -0
- package/dist/testing/contract-parity-suite.d.ts.map +1 -0
- package/dist/testing/contract-parity-suite.js +39 -0
- package/dist/testing/contract-parity-suite.js.map +1 -0
- package/dist/testing/coverage-guard-suite.d.ts +46 -0
- package/dist/testing/coverage-guard-suite.d.ts.map +1 -0
- package/dist/testing/coverage-guard-suite.js +39 -0
- package/dist/testing/coverage-guard-suite.js.map +1 -0
- package/dist/testing/fixtures/resume.pdf +54 -0
- package/dist/testing/integration-runner.d.ts +62 -0
- package/dist/testing/integration-runner.d.ts.map +1 -0
- package/dist/testing/integration-runner.js +46 -0
- package/dist/testing/integration-runner.js.map +1 -0
- package/dist/testing/mock-fetch-response.d.ts +7 -0
- package/dist/testing/mock-fetch-response.d.ts.map +1 -0
- package/dist/testing/mock-fetch-response.js +16 -0
- package/dist/testing/mock-fetch-response.js.map +1 -0
- package/dist/testing/persona-fixture.d.ts +77 -0
- package/dist/testing/persona-fixture.d.ts.map +1 -0
- package/dist/testing/persona-fixture.js +83 -0
- package/dist/testing/persona-fixture.js.map +1 -0
- package/dist/testing/replay-integration-suite.d.ts +50 -0
- package/dist/testing/replay-integration-suite.d.ts.map +1 -0
- package/dist/testing/replay-integration-suite.js +45 -0
- package/dist/testing/replay-integration-suite.js.map +1 -0
- package/dist/testing/resume-fixture.d.ts +39 -0
- package/dist/testing/resume-fixture.d.ts.map +1 -0
- package/dist/testing/resume-fixture.js +38 -0
- package/dist/testing/resume-fixture.js.map +1 -0
- package/dist/testmail/client.d.ts +61 -0
- package/dist/testmail/client.d.ts.map +1 -0
- package/dist/testmail/client.js +190 -0
- package/dist/testmail/client.js.map +1 -0
- package/dist/testmail/errors.d.ts +18 -0
- package/dist/testmail/errors.d.ts.map +1 -0
- package/dist/testmail/errors.js +29 -0
- package/dist/testmail/errors.js.map +1 -0
- package/dist/types/dispatch-metrics.d.ts +46 -0
- package/dist/types/dispatch-metrics.d.ts.map +1 -0
- package/dist/types/dispatch-metrics.js +25 -0
- package/dist/types/dispatch-metrics.js.map +1 -0
- package/dist/types/logging.d.ts +11 -0
- package/dist/types/logging.d.ts.map +1 -0
- package/dist/types/logging.js +3 -0
- package/dist/types/logging.js.map +1 -0
- 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
|