@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.70
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/AUTHORING.md +943 -51
- package/CHANGELOG.md +273 -1
- package/LICENSE +21 -0
- package/README.md +117 -29
- package/SUBMISSION.md +3 -2
- package/bin/apifuse-check.ts +293 -13
- package/bin/apifuse-dev.ts +42 -19
- package/bin/apifuse-migrate-deployment.ts +200 -0
- package/bin/apifuse-migrate-operation-declaration.ts +55 -0
- package/bin/apifuse-migrate-shape.ts +202 -0
- package/bin/apifuse-pack-check.ts +19 -6
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +736 -38
- package/bin/apifuse-perf.ts +59 -23
- package/bin/apifuse-record.ts +923 -94
- package/bin/apifuse-submit-check.ts +2313 -281
- package/bin/apifuse-test.ts +3 -1
- package/bin/submit-check-delimited-text.ts +50 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth-turn/index.js +1 -1
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +67 -0
- package/dist/ceremonies/index.d.ts +16 -0
- package/dist/ceremonies/index.js +141 -36
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +30 -0
- package/dist/cli/create.d.ts +1 -1
- package/dist/cli/create.js +8 -2
- package/dist/cli/deployment-intent.d.ts +178 -0
- package/dist/cli/deployment-intent.js +277 -0
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/migrate-deployment-intent.d.ts +132 -0
- package/dist/cli/migrate-deployment-intent.js +1552 -0
- package/dist/cli/migrate-operation-declaration.d.ts +70 -0
- package/dist/cli/migrate-operation-declaration.js +2206 -0
- package/dist/cli/migrate-operation-shape.d.ts +44 -0
- package/dist/cli/migrate-operation-shape.js +113 -0
- package/dist/cli/migrate-provider-shape.d.ts +56 -0
- package/dist/cli/migrate-provider-shape.js +580 -0
- package/dist/cli/resolver-runtime.d.ts +21 -0
- package/dist/cli/resolver-runtime.js +31 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +41 -19
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/dist/cli/templates/provider/provider.json.tpl +6 -0
- package/dist/config/loader.d.ts +202 -16
- package/dist/config/loader.js +491 -141
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +12 -5
- package/dist/contract.js +24 -10
- package/dist/declaration-validation.d.ts +34 -0
- package/dist/declaration-validation.js +305 -0
- package/dist/define.d.ts +71 -29
- package/dist/define.js +807 -69
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +8 -1
- package/dist/engine.d.ts +129 -0
- package/dist/engine.js +209 -0
- package/dist/error-observability.d.ts +7 -0
- package/dist/error-observability.js +61 -0
- package/dist/error-resolution.d.ts +18 -0
- package/dist/error-resolution.js +243 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.js +99 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +371 -0
- package/dist/handle-meta.d.ts +90 -0
- package/dist/handle-meta.js +73 -0
- package/dist/handle.d.ts +226 -0
- package/dist/handle.js +345 -0
- package/dist/health-scenario.d.ts +1901 -0
- package/dist/health-scenario.js +653 -0
- package/dist/i18n/catalog.js +21 -1
- package/dist/i18n/error-messages.d.ts +86 -0
- package/dist/i18n/error-messages.js +192 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/keys.d.ts +12 -0
- package/dist/i18n/keys.js +76 -5
- package/dist/i18n/operation-locale-namespace.d.ts +2 -0
- package/dist/i18n/operation-locale-namespace.js +7 -0
- package/dist/index.d.ts +40 -15
- package/dist/index.js +29 -11
- package/dist/lint.d.ts +43 -11
- package/dist/lint.js +2512 -177
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +31 -0
- package/dist/native-egress-policy.js +288 -0
- package/dist/observability.d.ts +5 -2
- package/dist/observability.js +48 -1
- package/dist/provider.d.ts +18 -8
- package/dist/provider.js +11 -6
- package/dist/runtime/auth-flow.d.ts +6 -1
- package/dist/runtime/auth-flow.js +8 -0
- package/dist/runtime/bounded-expiring-map.d.ts +21 -0
- package/dist/runtime/bounded-expiring-map.js +64 -0
- package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
- package/dist/runtime/browser-telemetry-binding.js +83 -0
- package/dist/runtime/browser-telemetry.d.ts +76 -0
- package/dist/runtime/browser-telemetry.js +165 -0
- package/dist/runtime/browser.d.ts +4 -0
- package/dist/runtime/browser.js +688 -83
- package/dist/runtime/cache-telemetry.d.ts +71 -0
- package/dist/runtime/cache-telemetry.js +177 -0
- package/dist/runtime/cache.d.ts +3 -0
- package/dist/runtime/cache.js +245 -23
- package/dist/runtime/capability-response.d.ts +3 -0
- package/dist/runtime/capability-response.js +56 -0
- package/dist/runtime/capability-telemetry.d.ts +46 -0
- package/dist/runtime/capability-telemetry.js +68 -0
- package/dist/runtime/chrome149-header-order.d.ts +58 -0
- package/dist/runtime/chrome149-header-order.js +289 -0
- package/dist/runtime/diagnostic-env-values.d.ts +5 -0
- package/dist/runtime/diagnostic-env-values.js +57 -0
- package/dist/runtime/diagnostic-env.d.ts +14 -0
- package/dist/runtime/diagnostic-env.js +44 -0
- package/dist/runtime/diagnostic-redactor.d.ts +54 -0
- package/dist/runtime/diagnostic-redactor.js +571 -0
- package/dist/runtime/egress-lease.d.ts +65 -0
- package/dist/runtime/egress-lease.js +236 -0
- package/dist/runtime/engine-mode.d.ts +74 -0
- package/dist/runtime/engine-mode.js +152 -0
- package/dist/runtime/env.js +14 -1
- package/dist/runtime/executor.d.ts +3 -2
- package/dist/runtime/executor.js +23 -26
- package/dist/runtime/handle-wordlist.d.ts +43 -0
- package/dist/runtime/handle-wordlist.js +256 -0
- package/dist/runtime/handle.d.ts +71 -0
- package/dist/runtime/handle.js +908 -0
- package/dist/runtime/http-telemetry-binding.d.ts +10 -0
- package/dist/runtime/http-telemetry-binding.js +152 -0
- package/dist/runtime/http-telemetry-guard.d.ts +16 -0
- package/dist/runtime/http-telemetry-guard.js +202 -0
- package/dist/runtime/http-telemetry.d.ts +104 -0
- package/dist/runtime/http-telemetry.js +190 -0
- package/dist/runtime/http.d.ts +34 -0
- package/dist/runtime/http.js +680 -121
- package/dist/runtime/insights.js +2 -2
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +462 -19
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +105 -0
- package/dist/runtime/native-network.js +1433 -0
- package/dist/runtime/native-telemetry.d.ts +142 -0
- package/dist/runtime/native-telemetry.js +231 -0
- package/dist/runtime/ocr-telemetry.d.ts +82 -0
- package/dist/runtime/ocr-telemetry.js +192 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +468 -0
- package/dist/runtime/otlp.d.ts +71 -2
- package/dist/runtime/otlp.js +398 -16
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +147 -0
- package/dist/runtime/proxy-retry-policy.d.ts +4 -1
- package/dist/runtime/proxy-retry-policy.js +11 -2
- package/dist/runtime/proxy-telemetry.d.ts +133 -3
- package/dist/runtime/proxy-telemetry.js +218 -70
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +61 -0
- package/dist/runtime/redirects.js +149 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/request-options.d.ts +75 -1
- package/dist/runtime/request-options.js +588 -0
- package/dist/runtime/request-telemetry.d.ts +70 -0
- package/dist/runtime/request-telemetry.js +229 -0
- package/dist/runtime/resolver-config.d.ts +13 -0
- package/dist/runtime/resolver-config.js +14 -0
- package/dist/runtime/resolver-public.d.ts +3 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-runtime-options.d.ts +14 -0
- package/dist/runtime/resolver-runtime-options.js +33 -0
- package/dist/runtime/resolver-shared.d.ts +24 -0
- package/dist/runtime/resolver-shared.js +69 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-telemetry.d.ts +135 -0
- package/dist/runtime/resolver-telemetry.js +204 -0
- package/dist/runtime/resolver-usage.d.ts +32 -0
- package/dist/runtime/resolver-usage.js +54 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
- package/dist/runtime/resolver-vendors/bindings.js +45 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
- package/dist/runtime/resolver-vendors/browser.js +423 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +544 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
- package/dist/runtime/resolver-vendors/hosts.js +68 -0
- package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
- package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
- package/dist/runtime/resolver-vendors/types.d.ts +125 -0
- package/dist/runtime/resolver-vendors/types.js +106 -0
- package/dist/runtime/resolver.d.ts +79 -0
- package/dist/runtime/resolver.js +1094 -0
- package/dist/runtime/state-telemetry.d.ts +79 -0
- package/dist/runtime/state-telemetry.js +194 -0
- package/dist/runtime/state.d.ts +10 -1
- package/dist/runtime/state.js +322 -82
- package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
- package/dist/runtime/stealth-akamai-sbsd.js +142 -0
- package/dist/runtime/stealth-cookies.d.ts +24 -0
- package/dist/runtime/stealth-cookies.js +131 -0
- package/dist/runtime/stealth-owned-headers.d.ts +39 -0
- package/dist/runtime/stealth-owned-headers.js +57 -0
- package/dist/runtime/stealth-telemetry.d.ts +92 -0
- package/dist/runtime/stealth-telemetry.js +179 -0
- package/dist/runtime/stealth.d.ts +45 -6
- package/dist/runtime/stealth.js +1775 -320
- package/dist/runtime/stt-telemetry.d.ts +77 -0
- package/dist/runtime/stt-telemetry.js +176 -0
- package/dist/runtime/stt.js +18 -19
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/runtime/trace-config.d.ts +12 -0
- package/dist/runtime/trace-config.js +62 -0
- package/dist/runtime/trace.d.ts +17 -0
- package/dist/runtime/trace.js +176 -25
- package/dist/runtime-boundary-lint.d.ts +137 -0
- package/dist/runtime-boundary-lint.js +590 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +72 -2
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/error-observability.d.ts +1 -0
- package/dist/server/error-observability.js +1 -0
- package/dist/server/error-stack-frames.d.ts +35 -0
- package/dist/server/error-stack-frames.js +219 -0
- package/dist/server/index.d.ts +21 -2
- package/dist/server/index.js +11 -2
- package/dist/server/runtime-diagnostics.d.ts +165 -0
- package/dist/server/runtime-diagnostics.js +360 -0
- package/dist/server/sdk-version.d.ts +3 -0
- package/dist/server/sdk-version.js +19 -0
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/server/self-test-redaction.js +8 -33
- package/dist/server/self-test.d.ts +18 -3
- package/dist/server/self-test.js +139 -76
- package/dist/server/sensitive-values.d.ts +26 -0
- package/dist/server/sensitive-values.js +137 -0
- package/dist/server/serve-implementation.d.ts +285 -0
- package/dist/server/serve-implementation.js +3172 -0
- package/dist/server/serve.d.ts +1 -75
- package/dist/server/serve.js +1 -1170
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/dist/server/trace-output.d.ts +7 -0
- package/dist/server/trace-output.js +111 -0
- package/dist/server/types.d.ts +37 -5
- package/dist/server/types.js +34 -1
- package/dist/stateful/errors.d.ts +19 -0
- package/dist/stateful/errors.js +24 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +237 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
- package/dist/stateful/stateful-provider-runtime-context.js +61 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
- package/dist/stateful/stateful-provider-session-routing.js +345 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/dist/stealth/profiles.d.ts +15 -4
- package/dist/stealth/profiles.js +169 -211
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.js +7 -1
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +509 -25
- package/dist/trace-sanitization.d.ts +6 -0
- package/dist/trace-sanitization.js +68 -0
- package/dist/types.d.ts +899 -210
- package/dist/types.js +1 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +52 -6
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- package/src/cli/commands.ts +37 -0
- package/src/cli/create.ts +8 -2
- package/src/cli/deployment-intent.ts +364 -0
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/migrate-deployment-intent.ts +1908 -0
- package/src/cli/migrate-operation-declaration.ts +3026 -0
- package/src/cli/migrate-operation-shape.ts +184 -0
- package/src/cli/migrate-provider-shape.ts +778 -0
- package/src/cli/resolver-runtime.ts +72 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +41 -19
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/src/cli/templates/provider/provider.json.tpl +6 -0
- package/src/config/loader.ts +774 -181
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +12 -5
- package/src/contract.ts +24 -10
- package/src/declaration-validation.ts +376 -0
- package/src/define.ts +1105 -131
- package/src/dev.ts +13 -1
- package/src/engine.ts +390 -0
- package/src/error-observability.ts +64 -0
- package/src/error-resolution.ts +269 -0
- package/src/errors.ts +157 -0
- package/src/fixture-sanitization.ts +422 -0
- package/src/handle-meta.ts +143 -0
- package/src/handle.ts +694 -0
- package/src/health-scenario.ts +960 -0
- package/src/i18n/catalog.ts +23 -1
- package/src/i18n/error-messages.ts +255 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/keys.ts +80 -6
- package/src/i18n/operation-locale-namespace.ts +10 -0
- package/src/index.ts +390 -23
- package/src/lint.ts +2975 -234
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +358 -0
- package/src/observability.ts +51 -1
- package/src/provider.ts +207 -27
- package/src/runtime/auth-flow.ts +15 -0
- package/src/runtime/bounded-expiring-map.ts +67 -0
- package/src/runtime/browser-telemetry-binding.ts +92 -0
- package/src/runtime/browser-telemetry.ts +289 -0
- package/src/runtime/browser.ts +882 -99
- package/src/runtime/cache-telemetry.ts +247 -0
- package/src/runtime/cache.ts +258 -22
- package/src/runtime/capability-response.ts +53 -0
- package/src/runtime/capability-telemetry.ts +101 -0
- package/src/runtime/chrome149-header-order.ts +330 -0
- package/src/runtime/diagnostic-env-values.ts +56 -0
- package/src/runtime/diagnostic-env.ts +65 -0
- package/src/runtime/diagnostic-redactor.ts +668 -0
- package/src/runtime/egress-lease.ts +341 -0
- package/src/runtime/engine-mode.ts +233 -0
- package/src/runtime/env.ts +15 -1
- package/src/runtime/executor.ts +39 -32
- package/src/runtime/handle-wordlist.ts +266 -0
- package/src/runtime/handle.ts +1180 -0
- package/src/runtime/http-telemetry-binding.ts +162 -0
- package/src/runtime/http-telemetry-guard.ts +208 -0
- package/src/runtime/http-telemetry.ts +292 -0
- package/src/runtime/http.ts +896 -150
- package/src/runtime/insights.ts +2 -2
- package/src/runtime/instrumentation.ts +628 -28
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1853 -0
- package/src/runtime/native-telemetry.ts +366 -0
- package/src/runtime/ocr-telemetry.ts +308 -0
- package/src/runtime/ocr.ts +564 -0
- package/src/runtime/otlp.ts +468 -21
- package/src/runtime/provider.ts +15 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +222 -0
- package/src/runtime/proxy-retry-policy.ts +12 -1
- package/src/runtime/proxy-telemetry.ts +358 -103
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +215 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +728 -1
- package/src/runtime/request-telemetry.ts +375 -0
- package/src/runtime/resolver-config.ts +18 -0
- package/src/runtime/resolver-public.ts +44 -0
- package/src/runtime/resolver-runtime-options.ts +50 -0
- package/src/runtime/resolver-shared.ts +113 -0
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-telemetry.ts +362 -0
- package/src/runtime/resolver-usage.ts +99 -0
- package/src/runtime/resolver-vendors/bindings.ts +61 -0
- package/src/runtime/resolver-vendors/browser.ts +591 -0
- package/src/runtime/resolver-vendors/capsolver.ts +727 -0
- package/src/runtime/resolver-vendors/hosts.ts +81 -0
- package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
- package/src/runtime/resolver-vendors/types.ts +261 -0
- package/src/runtime/resolver.ts +1543 -0
- package/src/runtime/state-telemetry.ts +298 -0
- package/src/runtime/state.ts +435 -89
- package/src/runtime/stealth-akamai-sbsd.ts +207 -0
- package/src/runtime/stealth-cookies.ts +155 -0
- package/src/runtime/stealth-owned-headers.ts +67 -0
- package/src/runtime/stealth-telemetry.ts +309 -0
- package/src/runtime/stealth.ts +2275 -372
- package/src/runtime/stt-telemetry.ts +282 -0
- package/src/runtime/stt.ts +96 -91
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +78 -0
- package/src/runtime/trace.ts +257 -35
- package/src/runtime-boundary-lint.ts +707 -0
- package/src/schema.ts +80 -2
- package/src/serve.ts +6 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/error-stack-frames.ts +214 -0
- package/src/server/index.ts +184 -2
- package/src/server/runtime-diagnostics.ts +522 -0
- package/src/server/sdk-version.ts +22 -0
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test-redaction.ts +10 -40
- package/src/server/self-test.ts +205 -89
- package/src/server/sensitive-values.ts +211 -0
- package/src/server/serve-implementation.ts +4596 -0
- package/src/server/serve.ts +1 -1685
- package/src/server/state-backend-report.ts +185 -0
- package/src/server/trace-output.ts +189 -0
- package/src/server/types.ts +34 -1
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +35 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +310 -0
- package/src/stateful/stateful-provider-runtime-context.ts +96 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +546 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +212 -231
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -1
- package/src/testing/index.ts +10 -1
- package/src/testing/run.ts +685 -19
- package/src/trace-sanitization.ts +110 -0
- package/src/types.ts +1014 -273
- package/src/typescript-module.ts +100 -0
- package/dist/choice-token.d.ts +0 -24
- package/dist/choice-token.js +0 -74
- package/dist/runtime/choice.d.ts +0 -15
- package/dist/runtime/choice.js +0 -413
- package/src/choice-token.ts +0 -165
- package/src/runtime/choice.ts +0 -686
package/src/lint.ts
CHANGED
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
import type { ZodType } from "zod";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
SDK_CANONICAL_ERROR_CODE_RETRYABILITY,
|
|
5
|
+
SDK_RUNTIME_OWNED_ERROR_CODES,
|
|
6
|
+
SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
|
|
7
|
+
} from "./error-resolution.js";
|
|
8
|
+
import {
|
|
9
|
+
APIFUSE_HANDLE_META_KEY,
|
|
10
|
+
type HandleFieldMeta,
|
|
11
|
+
type HandleKindDeclaration,
|
|
12
|
+
handleFieldDescription,
|
|
13
|
+
handleFieldNameFor,
|
|
14
|
+
handleHasDirectionalFieldNames,
|
|
15
|
+
handleIssuerList,
|
|
16
|
+
isHandleFieldMeta,
|
|
17
|
+
isHandleIssuedBy,
|
|
18
|
+
} from "./handle-meta.js";
|
|
19
|
+
import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
|
|
20
|
+
import {
|
|
21
|
+
getProviderLocaleSegments,
|
|
22
|
+
isProviderLocaleKey,
|
|
23
|
+
type ProviderLocaleCatalog,
|
|
24
|
+
} from "./i18n/keys.js";
|
|
3
25
|
import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
|
|
26
|
+
import {
|
|
27
|
+
isStealthOwnedHeaderName,
|
|
28
|
+
SDK_OWNED_CHROME_HEADER_PREFIX,
|
|
29
|
+
} from "./runtime/stealth-owned-headers.js";
|
|
30
|
+
import {
|
|
31
|
+
lintRuntimeBoundary,
|
|
32
|
+
// This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
|
|
33
|
+
// used by the older source rules; it does not cover a plain `tests/`
|
|
34
|
+
// directory. Aliased rather than merged because consolidating the two would
|
|
35
|
+
// silently change what those rules scan.
|
|
36
|
+
TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN,
|
|
37
|
+
scriptKindForSourceFile,
|
|
38
|
+
} from "./runtime-boundary-lint.js";
|
|
4
39
|
import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
|
|
40
|
+
import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
|
|
41
|
+
import { getTypeScript } from "./typescript-module.js";
|
|
42
|
+
|
|
43
|
+
type AuthModeLike =
|
|
44
|
+
| "none"
|
|
45
|
+
| "platform-managed"
|
|
46
|
+
| "credentials"
|
|
47
|
+
| "oauth2"
|
|
48
|
+
| "oauth2_proxied"
|
|
49
|
+
| "api-key";
|
|
50
|
+
|
|
51
|
+
const CREDENTIAL_BEARING_AUTH_MODES = [
|
|
52
|
+
"credentials",
|
|
53
|
+
"oauth2",
|
|
54
|
+
"oauth2_proxied",
|
|
55
|
+
] satisfies readonly AuthMode[];
|
|
56
|
+
|
|
57
|
+
function isCredentialBearingAuthMode(mode: AuthModeLike | undefined): boolean {
|
|
58
|
+
return CREDENTIAL_BEARING_AUTH_MODES.some((credentialMode) => credentialMode === mode);
|
|
59
|
+
}
|
|
5
60
|
|
|
6
|
-
|
|
61
|
+
function defaultApprovalPolicy(riskClass: OperationRiskClass): OperationApprovalPolicy {
|
|
62
|
+
if (riskClass === "read") return "never";
|
|
63
|
+
if (riskClass === "write") return "risk-based";
|
|
64
|
+
return "always";
|
|
65
|
+
}
|
|
7
66
|
|
|
8
67
|
type ProviderAuthLike = {
|
|
9
68
|
mode?: AuthModeLike;
|
|
@@ -17,11 +76,98 @@ type ProviderAuthLike = {
|
|
|
17
76
|
exchange?: unknown;
|
|
18
77
|
};
|
|
19
78
|
|
|
79
|
+
// Operations that perform an auth-lifecycle action belong on the single
|
|
80
|
+
// `auth.flow` interface, never on a provider operation:
|
|
81
|
+
// - entry (login / signin / authenticate) => auth.flow.start/continue
|
|
82
|
+
// - exit (logout / signout / disconnect) => auth.flow.abort
|
|
83
|
+
//
|
|
84
|
+
// Matching works on `-`/`_` separated segments rather than a raw substring or a
|
|
85
|
+
// leading anchor, so `shop-logout`, `shop_logout` and `user-sign-out-everywhere`
|
|
86
|
+
// are all recognised: a domain prefix does not make the operation any less of an
|
|
87
|
+
// auth-lifecycle action, and operation ids may use either separator.
|
|
88
|
+
//
|
|
89
|
+
// Vocabulary is split into two tiers because auth words collide with ordinary
|
|
90
|
+
// domain verbs. Measured against the live fleet plus synthetic domain ids:
|
|
91
|
+
// - `authorize-payment`, `revoke-invitation`, `unlink-record`,
|
|
92
|
+
// `disconnect-device` are domain actions that never touch the connection
|
|
93
|
+
// credential, so these verbs are NOT matched as segments;
|
|
94
|
+
// - the same verbs as a complete operation id (`authorize`, `revoke`) do
|
|
95
|
+
// refer to the credential itself, so they are matched only in that form.
|
|
96
|
+
// `exchange`, `callback`, `connect`, `session`, `token`, `credential`,
|
|
97
|
+
// `password` and `otp` stay out entirely for the same reason.
|
|
98
|
+
const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
|
|
99
|
+
"login",
|
|
100
|
+
"logout",
|
|
101
|
+
"signin",
|
|
102
|
+
"signout",
|
|
103
|
+
"signup",
|
|
104
|
+
"authenticate",
|
|
105
|
+
"reauth",
|
|
106
|
+
"auth",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Ambiguous as a prefix, unambiguous when they are the whole operation id.
|
|
110
|
+
const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set(["authorize", "revoke", "unlink", "disconnect"]);
|
|
111
|
+
|
|
112
|
+
// A verb stem followed by a direction word across two segments: `sign-out`,
|
|
113
|
+
// `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
|
|
114
|
+
// (their fused equivalents `login`/`logout` live in the segment set above).
|
|
115
|
+
// `sign` pairs match anywhere; `log` pairs match only at the END of the id,
|
|
116
|
+
// because mid-id `log` is the noun in domain phrases measured against real
|
|
117
|
+
// fleets (`audit-log-in-range`, `change-log-out-of-band` are reads of a log,
|
|
118
|
+
// while `shop-log-out` is a logout).
|
|
119
|
+
const AUTH_DIRECTION_PAIRS: ReadonlyMap<
|
|
120
|
+
string,
|
|
121
|
+
{ directions: ReadonlySet<string>; endOnly: boolean }
|
|
122
|
+
> = new Map([
|
|
123
|
+
["sign", { directions: new Set(["in", "out", "up"]), endOnly: false }],
|
|
124
|
+
["log", { directions: new Set(["in", "out"]), endOnly: true }],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
// Legacy anchored form kept for token-plumbing words whose bare use is only
|
|
128
|
+
// auth-related when it leads the operation id (`exchange-code`, `refresh`).
|
|
20
129
|
const AUTH_OPERATION_ID_PATTERN =
|
|
21
130
|
/^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
|
|
22
131
|
|
|
132
|
+
function isAuthLifecycleOperationId(operationId: string, authMode: string): boolean {
|
|
133
|
+
const segments = operationId.toLowerCase().split(/[-_]+/).filter(Boolean);
|
|
134
|
+
|
|
135
|
+
if (segments.some((segment) => AUTH_LIFECYCLE_SEGMENT_WORDS.has(segment))) return true;
|
|
136
|
+
|
|
137
|
+
// A verb stem + direction spread across two segments (`sign-out`,
|
|
138
|
+
// `sign_up`, `shop-log-out`); see AUTH_DIRECTION_PAIRS for positioning.
|
|
139
|
+
if (
|
|
140
|
+
segments.some((segment, index) => {
|
|
141
|
+
const pair = AUTH_DIRECTION_PAIRS.get(segment);
|
|
142
|
+
if (pair === undefined || index + 1 >= segments.length) return false;
|
|
143
|
+
if (!pair.directions.has(segments[index + 1] as string)) return false;
|
|
144
|
+
return pair.endOnly ? index + 2 === segments.length : true;
|
|
145
|
+
})
|
|
146
|
+
) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (segments.length === 1 && AUTH_LIFECYCLE_WHOLE_ID_WORDS.has(segments[0] as string)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The legacy anchored pattern keeps its original scope. It matches ordinary
|
|
155
|
+
// domain ids such as `exchange-rates` and `refresh-catalog`, so extending it
|
|
156
|
+
// to `oauth2_proxied` would spread that behavior to providers it never
|
|
157
|
+
// applied to; proxied providers are covered by the segment tiers above.
|
|
158
|
+
if (authMode === "credentials" || authMode === "oauth2") {
|
|
159
|
+
return AUTH_OPERATION_ID_PATTERN.test(operationId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
23
165
|
type ProviderContractMetaLike = {
|
|
24
166
|
publicSchemaFieldNames?: "normalized";
|
|
167
|
+
pinnedWireFieldPaths?: readonly {
|
|
168
|
+
readonly path: string;
|
|
169
|
+
readonly reason: string;
|
|
170
|
+
}[];
|
|
25
171
|
};
|
|
26
172
|
|
|
27
173
|
type SchemaLike = ZodType & {
|
|
@@ -55,6 +201,22 @@ type ProviderLintOptions = {
|
|
|
55
201
|
mode?: ProviderLintMode;
|
|
56
202
|
};
|
|
57
203
|
|
|
204
|
+
/**
|
|
205
|
+
* A non-blocking audit line: a finding the author acknowledged with
|
|
206
|
+
* `// @apifuse-allow <rule>: <reason>`, kept visible in `apifuse check` output.
|
|
207
|
+
*/
|
|
208
|
+
export interface ProviderLintInformation {
|
|
209
|
+
rule: string;
|
|
210
|
+
message: string;
|
|
211
|
+
field?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Diagnostics plus the acknowledged findings that accompany them. */
|
|
215
|
+
export interface ProviderLintResult {
|
|
216
|
+
diagnostics: LintDiagnostic[];
|
|
217
|
+
information: ProviderLintInformation[];
|
|
218
|
+
}
|
|
219
|
+
|
|
58
220
|
type ProviderSourceLike = {
|
|
59
221
|
authFlowSource?: string;
|
|
60
222
|
providerSourceFiles?: Record<string, string>;
|
|
@@ -251,7 +413,7 @@ function lintAuthModel(provider: {
|
|
|
251
413
|
rule: "platform-managed-no-credential-keys",
|
|
252
414
|
level: "error",
|
|
253
415
|
field: "credential.keys",
|
|
254
|
-
message: `${providerLabel} must not declare credential.keys
|
|
416
|
+
message: `${providerLabel} must not declare credential.keys because platform-managed auth mode already implies the unfiltered credential capability.`,
|
|
255
417
|
});
|
|
256
418
|
}
|
|
257
419
|
|
|
@@ -318,19 +480,30 @@ function getObjectShape(schema: SchemaLike): Record<string, SchemaLike> {
|
|
|
318
480
|
return {};
|
|
319
481
|
}
|
|
320
482
|
|
|
321
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Child edges of a schema node. `origin: "shape"` marks object properties whose
|
|
485
|
+
* key is provider-chosen (and may collide with a structural edge name such as
|
|
486
|
+
* `item`, `type`, or `schema`); `origin: "structural"` marks zod internals
|
|
487
|
+
* (wrappers, array elements, union options, pipe stages).
|
|
488
|
+
*/
|
|
489
|
+
type SchemaChildEdge = { key: string; schema: SchemaLike; origin: "shape" | "structural" };
|
|
490
|
+
|
|
491
|
+
function getChildSchemas(schema: SchemaLike): SchemaChildEdge[] {
|
|
322
492
|
const seen = new Map<string, SchemaLike>();
|
|
493
|
+
const origins = new Map<string, "shape" | "structural">();
|
|
323
494
|
const def = getSchemaDef(schema);
|
|
324
495
|
|
|
325
|
-
const add = (key: string, value: unknown) => {
|
|
496
|
+
const add = (key: string, value: unknown, origin: "shape" | "structural" = "structural") => {
|
|
326
497
|
if (!isSchema(value)) {
|
|
327
498
|
return;
|
|
328
499
|
}
|
|
329
|
-
|
|
500
|
+
const id = `${key}:${seen.size}`;
|
|
501
|
+
seen.set(id, value);
|
|
502
|
+
origins.set(id, origin);
|
|
330
503
|
};
|
|
331
504
|
|
|
332
505
|
for (const [key, value] of Object.entries(getObjectShape(schema))) {
|
|
333
|
-
add(key, value);
|
|
506
|
+
add(key, value, "shape");
|
|
334
507
|
}
|
|
335
508
|
|
|
336
509
|
add("element", schema.element);
|
|
@@ -373,6 +546,9 @@ function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: Schem
|
|
|
373
546
|
"schema",
|
|
374
547
|
"innerType",
|
|
375
548
|
"type",
|
|
549
|
+
// zod v4 intersections keep their branches on the definition.
|
|
550
|
+
"left",
|
|
551
|
+
"right",
|
|
376
552
|
"valueType",
|
|
377
553
|
"keyType",
|
|
378
554
|
"item",
|
|
@@ -396,6 +572,7 @@ function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: Schem
|
|
|
396
572
|
return Array.from(seen.entries()).map(([entryKey, child]) => ({
|
|
397
573
|
key: entryKey.split(":")[0] ?? entryKey,
|
|
398
574
|
schema: child,
|
|
575
|
+
origin: origins.get(entryKey) ?? "structural",
|
|
399
576
|
}));
|
|
400
577
|
}
|
|
401
578
|
|
|
@@ -403,18 +580,29 @@ function uniqueFields(fields: string[]): string[] {
|
|
|
403
580
|
return Array.from(new Set(fields));
|
|
404
581
|
}
|
|
405
582
|
|
|
406
|
-
function
|
|
407
|
-
|
|
408
|
-
|
|
583
|
+
function readSensitivityDeclaration(
|
|
584
|
+
schema: unknown,
|
|
585
|
+
seen = new Set<unknown>(),
|
|
586
|
+
): boolean | undefined {
|
|
587
|
+
// Declarations sit on the leaf; same-path wrappers such as .optional(),
|
|
588
|
+
// .nullable(), .pipe() or z.lazy() carry no metadata of their own, so look
|
|
589
|
+
// through them.
|
|
590
|
+
if (!isSchema(schema) || seen.has(schema)) return undefined;
|
|
591
|
+
seen.add(schema);
|
|
592
|
+
const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_SENSITIVE_META_KEY);
|
|
593
|
+
if (typeof value === "boolean") return value;
|
|
594
|
+
const def = getSchemaDef(schema);
|
|
595
|
+
const children =
|
|
596
|
+
def.type === "pipe"
|
|
597
|
+
? [def.in, def.out]
|
|
598
|
+
: def.type === "lazy" && typeof def.getter === "function"
|
|
599
|
+
? [def.getter()]
|
|
600
|
+
: [def.innerType];
|
|
601
|
+
for (const child of children) {
|
|
602
|
+
const declaration = readSensitivityDeclaration(child, seen);
|
|
603
|
+
if (declaration !== undefined) return declaration;
|
|
409
604
|
}
|
|
410
|
-
|
|
411
|
-
if (typeof meta !== "function") return false;
|
|
412
|
-
const metadata = meta.call(schema);
|
|
413
|
-
return (
|
|
414
|
-
!!metadata &&
|
|
415
|
-
typeof metadata === "object" &&
|
|
416
|
-
Reflect.get(metadata, APIFUSE_SENSITIVE_META_KEY) === true
|
|
417
|
-
);
|
|
605
|
+
return undefined;
|
|
418
606
|
}
|
|
419
607
|
|
|
420
608
|
function getSchemaMetadata(schema: SchemaLike): Record<string, unknown> {
|
|
@@ -444,9 +632,95 @@ const SENSITIVE_FIELD_NAMES = new Set([
|
|
|
444
632
|
"paymenturl",
|
|
445
633
|
]);
|
|
446
634
|
|
|
635
|
+
// Only phone-shaped keys can hold reviewed public data (facility phones); the
|
|
636
|
+
// rest are credentials, which a public declaration must not launder.
|
|
637
|
+
const PUBLIC_DECLARABLE_FIELD_NAMES = new Set(["phone", "phonenumber"]);
|
|
638
|
+
|
|
639
|
+
function normalizeFieldName(name: string): string {
|
|
640
|
+
return name.toLowerCase().replace(/[-_\s]/g, "");
|
|
641
|
+
}
|
|
642
|
+
|
|
447
643
|
function isSensitiveFieldName(name: string): boolean {
|
|
448
|
-
|
|
449
|
-
|
|
644
|
+
return SENSITIVE_FIELD_NAMES.has(normalizeFieldName(name));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function isDeclaredSensitiveField(key: string, schema: unknown): boolean {
|
|
648
|
+
const declaration = readSensitivityDeclaration(schema);
|
|
649
|
+
return (
|
|
650
|
+
declaration === true ||
|
|
651
|
+
(declaration === false && PUBLIC_DECLARABLE_FIELD_NAMES.has(normalizeFieldName(key)))
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Child keys produced by getChildSchemas() that wrap a schema without adding a
|
|
656
|
+
// property path segment (optional/nullable/default/pipe/effects/etc.).
|
|
657
|
+
const SCHEMA_WRAPPER_NODE_KEYS: readonly string[] = [
|
|
658
|
+
"unwrap",
|
|
659
|
+
"innerType",
|
|
660
|
+
"sourceType",
|
|
661
|
+
"schema",
|
|
662
|
+
"type",
|
|
663
|
+
"in",
|
|
664
|
+
"out",
|
|
665
|
+
"option",
|
|
666
|
+
"pipe",
|
|
667
|
+
"payload",
|
|
668
|
+
"item",
|
|
669
|
+
"rest",
|
|
670
|
+
"catchall",
|
|
671
|
+
"keyType",
|
|
672
|
+
"valueType",
|
|
673
|
+
// zod v4 intersection branches: transparent, the enclosing property key applies.
|
|
674
|
+
"left",
|
|
675
|
+
"right",
|
|
676
|
+
];
|
|
677
|
+
|
|
678
|
+
function isSchemaWrapperNodeKey(key: string): boolean {
|
|
679
|
+
return SCHEMA_WRAPPER_NODE_KEYS.includes(key) || key.startsWith("pipe.");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function getOwnHandleFieldMeta(schema: SchemaLike): HandleFieldMeta | undefined {
|
|
683
|
+
const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_HANDLE_META_KEY);
|
|
684
|
+
return isHandleFieldMeta(value) ? value : undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Handle meta placed by `kind.field()` lives on the string schema itself; zod
|
|
689
|
+
* wrappers (`.optional()`, `.nullable()`, `.default()`, pipes) do not inherit
|
|
690
|
+
* registry metadata, so look through wrapper nodes until a non-wrapper is hit.
|
|
691
|
+
*/
|
|
692
|
+
function getHandleFieldMeta(
|
|
693
|
+
schema: unknown,
|
|
694
|
+
seen = new Set<SchemaLike>(),
|
|
695
|
+
): HandleFieldMeta | undefined {
|
|
696
|
+
if (!isSchema(schema) || seen.has(schema)) return undefined;
|
|
697
|
+
seen.add(schema);
|
|
698
|
+
const own = getOwnHandleFieldMeta(schema);
|
|
699
|
+
if (own) return own;
|
|
700
|
+
for (const child of getChildSchemas(schema)) {
|
|
701
|
+
if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key)) continue;
|
|
702
|
+
const found = getHandleFieldMeta(child.schema, seen);
|
|
703
|
+
if (found) return found;
|
|
704
|
+
}
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* True when `schema` is a handle field (directly or through zod wrappers) whose
|
|
710
|
+
* description is still the SDK-generated wording and no wrapper adds its own
|
|
711
|
+
* description.
|
|
712
|
+
*/
|
|
713
|
+
function isSdkOwnedHandleField(schema: unknown, seen = new Set<SchemaLike>()): boolean {
|
|
714
|
+
if (!isSchema(schema) || seen.has(schema)) return false;
|
|
715
|
+
seen.add(schema);
|
|
716
|
+
const own = getOwnHandleFieldMeta(schema);
|
|
717
|
+
if (own) return schema.description === handleFieldDescription(own);
|
|
718
|
+
if (schema.description) return false;
|
|
719
|
+
for (const child of getChildSchemas(schema)) {
|
|
720
|
+
if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key)) continue;
|
|
721
|
+
if (isSdkOwnedHandleField(child.schema, seen)) return true;
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
450
724
|
}
|
|
451
725
|
|
|
452
726
|
function collectUnmarkedSensitiveFields(
|
|
@@ -461,30 +735,14 @@ function collectUnmarkedSensitiveFields(
|
|
|
461
735
|
const out: string[] = [];
|
|
462
736
|
for (const [key, child] of Object.entries(getObjectShape(schema))) {
|
|
463
737
|
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
464
|
-
if (isSensitiveFieldName(key) && !
|
|
738
|
+
if (isSensitiveFieldName(key) && !isDeclaredSensitiveField(key, child)) {
|
|
465
739
|
out.push(childPath);
|
|
466
740
|
}
|
|
467
741
|
out.push(...collectUnmarkedSensitiveFields(child, childPath, seen));
|
|
468
742
|
}
|
|
469
743
|
for (const child of getChildSchemas(schema)) {
|
|
470
744
|
if (Object.hasOwn(getObjectShape(schema), child.key)) continue;
|
|
471
|
-
const isWrapperNode =
|
|
472
|
-
"unwrap",
|
|
473
|
-
"innerType",
|
|
474
|
-
"sourceType",
|
|
475
|
-
"schema",
|
|
476
|
-
"type",
|
|
477
|
-
"in",
|
|
478
|
-
"out",
|
|
479
|
-
"option",
|
|
480
|
-
"pipe",
|
|
481
|
-
"payload",
|
|
482
|
-
"item",
|
|
483
|
-
"rest",
|
|
484
|
-
"catchall",
|
|
485
|
-
"keyType",
|
|
486
|
-
"valueType",
|
|
487
|
-
].includes(child.key);
|
|
745
|
+
const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
|
|
488
746
|
const childPath =
|
|
489
747
|
child.key === "element" || child.key.startsWith("element.")
|
|
490
748
|
? `${basePath}[]`
|
|
@@ -513,6 +771,15 @@ function collectSchemaDescriptionKeyDiagnostics(
|
|
|
513
771
|
const currentPath = basePath || "schema";
|
|
514
772
|
const hasDescriptionKey = getSchemaDescriptionKey(schema) !== undefined;
|
|
515
773
|
|
|
774
|
+
// Handle fields (`kind.field()`, ADR-0012) carry SDK-owned wording via
|
|
775
|
+
// .describe() plus x-apifuse-handle meta; they count as described and are
|
|
776
|
+
// leaves, so neither this node nor its wrapper chain needs a describeKey.
|
|
777
|
+
// A provider override of that wording (`.describe("…")` on the field or on
|
|
778
|
+
// a wrapper) is provider prose again and stays subject to the rules below.
|
|
779
|
+
if (isSdkOwnedHandleField(schema)) {
|
|
780
|
+
return diagnostics;
|
|
781
|
+
}
|
|
782
|
+
|
|
516
783
|
if (schema.description && !hasDescriptionKey) {
|
|
517
784
|
diagnostics.push({
|
|
518
785
|
rule: "schema-description-raw-prose",
|
|
@@ -534,23 +801,7 @@ function collectSchemaDescriptionKeyDiagnostics(
|
|
|
534
801
|
}
|
|
535
802
|
|
|
536
803
|
for (const child of getChildSchemas(schema)) {
|
|
537
|
-
const isWrapperNode =
|
|
538
|
-
"unwrap",
|
|
539
|
-
"innerType",
|
|
540
|
-
"sourceType",
|
|
541
|
-
"schema",
|
|
542
|
-
"type",
|
|
543
|
-
"in",
|
|
544
|
-
"out",
|
|
545
|
-
"option",
|
|
546
|
-
"pipe",
|
|
547
|
-
"payload",
|
|
548
|
-
"item",
|
|
549
|
-
"rest",
|
|
550
|
-
"catchall",
|
|
551
|
-
"keyType",
|
|
552
|
-
"valueType",
|
|
553
|
-
].includes(child.key);
|
|
804
|
+
const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
|
|
554
805
|
const isStructuralNode =
|
|
555
806
|
isWrapperNode ||
|
|
556
807
|
child.key.startsWith("pipe.") ||
|
|
@@ -573,21 +824,6 @@ function collectSchemaDescriptionKeyDiagnostics(
|
|
|
573
824
|
return diagnostics;
|
|
574
825
|
}
|
|
575
826
|
|
|
576
|
-
function isComplexSchema(schema: unknown, seen = new Set<SchemaLike>()): boolean {
|
|
577
|
-
if (!isSchema(schema) || seen.has(schema)) {
|
|
578
|
-
return false;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
seen.add(schema);
|
|
582
|
-
const children = getChildSchemas(schema);
|
|
583
|
-
const hasNestedComposite = children.some(({ schema: child }) => {
|
|
584
|
-
const childChildren = getChildSchemas(child);
|
|
585
|
-
return childChildren.length > 0;
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
return hasNestedComposite || children.some(({ schema: child }) => isComplexSchema(child, seen));
|
|
589
|
-
}
|
|
590
|
-
|
|
591
827
|
function hasBidirectionalFixtures(fixtures: unknown): boolean {
|
|
592
828
|
if (!fixtures || typeof fixtures !== "object") {
|
|
593
829
|
return true;
|
|
@@ -793,202 +1029,2696 @@ function lintSelfHostedBrowserPatterns(
|
|
|
793
1029
|
return diagnostics;
|
|
794
1030
|
}
|
|
795
1031
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
output: unknown;
|
|
805
|
-
fixtures?: unknown;
|
|
806
|
-
inputExamples?: readonly unknown[];
|
|
807
|
-
derivations?: Record<string, string>;
|
|
808
|
-
}): LintDiagnostic[] {
|
|
809
|
-
const diagnostics: LintDiagnostic[] = [];
|
|
810
|
-
const description = op.description ?? "";
|
|
811
|
-
const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
|
|
1032
|
+
// Captures the constructor name so each rule can pick its own scope: the
|
|
1033
|
+
// undeclared-code rule keeps its historical ProviderError/ValidationError
|
|
1034
|
+
// surface, while the localization rules also cover AuthError.
|
|
1035
|
+
const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(ProviderError|AuthError|ValidationError)\s*\(/g;
|
|
1036
|
+
const UNDECLARED_CODE_ERROR_CLASSES: ReadonlySet<string> = new Set([
|
|
1037
|
+
"ProviderError",
|
|
1038
|
+
"ValidationError",
|
|
1039
|
+
]);
|
|
812
1040
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1041
|
+
const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
1042
|
+
const RECORDED_FIXTURE_SOURCE_FILE_PATTERN =
|
|
1043
|
+
/(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
|
|
1044
|
+
const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
|
|
1045
|
+
const VERSIONED_PROFILE_LITERAL_PATTERN =
|
|
1046
|
+
/\b(?:chrome|chromium|firefox|safari|edge|opera|ios[-_]safari)[-_]\d+(?:[._-]\d+)*(?=$|[^A-Za-z0-9])/i;
|
|
1047
|
+
const VERSIONED_USER_AGENT_PATTERN = /\b(?:Chrome|CriOS|Firefox|FxiOS|EdgA?|OPR)\/\d+(?:\.\d+)*/i;
|
|
1048
|
+
const VERSIONED_SAFARI_USER_AGENT_PATTERN = /\bVersion\/(\d+(?:\.\d+)*)(?=[\s\S]*\bSafari\/\d)/i;
|
|
1049
|
+
const VERSIONED_CLIENT_HINT_PATTERN = /(?:^|[;,\s])v\s*=\s*["']?\d+/i;
|
|
1050
|
+
// Calls that write a header entry as (name, value): Headers/Map `set` and
|
|
1051
|
+
// `append`, Node's `setHeader`. Predicates, replacements, and logging calls
|
|
1052
|
+
// take string arguments too and must not count as header writes.
|
|
1053
|
+
const HEADER_SETTER_CALLEE_PATTERN = /^(?:set|append|setHeader|addHeader)$/;
|
|
1054
|
+
const OWNED_CLIENT_HINT_HEADER_PREFIX = "sec-ch-ua";
|
|
1055
|
+
|
|
1056
|
+
type BrowserVersionLiteralKind = "profile" | "user-agent" | "sec-ch-ua" | "owned-header";
|
|
1057
|
+
|
|
1058
|
+
type BrowserVersionLiteralFinding = {
|
|
1059
|
+
kind: BrowserVersionLiteralKind;
|
|
1060
|
+
literal: string;
|
|
1061
|
+
position: number;
|
|
1062
|
+
};
|
|
821
1063
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
});
|
|
829
|
-
}
|
|
1064
|
+
function staticStringText(node: import("typescript").Node | undefined): string | undefined {
|
|
1065
|
+
if (!node) return undefined;
|
|
1066
|
+
const ts = getTypeScript();
|
|
1067
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
1068
|
+
return undefined;
|
|
1069
|
+
}
|
|
830
1070
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
field: "whenToUse",
|
|
836
|
-
message: "Operation whenToUse must use whenToUseKeys instead of raw static prose.",
|
|
837
|
-
});
|
|
1071
|
+
function staticPropertyName(node: import("typescript").PropertyName): string | undefined {
|
|
1072
|
+
const ts = getTypeScript();
|
|
1073
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
|
|
1074
|
+
return node.text;
|
|
838
1075
|
}
|
|
1076
|
+
if (ts.isComputedPropertyName(node)) return staticStringText(node.expression);
|
|
1077
|
+
return undefined;
|
|
1078
|
+
}
|
|
839
1079
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1080
|
+
function isSecChUaHeaderName(value: string | undefined): boolean {
|
|
1081
|
+
return value?.toLowerCase() === "sec-ch-ua";
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* True for the code paths that reach the stealth transport: `ctx.stealth`,
|
|
1086
|
+
* `ctx["stealth"]`, and `const { stealth } = ctx`. Read from the AST so a
|
|
1087
|
+
* comment or string that mentions ctx.stealth does not scope a ctx.http file
|
|
1088
|
+
* into this rule.
|
|
1089
|
+
*/
|
|
1090
|
+
function isStealthContextAccess(node: import("typescript").Node): boolean {
|
|
1091
|
+
const ts = getTypeScript();
|
|
1092
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
1093
|
+
return (
|
|
1094
|
+
ts.isIdentifier(node.expression) &&
|
|
1095
|
+
node.expression.text === "ctx" &&
|
|
1096
|
+
node.name.text === "stealth"
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
if (ts.isElementAccessExpression(node)) {
|
|
1100
|
+
return (
|
|
1101
|
+
ts.isIdentifier(node.expression) &&
|
|
1102
|
+
node.expression.text === "ctx" &&
|
|
1103
|
+
staticStringText(node.argumentExpression) === "stealth"
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (ts.isBindingElement(node)) {
|
|
1107
|
+
const key = node.propertyName ?? node.name;
|
|
1108
|
+
if (!ts.isIdentifier(key) || key.text !== "stealth") return false;
|
|
1109
|
+
const declaration = node.parent?.parent;
|
|
1110
|
+
return (
|
|
1111
|
+
declaration !== undefined &&
|
|
1112
|
+
ts.isVariableDeclaration(declaration) &&
|
|
1113
|
+
declaration.initializer !== undefined &&
|
|
1114
|
+
ts.isIdentifier(declaration.initializer) &&
|
|
1115
|
+
declaration.initializer.text === "ctx"
|
|
1116
|
+
);
|
|
847
1117
|
}
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
848
1120
|
|
|
849
|
-
|
|
850
|
-
if (
|
|
851
|
-
|
|
852
|
-
|
|
1121
|
+
function isTupleTypeNode(type: import("typescript").TypeNode | undefined): boolean {
|
|
1122
|
+
if (!type) return false;
|
|
1123
|
+
const ts = getTypeScript();
|
|
1124
|
+
if (ts.isTypeOperatorNode(type)) return isTupleTypeNode(type.type);
|
|
1125
|
+
return ts.isTupleTypeNode(type);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* True where a two-element array is consumed as a `[name, value]` header entry:
|
|
1130
|
+
* inside an entries list (`new Headers([[name, value]])`,
|
|
1131
|
+
* `Object.fromEntries([...])`), passed to a call such as `tuples.push(...)`, or
|
|
1132
|
+
* declared with a tuple type. A two-name list is bound, iterated, or handed to
|
|
1133
|
+
* a constructor instead — `const STRIP = ["sec-fetch-dest", "referer"]`,
|
|
1134
|
+
* `for (const name of [...])`, `new Set([...])` — and is not an entry. The
|
|
1135
|
+
* position decides because a Sec-Fetch-* value ("document", "navigate") is
|
|
1136
|
+
* token-shaped exactly like a header name.
|
|
1137
|
+
*/
|
|
1138
|
+
function isHeaderEntryPosition(node: import("typescript").ArrayLiteralExpression): boolean {
|
|
1139
|
+
const ts = getTypeScript();
|
|
1140
|
+
let child: import("typescript").Node = node;
|
|
1141
|
+
let parent = node.parent;
|
|
1142
|
+
while (
|
|
1143
|
+
parent &&
|
|
1144
|
+
(ts.isParenthesizedExpression(parent) ||
|
|
1145
|
+
ts.isAsExpression(parent) ||
|
|
1146
|
+
ts.isSatisfiesExpression(parent) ||
|
|
1147
|
+
ts.isTypeAssertionExpression(parent))
|
|
853
1148
|
) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
field: "description",
|
|
858
|
-
message: 'Operation description should include both "use" and "when".',
|
|
859
|
-
});
|
|
1149
|
+
if (!ts.isParenthesizedExpression(parent) && isTupleTypeNode(parent.type)) return true;
|
|
1150
|
+
child = parent;
|
|
1151
|
+
parent = parent.parent;
|
|
860
1152
|
}
|
|
1153
|
+
if (!parent) return false;
|
|
1154
|
+
if (ts.isArrayLiteralExpression(parent)) return true;
|
|
1155
|
+
if (ts.isCallExpression(parent)) return parent.arguments.some((argument) => argument === child);
|
|
1156
|
+
if (ts.isVariableDeclaration(parent)) {
|
|
1157
|
+
return parent.initializer === child && isTupleTypeNode(parent.type);
|
|
1158
|
+
}
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
861
1161
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
);
|
|
1162
|
+
function isStaticUndefined(node: import("typescript").Node | undefined): boolean {
|
|
1163
|
+
if (!node) return false;
|
|
1164
|
+
const ts = getTypeScript();
|
|
1165
|
+
return (ts.isIdentifier(node) && node.text === "undefined") || ts.isVoidExpression(node);
|
|
1166
|
+
}
|
|
866
1167
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1168
|
+
function calleeName(node: import("typescript").CallExpression): string | undefined {
|
|
1169
|
+
const ts = getTypeScript();
|
|
1170
|
+
if (ts.isPropertyAccessExpression(node.expression)) return node.expression.name.text;
|
|
1171
|
+
if (ts.isIdentifier(node.expression)) return node.expression.text;
|
|
1172
|
+
return undefined;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Header names this rule reports in ctx.stealth files: the Sec-Fetch-* and
|
|
1177
|
+
* client-hint (sec-ch-ua*) families, restricted to what the stealth runtime
|
|
1178
|
+
* really rejects (isStealthOwnedHeaderName). Deliberately narrower than the
|
|
1179
|
+
* runtime rejection: host, connection, and accept-encoding are left alone
|
|
1180
|
+
* because ctx.http callers set them legitimately in the same files, and
|
|
1181
|
+
* user-agent only reaches a finding through the "user-agent" kind, which
|
|
1182
|
+
* matches a versioned literal value rather than the name.
|
|
1183
|
+
*
|
|
1184
|
+
* This rule is the early, same-file signal only. The complete check is the
|
|
1185
|
+
* offline test harness: `runStandardTests` runs every `ctx.stealth` request
|
|
1186
|
+
* through the runtime's own `assertCallerHeadersSupported`, so a header spread
|
|
1187
|
+
* from another module, or a user-agent read from a variable or
|
|
1188
|
+
* `getStealthProfile(...)`, fails the provider's tests exactly as it would fail
|
|
1189
|
+
* in production.
|
|
1190
|
+
*/
|
|
1191
|
+
function isReportedOwnedHeaderName(value: string | undefined): boolean {
|
|
1192
|
+
if (value === undefined) return false;
|
|
1193
|
+
const name = value.toLowerCase();
|
|
1194
|
+
if (!isStealthOwnedHeaderName(name)) return false;
|
|
1195
|
+
if (name.startsWith(SDK_OWNED_CHROME_HEADER_PREFIX)) {
|
|
1196
|
+
return name.length > SDK_OWNED_CHROME_HEADER_PREFIX.length;
|
|
874
1197
|
}
|
|
1198
|
+
return name.startsWith(OWNED_CLIENT_HINT_HEADER_PREFIX);
|
|
1199
|
+
}
|
|
875
1200
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1201
|
+
function collectBrowserVersionLiteralFindings(
|
|
1202
|
+
fileName: string,
|
|
1203
|
+
source: string,
|
|
1204
|
+
): BrowserVersionLiteralFinding[] {
|
|
1205
|
+
const ts = getTypeScript();
|
|
1206
|
+
const sourceFile = ts.createSourceFile(
|
|
1207
|
+
fileName,
|
|
1208
|
+
source,
|
|
1209
|
+
ts.ScriptTarget.Latest,
|
|
1210
|
+
true,
|
|
1211
|
+
scriptKindForSourceFile(ts, fileName),
|
|
1212
|
+
);
|
|
1213
|
+
let findings: BrowserVersionLiteralFinding[] = [];
|
|
1214
|
+
const seen = new Set<string>();
|
|
1215
|
+
// Owned-header entries only count once the file is known to reach
|
|
1216
|
+
// ctx.stealth, which may appear after the entry; collect them and decide
|
|
1217
|
+
// after the walk.
|
|
1218
|
+
let stealthAccess = false;
|
|
1219
|
+
const ownedHeaderEntries: Array<{ name: string; position: number; valuePosition?: number }> = [];
|
|
1220
|
+
|
|
1221
|
+
const addFinding = (kind: BrowserVersionLiteralKind, literal: string, position: number) => {
|
|
1222
|
+
const key = `${kind}:${position}:${literal}`;
|
|
1223
|
+
if (seen.has(key)) return;
|
|
1224
|
+
seen.add(key);
|
|
1225
|
+
findings.push({ kind, literal, position });
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
const inspectLiteral = (text: string, position: number) => {
|
|
1229
|
+
const profile = text.match(VERSIONED_PROFILE_LITERAL_PATTERN)?.[0];
|
|
1230
|
+
if (profile) addFinding("profile", profile, position);
|
|
1231
|
+
|
|
1232
|
+
const userAgent =
|
|
1233
|
+
text.match(VERSIONED_USER_AGENT_PATTERN)?.[0] ??
|
|
1234
|
+
text.match(VERSIONED_SAFARI_USER_AGENT_PATTERN)?.[0];
|
|
1235
|
+
if (userAgent) addFinding("user-agent", userAgent, position);
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const inspectSecChUaValue = (node: import("typescript").Node | undefined) => {
|
|
1239
|
+
const text = staticStringText(node);
|
|
1240
|
+
if (text && VERSIONED_CLIENT_HINT_PATTERN.test(text)) {
|
|
1241
|
+
addFinding("sec-ch-ua", "sec-ch-ua", node!.getStart(sourceFile));
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
// A header entry `name: value` in any of the shapes below. In a ctx.stealth
|
|
1246
|
+
// file an owned name is the finding (the runtime rejects it whatever the
|
|
1247
|
+
// value); elsewhere only a versioned sec-ch-ua value is. A statically
|
|
1248
|
+
// undefined value is exempt only in the "record" shapes, the ones the
|
|
1249
|
+
// runtime drops before the ownership check (`normalizedHeaderEntries`):
|
|
1250
|
+
// `Headers.set(name, undefined)` stringifies to "undefined" and still
|
|
1251
|
+
// throws, so it stays a finding.
|
|
1252
|
+
const inspectHeaderEntry = (
|
|
1253
|
+
nameNode: import("typescript").Node,
|
|
1254
|
+
name: string | undefined,
|
|
1255
|
+
valueNode: import("typescript").Node | undefined,
|
|
1256
|
+
ownedHeaderShape: false | "record" | "entry",
|
|
1257
|
+
) => {
|
|
1258
|
+
if (name === undefined) return;
|
|
1259
|
+
if (
|
|
1260
|
+
ownedHeaderShape !== false &&
|
|
1261
|
+
isReportedOwnedHeaderName(name) &&
|
|
1262
|
+
!(ownedHeaderShape === "record" && isStaticUndefined(valueNode))
|
|
1263
|
+
) {
|
|
1264
|
+
ownedHeaderEntries.push({
|
|
1265
|
+
name,
|
|
1266
|
+
position: nameNode.getStart(sourceFile),
|
|
1267
|
+
valuePosition: valueNode?.getStart(sourceFile),
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
if (isSecChUaHeaderName(name)) inspectSecChUaValue(valueNode);
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
const visit = (node: import("typescript").Node) => {
|
|
1274
|
+
const text = staticStringText(node);
|
|
1275
|
+
if (text !== undefined) inspectLiteral(text, node.getStart(sourceFile));
|
|
1276
|
+
if (isStealthContextAccess(node)) stealthAccess = true;
|
|
1277
|
+
|
|
1278
|
+
if (ts.isPropertyAssignment(node)) {
|
|
1279
|
+
inspectHeaderEntry(node.name, staticPropertyName(node.name), node.initializer, "record");
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (ts.isCallExpression(node) && node.arguments[0]) {
|
|
1283
|
+
// Only `headers.set(name, value)`-style calls write a header;
|
|
1284
|
+
// `name.startsWith("sec-fetch-")` or `log("sec-fetch-dest", x)` do not.
|
|
1285
|
+
inspectHeaderEntry(
|
|
1286
|
+
node.arguments[0],
|
|
1287
|
+
staticStringText(node.arguments[0]),
|
|
1288
|
+
node.arguments[1],
|
|
1289
|
+
node.arguments.length >= 2 && HEADER_SETTER_CALLEE_PATTERN.test(calleeName(node) ?? "")
|
|
1290
|
+
? "entry"
|
|
1291
|
+
: false,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (ts.isArrayLiteralExpression(node) && node.elements[0]) {
|
|
1296
|
+
// Only a `[name, value]` tuple is a header: a two-element array in an
|
|
1297
|
+
// entry position (isHeaderEntryPosition) whose second element is not
|
|
1298
|
+
// itself an owned name.
|
|
1299
|
+
const second = node.elements[1];
|
|
1300
|
+
inspectHeaderEntry(
|
|
1301
|
+
node.elements[0],
|
|
1302
|
+
staticStringText(node.elements[0]),
|
|
1303
|
+
second,
|
|
1304
|
+
node.elements.length === 2 &&
|
|
1305
|
+
isHeaderEntryPosition(node) &&
|
|
1306
|
+
!isStealthOwnedHeaderName(staticStringText(second) ?? "")
|
|
1307
|
+
? "entry"
|
|
1308
|
+
: false,
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (
|
|
1313
|
+
ts.isBinaryExpression(node) &&
|
|
1314
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
1315
|
+
ts.isElementAccessExpression(node.left)
|
|
1316
|
+
) {
|
|
1317
|
+
const nameNode = node.left.argumentExpression;
|
|
1318
|
+
inspectHeaderEntry(nameNode, staticStringText(nameNode), node.right, "record");
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
ts.forEachChild(node, visit);
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
visit(sourceFile);
|
|
1325
|
+
if (stealthAccess) {
|
|
1326
|
+
// The owned name is the finding; do not also report its sec-ch-ua value.
|
|
1327
|
+
const shadowedValues = new Set(ownedHeaderEntries.map((entry) => entry.valuePosition));
|
|
1328
|
+
findings = findings.filter(
|
|
1329
|
+
(finding) => !(finding.kind === "sec-ch-ua" && shadowedValues.has(finding.position)),
|
|
1330
|
+
);
|
|
1331
|
+
for (const entry of ownedHeaderEntries) {
|
|
1332
|
+
addFinding("owned-header", entry.name, entry.position);
|
|
1333
|
+
}
|
|
883
1334
|
}
|
|
1335
|
+
return findings.sort((left, right) => left.position - right.position);
|
|
1336
|
+
}
|
|
884
1337
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1338
|
+
function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): string {
|
|
1339
|
+
switch (finding.kind) {
|
|
1340
|
+
case "profile":
|
|
1341
|
+
return `Hardcoded stealth profile "${finding.literal}" pins a browser version and will rot. Select the browser and OS structurally, for example stealth: { browser: "chrome", os: "macos" }.`;
|
|
1342
|
+
case "user-agent":
|
|
1343
|
+
return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal. ctx.stealth owns User-Agent and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED, so omit the header there and select the profile with stealth: { browser: "chrome", os: "macos" }. For ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.`;
|
|
1344
|
+
case "sec-ch-ua":
|
|
1345
|
+
return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from stealth: { browser: "chrome", os: "macos" }. ctx.stealth also owns User-Agent, so omit that header there as well; for ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.';
|
|
1346
|
+
case "owned-header":
|
|
1347
|
+
return `ctx.stealth owns the "${finding.literal}" header and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED (HTTP 500 at request time). Remove it; declare stealth: { requestClass: "navigation" | "form-post" | "xhr" | "post" } to drive Sec-Fetch-* and select the profile with stealth: { browser: "chrome", os: "macos" } for client hints.`;
|
|
892
1348
|
}
|
|
1349
|
+
}
|
|
893
1350
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1351
|
+
function lintBrowserVersionLiterals(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
1352
|
+
const sources: Array<{ field: string; fileName: string; source: string }> = [];
|
|
1353
|
+
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
|
|
1354
|
+
([filePath]) =>
|
|
1355
|
+
JAVASCRIPT_SOURCE_FILE_PATTERN.test(filePath) &&
|
|
1356
|
+
!TEST_SOURCE_FILE_PATTERN.test(filePath) &&
|
|
1357
|
+
!RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(filePath),
|
|
1358
|
+
);
|
|
1359
|
+
if (sourceFiles.length > 0) {
|
|
1360
|
+
for (const [filePath, source] of sourceFiles) {
|
|
1361
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
1362
|
+
}
|
|
1363
|
+
} else {
|
|
1364
|
+
if (provider.authFlowSource)
|
|
1365
|
+
sources.push({
|
|
1366
|
+
field: "auth.flow",
|
|
1367
|
+
fileName: "auth-flow.ts",
|
|
1368
|
+
source: provider.authFlowSource,
|
|
1369
|
+
});
|
|
1370
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1371
|
+
const source = getOperationSource(operation);
|
|
1372
|
+
if (source)
|
|
1373
|
+
sources.push({
|
|
1374
|
+
field: `operations.${operationKey}.handler`,
|
|
1375
|
+
fileName: `${operationKey}.ts`,
|
|
1376
|
+
source,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
901
1379
|
}
|
|
902
1380
|
|
|
903
|
-
return
|
|
1381
|
+
return sources.flatMap(({ field, fileName, source }) =>
|
|
1382
|
+
collectBrowserVersionLiteralFindings(fileName, source).map((finding) => ({
|
|
1383
|
+
rule: "browser-version-literal",
|
|
1384
|
+
level: "error" as const,
|
|
1385
|
+
field,
|
|
1386
|
+
message: browserVersionLiteralMessage(finding),
|
|
1387
|
+
})),
|
|
1388
|
+
);
|
|
904
1389
|
}
|
|
905
1390
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
{
|
|
925
|
-
description?: string;
|
|
926
|
-
descriptionKey?: string;
|
|
927
|
-
whenToUse?: readonly string[];
|
|
928
|
-
whenToUseKeys?: readonly string[];
|
|
929
|
-
whenNotToUse?: readonly string[];
|
|
930
|
-
whenNotToUseKeys?: readonly string[];
|
|
931
|
-
input: unknown;
|
|
932
|
-
output: unknown;
|
|
933
|
-
fixtures?: unknown;
|
|
934
|
-
inputExamples?: readonly unknown[];
|
|
935
|
-
derivations?: Record<string, string>;
|
|
936
|
-
handler?: unknown;
|
|
937
|
-
source?: string;
|
|
938
|
-
}
|
|
939
|
-
>;
|
|
940
|
-
meta?: {
|
|
941
|
-
contract?: ProviderContractMetaLike;
|
|
942
|
-
};
|
|
943
|
-
reviewed?: string;
|
|
944
|
-
},
|
|
945
|
-
options: ProviderLintOptions = {},
|
|
946
|
-
): LintDiagnostic[] {
|
|
947
|
-
const diagnostics: LintDiagnostic[] = [
|
|
948
|
-
...lintAllowedHosts(provider.id, provider.allowedHosts),
|
|
949
|
-
...lintReviewed(provider.id, provider.reviewed),
|
|
950
|
-
...lintAuthModel(provider),
|
|
951
|
-
...lintStealthTransportUsage(provider),
|
|
952
|
-
...lintCredentialWriteUsage(provider),
|
|
953
|
-
...lintPlaywrightDirectImports(provider),
|
|
954
|
-
...lintSelfHostedBrowserPatterns(provider, options),
|
|
955
|
-
];
|
|
956
|
-
|
|
957
|
-
if (provider.operations) {
|
|
958
|
-
const authMode = provider.auth?.mode;
|
|
959
|
-
if (authMode === "credentials" || authMode === "oauth2") {
|
|
960
|
-
for (const operationKey of Object.keys(provider.operations)) {
|
|
961
|
-
if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
|
|
962
|
-
diagnostics.push({
|
|
963
|
-
rule: "auth-operation-unsupported",
|
|
964
|
-
level: "error",
|
|
965
|
-
field: `operations.${operationKey}`,
|
|
966
|
-
message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
|
|
967
|
-
});
|
|
968
|
-
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Skips a string literal starting at `startIndex` (which must point at the
|
|
1393
|
+
* opening quote). Returns the index of the closing quote, or -1 when the
|
|
1394
|
+
* literal is unterminated. Template literals handle nested `${...}`
|
|
1395
|
+
* expressions, including strings inside them.
|
|
1396
|
+
*/
|
|
1397
|
+
function skipStringLiteral(source: string, startIndex: number): number {
|
|
1398
|
+
const quote = source[startIndex];
|
|
1399
|
+
for (let index = startIndex + 1; index < source.length; index++) {
|
|
1400
|
+
const char = source[index];
|
|
1401
|
+
if (char === "\\") {
|
|
1402
|
+
index++;
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (quote === "`" && char === "$" && source[index + 1] === "{") {
|
|
1406
|
+
index = skipTemplateExpression(source, index + 2);
|
|
1407
|
+
if (index < 0) {
|
|
1408
|
+
return -1;
|
|
969
1409
|
}
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (char === quote) {
|
|
1413
|
+
return index;
|
|
1414
|
+
}
|
|
1415
|
+
if (quote !== "`" && char === "\n") {
|
|
1416
|
+
return -1;
|
|
970
1417
|
}
|
|
971
1418
|
}
|
|
1419
|
+
return -1;
|
|
1420
|
+
}
|
|
972
1421
|
|
|
973
|
-
|
|
974
|
-
|
|
1422
|
+
function skipTemplateExpression(source: string, startIndex: number): number {
|
|
1423
|
+
let depth = 1;
|
|
1424
|
+
for (let index = startIndex; index < source.length; index++) {
|
|
1425
|
+
const char = source[index];
|
|
1426
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1427
|
+
index = skipStringLiteral(source, index);
|
|
1428
|
+
if (index < 0) {
|
|
1429
|
+
return -1;
|
|
1430
|
+
}
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
if (char === "{") {
|
|
1434
|
+
depth++;
|
|
1435
|
+
} else if (char === "}") {
|
|
1436
|
+
depth--;
|
|
1437
|
+
if (depth === 0) {
|
|
1438
|
+
return index;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
975
1441
|
}
|
|
1442
|
+
return -1;
|
|
1443
|
+
}
|
|
976
1444
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1445
|
+
/**
|
|
1446
|
+
* Extracts the argument text of a call whose opening paren has already been
|
|
1447
|
+
* consumed (`startIndex` points just past it). Returns undefined when the
|
|
1448
|
+
* call never closes in this source, which the caller treats as "skip
|
|
1449
|
+
* silently" — this scanner is conservative by design.
|
|
1450
|
+
*/
|
|
1451
|
+
function extractBalancedCallArguments(source: string, startIndex: number): string | undefined {
|
|
1452
|
+
let depth = 1;
|
|
1453
|
+
for (let index = startIndex; index < source.length; index++) {
|
|
1454
|
+
const char = source[index];
|
|
1455
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1456
|
+
index = skipStringLiteral(source, index);
|
|
1457
|
+
if (index < 0) {
|
|
1458
|
+
return undefined;
|
|
1459
|
+
}
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (char === "/" && source[index + 1] === "/") {
|
|
1463
|
+
const newline = source.indexOf("\n", index);
|
|
1464
|
+
if (newline === -1) {
|
|
1465
|
+
return undefined;
|
|
1466
|
+
}
|
|
1467
|
+
index = newline;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
if (char === "/" && source[index + 1] === "*") {
|
|
1471
|
+
const end = source.indexOf("*/", index + 2);
|
|
1472
|
+
if (end === -1) {
|
|
1473
|
+
return undefined;
|
|
1474
|
+
}
|
|
1475
|
+
index = end + 1;
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (char === "(") {
|
|
1479
|
+
depth++;
|
|
1480
|
+
} else if (char === ")") {
|
|
1481
|
+
depth--;
|
|
1482
|
+
if (depth === 0) {
|
|
1483
|
+
return source.slice(startIndex, index);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return undefined;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Collects literal string values of the named top-level properties inside a
|
|
1492
|
+
* ProviderError/AuthError/ValidationError options object. Only plain `"..."` /
|
|
1493
|
+
* `'...'` literals at options-object depth count; computed values (identifiers,
|
|
1494
|
+
* ternaries, template substitutions, concatenations, escapes) are skipped
|
|
1495
|
+
* silently so the rules never guess. The first literal wins per property name.
|
|
1496
|
+
*/
|
|
1497
|
+
function collectLiteralErrorOptionValues(
|
|
1498
|
+
args: string,
|
|
1499
|
+
optionNames: readonly string[],
|
|
1500
|
+
): Record<string, string> {
|
|
1501
|
+
const collected: Record<string, string> = {};
|
|
1502
|
+
let braceDepth = 0;
|
|
1503
|
+
let parenDepth = 0;
|
|
1504
|
+
let bracketDepth = 0;
|
|
1505
|
+
let previousSignificantChar = "";
|
|
1506
|
+
for (let index = 0; index < args.length; index++) {
|
|
1507
|
+
const char = args[index] ?? "";
|
|
1508
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1509
|
+
const end = skipStringLiteral(args, index);
|
|
1510
|
+
if (end < 0) {
|
|
1511
|
+
return collected;
|
|
1512
|
+
}
|
|
1513
|
+
index = end;
|
|
1514
|
+
previousSignificantChar = char;
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
if (char === "/" && args[index + 1] === "/") {
|
|
1518
|
+
const newline = args.indexOf("\n", index);
|
|
1519
|
+
if (newline === -1) {
|
|
1520
|
+
return collected;
|
|
1521
|
+
}
|
|
1522
|
+
index = newline;
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
if (char === "/" && args[index + 1] === "*") {
|
|
1526
|
+
const end = args.indexOf("*/", index + 2);
|
|
1527
|
+
if (end === -1) {
|
|
1528
|
+
return collected;
|
|
1529
|
+
}
|
|
1530
|
+
index = end + 1;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
if (/\s/.test(char)) {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (char === "{") {
|
|
1537
|
+
braceDepth++;
|
|
1538
|
+
} else if (char === "}") {
|
|
1539
|
+
braceDepth--;
|
|
1540
|
+
} else if (char === "(") {
|
|
1541
|
+
parenDepth++;
|
|
1542
|
+
} else if (char === ")") {
|
|
1543
|
+
parenDepth--;
|
|
1544
|
+
} else if (char === "[") {
|
|
1545
|
+
bracketDepth++;
|
|
1546
|
+
} else if (char === "]") {
|
|
1547
|
+
bracketDepth--;
|
|
1548
|
+
} else if (
|
|
1549
|
+
braceDepth === 1 &&
|
|
1550
|
+
parenDepth === 0 &&
|
|
1551
|
+
bracketDepth === 0 &&
|
|
1552
|
+
(previousSignificantChar === "{" || previousSignificantChar === ",")
|
|
1553
|
+
) {
|
|
1554
|
+
const optionName = optionNames.find((name) => startsWithOptionName(args, index, name));
|
|
1555
|
+
if (optionName === undefined) {
|
|
1556
|
+
previousSignificantChar = char;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
let cursor = index + optionName.length;
|
|
1560
|
+
while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
|
|
1561
|
+
cursor++;
|
|
1562
|
+
}
|
|
1563
|
+
if (args[cursor] === ":") {
|
|
1564
|
+
cursor++;
|
|
1565
|
+
while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
|
|
1566
|
+
cursor++;
|
|
1567
|
+
}
|
|
1568
|
+
const quote = args[cursor];
|
|
1569
|
+
if (quote === '"' || quote === "'") {
|
|
1570
|
+
const end = skipStringLiteral(args, cursor);
|
|
1571
|
+
if (end > cursor) {
|
|
1572
|
+
const value = args.slice(cursor + 1, end);
|
|
1573
|
+
let after = end + 1;
|
|
1574
|
+
while (after < args.length && /\s/.test(args[after] ?? "")) {
|
|
1575
|
+
after++;
|
|
1576
|
+
}
|
|
1577
|
+
const nextChar = after < args.length ? (args[after] ?? "") : "";
|
|
1578
|
+
if (
|
|
1579
|
+
!value.includes("\\") &&
|
|
1580
|
+
(nextChar === "," || nextChar === "}" || nextChar === "")
|
|
1581
|
+
) {
|
|
1582
|
+
collected[optionName] ??= value;
|
|
1583
|
+
}
|
|
1584
|
+
index = end;
|
|
1585
|
+
previousSignificantChar = quote;
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
return collected;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
previousSignificantChar = char;
|
|
1593
|
+
}
|
|
1594
|
+
return collected;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** True when `name` starts at `index` and is not the prefix of a longer identifier. */
|
|
1598
|
+
function startsWithOptionName(args: string, index: number, name: string): boolean {
|
|
1599
|
+
if (!args.startsWith(name, index)) return false;
|
|
1600
|
+
const next = args[index + name.length] ?? "";
|
|
1601
|
+
return !/[A-Za-z0-9_$]/.test(next);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** One `new ProviderError|AuthError|ValidationError(...)` construction found by source scan. */
|
|
1605
|
+
type ThrownErrorConstructionSite = {
|
|
1606
|
+
/** Constructor name, e.g. `ProviderError`. */
|
|
1607
|
+
readonly errorClass: string;
|
|
1608
|
+
readonly code?: string;
|
|
1609
|
+
readonly messageKey?: string;
|
|
1610
|
+
readonly fixKey?: string;
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
const THROWN_ERROR_LOCALE_OPTION_NAMES = ["code", "messageKey", "fixKey"] as const;
|
|
1614
|
+
|
|
1615
|
+
function collectThrownErrorConstructionSites(source: string): ThrownErrorConstructionSite[] {
|
|
1616
|
+
const sites: ThrownErrorConstructionSite[] = [];
|
|
1617
|
+
THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
|
|
1618
|
+
for (
|
|
1619
|
+
let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source);
|
|
1620
|
+
match;
|
|
1621
|
+
match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)
|
|
1622
|
+
) {
|
|
1623
|
+
const argsStart = match.index + match[0].length;
|
|
1624
|
+
const args = extractBalancedCallArguments(source, argsStart);
|
|
1625
|
+
if (args !== undefined) {
|
|
1626
|
+
const values = collectLiteralErrorOptionValues(args, THROWN_ERROR_LOCALE_OPTION_NAMES);
|
|
1627
|
+
sites.push({
|
|
1628
|
+
errorClass: match[1] ?? "",
|
|
1629
|
+
...(values.code === undefined ? {} : { code: values.code }),
|
|
1630
|
+
...(values.messageKey === undefined ? {} : { messageKey: values.messageKey }),
|
|
1631
|
+
...(values.fixKey === undefined ? {} : { fixKey: values.fixKey }),
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
|
|
1635
|
+
}
|
|
1636
|
+
return sites;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
type ThrowSiteLintSource = { readonly field: string; readonly source: string };
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* The provider sources a throw-site rule scans, with the diagnostic field path
|
|
1643
|
+
* each one reports under. Prefers the CLI-collected source files (whole-repo
|
|
1644
|
+
* coverage) and falls back to the handler/auth-flow function text available
|
|
1645
|
+
* when `lintProvider` is called without them. Test sources are excluded.
|
|
1646
|
+
*/
|
|
1647
|
+
function collectThrowSiteLintSources(provider: {
|
|
1648
|
+
authFlowSource?: string;
|
|
1649
|
+
providerSourceFiles?: Record<string, string>;
|
|
1650
|
+
operations?: Record<string, { handler?: unknown; source?: string }>;
|
|
1651
|
+
}): ThrowSiteLintSource[] {
|
|
1652
|
+
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
|
|
1653
|
+
([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
|
|
1654
|
+
);
|
|
1655
|
+
if (sourceFiles.length > 0) {
|
|
1656
|
+
return sourceFiles.map(([filePath, source]) => ({
|
|
1657
|
+
field: `sourceFiles.${filePath}`,
|
|
1658
|
+
source,
|
|
1659
|
+
}));
|
|
1660
|
+
}
|
|
1661
|
+
const sources: ThrowSiteLintSource[] = [];
|
|
1662
|
+
if (provider.authFlowSource) {
|
|
1663
|
+
sources.push({ field: "auth.flow", source: provider.authFlowSource });
|
|
1664
|
+
}
|
|
1665
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1666
|
+
const source = getOperationSource(operation);
|
|
1667
|
+
if (source) {
|
|
1668
|
+
sources.push({ field: `operations.${operationKey}.handler`, source });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return sources;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Static counterpart of the runtime `unregistered_provider_error_code`
|
|
1676
|
+
* signal (honest-provider-error-contract Phase 3.5.5): flags
|
|
1677
|
+
* `new ProviderError(...)` / `new ValidationError(...)` constructions whose
|
|
1678
|
+
* literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
|
|
1679
|
+
* plus the canonical status-mapped codes shared with serve.ts toStatusCode)
|
|
1680
|
+
* nor declared in any operation's errorCodes. At runtime such a code
|
|
1681
|
+
* serves HTTP 500 and emits the signal; this rule surfaces it at check time.
|
|
1682
|
+
*
|
|
1683
|
+
* A throw site cannot be attributed to a specific operation statically —
|
|
1684
|
+
* providers routinely throw from helpers shared across operations — so this
|
|
1685
|
+
* rule matches against the provider-level union of declared codes. That is
|
|
1686
|
+
* the honest scope: it will not catch a code declared only on the "wrong"
|
|
1687
|
+
* operation, and it never claims per-operation attribution it cannot prove.
|
|
1688
|
+
* Only literal string codes are checked; computed/dynamic codes and test
|
|
1689
|
+
* sources are skipped silently. Warning level: the long tail of existing
|
|
1690
|
+
* providers converges gradually, so this must not fail `apifuse check`.
|
|
1691
|
+
*/
|
|
1692
|
+
function lintUndeclaredThrownErrorCodes(provider: {
|
|
1693
|
+
authFlowSource?: string;
|
|
1694
|
+
providerSourceFiles?: Record<string, string>;
|
|
1695
|
+
operations?: Record<
|
|
1696
|
+
string,
|
|
1697
|
+
{
|
|
1698
|
+
handler?: unknown;
|
|
1699
|
+
source?: string;
|
|
1700
|
+
errorCodes?: ReadonlyArray<{ code: string }>;
|
|
1701
|
+
}
|
|
1702
|
+
>;
|
|
1703
|
+
}): LintDiagnostic[] {
|
|
1704
|
+
const knownCodes = new Set<string>([
|
|
1705
|
+
...SDK_RUNTIME_OWNED_ERROR_CODES,
|
|
1706
|
+
...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
|
|
1707
|
+
]);
|
|
1708
|
+
for (const operation of Object.values(provider.operations ?? {})) {
|
|
1709
|
+
for (const entry of operation.errorCodes ?? []) {
|
|
1710
|
+
if (typeof entry?.code === "string") {
|
|
1711
|
+
knownCodes.add(entry.code);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const sources = collectThrowSiteLintSources(provider);
|
|
1717
|
+
|
|
1718
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
1719
|
+
for (const { field, source } of sources) {
|
|
1720
|
+
const undeclaredCodes = new Set(
|
|
1721
|
+
collectThrownErrorConstructionSites(source)
|
|
1722
|
+
.filter((site) => UNDECLARED_CODE_ERROR_CLASSES.has(site.errorClass))
|
|
1723
|
+
.flatMap((site) => (site.code === undefined ? [] : [site.code]))
|
|
1724
|
+
.filter((code) => !knownCodes.has(code)),
|
|
1725
|
+
);
|
|
1726
|
+
for (const code of undeclaredCodes) {
|
|
1727
|
+
diagnostics.push({
|
|
1728
|
+
rule: "thrown-error-code-undeclared",
|
|
1729
|
+
level: "warn",
|
|
1730
|
+
field,
|
|
1731
|
+
message: `Thrown error code "${code}" (${field}) is neither SDK-registered nor declared in any operation's errorCodes; at runtime it serves HTTP 500 and emits the unregistered_provider_error_code signal. Declare it in the owning operation's errorCodes with status and retryable.`,
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return diagnostics;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Flags an operation `errorCodes` entry whose declared `status` or `retryable`
|
|
1740
|
+
* contradicts the SDK's canonical resolution for that code.
|
|
1741
|
+
*
|
|
1742
|
+
* For a code the SDK status-maps but does not runtime-own (UPSTREAM_ERROR,
|
|
1743
|
+
* BLOCKED, and the fleet-consensus codes registered alongside them), the
|
|
1744
|
+
* declaration still wins at runtime — `toStatusCode` reads the declaration
|
|
1745
|
+
* before the canonical map. So registering a code never silently rewrites an
|
|
1746
|
+
* existing declaration's status, and it must not: that would change a served
|
|
1747
|
+
* status behind the author's back. What it does create is a fleet that answers
|
|
1748
|
+
* one code two ways. This rule is where that divergence surfaces, by name, with
|
|
1749
|
+
* the canonical value to converge on.
|
|
1750
|
+
*
|
|
1751
|
+
* Codes the SDK runtime-owns are handled separately: `defineProvider` already
|
|
1752
|
+
* warns there, because for those the declaration really is ignored.
|
|
1753
|
+
*
|
|
1754
|
+
* Warning level, deliberately: the divergent providers are live and converge on
|
|
1755
|
+
* their own schedule, so this must not fail `apifuse check`.
|
|
1756
|
+
*/
|
|
1757
|
+
function lintErrorCodeDeclarationConflicts(provider: {
|
|
1758
|
+
id?: string;
|
|
1759
|
+
operations?: Record<
|
|
1760
|
+
string,
|
|
1761
|
+
{
|
|
1762
|
+
errorCodes?: ReadonlyArray<{ code: string; status?: number; retryable?: boolean }>;
|
|
1763
|
+
}
|
|
1764
|
+
>;
|
|
1765
|
+
}): LintDiagnostic[] {
|
|
1766
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
1767
|
+
const providerId = provider.id ?? "unknown";
|
|
1768
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1769
|
+
for (const [index, entry] of (operation.errorCodes ?? []).entries()) {
|
|
1770
|
+
if (typeof entry?.code !== "string") continue;
|
|
1771
|
+
// SDK-owned codes ignore the declaration outright; defineProvider owns
|
|
1772
|
+
// that message so authors are not told two different things.
|
|
1773
|
+
if (SDK_RUNTIME_OWNED_ERROR_CODES.has(entry.code)) continue;
|
|
1774
|
+
|
|
1775
|
+
const field = `operations.${operationKey}.errorCodes[${index}]`;
|
|
1776
|
+
const canonicalStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(entry.code);
|
|
1777
|
+
if (canonicalStatus !== undefined && entry.status !== undefined) {
|
|
1778
|
+
if (entry.status !== canonicalStatus) {
|
|
1779
|
+
diagnostics.push({
|
|
1780
|
+
rule: "error-code-status-conflicts-sdk",
|
|
1781
|
+
level: "warn",
|
|
1782
|
+
field: `${field}.status`,
|
|
1783
|
+
message: `Provider "${providerId}" operation "${operationKey}" declares status ${entry.status} for SDK-registered error code "${entry.code}", whose canonical status is ${canonicalStatus}. The declaration still wins at runtime, so this operation serves ${entry.status} while the rest of the fleet serves ${canonicalStatus} for the same code. Drop the status (the SDK supplies ${canonicalStatus}) or, if ${entry.status} is genuinely the right answer here, throw a distinct code that means that.`,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const canonicalRetryable = SDK_CANONICAL_ERROR_CODE_RETRYABILITY.get(entry.code);
|
|
1789
|
+
if (
|
|
1790
|
+
canonicalRetryable !== undefined &&
|
|
1791
|
+
entry.retryable !== undefined &&
|
|
1792
|
+
entry.retryable !== canonicalRetryable
|
|
1793
|
+
) {
|
|
1794
|
+
diagnostics.push({
|
|
1795
|
+
rule: "error-code-retryable-conflicts-sdk",
|
|
1796
|
+
level: "warn",
|
|
1797
|
+
field: `${field}.retryable`,
|
|
1798
|
+
message: `Provider "${providerId}" operation "${operationKey}" declares retryable ${entry.retryable} for SDK-registered error code "${entry.code}", whose canonical retryability is ${canonicalRetryable}. The declaration still wins at runtime, so callers and the Gateway retry policy get a different answer here than everywhere else the same code is thrown. Drop the retryable (the SDK supplies ${canonicalRetryable}) or throw a distinct code for the retryable condition.`,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return diagnostics;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/** Declared `errorCodes[]` entry shape the localization rules read. */
|
|
1807
|
+
type OperationErrorCodeLike = {
|
|
1808
|
+
code: string;
|
|
1809
|
+
messageKey?: string;
|
|
1810
|
+
fixKey?: string;
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
type ErrorLocaleKeyStatus = "ok" | "missing" | "malformed";
|
|
1814
|
+
|
|
1815
|
+
function errorLocaleKeyStatus(catalog: ProviderLocaleCatalog, key: string): ErrorLocaleKeyStatus {
|
|
1816
|
+
if (!isProviderLocaleKey(key)) return "malformed";
|
|
1817
|
+
const value = getProviderLocaleSegments(catalog, key.split("."));
|
|
1818
|
+
return typeof value === "string" && value.trim().length > 0 ? "ok" : "missing";
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function hasDerivedErrorCatalogText(
|
|
1822
|
+
catalog: ProviderLocaleCatalog,
|
|
1823
|
+
code: string,
|
|
1824
|
+
field: "message" | "fix",
|
|
1825
|
+
): boolean {
|
|
1826
|
+
const value = getProviderLocaleSegments(catalog, [PROVIDER_ERROR_CATALOG_NAMESPACE, code, field]);
|
|
1827
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* True when a thrown code reaches the caller as a real provider error whose
|
|
1832
|
+
* text the declared/derived catalog path can actually reach.
|
|
1833
|
+
*
|
|
1834
|
+
* A code qualifies either because the provider declares it in an operation's
|
|
1835
|
+
* `errorCodes`, or because the SDK registers a canonical status for it. The
|
|
1836
|
+
* second arm matters because registering a code is precisely what lets a
|
|
1837
|
+
* provider *delete* the declaration — AUTHORING tells it to for
|
|
1838
|
+
* `UPSTREAM_AUTH_ERROR`, `UPSTREAM_SCHEMA_ERROR` and `INVALID_REQUEST` — and an
|
|
1839
|
+
* undeclared throw of a registered code is no longer the HTTP 500 that
|
|
1840
|
+
* `thrown-error-code-undeclared` reports. Without this arm the two rules leave
|
|
1841
|
+
* a silent hole exactly where the fleet is being told to converge: a 502
|
|
1842
|
+
* `UPSTREAM_SCHEMA_ERROR` served untranslated with nothing warning about it.
|
|
1843
|
+
*
|
|
1844
|
+
* Codes the SDK runtime-owns are excluded: `sdkOwnsErrorResolution` skips the
|
|
1845
|
+
* declaration and derived candidates for them at serve time, so the catalog
|
|
1846
|
+
* entry this rule asks for would never take effect.
|
|
1847
|
+
*/
|
|
1848
|
+
function isCatalogReachableThrownCode(code: string, declaredCodes: ReadonlySet<string>): boolean {
|
|
1849
|
+
if (declaredCodes.has(code)) return true;
|
|
1850
|
+
return (
|
|
1851
|
+
!SDK_RUNTIME_OWNED_ERROR_CODES.has(code) && SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.has(code)
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* Authoring rules for localized provider error text (see
|
|
1857
|
+
* `ProviderErrorOptions.messageKey`).
|
|
1858
|
+
*
|
|
1859
|
+
* - `error-locale-key-missing` / `error-locale-key-malformed` (**error**): a
|
|
1860
|
+
* literal `messageKey`/`fixKey`, whether on a throw site or on an
|
|
1861
|
+
* `errorCodes[]` declaration, that the provider's `en` catalog does not
|
|
1862
|
+
* resolve to non-empty text, or that is not a legal locale dot path. At
|
|
1863
|
+
* runtime such a key is silently skipped and the caller gets the English
|
|
1864
|
+
* literal, so nothing fails loudly without this rule.
|
|
1865
|
+
* - `thrown-error-message-not-localized` (**warn**): a throw site whose literal
|
|
1866
|
+
* `code` is declared in the provider's `errorCodes` — or SDK-registered, and
|
|
1867
|
+
* so servable without a declaration — but that carries no `messageKey`, no
|
|
1868
|
+
* declaration-level `messageKey`, and no derived `errors.<code>.message` in
|
|
1869
|
+
* `en`. Such an error is served untranslated to every caller. Warning while
|
|
1870
|
+
* the ~2,000 existing sites migrate; it is promoted to error per provider as
|
|
1871
|
+
* each migration wave lands.
|
|
1872
|
+
*
|
|
1873
|
+
* Both rules are skipped entirely when the caller did not supply
|
|
1874
|
+
* `localeCatalogEn`: without the catalog they could only guess.
|
|
1875
|
+
*/
|
|
1876
|
+
function lintErrorMessageLocalization(provider: {
|
|
1877
|
+
authFlowSource?: string;
|
|
1878
|
+
providerSourceFiles?: Record<string, string>;
|
|
1879
|
+
localeCatalogEn?: ProviderLocaleCatalog;
|
|
1880
|
+
operations?: Record<
|
|
1881
|
+
string,
|
|
1882
|
+
{
|
|
1883
|
+
handler?: unknown;
|
|
1884
|
+
source?: string;
|
|
1885
|
+
errorCodes?: ReadonlyArray<OperationErrorCodeLike>;
|
|
1886
|
+
}
|
|
1887
|
+
>;
|
|
1888
|
+
}): LintDiagnostic[] {
|
|
1889
|
+
const catalog = provider.localeCatalogEn;
|
|
1890
|
+
if (!catalog) return [];
|
|
1891
|
+
|
|
1892
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
1893
|
+
const declaredCodes = new Set<string>();
|
|
1894
|
+
const codesWithDeclaredMessageKey = new Set<string>();
|
|
1895
|
+
|
|
1896
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1897
|
+
for (const entry of operation.errorCodes ?? []) {
|
|
1898
|
+
if (typeof entry?.code !== "string") continue;
|
|
1899
|
+
declaredCodes.add(entry.code);
|
|
1900
|
+
for (const keyField of ["messageKey", "fixKey"] as const) {
|
|
1901
|
+
const key = entry[keyField];
|
|
1902
|
+
if (typeof key !== "string") continue;
|
|
1903
|
+
if (keyField === "messageKey") codesWithDeclaredMessageKey.add(entry.code);
|
|
1904
|
+
const status = errorLocaleKeyStatus(catalog, key);
|
|
1905
|
+
if (status === "ok") continue;
|
|
1906
|
+
diagnostics.push(
|
|
1907
|
+
errorLocaleKeyDiagnostic(
|
|
1908
|
+
status,
|
|
1909
|
+
`operations.${operationKey}.errorCodes`,
|
|
1910
|
+
key,
|
|
1911
|
+
`errorCodes entry "${entry.code}" of operation "${operationKey}"`,
|
|
1912
|
+
),
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
for (const { field, source } of collectThrowSiteLintSources(provider)) {
|
|
1919
|
+
const reportedKeys = new Set<string>();
|
|
1920
|
+
const reportedCodes = new Set<string>();
|
|
1921
|
+
for (const site of collectThrownErrorConstructionSites(source)) {
|
|
1922
|
+
for (const key of [site.messageKey, site.fixKey]) {
|
|
1923
|
+
if (key === undefined || reportedKeys.has(key)) continue;
|
|
1924
|
+
const status = errorLocaleKeyStatus(catalog, key);
|
|
1925
|
+
if (status === "ok") continue;
|
|
1926
|
+
reportedKeys.add(key);
|
|
1927
|
+
diagnostics.push(
|
|
1928
|
+
errorLocaleKeyDiagnostic(status, field, key, `a thrown error in ${field}`),
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
const code = site.code;
|
|
1932
|
+
if (code === undefined || reportedCodes.has(code)) continue;
|
|
1933
|
+
if (site.messageKey !== undefined) continue;
|
|
1934
|
+
if (!isCatalogReachableThrownCode(code, declaredCodes)) continue;
|
|
1935
|
+
if (codesWithDeclaredMessageKey.has(code)) continue;
|
|
1936
|
+
if (hasDerivedErrorCatalogText(catalog, code, "message")) continue;
|
|
1937
|
+
reportedCodes.add(code);
|
|
1938
|
+
const origin = declaredCodes.has(code)
|
|
1939
|
+
? `declared code "${code}"`
|
|
1940
|
+
: `SDK-registered code "${code}"`;
|
|
1941
|
+
const declarationClause = declaredCodes.has(code)
|
|
1942
|
+
? "its errorCodes declaration has none, and"
|
|
1943
|
+
: "no errorCodes declaration supplies one, and";
|
|
1944
|
+
diagnostics.push({
|
|
1945
|
+
rule: "thrown-error-message-not-localized",
|
|
1946
|
+
level: "warn",
|
|
1947
|
+
field,
|
|
1948
|
+
message: `${site.errorClass} with ${origin} (${field}) has no messageKey, ${declarationClause} locales/en.json has no "${PROVIDER_ERROR_CATALOG_NAMESPACE}.${code}.message"; this error is served untranslated to ko and ja callers. Add messageKey (or "${PROVIDER_ERROR_CATALOG_NAMESPACE}.${code}.message" to every locale catalog).`,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
return diagnostics;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function errorLocaleKeyDiagnostic(
|
|
1957
|
+
status: Exclude<ErrorLocaleKeyStatus, "ok">,
|
|
1958
|
+
field: string,
|
|
1959
|
+
key: string,
|
|
1960
|
+
owner: string,
|
|
1961
|
+
): LintDiagnostic {
|
|
1962
|
+
return status === "malformed"
|
|
1963
|
+
? {
|
|
1964
|
+
rule: "error-locale-key-malformed",
|
|
1965
|
+
level: "error",
|
|
1966
|
+
field,
|
|
1967
|
+
message: `Locale key ${JSON.stringify(key)} on ${owner} is not a locale dot path such as "errors.upstreamSchema.message" or "errors.UPSTREAM_SCHEMA_ERROR.message"; segments are camelCase, an array index, or — directly under "errors" — the error code itself; the SDK skips a malformed key at serve time and the caller gets the English literal instead.`,
|
|
1968
|
+
}
|
|
1969
|
+
: {
|
|
1970
|
+
rule: "error-locale-key-missing",
|
|
1971
|
+
level: "error",
|
|
1972
|
+
field,
|
|
1973
|
+
message: `Locale key ${JSON.stringify(key)} on ${owner} is missing from locales/en.json (or resolves to a non-string/empty value); the SDK falls back to the English literal at serve time, so the key never takes effect.`,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
type HandleFieldOccurrence = {
|
|
1978
|
+
/** Diagnostic path, e.g. `output.offers[].waiting_token`. */
|
|
1979
|
+
path: string;
|
|
1980
|
+
/** Property key the field is declared under (inherited through arrays/wrappers). */
|
|
1981
|
+
key: string;
|
|
1982
|
+
meta: HandleFieldMeta;
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
function collectHandleFields(
|
|
1986
|
+
schema: unknown,
|
|
1987
|
+
basePath: string,
|
|
1988
|
+
key: string | undefined = undefined,
|
|
1989
|
+
seen = new Set<SchemaLike>(),
|
|
1990
|
+
): HandleFieldOccurrence[] {
|
|
1991
|
+
if (!isSchema(schema)) return [];
|
|
1992
|
+
|
|
1993
|
+
// Record before the cycle guard so one `kind.field()` instance reused under
|
|
1994
|
+
// two property keys is still reported at both positions.
|
|
1995
|
+
const own = getOwnHandleFieldMeta(schema);
|
|
1996
|
+
if (own && key !== undefined) {
|
|
1997
|
+
return [{ path: basePath, key, meta: own }];
|
|
1998
|
+
}
|
|
1999
|
+
// Path-local cycle guard: the same wrapped schema instance may legitimately
|
|
2000
|
+
// appear under several property keys (`Kind.field().optional()` reused), and
|
|
2001
|
+
// each position must be checked. Only true ancestor cycles are cut.
|
|
2002
|
+
if (seen.has(schema)) return [];
|
|
2003
|
+
seen.add(schema);
|
|
2004
|
+
try {
|
|
2005
|
+
return collectHandleFieldChildren(schema, basePath, key, seen);
|
|
2006
|
+
} finally {
|
|
2007
|
+
seen.delete(schema);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function collectHandleFieldChildren(
|
|
2012
|
+
schema: SchemaLike,
|
|
2013
|
+
basePath: string,
|
|
2014
|
+
key: string | undefined,
|
|
2015
|
+
seen: Set<SchemaLike>,
|
|
2016
|
+
): HandleFieldOccurrence[] {
|
|
2017
|
+
const out: HandleFieldOccurrence[] = [];
|
|
2018
|
+
// zod exposes the same inner schema under several structural edges
|
|
2019
|
+
// (`unwrap`/`innerType`, `element`/`def.element`); traverse each structural
|
|
2020
|
+
// child object once. Shape children are always traversed: the same instance
|
|
2021
|
+
// under two property keys is two fields.
|
|
2022
|
+
const structuralSeen = new Set<SchemaLike>();
|
|
2023
|
+
for (const child of getChildSchemas(schema)) {
|
|
2024
|
+
if (child.origin !== "shape") {
|
|
2025
|
+
if (structuralSeen.has(child.schema)) continue;
|
|
2026
|
+
structuralSeen.add(child.schema);
|
|
2027
|
+
}
|
|
2028
|
+
if (child.origin === "shape") {
|
|
2029
|
+
// Provider-chosen property key: always a real field, even when it is
|
|
2030
|
+
// spelled like a structural edge (`item`, `type`, `schema`, ...).
|
|
2031
|
+
out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
|
|
2032
|
+
} else if (isSchemaWrapperNodeKey(child.key)) {
|
|
2033
|
+
out.push(...collectHandleFields(child.schema, basePath, key, seen));
|
|
2034
|
+
} else if (child.key === "element" || child.key.startsWith("element.")) {
|
|
2035
|
+
out.push(...collectHandleFields(child.schema, `${basePath}[]`, key, seen));
|
|
2036
|
+
} else if (/^\d+$/.test(child.key)) {
|
|
2037
|
+
out.push(...collectHandleFields(child.schema, `${basePath}[${child.key}]`, key, seen));
|
|
2038
|
+
} else {
|
|
2039
|
+
out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
return out;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Reads `provider.handle` structurally. The declaration type is owned by
|
|
2047
|
+
* define.ts/types.ts; lint only needs the HandleKindDeclaration view, so
|
|
2048
|
+
* malformed entries are skipped rather than reported.
|
|
2049
|
+
*/
|
|
2050
|
+
function readHandleKindDeclarations(value: unknown): HandleKindDeclaration[] {
|
|
2051
|
+
if (!Array.isArray(value)) return [];
|
|
2052
|
+
return value.filter((entry): entry is HandleKindDeclaration => {
|
|
2053
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
2054
|
+
const record = entry as Record<string, unknown>;
|
|
2055
|
+
return (
|
|
2056
|
+
typeof record.name === "string" &&
|
|
2057
|
+
(record.type === "cursor" || record.type === "draft") &&
|
|
2058
|
+
typeof record.fieldName === "string" &&
|
|
2059
|
+
(record.outputFieldName === undefined || typeof record.outputFieldName === "string") &&
|
|
2060
|
+
(record.issuedBy === undefined || isHandleIssuedBy(record.issuedBy))
|
|
2061
|
+
);
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/**
|
|
2066
|
+
* ADR-0012 handle rules: every `x-apifuse-handle` schema field must belong to
|
|
2067
|
+
* a declared kind under its declared property key; every declared kind must be
|
|
2068
|
+
* both issued (some output) and accepted (some input); every `issuedBy` entry
|
|
2069
|
+
* must be an operation key and at least one of them must output the field; and
|
|
2070
|
+
* `ctx.handle` needs `state`.
|
|
2071
|
+
*/
|
|
2072
|
+
function lintHandleDeclarations(provider: {
|
|
2073
|
+
id?: string;
|
|
2074
|
+
handle?: unknown;
|
|
2075
|
+
state?: unknown;
|
|
2076
|
+
operations?: Record<string, { input: unknown; output: unknown }>;
|
|
2077
|
+
}): LintDiagnostic[] {
|
|
2078
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
2079
|
+
const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
|
|
2080
|
+
const kinds = readHandleKindDeclarations(provider.handle);
|
|
2081
|
+
const kindByName = new Map(kinds.map((kind) => [kind.name, kind]));
|
|
2082
|
+
const operations = provider.operations ?? {};
|
|
2083
|
+
|
|
2084
|
+
if (kinds.length > 0 && !provider.state) {
|
|
2085
|
+
diagnostics.push({
|
|
2086
|
+
rule: "handle-requires-state",
|
|
2087
|
+
level: "warn",
|
|
2088
|
+
field: "handle",
|
|
2089
|
+
message: `${providerLabel} declares handle kinds but not the state capability; ctx.handle stores records through ctx.state, so declare state: true.`,
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const kindsInInputs = new Set<string>();
|
|
2094
|
+
const kindsInOutputs = new Set<string>();
|
|
2095
|
+
const outputKindsByOperation = new Map<string, Set<string>>();
|
|
2096
|
+
|
|
2097
|
+
for (const [operationKey, operation] of Object.entries(operations)) {
|
|
2098
|
+
const positions = [
|
|
2099
|
+
{ side: "input", fields: collectHandleFields(operation.input, "input") },
|
|
2100
|
+
{ side: "output", fields: collectHandleFields(operation.output, "output") },
|
|
2101
|
+
] as const;
|
|
2102
|
+
for (const { side, fields } of positions) {
|
|
2103
|
+
for (const occurrence of fields) {
|
|
2104
|
+
const field = `operations.${operationKey}.${occurrence.path}`;
|
|
2105
|
+
const { kind } = occurrence.meta;
|
|
2106
|
+
// A kind may name one handle differently in each direction (the canon's
|
|
2107
|
+
// `cursor` in, `next_cursor` out). The rule is still exact: each side
|
|
2108
|
+
// must use the key declared for THAT side, so the handle's identity
|
|
2109
|
+
// stays readable from the property key and the round trip has exactly
|
|
2110
|
+
// one edge — read `output`, send back under `input`.
|
|
2111
|
+
const expected = handleFieldNameFor(occurrence.meta, side);
|
|
2112
|
+
|
|
2113
|
+
if (!kindByName.has(kind)) {
|
|
2114
|
+
diagnostics.push({
|
|
2115
|
+
rule: "handle-kind-undeclared",
|
|
2116
|
+
level: "error",
|
|
2117
|
+
field,
|
|
2118
|
+
message: `${providerLabel} operation "${operationKey}" ${side} field "${occurrence.path}" is a handle of kind "${kind}", which is not declared in provider.handle. Add the kind to handle: [...] or remove the field.`,
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
if (occurrence.key !== expected) {
|
|
2123
|
+
const directional = handleHasDirectionalFieldNames(occurrence.meta);
|
|
2124
|
+
const advice = directional
|
|
2125
|
+
? `Use the kind's ${side} fieldName on every ${side} schema; its ${side === "input" ? "output" : "input"} fieldName is "${handleFieldNameFor(occurrence.meta, side === "input" ? "output" : "input")}".`
|
|
2126
|
+
: "Use the same key in every schema so the LLM sees one name.";
|
|
2127
|
+
diagnostics.push({
|
|
2128
|
+
rule: "handle-field-name",
|
|
2129
|
+
level: "error",
|
|
2130
|
+
field,
|
|
2131
|
+
message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's ${directional ? `${side} ` : ""}fieldName is "${expected}". ${advice}`,
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
if (side === "input") {
|
|
2136
|
+
kindsInInputs.add(kind);
|
|
2137
|
+
} else {
|
|
2138
|
+
kindsInOutputs.add(kind);
|
|
2139
|
+
let outputKinds = outputKindsByOperation.get(operationKey);
|
|
2140
|
+
if (!outputKinds) {
|
|
2141
|
+
outputKinds = new Set<string>();
|
|
2142
|
+
outputKindsByOperation.set(operationKey, outputKinds);
|
|
2143
|
+
}
|
|
2144
|
+
outputKinds.add(kind);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
for (const kind of kinds) {
|
|
2151
|
+
const field = `handle.${kind.name}`;
|
|
2152
|
+
const issued = kindsInOutputs.has(kind.name);
|
|
2153
|
+
const accepted = kindsInInputs.has(kind.name);
|
|
2154
|
+
|
|
2155
|
+
if (!issued) {
|
|
2156
|
+
diagnostics.push({
|
|
2157
|
+
rule: "handle-field-parity",
|
|
2158
|
+
level: "error",
|
|
2159
|
+
field,
|
|
2160
|
+
message: `${providerLabel} handle kind "${kind.name}" is never issued: no operation output contains its field "${handleFieldNameFor(kind, "output")}". Add ${kind.name}.field() to the issuing operation's output.`,
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
if (!accepted) {
|
|
2164
|
+
diagnostics.push({
|
|
2165
|
+
rule: "handle-field-parity",
|
|
2166
|
+
level: "error",
|
|
2167
|
+
field,
|
|
2168
|
+
message: `${providerLabel} handle kind "${kind.name}" is never accepted: no operation input contains its field "${handleFieldNameFor(kind, "input")}". Add ${kind.name}.field() to the consuming operation's input.`,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
if (kind.issuedBy === undefined) continue;
|
|
2173
|
+
const issuers = handleIssuerList(kind.issuedBy);
|
|
2174
|
+
const known = issuers.filter((operationKey) => Object.hasOwn(operations, operationKey));
|
|
2175
|
+
for (const operationKey of issuers) {
|
|
2176
|
+
if (known.includes(operationKey)) continue;
|
|
2177
|
+
diagnostics.push({
|
|
2178
|
+
rule: "handle-issued-by",
|
|
2179
|
+
level: "error",
|
|
2180
|
+
field,
|
|
2181
|
+
message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${operationKey}", which is not an operation key.`,
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
if (known.length === 0) continue;
|
|
2185
|
+
const issuing = known.filter((operationKey) =>
|
|
2186
|
+
outputKindsByOperation.get(operationKey)?.has(kind.name),
|
|
2187
|
+
);
|
|
2188
|
+
if (issuing.length > 0) continue;
|
|
2189
|
+
if (known.length === 1) {
|
|
2190
|
+
diagnostics.push({
|
|
2191
|
+
rule: "handle-issued-by",
|
|
2192
|
+
level: "error",
|
|
2193
|
+
field,
|
|
2194
|
+
message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${handleFieldNameFor(kind, "output")}" handle field.`,
|
|
2195
|
+
});
|
|
2196
|
+
} else {
|
|
2197
|
+
diagnostics.push({
|
|
2198
|
+
rule: "handle-issued-by",
|
|
2199
|
+
level: "error",
|
|
2200
|
+
field,
|
|
2201
|
+
message: `${providerLabel} handle kind "${kind.name}" declares issuedBy ${known
|
|
2202
|
+
.map((operationKey) => `"${operationKey}"`)
|
|
2203
|
+
.join(
|
|
2204
|
+
", ",
|
|
2205
|
+
)}, but none of those operations' outputs has a "${handleFieldNameFor(kind, "output")}" handle field. At least one listed issuer must output it.`,
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
return diagnostics;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
const LEGACY_CHOICE_USAGE_PATTERN =
|
|
2214
|
+
/\bctx\.choice\b|\b(?:createProviderChoiceToken|parseProviderChoiceToken|ProviderChoiceTokenError|createTestProviderChoiceContext)\b/;
|
|
2215
|
+
|
|
2216
|
+
const LEGACY_CHOICE_USAGE_MESSAGE =
|
|
2217
|
+
"Choice tokens were replaced by handles (ADR-0012). See docs/migrations/handle.md.";
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* The inline choice-token API was removed outright (no aliases). Any surviving
|
|
2221
|
+
* reference in provider source would fail at runtime, so report it at check time.
|
|
2222
|
+
*/
|
|
2223
|
+
const LEGACY_CHOICE_IDENTIFIERS = new Set([
|
|
2224
|
+
"createProviderChoiceToken",
|
|
2225
|
+
"parseProviderChoiceToken",
|
|
2226
|
+
"ProviderChoiceTokenError",
|
|
2227
|
+
"createTestProviderChoiceContext",
|
|
2228
|
+
]);
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* Finds the first legacy choice API reference in executable code. Uses the
|
|
2232
|
+
* TypeScript AST when available so comments, string and template literal text
|
|
2233
|
+
* never count (a migration note such as `// moved from ctx.choice` must not fail
|
|
2234
|
+
* the provider) while code inside `${}` interpolations still does. Falls back
|
|
2235
|
+
* to a raw-source regex when TypeScript is not installed.
|
|
2236
|
+
*/
|
|
2237
|
+
function findLegacyChoiceUsage(fileName: string, source: string): string | undefined {
|
|
2238
|
+
let ts: typeof import("typescript");
|
|
2239
|
+
try {
|
|
2240
|
+
ts = getTypeScript();
|
|
2241
|
+
} catch {
|
|
2242
|
+
return LEGACY_CHOICE_USAGE_PATTERN.exec(source)?.[0];
|
|
2243
|
+
}
|
|
2244
|
+
const file = ts.createSourceFile(
|
|
2245
|
+
fileName,
|
|
2246
|
+
source,
|
|
2247
|
+
ts.ScriptTarget.Latest,
|
|
2248
|
+
false,
|
|
2249
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2250
|
+
);
|
|
2251
|
+
let found: string | undefined;
|
|
2252
|
+
const visit = (node: import("typescript").Node): void => {
|
|
2253
|
+
if (found) return;
|
|
2254
|
+
if (ts.isIdentifier(node) && LEGACY_CHOICE_IDENTIFIERS.has(node.text)) {
|
|
2255
|
+
found = node.text;
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
if (
|
|
2259
|
+
ts.isPropertyAccessExpression(node) &&
|
|
2260
|
+
ts.isIdentifier(node.expression) &&
|
|
2261
|
+
node.expression.text === "ctx" &&
|
|
2262
|
+
node.name.text === "choice"
|
|
2263
|
+
) {
|
|
2264
|
+
found = "ctx.choice";
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
ts.forEachChild(node, visit);
|
|
2268
|
+
};
|
|
2269
|
+
visit(file);
|
|
2270
|
+
return found;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function lintLegacyChoiceUsage(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
2274
|
+
const sources: Array<{ field: string; fileName: string; source: string }> = [];
|
|
2275
|
+
|
|
2276
|
+
if (provider.authFlowSource) {
|
|
2277
|
+
sources.push({ field: "auth.flow", fileName: "auth-flow.ts", source: provider.authFlowSource });
|
|
2278
|
+
}
|
|
2279
|
+
for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
|
|
2280
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
2281
|
+
}
|
|
2282
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
2283
|
+
const source = getOperationSource(operation);
|
|
2284
|
+
if (source) {
|
|
2285
|
+
sources.push({
|
|
2286
|
+
field: `operations.${operationKey}.handler`,
|
|
2287
|
+
fileName: `${operationKey}.ts`,
|
|
2288
|
+
source,
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
return sources.flatMap(({ field, fileName, source }) => {
|
|
2294
|
+
const match = findLegacyChoiceUsage(fileName, source);
|
|
2295
|
+
if (!match) return [];
|
|
2296
|
+
return [
|
|
2297
|
+
{
|
|
2298
|
+
rule: "legacy-choice-usage",
|
|
2299
|
+
level: "error" as const,
|
|
2300
|
+
field,
|
|
2301
|
+
message: `Legacy choice API "${match}" is no longer supported. ${LEGACY_CHOICE_USAGE_MESSAGE}`,
|
|
2302
|
+
},
|
|
2303
|
+
];
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// ---------------------------------------------------------------------------
|
|
2308
|
+
// meta-requires-as-const
|
|
2309
|
+
// ---------------------------------------------------------------------------
|
|
2310
|
+
|
|
2311
|
+
const META_AS_CONST_RULE = "meta-requires-as-const";
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Why this is checked from source and not from the loaded provider.
|
|
2315
|
+
*
|
|
2316
|
+
* `defineProvider` is declared `<const TConfig extends ProviderDeclaration>`, so
|
|
2317
|
+
* an object literal written AT the call keeps its literal types. A `const` type
|
|
2318
|
+
* parameter cannot re-narrow a value that widened somewhere else: when the
|
|
2319
|
+
* declaration — or its `meta` block — lives in its own module and is passed by
|
|
2320
|
+
* name, the call sees that module's declared type. `as const` has no runtime
|
|
2321
|
+
* trace at all (the loaded object is byte-identical with and without it), so
|
|
2322
|
+
* only the syntax carries the narrowing, which is why this rule reads
|
|
2323
|
+
* `providerSourceFiles` rather than the provider value.
|
|
2324
|
+
*
|
|
2325
|
+
* What is actually at stake, measured against this SDK rather than reasoned
|
|
2326
|
+
* about (probe: declare `http` only, then read `ctx.files`):
|
|
2327
|
+
*
|
|
2328
|
+
* | how the extracted `meta` is declared | at the call | ProviderContextOf |
|
|
2329
|
+
* |---------------------------------------------|-------------|-------------------|
|
|
2330
|
+
* | `meta` written inline in `defineProvider({...})` | ok | narrowed |
|
|
2331
|
+
* | `meta = { ... } as const` | ok | narrowed |
|
|
2332
|
+
* | `meta = { ... } satisfies ProviderMeta` | ok | narrowed |
|
|
2333
|
+
* | `meta: ProviderMeta = { ... }` | ok | narrowed |
|
|
2334
|
+
* | `meta = { ... }`, no literal-typed field | ok | narrowed |
|
|
2335
|
+
* | `meta = { ... }` with a literal-typed field | **TS2322** | **degenerate** |
|
|
2336
|
+
* | `decl: ProviderDeclaration = { ... }` | **ok** | **degenerate** |
|
|
2337
|
+
* | `decl: Pick<ProviderDeclaration, …>` | ok | narrowed |
|
|
2338
|
+
* | `decl: ProviderDeclaration & { … }` | **ok** | **degenerate** |
|
|
2339
|
+
* | `defineProvider<ProviderDeclaration>({…})` | **ok** | **degenerate** |
|
|
2340
|
+
*
|
|
2341
|
+
* The last two rows are the rule, and they fail differently.
|
|
2342
|
+
*
|
|
2343
|
+
* LOUD (`warn`). A widened `meta` is one property of the call literal, so it
|
|
2344
|
+
* cannot change `keyof TConfig` by itself — rows 5 and 6 differ only in whether
|
|
2345
|
+
* some widened value is still assignable. Once one is not
|
|
2346
|
+
* (`contract.publicSchemaFieldNames` going from `"normalized"` to `string` is
|
|
2347
|
+
* the one the fleet hits), inference for `TConfig` falls back to the constraint
|
|
2348
|
+
* and the context degenerates — but `tsc` also rejects the call with TS2322, so
|
|
2349
|
+
* nothing ships. It warns because row 5 is one added field away from row 6.
|
|
2350
|
+
*
|
|
2351
|
+
* SILENT (`error`). Annotating the WHOLE declaration
|
|
2352
|
+
* (`const decl: ProviderDeclaration = { ... }; defineProvider(decl)`) is
|
|
2353
|
+
* different in kind. It is perfectly assignable, so the call compiles; but
|
|
2354
|
+
* every capability is an OPTIONAL key of `ProviderDeclaration`, so
|
|
2355
|
+
* `keyof TConfig` is all of them at once. Every branch of `ProviderContext` is
|
|
2356
|
+
* keyed on `"<capability>" extends keyof TConfig`, so the context hands
|
|
2357
|
+
* `ctx.files`, `ctx.browser`, `ctx.state` and the rest to a provider that
|
|
2358
|
+
* declared none of them, and no diagnostic anywhere points at it. `satisfies
|
|
2359
|
+
* ProviderDeclaration` checks the same shape and keeps the key set.
|
|
2360
|
+
*
|
|
2361
|
+
* The rule is scoped to what is mechanically decidable from the provider's own
|
|
2362
|
+
* sources. A `meta` written inline at the call is fine (the `const` type
|
|
2363
|
+
* parameter does the work). A value passed by name is checked at its
|
|
2364
|
+
* declaration, following at most {@link MODULE_HOP_LIMIT} re-export hops. An
|
|
2365
|
+
* indexed access such as `ProviderDeclaration["meta"]` names one member and is
|
|
2366
|
+
* not a key-erasing annotation. A value that cannot be resolved inside the
|
|
2367
|
+
* provider tree (a bare-specifier import, a computed expression, a function
|
|
2368
|
+
* call) is left alone rather than guessed at.
|
|
2369
|
+
*/
|
|
2370
|
+
const MODULE_HOP_LIMIT = 4;
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Narrowing verdict for one `const` declaration.
|
|
2374
|
+
*
|
|
2375
|
+
* `silent` separates the two failure modes measured in the table above. A
|
|
2376
|
+
* declaration typed as `ProviderDeclaration` keeps every optional capability
|
|
2377
|
+
* key, so `keyof TConfig` is the whole union, the context degenerates, and
|
|
2378
|
+
* `tsc` says NOTHING — that is an error. A bare object literal degenerates only
|
|
2379
|
+
* once one of its widened values stops being assignable, and when it does the
|
|
2380
|
+
* call fails to compile — that is a warning.
|
|
2381
|
+
*/
|
|
2382
|
+
type NarrowingVerdict =
|
|
2383
|
+
| { kind: "narrowed" }
|
|
2384
|
+
| { kind: "widened"; reason: string; silent: boolean }
|
|
2385
|
+
| { kind: "unresolved" };
|
|
2386
|
+
|
|
2387
|
+
/**
|
|
2388
|
+
* True for a type node that erases the authored key set to
|
|
2389
|
+
* `keyof ProviderDeclaration` — the declaration type itself, or a mapped type
|
|
2390
|
+
* over it (`Partial<ProviderDeclaration>`, `Omit<ProviderDeclaration, "auth">`).
|
|
2391
|
+
*
|
|
2392
|
+
* An indexed access into it (`ProviderDeclaration["meta"]`) is NOT this: it
|
|
2393
|
+
* names one member's type and leaves the declaration's own key set alone, which
|
|
2394
|
+
* is why `satisfies ProviderDeclaration["meta"]` is a safe and used convention.
|
|
2395
|
+
*/
|
|
2396
|
+
function erasesDeclarationKeys(
|
|
2397
|
+
ts: typeof import("typescript"),
|
|
2398
|
+
node: import("typescript").TypeNode,
|
|
2399
|
+
declarationTypeNames: ReadonlySet<string>,
|
|
2400
|
+
): boolean {
|
|
2401
|
+
if (ts.isParenthesizedTypeNode(node)) {
|
|
2402
|
+
return erasesDeclarationKeys(ts, node.type, declarationTypeNames);
|
|
2403
|
+
}
|
|
2404
|
+
// `ProviderDeclaration & { http: true }` still carries every optional
|
|
2405
|
+
// capability key, so an intersection is erasing when any constituent is.
|
|
2406
|
+
if (ts.isIntersectionTypeNode(node) || ts.isUnionTypeNode(node)) {
|
|
2407
|
+
return node.types.some((member) => erasesDeclarationKeys(ts, member, declarationTypeNames));
|
|
2408
|
+
}
|
|
2409
|
+
if (ts.isIndexedAccessTypeNode(node)) return false;
|
|
2410
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
2411
|
+
const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
|
|
2412
|
+
if (declarationTypeNames.has(name)) return true;
|
|
2413
|
+
// `Pick<ProviderDeclaration, "id" | "http">` names an explicit key subset,
|
|
2414
|
+
// so the authored keys survive and the context stays narrow — measured.
|
|
2415
|
+
// Every other transformation over the declaration (`Omit` keeps all the
|
|
2416
|
+
// keys but one, `Partial`/`Readonly`/`Required` keep them all) leaves the
|
|
2417
|
+
// optional capability keys in place and is erasing.
|
|
2418
|
+
if (name === "Pick") return false;
|
|
2419
|
+
return (node.typeArguments ?? []).some((argument) =>
|
|
2420
|
+
erasesDeclarationKeys(ts, argument, declarationTypeNames),
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/** True for an `@apifuse/provider-sdk` (or subpath) module specifier. */
|
|
2427
|
+
function isSdkSpecifier(specifier: string): boolean {
|
|
2428
|
+
return specifier === "@apifuse/provider-sdk" || specifier.startsWith("@apifuse/provider-sdk/");
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/**
|
|
2432
|
+
* Local names that denote `ProviderDeclaration` inside one file.
|
|
2433
|
+
*
|
|
2434
|
+
* The type is usually imported under its own name, but `import type
|
|
2435
|
+
* { ProviderDeclaration as Decl }` and `type Decl = ProviderDeclaration` are
|
|
2436
|
+
* the same type wearing another word, and annotating with either erases the
|
|
2437
|
+
* key set just as thoroughly. Comparing the written name alone would miss both.
|
|
2438
|
+
*/
|
|
2439
|
+
function declarationTypeNamesIn(
|
|
2440
|
+
ts: typeof import("typescript"),
|
|
2441
|
+
file: import("typescript").SourceFile,
|
|
2442
|
+
): Set<string> {
|
|
2443
|
+
const names = new Set<string>(["ProviderDeclaration"]);
|
|
2444
|
+
for (const statement of file.statements) {
|
|
2445
|
+
if (ts.isImportDeclaration(statement)) {
|
|
2446
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2447
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
2448
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
2449
|
+
if (!isSdkSpecifier(statement.moduleSpecifier.text)) continue;
|
|
2450
|
+
for (const element of bindings.elements) {
|
|
2451
|
+
if ((element.propertyName?.text ?? element.name.text) === "ProviderDeclaration") {
|
|
2452
|
+
names.add(element.name.text);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
if (
|
|
2458
|
+
ts.isTypeAliasDeclaration(statement) &&
|
|
2459
|
+
ts.isTypeReferenceNode(statement.type) &&
|
|
2460
|
+
ts.isIdentifier(statement.type.typeName) &&
|
|
2461
|
+
names.has(statement.type.typeName.text)
|
|
2462
|
+
) {
|
|
2463
|
+
names.add(statement.name.text);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return names;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Local names bound to the SDK's `defineProvider` inside one file.
|
|
2471
|
+
*
|
|
2472
|
+
* Restricting the callee match to these keeps an unrelated local helper called
|
|
2473
|
+
* `defineProvider` out of the rule, and picks the SDK call up when it is
|
|
2474
|
+
* imported under another name. A file with no SDK import at all falls back to
|
|
2475
|
+
* the bare name, which is what an in-repo fixture looks like.
|
|
2476
|
+
*/
|
|
2477
|
+
function defineProviderNamesIn(
|
|
2478
|
+
ts: typeof import("typescript"),
|
|
2479
|
+
file: import("typescript").SourceFile,
|
|
2480
|
+
): Set<string> {
|
|
2481
|
+
const names = new Set<string>();
|
|
2482
|
+
for (const statement of file.statements) {
|
|
2483
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
2484
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
2485
|
+
if (!isSdkSpecifier(statement.moduleSpecifier.text)) continue;
|
|
2486
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2487
|
+
if (!bindings) continue;
|
|
2488
|
+
if (ts.isNamedImports(bindings)) {
|
|
2489
|
+
for (const element of bindings.elements) {
|
|
2490
|
+
if ((element.propertyName?.text ?? element.name.text) === "defineProvider") {
|
|
2491
|
+
names.add(element.name.text);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
// `import * as sdk from "@apifuse/provider-sdk"` reaches the call as
|
|
2496
|
+
// `sdk.defineProvider`, and calleeName() already reduces that to the
|
|
2497
|
+
// member name.
|
|
2498
|
+
if (ts.isNamespaceImport(bindings)) names.add("defineProvider");
|
|
2499
|
+
}
|
|
2500
|
+
if (names.size > 0) return names;
|
|
2501
|
+
// No SDK import in this file. The bare name is the right fallback for an
|
|
2502
|
+
// in-repo fixture, but not when the file declares or imports a
|
|
2503
|
+
// `defineProvider` of its own — passing a ProviderDeclaration to an
|
|
2504
|
+
// unrelated local helper is not an SDK call and must not fail a check.
|
|
2505
|
+
for (const statement of file.statements) {
|
|
2506
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "defineProvider") {
|
|
2507
|
+
return names;
|
|
2508
|
+
}
|
|
2509
|
+
if (ts.isVariableStatement(statement)) {
|
|
2510
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2511
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === "defineProvider") {
|
|
2512
|
+
return names;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
if (ts.isImportDeclaration(statement)) {
|
|
2517
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2518
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
2519
|
+
for (const element of bindings.elements) {
|
|
2520
|
+
if (element.name.text === "defineProvider") return names;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return new Set(["defineProvider"]);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function normalizeSourcePath(path: string): string {
|
|
2529
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/** Resolves `./meta` (or `./meta.js`) from `index.ts` against the source map. */
|
|
2533
|
+
function resolveProviderModule(
|
|
2534
|
+
fromFile: string,
|
|
2535
|
+
specifier: string,
|
|
2536
|
+
files: Record<string, string>,
|
|
2537
|
+
): string | undefined {
|
|
2538
|
+
if (!specifier.startsWith(".")) return undefined;
|
|
2539
|
+
const fromSegments = normalizeSourcePath(fromFile).split("/");
|
|
2540
|
+
fromSegments.pop();
|
|
2541
|
+
for (const segment of specifier.split("/")) {
|
|
2542
|
+
if (segment === "" || segment === ".") continue;
|
|
2543
|
+
if (segment === "..") fromSegments.pop();
|
|
2544
|
+
else fromSegments.push(segment);
|
|
2545
|
+
}
|
|
2546
|
+
const joined = fromSegments.join("/");
|
|
2547
|
+
// An ESM provider writes `./meta.js` and ships `meta.mts`; the extension in
|
|
2548
|
+
// the specifier is the EMITTED one, so every TypeScript counterpart of the
|
|
2549
|
+
// scan set has to be tried, not just `.ts`.
|
|
2550
|
+
const withoutJs = joined.replace(/\.(js|mjs|cjs)$/, "");
|
|
2551
|
+
const candidates = [
|
|
2552
|
+
joined,
|
|
2553
|
+
...["ts", "tsx", "mts", "cts"].flatMap((extension) => [
|
|
2554
|
+
`${withoutJs}.${extension}`,
|
|
2555
|
+
`${withoutJs}/index.${extension}`,
|
|
2556
|
+
]),
|
|
2557
|
+
];
|
|
2558
|
+
const normalized = new Map(Object.keys(files).map((key) => [normalizeSourcePath(key), key]));
|
|
2559
|
+
for (const candidate of candidates) {
|
|
2560
|
+
const match = normalized.get(candidate);
|
|
2561
|
+
if (match !== undefined) return match;
|
|
2562
|
+
}
|
|
2563
|
+
return undefined;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/** True for `<expr> as const`. */
|
|
2567
|
+
function isConstAssertion(
|
|
2568
|
+
ts: typeof import("typescript"),
|
|
2569
|
+
node: import("typescript").Node,
|
|
2570
|
+
): node is import("typescript").AsExpression {
|
|
2571
|
+
return (
|
|
2572
|
+
ts.isAsExpression(node) &&
|
|
2573
|
+
ts.isTypeReferenceNode(node.type) &&
|
|
2574
|
+
ts.isIdentifier(node.type.typeName) &&
|
|
2575
|
+
node.type.typeName.text === "const"
|
|
2576
|
+
);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* Decides whether `name`, as declared in `fileName`, still carries its literal
|
|
2581
|
+
* types when it is read from another module. Follows `import`/`export ... from`
|
|
2582
|
+
* re-exports up to {@link MODULE_HOP_LIMIT} hops.
|
|
2583
|
+
*/
|
|
2584
|
+
function narrowingOfExportedConst(
|
|
2585
|
+
ts: typeof import("typescript"),
|
|
2586
|
+
files: Record<string, string>,
|
|
2587
|
+
fileName: string,
|
|
2588
|
+
name: string,
|
|
2589
|
+
hops = 0,
|
|
2590
|
+
): NarrowingVerdict {
|
|
2591
|
+
if (hops > MODULE_HOP_LIMIT) return { kind: "unresolved" };
|
|
2592
|
+
const source = files[fileName];
|
|
2593
|
+
if (source === undefined) return { kind: "unresolved" };
|
|
2594
|
+
const file = ts.createSourceFile(
|
|
2595
|
+
fileName,
|
|
2596
|
+
source,
|
|
2597
|
+
ts.ScriptTarget.Latest,
|
|
2598
|
+
true,
|
|
2599
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2600
|
+
);
|
|
2601
|
+
const declarationTypeNames = declarationTypeNamesIn(ts, file);
|
|
2602
|
+
|
|
2603
|
+
for (const statement of file.statements) {
|
|
2604
|
+
if (ts.isVariableStatement(statement)) {
|
|
2605
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2606
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue;
|
|
2607
|
+
// An annotation (`: ProviderMeta`) and a `satisfies` check both give
|
|
2608
|
+
// the literal a contextual type whose literal-typed members stay
|
|
2609
|
+
// literal. Measured: neither degenerates ProviderContextOf — UNLESS
|
|
2610
|
+
// the type named is the declaration itself, which replaces the
|
|
2611
|
+
// authored key set with every optional capability key.
|
|
2612
|
+
if (declaration.type) {
|
|
2613
|
+
if (erasesDeclarationKeys(ts, declaration.type, declarationTypeNames)) {
|
|
2614
|
+
return {
|
|
2615
|
+
kind: "widened",
|
|
2616
|
+
silent: true,
|
|
2617
|
+
reason: `it is annotated \`: ${declaration.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
return { kind: "narrowed" };
|
|
2621
|
+
}
|
|
2622
|
+
let initializer = declaration.initializer;
|
|
2623
|
+
while (initializer && ts.isParenthesizedExpression(initializer)) {
|
|
2624
|
+
initializer = initializer.expression;
|
|
2625
|
+
}
|
|
2626
|
+
if (!initializer) return { kind: "unresolved" };
|
|
2627
|
+
if (ts.isSatisfiesExpression(initializer)) return { kind: "narrowed" };
|
|
2628
|
+
if (isConstAssertion(ts, initializer)) return { kind: "narrowed" };
|
|
2629
|
+
if (ts.isAsExpression(initializer)) {
|
|
2630
|
+
if (erasesDeclarationKeys(ts, initializer.type, declarationTypeNames)) {
|
|
2631
|
+
return {
|
|
2632
|
+
kind: "widened",
|
|
2633
|
+
silent: true,
|
|
2634
|
+
reason: `it is asserted \`as ${initializer.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
return { kind: "narrowed" };
|
|
2638
|
+
}
|
|
2639
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
2640
|
+
return {
|
|
2641
|
+
kind: "widened",
|
|
2642
|
+
silent: false,
|
|
2643
|
+
reason:
|
|
2644
|
+
"it is a bare object literal — no `as const`, no annotation, no `satisfies` — so TypeScript widens every literal in it before `defineProvider` ever sees it",
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
return { kind: "unresolved" };
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// `export { providerMeta } from "./meta";` and
|
|
2652
|
+
// `import { providerMeta } from "./meta"; export { providerMeta };`
|
|
2653
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause) {
|
|
2654
|
+
if (!ts.isNamedExports(statement.exportClause)) continue;
|
|
2655
|
+
for (const element of statement.exportClause.elements) {
|
|
2656
|
+
if (element.name.text !== name) continue;
|
|
2657
|
+
const origin = element.propertyName?.text ?? name;
|
|
2658
|
+
const specifier =
|
|
2659
|
+
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
|
2660
|
+
? statement.moduleSpecifier.text
|
|
2661
|
+
: undefined;
|
|
2662
|
+
if (specifier === undefined) {
|
|
2663
|
+
return narrowingOfImportedName(ts, files, file, fileName, origin, hops + 1);
|
|
2664
|
+
}
|
|
2665
|
+
const target = resolveProviderModule(fileName, specifier, files);
|
|
2666
|
+
if (target === undefined) return { kind: "unresolved" };
|
|
2667
|
+
return narrowingOfExportedConst(ts, files, target, origin, hops + 1);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
return narrowingOfImportedName(ts, files, file, fileName, name, hops + 1);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/** Follows `import { name } from "./x"` declared in `file` to its definition. */
|
|
2676
|
+
function narrowingOfImportedName(
|
|
2677
|
+
ts: typeof import("typescript"),
|
|
2678
|
+
files: Record<string, string>,
|
|
2679
|
+
file: import("typescript").SourceFile,
|
|
2680
|
+
fileName: string,
|
|
2681
|
+
name: string,
|
|
2682
|
+
hops: number,
|
|
2683
|
+
): NarrowingVerdict {
|
|
2684
|
+
if (hops > MODULE_HOP_LIMIT) return { kind: "unresolved" };
|
|
2685
|
+
for (const statement of file.statements) {
|
|
2686
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
2687
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2688
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
2689
|
+
for (const element of bindings.elements) {
|
|
2690
|
+
if (element.name.text !== name) continue;
|
|
2691
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) return { kind: "unresolved" };
|
|
2692
|
+
const target = resolveProviderModule(fileName, statement.moduleSpecifier.text, files);
|
|
2693
|
+
if (target === undefined) return { kind: "unresolved" };
|
|
2694
|
+
return narrowingOfExportedConst(ts, files, target, element.propertyName?.text ?? name, hops);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
return { kind: "unresolved" };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
type MetaNarrowingFinding = {
|
|
2701
|
+
/** `meta` when the meta block alone is widened, `declaration` for the whole call argument. */
|
|
2702
|
+
subject: "meta" | "declaration";
|
|
2703
|
+
name: string;
|
|
2704
|
+
reason: string;
|
|
2705
|
+
/** True when the degeneration compiles cleanly; see {@link NarrowingVerdict}. */
|
|
2706
|
+
silent: boolean;
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
/**
|
|
2710
|
+
* Finds every `defineProvider(...)` in one file and reports the arguments that
|
|
2711
|
+
* reach it already widened.
|
|
2712
|
+
*/
|
|
2713
|
+
function findWidenedDefineProviderArguments(
|
|
2714
|
+
ts: typeof import("typescript"),
|
|
2715
|
+
files: Record<string, string>,
|
|
2716
|
+
fileName: string,
|
|
2717
|
+
): MetaNarrowingFinding[] {
|
|
2718
|
+
const source = files[fileName];
|
|
2719
|
+
if (source === undefined) return [];
|
|
2720
|
+
const file = ts.createSourceFile(
|
|
2721
|
+
fileName,
|
|
2722
|
+
source,
|
|
2723
|
+
ts.ScriptTarget.Latest,
|
|
2724
|
+
true,
|
|
2725
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2726
|
+
);
|
|
2727
|
+
const findings: MetaNarrowingFinding[] = [];
|
|
2728
|
+
const declarationTypeNames = declarationTypeNamesIn(ts, file);
|
|
2729
|
+
const calleeNames = defineProviderNamesIn(ts, file);
|
|
2730
|
+
|
|
2731
|
+
const check = (subject: "meta" | "declaration", expression: import("typescript").Expression) => {
|
|
2732
|
+
let current = expression;
|
|
2733
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2734
|
+
// `satisfies` and `as const` narrow a literal written HERE; neither can
|
|
2735
|
+
// re-narrow a binding that widened at its own declaration, and neither
|
|
2736
|
+
// re-narrows a value spread in from one. Unwrap and keep looking rather
|
|
2737
|
+
// than treating the wrapper as proof.
|
|
2738
|
+
while (ts.isSatisfiesExpression(current) || isConstAssertion(ts, current)) {
|
|
2739
|
+
current = current.expression;
|
|
2740
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2741
|
+
}
|
|
2742
|
+
// `defineProvider({...} as ProviderDeclaration)` and
|
|
2743
|
+
// `defineProvider(declaration as ProviderDeclaration)` erase the key set
|
|
2744
|
+
// at the call itself, without any widened binding to resolve to.
|
|
2745
|
+
if (ts.isAsExpression(current)) {
|
|
2746
|
+
if (erasesDeclarationKeys(ts, current.type, declarationTypeNames)) {
|
|
2747
|
+
findings.push({
|
|
2748
|
+
subject,
|
|
2749
|
+
name: current.expression.getText(file).slice(0, 60),
|
|
2750
|
+
reason: `it is asserted \`as ${current.type.getText(file)}\` at the call, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
|
|
2751
|
+
silent: subject === "declaration",
|
|
2752
|
+
});
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
current = current.expression;
|
|
2756
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2757
|
+
}
|
|
2758
|
+
if (ts.isObjectLiteralExpression(current)) {
|
|
2759
|
+
// Written at the call: the `const` type parameter narrows it. Only a
|
|
2760
|
+
// spread of an already-widened binding can still lose the literals.
|
|
2761
|
+
for (const property of current.properties) {
|
|
2762
|
+
if (ts.isSpreadAssignment(property) && ts.isIdentifier(property.expression)) {
|
|
2763
|
+
check(subject, property.expression);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
if (!ts.isIdentifier(current)) return;
|
|
2769
|
+
const verdict = narrowingOfImportedName(ts, files, file, fileName, current.text, 1);
|
|
2770
|
+
const resolved =
|
|
2771
|
+
verdict.kind === "unresolved"
|
|
2772
|
+
? narrowingOfExportedConst(ts, files, fileName, current.text, 1)
|
|
2773
|
+
: verdict;
|
|
2774
|
+
if (resolved.kind === "widened") {
|
|
2775
|
+
// A widened `meta` is only ever the loud row: it is one property of the
|
|
2776
|
+
// call literal, so it cannot change `keyof TConfig` by itself.
|
|
2777
|
+
const silent = resolved.silent && subject === "declaration";
|
|
2778
|
+
findings.push({ subject, name: current.text, reason: resolved.reason, silent });
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
const visit = (node: import("typescript").Node): void => {
|
|
2783
|
+
const callee = ts.isCallExpression(node) ? calleeName(node) : undefined;
|
|
2784
|
+
if (ts.isCallExpression(node) && callee !== undefined && calleeNames.has(callee)) {
|
|
2785
|
+
// `defineProvider<ProviderDeclaration>({...})` pins TConfig explicitly,
|
|
2786
|
+
// so the `const` type parameter never infers anything and even an inline
|
|
2787
|
+
// literal gets the full optional-key set. The value argument looks
|
|
2788
|
+
// perfect; the type argument is the whole defect.
|
|
2789
|
+
const pinned = (node.typeArguments ?? []).find((argument) =>
|
|
2790
|
+
erasesDeclarationKeys(ts, argument, declarationTypeNames),
|
|
2791
|
+
);
|
|
2792
|
+
if (pinned) {
|
|
2793
|
+
findings.push({
|
|
2794
|
+
subject: "declaration",
|
|
2795
|
+
name: `${callee}<${pinned.getText(file)}>`,
|
|
2796
|
+
reason: `the call pins its type parameter with \`<${pinned.getText(file)}>\`, so the \`const TConfig\` inference never runs and every capability is an OPTIONAL key of ProviderDeclaration`,
|
|
2797
|
+
silent: true,
|
|
2798
|
+
});
|
|
2799
|
+
ts.forEachChild(node, visit);
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
const argument = node.arguments[0];
|
|
2803
|
+
if (argument) {
|
|
2804
|
+
let current: import("typescript").Expression = argument;
|
|
2805
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2806
|
+
if (ts.isAsExpression(current)) {
|
|
2807
|
+
// `defineProvider(x as T)`: check() decides whether T erases the
|
|
2808
|
+
// key set, and unwraps to x when it does not.
|
|
2809
|
+
check("declaration", current);
|
|
2810
|
+
ts.forEachChild(node, visit);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (ts.isObjectLiteralExpression(current)) {
|
|
2814
|
+
for (const property of current.properties) {
|
|
2815
|
+
if (ts.isPropertyAssignment(property) && staticPropertyName(property.name) === "meta") {
|
|
2816
|
+
check("meta", property.initializer);
|
|
2817
|
+
} else if (
|
|
2818
|
+
ts.isShorthandPropertyAssignment(property) &&
|
|
2819
|
+
property.name.text === "meta"
|
|
2820
|
+
) {
|
|
2821
|
+
check("meta", property.name);
|
|
2822
|
+
} else if (ts.isSpreadAssignment(property)) {
|
|
2823
|
+
check("declaration", property.expression);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
} else {
|
|
2827
|
+
check("declaration", argument);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
ts.forEachChild(node, visit);
|
|
2832
|
+
};
|
|
2833
|
+
visit(file);
|
|
2834
|
+
return findings;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* `meta-requires-as-const`: the provider declaration, and its `meta` block in
|
|
2839
|
+
* particular, should reach `defineProvider` with its literal types intact.
|
|
2840
|
+
*
|
|
2841
|
+
* See the table above {@link MODULE_HOP_LIMIT} for the measured consequences
|
|
2842
|
+
* and for why this warns rather than errors.
|
|
2843
|
+
*/
|
|
2844
|
+
function lintProviderMetaNarrowing(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
2845
|
+
const files = provider.providerSourceFiles ?? {};
|
|
2846
|
+
if (Object.keys(files).length === 0) return [];
|
|
2847
|
+
let ts: typeof import("typescript");
|
|
2848
|
+
try {
|
|
2849
|
+
ts = getTypeScript();
|
|
2850
|
+
} catch {
|
|
2851
|
+
// No TypeScript in the consumer's tree: the other AST rules degrade the
|
|
2852
|
+
// same way rather than guessing from raw text.
|
|
2853
|
+
return [];
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
2857
|
+
for (const fileName of Object.keys(files)) {
|
|
2858
|
+
const normalized = normalizeSourcePath(fileName);
|
|
2859
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(normalized) || /\.d\.(ts|mts|cts)$/.test(normalized)) continue;
|
|
2860
|
+
// Test sources routinely build throwaway declarations to exercise a
|
|
2861
|
+
// capability shape, and several do it with a `ProviderDeclaration`
|
|
2862
|
+
// annotation on purpose. Reporting those would fail `apifuse check` over
|
|
2863
|
+
// code that is not the provider's runtime contract.
|
|
2864
|
+
if (SHARED_TEST_SOURCE_FILE_PATTERN.test(normalized)) continue;
|
|
2865
|
+
for (const finding of findWidenedDefineProviderArguments(ts, files, fileName)) {
|
|
2866
|
+
const what =
|
|
2867
|
+
finding.subject === "meta"
|
|
2868
|
+
? `The \`meta\` block passed to defineProvider (\`${finding.name}\`)`
|
|
2869
|
+
: `The declaration passed to defineProvider (\`${finding.name}\`)`;
|
|
2870
|
+
const consequence = finding.silent
|
|
2871
|
+
? "ProviderContextOf<typeof buildProvider> is therefore ProviderContext<ProviderDeclaration>: ctx.files, ctx.browser, ctx.state and every other capability type-check in a provider that declared none of them, and nothing anywhere reports it — the call itself still compiles. Replace the annotation with `satisfies ProviderDeclaration`, which checks the same shape and leaves the authored key set intact."
|
|
2872
|
+
: 'That is latent rather than broken today: a widened property of `meta` cannot change `keyof TConfig` on its own. It turns into a failure the moment one widened value stops being assignable to ProviderDeclaration — `contract.publicSchemaFieldNames` going from "normalized" to `string` is the one the fleet hits — and then the call fails with TS2322 and the context degenerates at the same time. Add `as const` to the declaration; `satisfies ProviderMeta`, an explicit annotation, or an `as const` on each literal-union field are equally sound.';
|
|
2873
|
+
diagnostics.push({
|
|
2874
|
+
rule: META_AS_CONST_RULE,
|
|
2875
|
+
level: finding.silent ? "error" : "warn",
|
|
2876
|
+
field: finding.subject,
|
|
2877
|
+
message: `${what} reaches the call already widened: ${finding.reason}. ${consequence}`,
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
return diagnostics;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
const STALE_IF_ERROR_OPTION = "staleIfErrorMs";
|
|
2885
|
+
const STALE_IF_ERROR_PATTERN = /\bstaleIfErrorMs\b/;
|
|
2886
|
+
|
|
2887
|
+
/**
|
|
2888
|
+
* Whether a property assignment's initializer leaves no stale window.
|
|
2889
|
+
* `staleIfErrorMs: 0` makes `staleUntil` equal `freshUntil` in
|
|
2890
|
+
* `runtime/cache.ts`, so a loader failure propagates instead of being answered
|
|
2891
|
+
* from cache — a provider deliberately turning stale serving OFF must not be
|
|
2892
|
+
* told its probes conceal outages.
|
|
2893
|
+
*/
|
|
2894
|
+
function disablesStaleWindow(
|
|
2895
|
+
ts: typeof import("typescript"),
|
|
2896
|
+
initializer: import("typescript").Expression,
|
|
2897
|
+
): boolean {
|
|
2898
|
+
if (ts.isNumericLiteral(initializer)) return Number(initializer.text) === 0;
|
|
2899
|
+
return ts.isIdentifier(initializer) && initializer.text === "undefined";
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
/**
|
|
2903
|
+
* Whether executable source configures a stale-if-error window.
|
|
2904
|
+
*
|
|
2905
|
+
* Parsed, not matched: comments and unrelated string literals must not count,
|
|
2906
|
+
* and a regex cannot tell them apart (an `Accept: "*/*"` header literal opens a
|
|
2907
|
+
* block comment to a stripping regex and swallows the cache call after it).
|
|
2908
|
+
* This is the approach `findLegacyChoiceUsage` already uses here. All three
|
|
2909
|
+
* spellings of the option count — `staleIfErrorMs: n`, the shorthand
|
|
2910
|
+
* `{ ttlMs, staleIfErrorMs }`, and the quoted `"staleIfErrorMs": n` — because
|
|
2911
|
+
* refactoring between them must not change the verdict. Falls back to the raw
|
|
2912
|
+
* identifier match when TypeScript is not installed, which is the fail-closed
|
|
2913
|
+
* direction for this rule: it warns rather than goes quiet.
|
|
2914
|
+
*/
|
|
2915
|
+
function servesStaleIfError(source: string, fileName = "provider.ts"): boolean {
|
|
2916
|
+
if (!source.includes(STALE_IF_ERROR_OPTION)) return false;
|
|
2917
|
+
let ts: typeof import("typescript");
|
|
2918
|
+
try {
|
|
2919
|
+
ts = getTypeScript();
|
|
2920
|
+
} catch {
|
|
2921
|
+
return STALE_IF_ERROR_PATTERN.test(source);
|
|
2922
|
+
}
|
|
2923
|
+
const file = ts.createSourceFile(
|
|
2924
|
+
fileName,
|
|
2925
|
+
source,
|
|
2926
|
+
ts.ScriptTarget.Latest,
|
|
2927
|
+
false,
|
|
2928
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2929
|
+
);
|
|
2930
|
+
/** The statically known name of a property, however it is written. */
|
|
2931
|
+
const propertyName = (name: import("typescript").PropertyName): string | undefined => {
|
|
2932
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
2933
|
+
if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
|
|
2934
|
+
return name.expression.text;
|
|
2935
|
+
}
|
|
2936
|
+
return undefined;
|
|
2937
|
+
};
|
|
2938
|
+
/**
|
|
2939
|
+
* A type declaring the option is not a cache call setting it. Skipping these
|
|
2940
|
+
* whole subtrees also stops a `CachePolicy` interface from overriding the
|
|
2941
|
+
* disabled-window exemption for a provider whose only real call passes 0.
|
|
2942
|
+
*/
|
|
2943
|
+
const isTypeOnly = (node: import("typescript").Node): boolean =>
|
|
2944
|
+
ts.isInterfaceDeclaration(node) ||
|
|
2945
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
2946
|
+
ts.isTypeLiteralNode(node) ||
|
|
2947
|
+
ts.isPropertySignature(node) ||
|
|
2948
|
+
ts.isTypeReferenceNode(node) ||
|
|
2949
|
+
ts.isTypeParameterDeclaration(node);
|
|
2950
|
+
/**
|
|
2951
|
+
* The cache write options that travel with `staleIfErrorMs`
|
|
2952
|
+
* (`ProviderCacheWriteOptions`). An object literal carrying one of these
|
|
2953
|
+
* alongside the option is a cache configuration wherever it is declared.
|
|
2954
|
+
*/
|
|
2955
|
+
const siblingCacheOptions = new Set(["ttlMs", "jitterPct"]);
|
|
2956
|
+
/**
|
|
2957
|
+
* A callee that writes the provider cache: `ctx.cache.getOrSet(...)`,
|
|
2958
|
+
* `ctx.cache.set(...)`, and the `cached*` helpers built on them. An unrelated
|
|
2959
|
+
* call receiving an object that happens to carry the name is not cache
|
|
2960
|
+
* configuration. Every one of these takes its options LAST.
|
|
2961
|
+
*/
|
|
2962
|
+
const isCacheCallee = (expression: import("typescript").Expression): boolean => {
|
|
2963
|
+
if (ts.isIdentifier(expression)) return /^cached[A-Z]/.test(expression.text);
|
|
2964
|
+
if (!ts.isPropertyAccessExpression(expression)) return false;
|
|
2965
|
+
if (expression.name.text === "getOrSet") return true;
|
|
2966
|
+
return (
|
|
2967
|
+
(expression.name.text === "set" || expression.name.text === "write") &&
|
|
2968
|
+
ts.isPropertyAccessExpression(expression.expression) &&
|
|
2969
|
+
expression.expression.name.text === "cache"
|
|
2970
|
+
);
|
|
2971
|
+
};
|
|
2972
|
+
/**
|
|
2973
|
+
* `const POLICY = { ... }` object literals, so `{ ...POLICY }` can be judged.
|
|
2974
|
+
* Keyed by name file-wide: a shadowed name may resolve to the wrong literal,
|
|
2975
|
+
* which at warning level is a mis-nudge rather than a wrong build — but the
|
|
2976
|
+
* expansion below still has to be cycle-safe, because shadowing can make the
|
|
2977
|
+
* resolution circular in valid source.
|
|
2978
|
+
*/
|
|
2979
|
+
const literalsByName = new Map<string, import("typescript").ObjectLiteralExpression>();
|
|
2980
|
+
const collect = (node: import("typescript").Node): void => {
|
|
2981
|
+
if (
|
|
2982
|
+
ts.isVariableDeclaration(node) &&
|
|
2983
|
+
ts.isIdentifier(node.name) &&
|
|
2984
|
+
node.initializer !== undefined &&
|
|
2985
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
2986
|
+
) {
|
|
2987
|
+
literalsByName.set(node.name.text, node.initializer);
|
|
2988
|
+
}
|
|
2989
|
+
ts.forEachChild(node, collect);
|
|
2990
|
+
};
|
|
2991
|
+
collect(file);
|
|
2992
|
+
|
|
2993
|
+
type OptionReading = { window: "enabled" | "disabled" | undefined; hasSibling: boolean };
|
|
2994
|
+
|
|
2995
|
+
/**
|
|
2996
|
+
* The EFFECTIVE stale-window setting of an object literal, spreads expanded
|
|
2997
|
+
* and later properties overriding earlier ones the way JavaScript does — so
|
|
2998
|
+
* `{ ...policy, staleIfErrorMs: 0 }` reads as disabled even when `policy`
|
|
2999
|
+
* enables it.
|
|
3000
|
+
*/
|
|
3001
|
+
const readOptions = (
|
|
3002
|
+
literal: import("typescript").ObjectLiteralExpression,
|
|
3003
|
+
seen: Set<import("typescript").ObjectLiteralExpression>,
|
|
3004
|
+
): OptionReading => {
|
|
3005
|
+
if (seen.has(literal)) return { window: undefined, hasSibling: false };
|
|
3006
|
+
seen.add(literal);
|
|
3007
|
+
let window: OptionReading["window"];
|
|
3008
|
+
let hasSibling = false;
|
|
3009
|
+
for (const property of literal.properties) {
|
|
3010
|
+
if (ts.isSpreadAssignment(property)) {
|
|
3011
|
+
const target = ts.isIdentifier(property.expression)
|
|
3012
|
+
? literalsByName.get(property.expression.text)
|
|
3013
|
+
: ts.isObjectLiteralExpression(property.expression)
|
|
3014
|
+
? property.expression
|
|
3015
|
+
: undefined;
|
|
3016
|
+
if (target === undefined) continue;
|
|
3017
|
+
const inner = readOptions(target, seen);
|
|
3018
|
+
if (inner.window !== undefined) window = inner.window;
|
|
3019
|
+
hasSibling ||= inner.hasSibling;
|
|
3020
|
+
continue;
|
|
3021
|
+
}
|
|
3022
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
3023
|
+
if (property.name.text === STALE_IF_ERROR_OPTION) window = "enabled";
|
|
3024
|
+
else if (siblingCacheOptions.has(property.name.text)) hasSibling = true;
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
3028
|
+
const name = propertyName(property.name);
|
|
3029
|
+
if (name === STALE_IF_ERROR_OPTION) {
|
|
3030
|
+
window = disablesStaleWindow(ts, property.initializer) ? "disabled" : "enabled";
|
|
3031
|
+
} else if (name !== undefined && siblingCacheOptions.has(name)) {
|
|
3032
|
+
hasSibling = true;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
seen.delete(literal);
|
|
3036
|
+
return { window, hasSibling };
|
|
3037
|
+
};
|
|
3038
|
+
|
|
3039
|
+
let found = false;
|
|
3040
|
+
/**
|
|
3041
|
+
* `inOptionsArgument` is the second way to recognise a cache configuration:
|
|
3042
|
+
* the literal is the options argument of a cache call. Together with the
|
|
3043
|
+
* sibling-option test this covers the real shapes, while an upstream payload
|
|
3044
|
+
* that merely carries a field of the same name does not count.
|
|
3045
|
+
*/
|
|
3046
|
+
const visit = (node: import("typescript").Node, inOptionsArgument: boolean): void => {
|
|
3047
|
+
if (found || isTypeOnly(node)) return;
|
|
3048
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
3049
|
+
const { window, hasSibling } = readOptions(node, new Set());
|
|
3050
|
+
if (window === "enabled" && (hasSibling || inOptionsArgument)) {
|
|
3051
|
+
found = true;
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
3056
|
+
const argumentList = node.arguments ?? [];
|
|
3057
|
+
// Options come last on every cache API; the earlier arguments are the
|
|
3058
|
+
// key, the loader, or the value being cached.
|
|
3059
|
+
const optionsIndex = isCacheCallee(node.expression) ? argumentList.length - 1 : -1;
|
|
3060
|
+
visit(node.expression, false);
|
|
3061
|
+
for (const [index, argument] of [...argumentList].entries()) {
|
|
3062
|
+
visit(argument, index === optionsIndex);
|
|
3063
|
+
}
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
ts.forEachChild(node, (child) => visit(child, false));
|
|
3067
|
+
};
|
|
3068
|
+
visit(file, false);
|
|
3069
|
+
return found;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/** The operation-step field that distinguishes a stale-if-error serve. */
|
|
3073
|
+
const SERVED_STALE_CACHE_OPERAND = "served_stale_cache";
|
|
3074
|
+
|
|
3075
|
+
/**
|
|
3076
|
+
* Sources that describe behaviour rather than serve it. Mirrors the broadest
|
|
3077
|
+
* test-source predicate in the repository (`runtime-boundary-lint.ts:82`, which
|
|
3078
|
+
* covers a plain `tests/` directory and `__mocks__/` as well as `__tests__/`)
|
|
3079
|
+
* and adds recorded-fixture directories, so a mock that mentions
|
|
3080
|
+
* `staleIfErrorMs` cannot raise a warning about code the runtime never serves.
|
|
3081
|
+
*/
|
|
3082
|
+
const NON_SERVING_SOURCE_FILE_PATTERN =
|
|
3083
|
+
/(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests|fixtures)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
3084
|
+
|
|
3085
|
+
/**
|
|
3086
|
+
* Operator tooling and bootstrap entrypoints, mirroring
|
|
3087
|
+
* `runtime-boundary-lint.ts`: a cache option used by a fixture recorder or a
|
|
3088
|
+
* start script is not the request path a probe reads through.
|
|
3089
|
+
*/
|
|
3090
|
+
const NON_SERVING_TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
|
|
3091
|
+
const NON_SERVING_BOOTSTRAP_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
|
|
3092
|
+
|
|
3093
|
+
function isServingProviderSourcePath(relativePath: string): boolean {
|
|
3094
|
+
return (
|
|
3095
|
+
JAVASCRIPT_SOURCE_FILE_PATTERN.test(relativePath) &&
|
|
3096
|
+
!NON_SERVING_SOURCE_FILE_PATTERN.test(relativePath) &&
|
|
3097
|
+
!NON_SERVING_TOOLING_DIRECTORY_PATTERN.test(relativePath) &&
|
|
3098
|
+
!NON_SERVING_BOOTSTRAP_FILE_PATTERN.test(relativePath)
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
function isLintRecord(value: unknown): value is Record<string, unknown> {
|
|
3103
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
/**
|
|
3107
|
+
* Reads `healthCheck.cases` structurally. The lint input is the loaded provider
|
|
3108
|
+
* declaration, which `defineProvider` has already validated, but this rule must
|
|
3109
|
+
* not throw on a shape it did not expect.
|
|
3110
|
+
*/
|
|
3111
|
+
function readHealthCheckCases(healthCheck: unknown): Record<string, unknown>[] {
|
|
3112
|
+
if (!isLintRecord(healthCheck)) return [];
|
|
3113
|
+
const cases = healthCheck.cases;
|
|
3114
|
+
if (!Array.isArray(cases)) return [];
|
|
3115
|
+
return cases.filter(isLintRecord);
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
/**
|
|
3119
|
+
* The `result` bindings of the `operation` steps that invoke `operationId`.
|
|
3120
|
+
*
|
|
3121
|
+
* Scoped to the probe's own operation on purpose: a scenario may invoke a
|
|
3122
|
+
* preparatory read before the operation the case is about, and a guard on the
|
|
3123
|
+
* preparation's result says nothing about whether the checked operation was
|
|
3124
|
+
* answered from a stale cache.
|
|
3125
|
+
*/
|
|
3126
|
+
function operationStepBindings(steps: readonly unknown[], operationId: string): Set<string> {
|
|
3127
|
+
const bindings = new Set<string>();
|
|
3128
|
+
for (const step of steps) {
|
|
3129
|
+
if (
|
|
3130
|
+
isLintRecord(step) &&
|
|
3131
|
+
step.kind === "operation" &&
|
|
3132
|
+
step.operationId === operationId &&
|
|
3133
|
+
typeof step.result === "string"
|
|
3134
|
+
) {
|
|
3135
|
+
bindings.add(step.result);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
return bindings;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* Whether an operand is the operand: the runtime's `served_stale_cache`
|
|
3143
|
+
* projection on an operation step result for the probe's own operation.
|
|
3144
|
+
*
|
|
3145
|
+
* Exact on three counts, because each looser form is a way to silence the rule
|
|
3146
|
+
* without checking cache provenance: a literal `"served_stale_cache"` in an
|
|
3147
|
+
* `inputTemplate` or a `reasonKey` is not a reference at all;
|
|
3148
|
+
* `["data", "served_stale_cache"]` is an upstream payload field the provider
|
|
3149
|
+
* happens to have named that way; and a reference bound to an `extract` result
|
|
3150
|
+
* is not the operation step this probe invoked.
|
|
3151
|
+
*/
|
|
3152
|
+
function isStaleCacheOperand(operand: unknown, bindings: ReadonlySet<string>): boolean {
|
|
3153
|
+
if (!isLintRecord(operand)) return false;
|
|
3154
|
+
const reference = operand.ref;
|
|
3155
|
+
return (
|
|
3156
|
+
isLintRecord(reference) &&
|
|
3157
|
+
reference.namespace === "steps" &&
|
|
3158
|
+
typeof reference.binding === "string" &&
|
|
3159
|
+
bindings.has(reference.binding) &&
|
|
3160
|
+
Array.isArray(reference.path) &&
|
|
3161
|
+
reference.path.length === 1 &&
|
|
3162
|
+
reference.path[0] === SERVED_STALE_CACHE_OPERAND
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/**
|
|
3167
|
+
* Whether a single predicate over the operand is FALSE for a stale serve, under
|
|
3168
|
+
* `negated` negation depth. `served_stale_cache` is always present and always a
|
|
3169
|
+
* boolean, so `exists` and `type_is: "boolean"` pass for a stale serve too —
|
|
3170
|
+
* reading the operand is not the same as rejecting it.
|
|
3171
|
+
*/
|
|
3172
|
+
function predicateRejectsStaleServe(
|
|
3173
|
+
predicate: Record<string, unknown>,
|
|
3174
|
+
bindings: ReadonlySet<string>,
|
|
3175
|
+
negated: boolean,
|
|
3176
|
+
): boolean {
|
|
3177
|
+
// `equals`/`not_equals` are symmetric and the schema allows a reference on
|
|
3178
|
+
// either side, so `equals(actual: false, expected: <ref>)` is the same check
|
|
3179
|
+
// written the other way round.
|
|
3180
|
+
const onActual = isStaleCacheOperand(predicate.actual, bindings);
|
|
3181
|
+
const onExpected = isStaleCacheOperand(predicate.expected, bindings);
|
|
3182
|
+
if (!onActual && !onExpected) return false;
|
|
3183
|
+
const { operator } = predicate;
|
|
3184
|
+
const literal = onActual ? predicate.expected : predicate.actual;
|
|
3185
|
+
const failsWhenStale =
|
|
3186
|
+
(operator === "not_equals" && literal === true) || (operator === "equals" && literal === false);
|
|
3187
|
+
const holdsWhenStale =
|
|
3188
|
+
(operator === "is_true" && onActual) ||
|
|
3189
|
+
(operator === "equals" && literal === true) ||
|
|
3190
|
+
(operator === "not_equals" && literal === false);
|
|
3191
|
+
return negated ? holdsWhenStale : failsWhenStale;
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
/**
|
|
3195
|
+
* Whether an assertion expression evaluates FALSE when the operation step was
|
|
3196
|
+
* answered from a stale cache — the only thing that makes a guard degrade or an
|
|
3197
|
+
* assert fail. A clause under `any` only counts if every sibling branch also
|
|
3198
|
+
* rejects the stale serve, because `any` is satisfied by the branch that still
|
|
3199
|
+
* passes (`status_2xx` on a stale 200 being the obvious one).
|
|
3200
|
+
*/
|
|
3201
|
+
function rejectsStaleServe(node: unknown, bindings: ReadonlySet<string>, negated = false): boolean {
|
|
3202
|
+
if (!isLintRecord(node)) return false;
|
|
3203
|
+
if (node.kind === "not") return rejectsStaleServe(node.clause, bindings, !negated);
|
|
3204
|
+
if (node.kind === "all" || node.kind === "any") {
|
|
3205
|
+
if (!Array.isArray(node.clauses)) return false;
|
|
3206
|
+
// `all` fails if ANY clause fails; `any` fails only if EVERY clause fails.
|
|
3207
|
+
// Negation swaps which of the two the operator behaves like.
|
|
3208
|
+
const conjunctive = negated ? node.kind === "any" : node.kind === "all";
|
|
3209
|
+
return conjunctive
|
|
3210
|
+
? node.clauses.some((clause) => rejectsStaleServe(clause, bindings, negated))
|
|
3211
|
+
: node.clauses.every((clause) => rejectsStaleServe(clause, bindings, negated));
|
|
3212
|
+
}
|
|
3213
|
+
if (node.kind === "predicate") return predicateRejectsStaleServe(node, bindings, negated);
|
|
3214
|
+
return false;
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
/**
|
|
3218
|
+
* Whether a guard's degrade lands on the operation the case is about. A guard
|
|
3219
|
+
* whose condition reads the checked operation's freshness but attributes the
|
|
3220
|
+
* failure to a different operation leaves the checked one green through the
|
|
3221
|
+
* stale serve — covered on paper, not on the status page.
|
|
3222
|
+
*/
|
|
3223
|
+
/**
|
|
3224
|
+
* Whether an assert records its verdict against the checked operation. Same
|
|
3225
|
+
* concern as `guardAttributesTo`: an assertion that covers only some other
|
|
3226
|
+
* operation fails that one, not this one.
|
|
3227
|
+
*/
|
|
3228
|
+
function assertCoversOperation(coversOperations: unknown, operationId: string): boolean {
|
|
3229
|
+
return Array.isArray(coversOperations) && coversOperations.includes(operationId);
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function guardAttributesTo(onFail: unknown, operationId: string): boolean {
|
|
3233
|
+
if (!isLintRecord(onFail) || !Array.isArray(onFail.attribute)) return false;
|
|
3234
|
+
return onFail.attribute.some((entry) => isLintRecord(entry) && entry.operationId === operationId);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
/**
|
|
3238
|
+
* Whether a scenario reaches a verdict on freshness: a `guard` condition
|
|
3239
|
+
* (degraded, the shape this rule recommends) or an `assert` expression (down,
|
|
3240
|
+
* harsher but still real coverage). Any other position — an extract selector,
|
|
3241
|
+
* an operation input — is not a verdict.
|
|
3242
|
+
*
|
|
3243
|
+
* Order matters, and this is the rule the guidance states: the freshness
|
|
3244
|
+
* verdict must come before any OTHER guard. Every guard stops the scenario when
|
|
3245
|
+
* it fires, so a row or emptiness guard placed first answers a stale serve that
|
|
3246
|
+
* happens to be empty with its own reason and the freshness guard never runs —
|
|
3247
|
+
* the probe degrades for the wrong cause and the tenant reads "no rows" during
|
|
3248
|
+
* an upstream outage. Asserts before it are fine; they are the evidence clauses
|
|
3249
|
+
* and only stop the scenario by failing it outright.
|
|
3250
|
+
*/
|
|
3251
|
+
function scenarioChecksFreshness(scenario: unknown, operationId: string): boolean {
|
|
3252
|
+
if (!isLintRecord(scenario) || !Array.isArray(scenario.steps)) return false;
|
|
3253
|
+
const bindings = [...operationStepBindings(scenario.steps, operationId)];
|
|
3254
|
+
if (bindings.length === 0) return false;
|
|
3255
|
+
// EVERY read of the operation must be checked. A scenario that invokes it
|
|
3256
|
+
// twice with different inputs has two chances to serve stale, and one fresh
|
|
3257
|
+
// read must not conceal the other.
|
|
3258
|
+
return bindings.every((binding) =>
|
|
3259
|
+
resultBindingChecksFreshness(
|
|
3260
|
+
scenario.steps as readonly unknown[],
|
|
3261
|
+
binding,
|
|
3262
|
+
operationId,
|
|
3263
|
+
bindings,
|
|
3264
|
+
),
|
|
3265
|
+
);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
function resultBindingChecksFreshness(
|
|
3269
|
+
steps: readonly unknown[],
|
|
3270
|
+
binding: string,
|
|
3271
|
+
operationId: string,
|
|
3272
|
+
siblingBindings: readonly string[],
|
|
3273
|
+
): boolean {
|
|
3274
|
+
const operand = new Set([binding]);
|
|
3275
|
+
let produced = false;
|
|
3276
|
+
for (const step of steps) {
|
|
3277
|
+
if (!isLintRecord(step)) continue;
|
|
3278
|
+
if (!produced) {
|
|
3279
|
+
// Nothing before the read can have checked its freshness.
|
|
3280
|
+
produced = step.kind === "operation" && step.result === binding;
|
|
3281
|
+
continue;
|
|
3282
|
+
}
|
|
3283
|
+
if (
|
|
3284
|
+
step.kind === "assert" &&
|
|
3285
|
+
rejectsStaleServe(step.expression, operand) &&
|
|
3286
|
+
assertCoversOperation(step.coversOperations, operationId)
|
|
3287
|
+
) {
|
|
3288
|
+
return true;
|
|
3289
|
+
}
|
|
3290
|
+
if (step.kind !== "guard") continue;
|
|
3291
|
+
if (rejectsStaleServe(step.condition, operand)) {
|
|
3292
|
+
return guardAttributesTo(step.onFail, operationId);
|
|
3293
|
+
}
|
|
3294
|
+
// A freshness guard for ANOTHER read of the same operation is not a row or
|
|
3295
|
+
// emptiness guard masking the cause: it degrades on exactly this concern,
|
|
3296
|
+
// so `read A, read B, guard A, guard B` covers both. Anything else stops
|
|
3297
|
+
// the scenario first and shadows a later freshness guard.
|
|
3298
|
+
const coversSibling = siblingBindings.some(
|
|
3299
|
+
(sibling) =>
|
|
3300
|
+
sibling !== binding &&
|
|
3301
|
+
rejectsStaleServe(step.condition, new Set([sibling])) &&
|
|
3302
|
+
guardAttributesTo(step.onFail, operationId),
|
|
3303
|
+
);
|
|
3304
|
+
if (!coversSibling) return false;
|
|
3305
|
+
}
|
|
3306
|
+
return false;
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
/**
|
|
3310
|
+
* Whether a freshness guard for this operation still attributes
|
|
3311
|
+
* `expected_absence` — the only code the attribution schema admitted before
|
|
3312
|
+
* `served_stale_cache` existed, so earlier guidance recommended it. It IS
|
|
3313
|
+
* freshness coverage (the guard runs and stops the scenario), which is why
|
|
3314
|
+
* `scenarioChecksFreshness` accepts it; but the platform publishes
|
|
3315
|
+
* `expected_absence` as a non-incident status, so the stale serve it catches
|
|
3316
|
+
* would read as "nothing to return" instead of `degraded`.
|
|
3317
|
+
*/
|
|
3318
|
+
function freshnessGuardAttributesExpectedAbsence(scenario: unknown, operationId: string): boolean {
|
|
3319
|
+
if (!isLintRecord(scenario) || !Array.isArray(scenario.steps)) return false;
|
|
3320
|
+
const bindings = operationStepBindings(scenario.steps, operationId);
|
|
3321
|
+
if (bindings.size === 0) return false;
|
|
3322
|
+
return scenario.steps.some(
|
|
3323
|
+
(step) =>
|
|
3324
|
+
isLintRecord(step) &&
|
|
3325
|
+
step.kind === "guard" &&
|
|
3326
|
+
rejectsStaleServe(step.condition, bindings) &&
|
|
3327
|
+
isLintRecord(step.onFail) &&
|
|
3328
|
+
Array.isArray(step.onFail.attribute) &&
|
|
3329
|
+
step.onFail.attribute.some(
|
|
3330
|
+
(entry) =>
|
|
3331
|
+
isLintRecord(entry) &&
|
|
3332
|
+
entry.operationId === operationId &&
|
|
3333
|
+
entry.reasonCode === "expected_absence",
|
|
3334
|
+
),
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
/** How many uncovered probes the stale-serve message names before eliding. */
|
|
3339
|
+
const STALE_SERVE_MESSAGE_PROBE_LIMIT = 8;
|
|
3340
|
+
|
|
3341
|
+
/**
|
|
3342
|
+
* Health-check cases the platform monitor cannot execute, and stale-if-error
|
|
3343
|
+
* serves no probe can see.
|
|
3344
|
+
*
|
|
3345
|
+
* The monitor runs a case's SERIALIZED `scenario` and nothing else — a closure
|
|
3346
|
+
* cannot cross the registry's serialization boundary. A case carrying only
|
|
3347
|
+
* `assertions` is still published as a probe, but the registry projects it as
|
|
3348
|
+
* `legacy_function_assertions_not_serializable` and the monitor answers
|
|
3349
|
+
* `unknown` / `monitoring_unavailable` for it forever: the operation reads as
|
|
3350
|
+
* monitored on the status page while nothing is checked.
|
|
3351
|
+
*
|
|
3352
|
+
* A provider that reads its upstream through `ctx.cache.getOrSet(..., {
|
|
3353
|
+
* staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
|
|
3354
|
+
* schema-valid body for the whole stale window, so every clause over
|
|
3355
|
+
* `status_code` and `data` passes and the probe rolls up `ok` through the
|
|
3356
|
+
* outage. `served_stale_cache` on the operation step result is the only operand
|
|
3357
|
+
* that separates that from a live read.
|
|
3358
|
+
*
|
|
3359
|
+
* Both are warnings while the fleet migrates; they become errors once no
|
|
3360
|
+
* repository carries an unmonitored case.
|
|
3361
|
+
*/
|
|
3362
|
+
function lintHealthCheckMonitorability(provider: {
|
|
3363
|
+
id?: string;
|
|
3364
|
+
providerSourceFiles?: Record<string, string>;
|
|
3365
|
+
operations?: Record<string, { handler?: unknown; source?: string; healthCheck?: unknown }>;
|
|
3366
|
+
}): LintDiagnostic[] {
|
|
3367
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
3368
|
+
const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
|
|
3369
|
+
// Freshness is judged per CASE. Operation-level bookkeeping would let one
|
|
3370
|
+
// guarded case silence its unguarded siblings, which still report `ok`
|
|
3371
|
+
// through a stale serve — the same fail-open this rule exists to close.
|
|
3372
|
+
const uncoveredByOperation = new Map<string, string[]>();
|
|
3373
|
+
|
|
3374
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
3375
|
+
const cases = readHealthCheckCases(operation.healthCheck);
|
|
3376
|
+
const unmonitored: string[] = [];
|
|
3377
|
+
const absenceAttributed: string[] = [];
|
|
3378
|
+
for (const [caseIndex, healthCase] of cases.entries()) {
|
|
3379
|
+
const caseLabel =
|
|
3380
|
+
typeof healthCase.name === "string" && healthCase.name.length > 0
|
|
3381
|
+
? `"${healthCase.name}"`
|
|
3382
|
+
: `[${caseIndex}]`;
|
|
3383
|
+
if (healthCase.scenario === undefined) {
|
|
3384
|
+
unmonitored.push(caseLabel);
|
|
3385
|
+
}
|
|
3386
|
+
if (!scenarioChecksFreshness(healthCase.scenario, operationKey)) {
|
|
3387
|
+
const uncovered = uncoveredByOperation.get(operationKey) ?? [];
|
|
3388
|
+
uncovered.push(`${operationKey} ${caseLabel}`);
|
|
3389
|
+
uncoveredByOperation.set(operationKey, uncovered);
|
|
3390
|
+
}
|
|
3391
|
+
if (freshnessGuardAttributesExpectedAbsence(healthCase.scenario, operationKey)) {
|
|
3392
|
+
absenceAttributed.push(caseLabel);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
if (absenceAttributed.length > 0) {
|
|
3396
|
+
diagnostics.push({
|
|
3397
|
+
rule: "health-check-stale-serve-reason",
|
|
3398
|
+
level: "warn",
|
|
3399
|
+
field: `operations.${operationKey}.healthCheck.cases`,
|
|
3400
|
+
message: `${providerLabel} operation "${operationKey}" health-check ${absenceAttributed.length === 1 ? "case" : "cases"} ${absenceAttributed.join(", ")} guard ${SERVED_STALE_CACHE_OPERAND} but attribute reasonCode "expected_absence". The platform publishes expected_absence as a non-incident status (neither ok nor degraded, excluded from uptime and incidents), so an upstream outage this guard catches would read as "nothing to return" instead of degraded. Attribute reasonCode "served_stale_cache" instead; the reasonKey and its copy can stay.`,
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
if (unmonitored.length > 0) {
|
|
3404
|
+
diagnostics.push({
|
|
3405
|
+
rule: "health-check-assertions-not-monitored",
|
|
3406
|
+
level: "warn",
|
|
3407
|
+
field: `operations.${operationKey}.healthCheck.cases`,
|
|
3408
|
+
message: `${providerLabel} operation "${operationKey}" health-check ${unmonitored.length === 1 ? "case" : "cases"} ${unmonitored.join(", ")} declare assertions without a scenario. The platform health monitor executes only a case's serialized scenario, so this operation publishes a probe whose outcome is permanently unknown/monitoring_unavailable — it reads as monitored while nothing is checked. Express the checks as scenario: defineHealthScenario({ ... }).`,
|
|
3409
|
+
});
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
if (uncoveredByOperation.size === 0) return diagnostics;
|
|
3414
|
+
|
|
3415
|
+
// Attribution. A handler that carries the cache call in its OWN source names
|
|
3416
|
+
// exactly the operation that serves stale. A file does not: the linter cannot
|
|
3417
|
+
// follow the call graph, and it cannot reliably tell a handler's own file
|
|
3418
|
+
// from a shared helper either — `getOperationSource` returns the runtime's
|
|
3419
|
+
// `handler.toString()`, which type erasure and reformatting make textually
|
|
3420
|
+
// unequal to the TypeScript on disk. So any file-level evidence degrades the
|
|
3421
|
+
// verdict to "somewhere in this provider", and the message says so instead of
|
|
3422
|
+
// implying every listed probe reads through that cache.
|
|
3423
|
+
const sources = new Set<string>();
|
|
3424
|
+
const operationsServingStale = new Set<string>();
|
|
3425
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
3426
|
+
if (servesStaleIfError(getOperationSource(operation), `${operationKey}.ts`)) {
|
|
3427
|
+
sources.add(`operations.${operationKey} handler`);
|
|
3428
|
+
operationsServingStale.add(operationKey);
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
let unattributed = false;
|
|
3432
|
+
for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
|
|
3433
|
+
if (!isServingProviderSourcePath(filePath) || !servesStaleIfError(source, filePath)) continue;
|
|
3434
|
+
sources.add(filePath);
|
|
3435
|
+
unattributed = true;
|
|
3436
|
+
}
|
|
3437
|
+
if (sources.size === 0) return diagnostics;
|
|
3438
|
+
|
|
3439
|
+
const attributable = !unattributed;
|
|
3440
|
+
const uncovered = [...uncoveredByOperation]
|
|
3441
|
+
.filter(([operationKey]) => !attributable || operationsServingStale.has(operationKey))
|
|
3442
|
+
.flatMap(([, probes]) => probes);
|
|
3443
|
+
if (uncovered.length === 0) return diagnostics;
|
|
3444
|
+
|
|
3445
|
+
const elided = uncovered.length - STALE_SERVE_MESSAGE_PROBE_LIMIT;
|
|
3446
|
+
const named = uncovered
|
|
3447
|
+
.slice(0, STALE_SERVE_MESSAGE_PROBE_LIMIT)
|
|
3448
|
+
.join(", ")
|
|
3449
|
+
.concat(elided > 0 ? `, and ${elided} more` : "");
|
|
3450
|
+
const caveat = attributable
|
|
3451
|
+
? ""
|
|
3452
|
+
: ` The cache call is in provider source the linter cannot attribute to one operation, so every uncovered probe is listed — skip any whose operation does not read through that cache.`;
|
|
3453
|
+
diagnostics.push({
|
|
3454
|
+
rule: "health-check-stale-serve-unguarded",
|
|
3455
|
+
level: "warn",
|
|
3456
|
+
field: "healthCheck",
|
|
3457
|
+
message: `${providerLabel} serves stale-if-error (staleIfErrorMs in ${[...sources].sort().join(", ")}) and these health-check cases never check ${SERVED_STALE_CACHE_OPERAND}: ${named}. During an upstream outage the cache answers HTTP 200 with a schema-valid body, so every clause over status_code and data still passes and those probes report ok for the whole stale window. Add one guard step per affected probe on { ref: { namespace: "steps", binding: "<operation step result>", path: ["${SERVED_STALE_CACHE_OPERAND}"] } } attributing status "degraded" with reasonCode "served_stale_cache" (a stale serve is an upstream fault; "expected_absence" is the non-incident status for an absence the provider declares in advance), placed before any row or emptiness guard.${caveat}`,
|
|
3458
|
+
});
|
|
3459
|
+
return diagnostics;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
export function lintOperation(op: {
|
|
3463
|
+
descriptionKey?: string;
|
|
3464
|
+
whenToUseKeys?: readonly string[];
|
|
3465
|
+
whenNotToUseKeys?: readonly string[];
|
|
3466
|
+
input: unknown;
|
|
3467
|
+
output: unknown;
|
|
3468
|
+
fixtures?: unknown;
|
|
3469
|
+
}): LintDiagnostic[] {
|
|
3470
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
3471
|
+
const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
|
|
3472
|
+
|
|
3473
|
+
if (!hasDescriptionKey) {
|
|
3474
|
+
diagnostics.push({
|
|
3475
|
+
rule: "description-key-required",
|
|
3476
|
+
level: "error",
|
|
3477
|
+
field: "descriptionKey",
|
|
3478
|
+
message: "Operation must declare a locale-backed descriptionKey.",
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
diagnostics.push(
|
|
3483
|
+
...collectSchemaDescriptionKeyDiagnostics(op.input, "input"),
|
|
3484
|
+
...collectSchemaDescriptionKeyDiagnostics(op.output, "output"),
|
|
3485
|
+
);
|
|
3486
|
+
|
|
3487
|
+
if (!hasBidirectionalFixtures(op.fixtures)) {
|
|
3488
|
+
diagnostics.push({
|
|
3489
|
+
rule: "fixtures-both-directions",
|
|
3490
|
+
level: "error",
|
|
3491
|
+
field: "fixtures",
|
|
3492
|
+
message: "Fixtures must include both request and response.",
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.input, "input"))) {
|
|
3497
|
+
diagnostics.push({
|
|
3498
|
+
rule: "sensitive-field-unmarked",
|
|
3499
|
+
level: "warn",
|
|
3500
|
+
field,
|
|
3501
|
+
message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.output, "output"))) {
|
|
3506
|
+
diagnostics.push({
|
|
3507
|
+
rule: "sensitive-field-unmarked",
|
|
3508
|
+
level: "warn",
|
|
3509
|
+
field,
|
|
3510
|
+
message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
return diagnostics;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function declaredPinnedWireFieldPaths(provider: {
|
|
3518
|
+
meta?: { contract?: ProviderContractMetaLike };
|
|
3519
|
+
}): readonly { readonly path: string; readonly reason: string }[] {
|
|
3520
|
+
const pins = provider.meta?.contract?.pinnedWireFieldPaths;
|
|
3521
|
+
if (!Array.isArray(pins)) return [];
|
|
3522
|
+
return pins.filter(
|
|
3523
|
+
(pin) =>
|
|
3524
|
+
pin !== null &&
|
|
3525
|
+
typeof pin === "object" &&
|
|
3526
|
+
typeof pin.path === "string" &&
|
|
3527
|
+
pin.path.trim().length > 0 &&
|
|
3528
|
+
typeof pin.reason === "string" &&
|
|
3529
|
+
pin.reason.trim().length > 0,
|
|
3530
|
+
);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
function applyPinnedWireFieldPaths(
|
|
3534
|
+
provider: { meta?: { contract?: ProviderContractMetaLike } },
|
|
3535
|
+
diagnostics: readonly LintDiagnostic[],
|
|
3536
|
+
): ProviderLintResult {
|
|
3537
|
+
const pins = declaredPinnedWireFieldPaths(provider);
|
|
3538
|
+
if (pins.length === 0) {
|
|
3539
|
+
return { diagnostics: [...diagnostics], information: [] };
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
const pinsByPath = new Map(pins.map((pin) => [pin.path, pin]));
|
|
3543
|
+
const matchedPaths = new Set<string>();
|
|
3544
|
+
const remainingDiagnostics = diagnostics.filter((diagnostic) => {
|
|
3545
|
+
if (
|
|
3546
|
+
diagnostic.rule !== "public-schema-upstream-field" ||
|
|
3547
|
+
diagnostic.field === undefined ||
|
|
3548
|
+
!pinsByPath.has(diagnostic.field)
|
|
3549
|
+
) {
|
|
3550
|
+
return true;
|
|
3551
|
+
}
|
|
3552
|
+
matchedPaths.add(diagnostic.field);
|
|
3553
|
+
return false;
|
|
3554
|
+
});
|
|
3555
|
+
|
|
3556
|
+
const information: ProviderLintInformation[] = [];
|
|
3557
|
+
for (const pin of pins) {
|
|
3558
|
+
if (matchedPaths.has(pin.path)) {
|
|
3559
|
+
information.push({
|
|
3560
|
+
rule: "public-schema-pinned-wire-field",
|
|
3561
|
+
field: pin.path,
|
|
3562
|
+
message: `Suppressed exact public-schema-upstream-field diagnostic. Reason: ${pin.reason}`,
|
|
3563
|
+
});
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
remainingDiagnostics.push({
|
|
3568
|
+
rule: "public-schema-pinned-wire-field-stale",
|
|
3569
|
+
level: "error",
|
|
3570
|
+
field: pin.path,
|
|
3571
|
+
message: `Pinned wire field path ${JSON.stringify(pin.path)} matches no current public-schema-upstream-field diagnostic; remove the stale declaration.`,
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
return { diagnostics: remainingDiagnostics, information };
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
export function lintProvider(
|
|
3579
|
+
provider: {
|
|
3580
|
+
id?: string;
|
|
3581
|
+
allowedHosts?: readonly string[];
|
|
3582
|
+
stealth?: unknown;
|
|
3583
|
+
auth?: ProviderAuthLike;
|
|
3584
|
+
credential?: {
|
|
3585
|
+
keys?: readonly string[];
|
|
3586
|
+
storesReusableSecret?: boolean;
|
|
3587
|
+
justification?: string;
|
|
3588
|
+
};
|
|
3589
|
+
context?: {
|
|
3590
|
+
keys?: readonly string[];
|
|
3591
|
+
};
|
|
3592
|
+
authFlowSource?: string;
|
|
3593
|
+
providerSourceFiles?: Record<string, string>;
|
|
3594
|
+
/**
|
|
3595
|
+
* The provider's `locales/en.json`, injected by `apifuse check`. Error
|
|
3596
|
+
* message localization rules are skipped when it is absent.
|
|
3597
|
+
*/
|
|
3598
|
+
localeCatalogEn?: ProviderLocaleCatalog;
|
|
3599
|
+
operations?: Record<
|
|
3600
|
+
string,
|
|
3601
|
+
{
|
|
3602
|
+
descriptionKey?: string;
|
|
3603
|
+
whenToUseKeys?: readonly string[];
|
|
3604
|
+
whenNotToUseKeys?: readonly string[];
|
|
3605
|
+
connectionMode?: "none" | "optional" | "required";
|
|
3606
|
+
riskClass?: OperationRiskClass;
|
|
3607
|
+
approval?: OperationApprovalPolicy;
|
|
3608
|
+
input: unknown;
|
|
3609
|
+
output: unknown;
|
|
3610
|
+
fixtures?: unknown;
|
|
3611
|
+
handler?: unknown;
|
|
3612
|
+
source?: string;
|
|
3613
|
+
/** Read structurally by the health-check monitorability rules. */
|
|
3614
|
+
healthCheck?: unknown;
|
|
3615
|
+
errorCodes?: ReadonlyArray<{
|
|
3616
|
+
code: string;
|
|
3617
|
+
status?: number;
|
|
3618
|
+
retryable?: boolean;
|
|
3619
|
+
messageKey?: string;
|
|
3620
|
+
fixKey?: string;
|
|
3621
|
+
}>;
|
|
3622
|
+
}
|
|
3623
|
+
>;
|
|
3624
|
+
meta?: {
|
|
3625
|
+
contract?: ProviderContractMetaLike;
|
|
3626
|
+
};
|
|
3627
|
+
reviewed?: string;
|
|
3628
|
+
/** Declared handle kinds (ADR-0012); read structurally, see readHandleKindDeclarations. */
|
|
3629
|
+
handle?: unknown;
|
|
3630
|
+
state?: unknown;
|
|
3631
|
+
},
|
|
3632
|
+
options: ProviderLintOptions = {},
|
|
3633
|
+
): LintDiagnostic[] {
|
|
3634
|
+
return lintProviderWithInformation(provider, options).diagnostics;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
/** Internal detailed result used by the CLI to render suppression audits. */
|
|
3638
|
+
export function lintProviderWithInformation(
|
|
3639
|
+
provider: Parameters<typeof lintProvider>[0],
|
|
3640
|
+
options: ProviderLintOptions = {},
|
|
3641
|
+
): ProviderLintResult {
|
|
3642
|
+
const diagnostics: LintDiagnostic[] = [
|
|
3643
|
+
...lintAllowedHosts(provider.id, provider.allowedHosts),
|
|
3644
|
+
...lintReviewed(provider.id, provider.reviewed),
|
|
3645
|
+
...lintAuthModel(provider),
|
|
3646
|
+
...lintStealthTransportUsage(provider),
|
|
3647
|
+
...lintCredentialWriteUsage(provider),
|
|
3648
|
+
...lintPlaywrightDirectImports(provider),
|
|
3649
|
+
...lintSelfHostedBrowserPatterns(provider, options),
|
|
3650
|
+
...lintBrowserVersionLiterals(provider),
|
|
3651
|
+
...lintUndeclaredThrownErrorCodes(provider),
|
|
3652
|
+
...lintErrorCodeDeclarationConflicts(provider),
|
|
3653
|
+
...lintErrorMessageLocalization(provider),
|
|
3654
|
+
...lintLegacyChoiceUsage(provider),
|
|
3655
|
+
...lintProviderMetaNarrowing(provider),
|
|
3656
|
+
...lintHandleDeclarations(provider),
|
|
3657
|
+
...lintHealthCheckMonitorability(provider),
|
|
3658
|
+
];
|
|
3659
|
+
// Runtime boundary rules (process.env, node:fs/net/child_process, raw
|
|
3660
|
+
// fetch) also yield information entries for `@apifuse-allow` acknowledgements.
|
|
3661
|
+
const runtimeBoundary = lintRuntimeBoundary(getTypeScript(), provider, getOperationSource);
|
|
3662
|
+
diagnostics.push(...runtimeBoundary.diagnostics);
|
|
3663
|
+
|
|
3664
|
+
if (provider.operations) {
|
|
3665
|
+
const authMode = provider.auth?.mode;
|
|
3666
|
+
for (const [operationKey, operation] of Object.entries(provider.operations)) {
|
|
3667
|
+
if (isCredentialBearingAuthMode(authMode) && operation.connectionMode === undefined) {
|
|
3668
|
+
diagnostics.push({
|
|
3669
|
+
rule: "mixed-auth-connection-mode-required",
|
|
3670
|
+
level: "error",
|
|
3671
|
+
field: `operations.${operationKey}.connectionMode`,
|
|
3672
|
+
message: `Provider "${provider.id ?? "unknown"}" uses credential-bearing auth.mode "${authMode}"; operation "${operationKey}" must declare connectionMode explicitly.`,
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
if (
|
|
3676
|
+
operation.riskClass !== undefined &&
|
|
3677
|
+
operation.approval !== undefined &&
|
|
3678
|
+
operation.approval === defaultApprovalPolicy(operation.riskClass)
|
|
3679
|
+
) {
|
|
3680
|
+
diagnostics.push({
|
|
3681
|
+
rule: "redundant-approval",
|
|
3682
|
+
level: "error",
|
|
3683
|
+
field: `operations.${operationKey}.approval`,
|
|
3684
|
+
message: `Operation "${operationKey}" approval "${operation.approval}" repeats the default for riskClass "${operation.riskClass}"; omit approval unless it is a deliberate override.`,
|
|
3685
|
+
});
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
// Every authenticated mode owns an auth.flow; `oauth2_proxied` was
|
|
3689
|
+
// previously exempt, which let auth-lifecycle operations ship on
|
|
3690
|
+
// proxied providers unchecked.
|
|
3691
|
+
if (authMode === "credentials" || authMode === "oauth2" || authMode === "oauth2_proxied") {
|
|
3692
|
+
for (const operationKey of Object.keys(provider.operations)) {
|
|
3693
|
+
if (isAuthLifecycleOperationId(operationKey, authMode)) {
|
|
3694
|
+
diagnostics.push({
|
|
3695
|
+
rule: "auth-operation-unsupported",
|
|
3696
|
+
level: "error",
|
|
3697
|
+
field: `operations.${operationKey}`,
|
|
3698
|
+
message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" performs an auth-lifecycle action (login, logout, token exchange or similar). Authenticated providers must expose the whole credential lifecycle through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential, and an operation that mutates the session outside that interface leaves the stored connection stale. Move sign-in logic into auth.flow.start/continue and sign-out/disconnect logic into auth.flow.abort (served by POST /auth/disconnect) instead of a provider operation.`,
|
|
3699
|
+
});
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
if (!provider.operations) {
|
|
3706
|
+
return withInformation(
|
|
3707
|
+
applyPinnedWireFieldPaths(provider, diagnostics),
|
|
3708
|
+
runtimeBoundary.information,
|
|
3709
|
+
);
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
diagnostics.push(
|
|
3713
|
+
...Object.entries(provider.operations).flatMap(([operationKey, operation]) =>
|
|
3714
|
+
[
|
|
3715
|
+
...lintOperation({
|
|
3716
|
+
descriptionKey: operation.descriptionKey,
|
|
984
3717
|
whenToUseKeys: operation.whenToUseKeys,
|
|
985
|
-
whenNotToUse: operation.whenNotToUse,
|
|
986
3718
|
whenNotToUseKeys: operation.whenNotToUseKeys,
|
|
987
3719
|
input: operation.input,
|
|
988
3720
|
output: operation.output,
|
|
989
3721
|
fixtures: operation.fixtures,
|
|
990
|
-
inputExamples: operation.inputExamples,
|
|
991
|
-
derivations: operation.derivations,
|
|
992
3722
|
}),
|
|
993
3723
|
...lintPublicSchemaFieldNames(
|
|
994
3724
|
provider.id,
|
|
@@ -1007,5 +3737,16 @@ export function lintProvider(
|
|
|
1007
3737
|
),
|
|
1008
3738
|
);
|
|
1009
3739
|
|
|
1010
|
-
return
|
|
3740
|
+
return withInformation(
|
|
3741
|
+
applyPinnedWireFieldPaths(provider, diagnostics),
|
|
3742
|
+
runtimeBoundary.information,
|
|
3743
|
+
);
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
function withInformation(
|
|
3747
|
+
result: ProviderLintResult,
|
|
3748
|
+
information: readonly ProviderLintInformation[],
|
|
3749
|
+
): ProviderLintResult {
|
|
3750
|
+
if (information.length === 0) return result;
|
|
3751
|
+
return { diagnostics: result.diagnostics, information: [...information, ...result.information] };
|
|
1011
3752
|
}
|