@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.
Files changed (478) hide show
  1. package/AUTHORING.md +943 -51
  2. package/CHANGELOG.md +273 -1
  3. package/LICENSE +21 -0
  4. package/README.md +117 -29
  5. package/SUBMISSION.md +3 -2
  6. package/bin/apifuse-check.ts +293 -13
  7. package/bin/apifuse-dev.ts +42 -19
  8. package/bin/apifuse-migrate-deployment.ts +200 -0
  9. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  10. package/bin/apifuse-migrate-shape.ts +202 -0
  11. package/bin/apifuse-pack-check.ts +19 -6
  12. package/bin/apifuse-pack-smoke.ts +57 -2
  13. package/bin/apifuse-pack-types.ts +736 -38
  14. package/bin/apifuse-perf.ts +59 -23
  15. package/bin/apifuse-record.ts +923 -94
  16. package/bin/apifuse-submit-check.ts +2313 -281
  17. package/bin/apifuse-test.ts +3 -1
  18. package/bin/submit-check-delimited-text.ts +50 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth-turn/index.js +1 -1
  21. package/dist/auth.d.ts +14 -0
  22. package/dist/auth.js +67 -0
  23. package/dist/ceremonies/index.d.ts +16 -0
  24. package/dist/ceremonies/index.js +141 -36
  25. package/dist/cli/commands.d.ts +1 -1
  26. package/dist/cli/commands.js +30 -0
  27. package/dist/cli/create.d.ts +1 -1
  28. package/dist/cli/create.js +8 -2
  29. package/dist/cli/deployment-intent.d.ts +178 -0
  30. package/dist/cli/deployment-intent.js +277 -0
  31. package/dist/cli/local-connection.d.ts +111 -0
  32. package/dist/cli/local-connection.js +151 -0
  33. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  34. package/dist/cli/migrate-deployment-intent.js +1552 -0
  35. package/dist/cli/migrate-operation-declaration.d.ts +70 -0
  36. package/dist/cli/migrate-operation-declaration.js +2206 -0
  37. package/dist/cli/migrate-operation-shape.d.ts +44 -0
  38. package/dist/cli/migrate-operation-shape.js +113 -0
  39. package/dist/cli/migrate-provider-shape.d.ts +56 -0
  40. package/dist/cli/migrate-provider-shape.js +580 -0
  41. package/dist/cli/resolver-runtime.d.ts +21 -0
  42. package/dist/cli/resolver-runtime.js +31 -0
  43. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  44. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  45. package/dist/cli/templates/provider/README.md.tpl +41 -19
  46. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  47. package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
  48. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  49. package/dist/config/loader.d.ts +202 -16
  50. package/dist/config/loader.js +491 -141
  51. package/dist/contract-serialization.js +4 -8
  52. package/dist/contract-types.d.ts +12 -5
  53. package/dist/contract.js +24 -10
  54. package/dist/declaration-validation.d.ts +34 -0
  55. package/dist/declaration-validation.js +305 -0
  56. package/dist/define.d.ts +71 -29
  57. package/dist/define.js +807 -69
  58. package/dist/dev.d.ts +5 -0
  59. package/dist/dev.js +8 -1
  60. package/dist/engine.d.ts +129 -0
  61. package/dist/engine.js +209 -0
  62. package/dist/error-observability.d.ts +7 -0
  63. package/dist/error-observability.js +61 -0
  64. package/dist/error-resolution.d.ts +18 -0
  65. package/dist/error-resolution.js +243 -0
  66. package/dist/errors.d.ts +74 -0
  67. package/dist/errors.js +99 -0
  68. package/dist/fixture-sanitization.d.ts +28 -0
  69. package/dist/fixture-sanitization.js +371 -0
  70. package/dist/handle-meta.d.ts +90 -0
  71. package/dist/handle-meta.js +73 -0
  72. package/dist/handle.d.ts +226 -0
  73. package/dist/handle.js +345 -0
  74. package/dist/health-scenario.d.ts +1901 -0
  75. package/dist/health-scenario.js +653 -0
  76. package/dist/i18n/catalog.js +21 -1
  77. package/dist/i18n/error-messages.d.ts +86 -0
  78. package/dist/i18n/error-messages.js +192 -0
  79. package/dist/i18n/index.d.ts +1 -0
  80. package/dist/i18n/index.js +1 -0
  81. package/dist/i18n/keys.d.ts +12 -0
  82. package/dist/i18n/keys.js +76 -5
  83. package/dist/i18n/operation-locale-namespace.d.ts +2 -0
  84. package/dist/i18n/operation-locale-namespace.js +7 -0
  85. package/dist/index.d.ts +40 -15
  86. package/dist/index.js +29 -11
  87. package/dist/lint.d.ts +43 -11
  88. package/dist/lint.js +2512 -177
  89. package/dist/native-address.d.ts +43 -0
  90. package/dist/native-address.js +281 -0
  91. package/dist/native-egress-policy.d.ts +31 -0
  92. package/dist/native-egress-policy.js +288 -0
  93. package/dist/observability.d.ts +5 -2
  94. package/dist/observability.js +48 -1
  95. package/dist/provider.d.ts +18 -8
  96. package/dist/provider.js +11 -6
  97. package/dist/runtime/auth-flow.d.ts +6 -1
  98. package/dist/runtime/auth-flow.js +8 -0
  99. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  100. package/dist/runtime/bounded-expiring-map.js +64 -0
  101. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  102. package/dist/runtime/browser-telemetry-binding.js +83 -0
  103. package/dist/runtime/browser-telemetry.d.ts +76 -0
  104. package/dist/runtime/browser-telemetry.js +165 -0
  105. package/dist/runtime/browser.d.ts +4 -0
  106. package/dist/runtime/browser.js +688 -83
  107. package/dist/runtime/cache-telemetry.d.ts +71 -0
  108. package/dist/runtime/cache-telemetry.js +177 -0
  109. package/dist/runtime/cache.d.ts +3 -0
  110. package/dist/runtime/cache.js +245 -23
  111. package/dist/runtime/capability-response.d.ts +3 -0
  112. package/dist/runtime/capability-response.js +56 -0
  113. package/dist/runtime/capability-telemetry.d.ts +46 -0
  114. package/dist/runtime/capability-telemetry.js +68 -0
  115. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  116. package/dist/runtime/chrome149-header-order.js +289 -0
  117. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  118. package/dist/runtime/diagnostic-env-values.js +57 -0
  119. package/dist/runtime/diagnostic-env.d.ts +14 -0
  120. package/dist/runtime/diagnostic-env.js +44 -0
  121. package/dist/runtime/diagnostic-redactor.d.ts +54 -0
  122. package/dist/runtime/diagnostic-redactor.js +571 -0
  123. package/dist/runtime/egress-lease.d.ts +65 -0
  124. package/dist/runtime/egress-lease.js +236 -0
  125. package/dist/runtime/engine-mode.d.ts +74 -0
  126. package/dist/runtime/engine-mode.js +152 -0
  127. package/dist/runtime/env.js +14 -1
  128. package/dist/runtime/executor.d.ts +3 -2
  129. package/dist/runtime/executor.js +23 -26
  130. package/dist/runtime/handle-wordlist.d.ts +43 -0
  131. package/dist/runtime/handle-wordlist.js +256 -0
  132. package/dist/runtime/handle.d.ts +71 -0
  133. package/dist/runtime/handle.js +908 -0
  134. package/dist/runtime/http-telemetry-binding.d.ts +10 -0
  135. package/dist/runtime/http-telemetry-binding.js +152 -0
  136. package/dist/runtime/http-telemetry-guard.d.ts +16 -0
  137. package/dist/runtime/http-telemetry-guard.js +202 -0
  138. package/dist/runtime/http-telemetry.d.ts +104 -0
  139. package/dist/runtime/http-telemetry.js +190 -0
  140. package/dist/runtime/http.d.ts +34 -0
  141. package/dist/runtime/http.js +680 -121
  142. package/dist/runtime/insights.js +2 -2
  143. package/dist/runtime/instrumentation.d.ts +2 -2
  144. package/dist/runtime/instrumentation.js +462 -19
  145. package/dist/runtime/native-network-errors.d.ts +33 -0
  146. package/dist/runtime/native-network-errors.js +69 -0
  147. package/dist/runtime/native-network.d.ts +105 -0
  148. package/dist/runtime/native-network.js +1433 -0
  149. package/dist/runtime/native-telemetry.d.ts +142 -0
  150. package/dist/runtime/native-telemetry.js +231 -0
  151. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  152. package/dist/runtime/ocr-telemetry.js +192 -0
  153. package/dist/runtime/ocr.d.ts +29 -0
  154. package/dist/runtime/ocr.js +468 -0
  155. package/dist/runtime/otlp.d.ts +71 -2
  156. package/dist/runtime/otlp.js +398 -16
  157. package/dist/runtime/provider.d.ts +7 -0
  158. package/dist/runtime/provider.js +14 -0
  159. package/dist/runtime/proxy-errors.js +6 -2
  160. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  161. package/dist/runtime/proxy-nodemaven.js +147 -0
  162. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  163. package/dist/runtime/proxy-retry-policy.js +11 -2
  164. package/dist/runtime/proxy-telemetry.d.ts +133 -3
  165. package/dist/runtime/proxy-telemetry.js +218 -70
  166. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  167. package/dist/runtime/redirect-hop-headers.js +58 -0
  168. package/dist/runtime/redirects.d.ts +61 -0
  169. package/dist/runtime/redirects.js +149 -0
  170. package/dist/runtime/redis.d.ts +1 -1
  171. package/dist/runtime/redis.js +4 -2
  172. package/dist/runtime/request-options.d.ts +75 -1
  173. package/dist/runtime/request-options.js +588 -0
  174. package/dist/runtime/request-telemetry.d.ts +70 -0
  175. package/dist/runtime/request-telemetry.js +229 -0
  176. package/dist/runtime/resolver-config.d.ts +13 -0
  177. package/dist/runtime/resolver-config.js +14 -0
  178. package/dist/runtime/resolver-public.d.ts +3 -0
  179. package/dist/runtime/resolver-public.js +1 -0
  180. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  181. package/dist/runtime/resolver-runtime-options.js +33 -0
  182. package/dist/runtime/resolver-shared.d.ts +24 -0
  183. package/dist/runtime/resolver-shared.js +69 -0
  184. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  185. package/dist/runtime/resolver-span-attributes.js +93 -0
  186. package/dist/runtime/resolver-telemetry.d.ts +135 -0
  187. package/dist/runtime/resolver-telemetry.js +204 -0
  188. package/dist/runtime/resolver-usage.d.ts +32 -0
  189. package/dist/runtime/resolver-usage.js +54 -0
  190. package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
  191. package/dist/runtime/resolver-vendors/bindings.js +45 -0
  192. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  193. package/dist/runtime/resolver-vendors/browser.js +423 -0
  194. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  195. package/dist/runtime/resolver-vendors/capsolver.js +544 -0
  196. package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
  197. package/dist/runtime/resolver-vendors/hosts.js +68 -0
  198. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  199. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  200. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  201. package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
  202. package/dist/runtime/resolver-vendors/types.d.ts +125 -0
  203. package/dist/runtime/resolver-vendors/types.js +106 -0
  204. package/dist/runtime/resolver.d.ts +79 -0
  205. package/dist/runtime/resolver.js +1094 -0
  206. package/dist/runtime/state-telemetry.d.ts +79 -0
  207. package/dist/runtime/state-telemetry.js +194 -0
  208. package/dist/runtime/state.d.ts +10 -1
  209. package/dist/runtime/state.js +322 -82
  210. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  211. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  212. package/dist/runtime/stealth-cookies.d.ts +24 -0
  213. package/dist/runtime/stealth-cookies.js +131 -0
  214. package/dist/runtime/stealth-owned-headers.d.ts +39 -0
  215. package/dist/runtime/stealth-owned-headers.js +57 -0
  216. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  217. package/dist/runtime/stealth-telemetry.js +179 -0
  218. package/dist/runtime/stealth.d.ts +45 -6
  219. package/dist/runtime/stealth.js +1775 -320
  220. package/dist/runtime/stt-telemetry.d.ts +77 -0
  221. package/dist/runtime/stt-telemetry.js +176 -0
  222. package/dist/runtime/stt.js +18 -19
  223. package/dist/runtime/timeout.d.ts +5 -0
  224. package/dist/runtime/timeout.js +12 -0
  225. package/dist/runtime/trace-config.d.ts +12 -0
  226. package/dist/runtime/trace-config.js +62 -0
  227. package/dist/runtime/trace.d.ts +17 -0
  228. package/dist/runtime/trace.js +176 -25
  229. package/dist/runtime-boundary-lint.d.ts +137 -0
  230. package/dist/runtime-boundary-lint.js +590 -0
  231. package/dist/schema.d.ts +20 -0
  232. package/dist/schema.js +72 -2
  233. package/dist/serve.d.ts +1 -1
  234. package/dist/serve.js +1 -1
  235. package/dist/server/error-observability.d.ts +1 -0
  236. package/dist/server/error-observability.js +1 -0
  237. package/dist/server/error-stack-frames.d.ts +35 -0
  238. package/dist/server/error-stack-frames.js +219 -0
  239. package/dist/server/index.d.ts +21 -2
  240. package/dist/server/index.js +11 -2
  241. package/dist/server/runtime-diagnostics.d.ts +165 -0
  242. package/dist/server/runtime-diagnostics.js +360 -0
  243. package/dist/server/sdk-version.d.ts +3 -0
  244. package/dist/server/sdk-version.js +19 -0
  245. package/dist/server/self-test-input-tokens.d.ts +2 -1
  246. package/dist/server/self-test-input-tokens.js +18 -14
  247. package/dist/server/self-test-redaction.js +8 -33
  248. package/dist/server/self-test.d.ts +18 -3
  249. package/dist/server/self-test.js +139 -76
  250. package/dist/server/sensitive-values.d.ts +26 -0
  251. package/dist/server/sensitive-values.js +137 -0
  252. package/dist/server/serve-implementation.d.ts +285 -0
  253. package/dist/server/serve-implementation.js +3172 -0
  254. package/dist/server/serve.d.ts +1 -75
  255. package/dist/server/serve.js +1 -1170
  256. package/dist/server/state-backend-report.d.ts +70 -0
  257. package/dist/server/state-backend-report.js +113 -0
  258. package/dist/server/trace-output.d.ts +7 -0
  259. package/dist/server/trace-output.js +111 -0
  260. package/dist/server/types.d.ts +37 -5
  261. package/dist/server/types.js +34 -1
  262. package/dist/stateful/errors.d.ts +19 -0
  263. package/dist/stateful/errors.js +24 -0
  264. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  265. package/dist/stateful/http-provider-event-emitter.js +237 -0
  266. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  267. package/dist/stateful/http-session-owner-registry.js +210 -0
  268. package/dist/stateful/index.d.ts +18 -0
  269. package/dist/stateful/index.js +18 -0
  270. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  271. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  272. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  273. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  274. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  275. package/dist/stateful/provider-event-pipeline.js +1 -0
  276. package/dist/stateful/provider-events.d.ts +101 -0
  277. package/dist/stateful/provider-events.js +289 -0
  278. package/dist/stateful/session-key.d.ts +15 -0
  279. package/dist/stateful/session-key.js +86 -0
  280. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  281. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  282. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  283. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  284. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  285. package/dist/stateful/stateful-provider-adapter.js +287 -0
  286. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  287. package/dist/stateful/stateful-provider-observability.js +161 -0
  288. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  289. package/dist/stateful/stateful-provider-owner-forwarder.js +237 -0
  290. package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
  291. package/dist/stateful/stateful-provider-runtime-context.js +61 -0
  292. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  293. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  294. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  295. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  296. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  297. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  298. package/dist/stateful-signing.d.ts +18 -0
  299. package/dist/stateful-signing.js +27 -0
  300. package/dist/stealth/profiles.d.ts +15 -4
  301. package/dist/stealth/profiles.js +169 -211
  302. package/dist/stream-evidence.d.ts +74 -0
  303. package/dist/stream-evidence.js +785 -0
  304. package/dist/stream.js +7 -1
  305. package/dist/testing/index.d.ts +2 -1
  306. package/dist/testing/index.js +2 -1
  307. package/dist/testing/run.d.ts +32 -2
  308. package/dist/testing/run.js +509 -25
  309. package/dist/trace-sanitization.d.ts +6 -0
  310. package/dist/trace-sanitization.js +68 -0
  311. package/dist/types.d.ts +899 -210
  312. package/dist/types.js +1 -0
  313. package/dist/typescript-module.d.ts +39 -0
  314. package/dist/typescript-module.js +72 -0
  315. package/package.json +52 -6
  316. package/src/auth-turn/index.ts +1 -1
  317. package/src/auth.ts +118 -0
  318. package/src/ceremonies/index.ts +189 -46
  319. package/src/cli/commands.ts +37 -0
  320. package/src/cli/create.ts +8 -2
  321. package/src/cli/deployment-intent.ts +364 -0
  322. package/src/cli/local-connection.ts +221 -0
  323. package/src/cli/migrate-deployment-intent.ts +1908 -0
  324. package/src/cli/migrate-operation-declaration.ts +3026 -0
  325. package/src/cli/migrate-operation-shape.ts +184 -0
  326. package/src/cli/migrate-provider-shape.ts +778 -0
  327. package/src/cli/resolver-runtime.ts +72 -0
  328. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  329. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  330. package/src/cli/templates/provider/README.md.tpl +41 -19
  331. package/src/cli/templates/provider/index.ts.tpl +6 -3
  332. package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
  333. package/src/cli/templates/provider/provider.json.tpl +6 -0
  334. package/src/config/loader.ts +774 -181
  335. package/src/contract-serialization.ts +5 -7
  336. package/src/contract-types.ts +12 -5
  337. package/src/contract.ts +24 -10
  338. package/src/declaration-validation.ts +376 -0
  339. package/src/define.ts +1105 -131
  340. package/src/dev.ts +13 -1
  341. package/src/engine.ts +390 -0
  342. package/src/error-observability.ts +64 -0
  343. package/src/error-resolution.ts +269 -0
  344. package/src/errors.ts +157 -0
  345. package/src/fixture-sanitization.ts +422 -0
  346. package/src/handle-meta.ts +143 -0
  347. package/src/handle.ts +694 -0
  348. package/src/health-scenario.ts +960 -0
  349. package/src/i18n/catalog.ts +23 -1
  350. package/src/i18n/error-messages.ts +255 -0
  351. package/src/i18n/index.ts +1 -0
  352. package/src/i18n/keys.ts +80 -6
  353. package/src/i18n/operation-locale-namespace.ts +10 -0
  354. package/src/index.ts +390 -23
  355. package/src/lint.ts +2975 -234
  356. package/src/native-address.ts +340 -0
  357. package/src/native-egress-policy.ts +358 -0
  358. package/src/observability.ts +51 -1
  359. package/src/provider.ts +207 -27
  360. package/src/runtime/auth-flow.ts +15 -0
  361. package/src/runtime/bounded-expiring-map.ts +67 -0
  362. package/src/runtime/browser-telemetry-binding.ts +92 -0
  363. package/src/runtime/browser-telemetry.ts +289 -0
  364. package/src/runtime/browser.ts +882 -99
  365. package/src/runtime/cache-telemetry.ts +247 -0
  366. package/src/runtime/cache.ts +258 -22
  367. package/src/runtime/capability-response.ts +53 -0
  368. package/src/runtime/capability-telemetry.ts +101 -0
  369. package/src/runtime/chrome149-header-order.ts +330 -0
  370. package/src/runtime/diagnostic-env-values.ts +56 -0
  371. package/src/runtime/diagnostic-env.ts +65 -0
  372. package/src/runtime/diagnostic-redactor.ts +668 -0
  373. package/src/runtime/egress-lease.ts +341 -0
  374. package/src/runtime/engine-mode.ts +233 -0
  375. package/src/runtime/env.ts +15 -1
  376. package/src/runtime/executor.ts +39 -32
  377. package/src/runtime/handle-wordlist.ts +266 -0
  378. package/src/runtime/handle.ts +1180 -0
  379. package/src/runtime/http-telemetry-binding.ts +162 -0
  380. package/src/runtime/http-telemetry-guard.ts +208 -0
  381. package/src/runtime/http-telemetry.ts +292 -0
  382. package/src/runtime/http.ts +896 -150
  383. package/src/runtime/insights.ts +2 -2
  384. package/src/runtime/instrumentation.ts +628 -28
  385. package/src/runtime/native-network-errors.ts +99 -0
  386. package/src/runtime/native-network.ts +1853 -0
  387. package/src/runtime/native-telemetry.ts +366 -0
  388. package/src/runtime/ocr-telemetry.ts +308 -0
  389. package/src/runtime/ocr.ts +564 -0
  390. package/src/runtime/otlp.ts +468 -21
  391. package/src/runtime/provider.ts +15 -0
  392. package/src/runtime/proxy-errors.ts +12 -4
  393. package/src/runtime/proxy-nodemaven.ts +222 -0
  394. package/src/runtime/proxy-retry-policy.ts +12 -1
  395. package/src/runtime/proxy-telemetry.ts +358 -103
  396. package/src/runtime/redirect-hop-headers.ts +71 -0
  397. package/src/runtime/redirects.ts +215 -0
  398. package/src/runtime/redis.ts +7 -2
  399. package/src/runtime/request-options.ts +728 -1
  400. package/src/runtime/request-telemetry.ts +375 -0
  401. package/src/runtime/resolver-config.ts +18 -0
  402. package/src/runtime/resolver-public.ts +44 -0
  403. package/src/runtime/resolver-runtime-options.ts +50 -0
  404. package/src/runtime/resolver-shared.ts +113 -0
  405. package/src/runtime/resolver-span-attributes.ts +139 -0
  406. package/src/runtime/resolver-telemetry.ts +362 -0
  407. package/src/runtime/resolver-usage.ts +99 -0
  408. package/src/runtime/resolver-vendors/bindings.ts +61 -0
  409. package/src/runtime/resolver-vendors/browser.ts +591 -0
  410. package/src/runtime/resolver-vendors/capsolver.ts +727 -0
  411. package/src/runtime/resolver-vendors/hosts.ts +81 -0
  412. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  413. package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
  414. package/src/runtime/resolver-vendors/types.ts +261 -0
  415. package/src/runtime/resolver.ts +1543 -0
  416. package/src/runtime/state-telemetry.ts +298 -0
  417. package/src/runtime/state.ts +435 -89
  418. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  419. package/src/runtime/stealth-cookies.ts +155 -0
  420. package/src/runtime/stealth-owned-headers.ts +67 -0
  421. package/src/runtime/stealth-telemetry.ts +309 -0
  422. package/src/runtime/stealth.ts +2275 -372
  423. package/src/runtime/stt-telemetry.ts +282 -0
  424. package/src/runtime/stt.ts +96 -91
  425. package/src/runtime/timeout.ts +18 -0
  426. package/src/runtime/trace-config.ts +78 -0
  427. package/src/runtime/trace.ts +257 -35
  428. package/src/runtime-boundary-lint.ts +707 -0
  429. package/src/schema.ts +80 -2
  430. package/src/serve.ts +6 -1
  431. package/src/server/error-observability.ts +1 -0
  432. package/src/server/error-stack-frames.ts +214 -0
  433. package/src/server/index.ts +184 -2
  434. package/src/server/runtime-diagnostics.ts +522 -0
  435. package/src/server/sdk-version.ts +22 -0
  436. package/src/server/self-test-input-tokens.ts +29 -14
  437. package/src/server/self-test-redaction.ts +10 -40
  438. package/src/server/self-test.ts +205 -89
  439. package/src/server/sensitive-values.ts +211 -0
  440. package/src/server/serve-implementation.ts +4596 -0
  441. package/src/server/serve.ts +1 -1685
  442. package/src/server/state-backend-report.ts +185 -0
  443. package/src/server/trace-output.ts +189 -0
  444. package/src/server/types.ts +34 -1
  445. package/src/stateful/README.md +146 -0
  446. package/src/stateful/errors.ts +35 -0
  447. package/src/stateful/http-provider-event-emitter.ts +314 -0
  448. package/src/stateful/http-session-owner-registry.ts +306 -0
  449. package/src/stateful/index.ts +18 -0
  450. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  451. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  452. package/src/stateful/provider-event-pipeline.ts +61 -0
  453. package/src/stateful/provider-events.ts +462 -0
  454. package/src/stateful/session-key.ts +111 -0
  455. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  456. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  457. package/src/stateful/stateful-provider-adapter.ts +562 -0
  458. package/src/stateful/stateful-provider-observability.ts +261 -0
  459. package/src/stateful/stateful-provider-owner-forwarder.ts +310 -0
  460. package/src/stateful/stateful-provider-runtime-context.ts +96 -0
  461. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  462. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  463. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  464. package/src/stateful-signing.ts +46 -0
  465. package/src/stealth/profiles.ts +212 -231
  466. package/src/stream-evidence.ts +988 -0
  467. package/src/stream.ts +8 -1
  468. package/src/testing/index.ts +10 -1
  469. package/src/testing/run.ts +685 -19
  470. package/src/trace-sanitization.ts +110 -0
  471. package/src/types.ts +1014 -273
  472. package/src/typescript-module.ts +100 -0
  473. package/dist/choice-token.d.ts +0 -24
  474. package/dist/choice-token.js +0 -74
  475. package/dist/runtime/choice.d.ts +0 -15
  476. package/dist/runtime/choice.js +0 -413
  477. package/src/choice-token.ts +0 -165
  478. 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
- type AuthModeLike = "none" | "platform-managed" | "credentials" | "oauth2" | "api-key";
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 for platform-managed auth mode.`,
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
- function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: SchemaLike }> {
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
- seen.set(`${key}:${seen.size}`, value);
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 isSensitiveSchema(schema: unknown): boolean {
407
- if (!schema || typeof schema !== "object" || !("meta" in schema)) {
408
- return false;
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
- const meta = schema.meta;
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
- const normalized = name.toLowerCase().replace(/[-_\s]/g, "");
449
- return SENSITIVE_FIELD_NAMES.has(normalized);
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) && !isSensitiveSchema(child)) {
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
- export function lintOperation(op: {
797
- description?: string;
798
- descriptionKey?: string;
799
- whenToUse?: readonly string[];
800
- whenToUseKeys?: readonly string[];
801
- whenNotToUse?: readonly string[];
802
- whenNotToUseKeys?: readonly string[];
803
- input: unknown;
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
- if (description.trim().length > 0 && !hasDescriptionKey) {
814
- diagnostics.push({
815
- rule: "operation-description-raw-prose",
816
- level: "error",
817
- field: "description",
818
- message: "Operation description must use descriptionKey instead of raw static prose.",
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
- if (!hasDescriptionKey && description.length < 150) {
823
- diagnostics.push({
824
- rule: "description-min-length",
825
- level: "error",
826
- field: "description",
827
- message: "Operation description must be at least 150 characters.",
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
- if ((op.whenToUse?.length ?? 0) > 0 && !(op.whenToUseKeys?.length ?? 0)) {
832
- diagnostics.push({
833
- rule: "operation-when-to-use-raw-prose",
834
- level: "error",
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
- if ((op.whenNotToUse?.length ?? 0) > 0 && !(op.whenNotToUseKeys?.length ?? 0)) {
841
- diagnostics.push({
842
- rule: "operation-when-not-to-use-raw-prose",
843
- level: "error",
844
- field: "whenNotToUse",
845
- message: "Operation whenNotToUse must use whenNotToUseKeys instead of raw static prose.",
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
- const lowerDescription = description.toLowerCase();
850
- if (
851
- !hasDescriptionKey &&
852
- !(lowerDescription.includes("use") && lowerDescription.includes("when"))
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
- diagnostics.push({
855
- rule: "description-has-when-clause",
856
- level: "warn",
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
- diagnostics.push(
863
- ...collectSchemaDescriptionKeyDiagnostics(op.input, "input"),
864
- ...collectSchemaDescriptionKeyDiagnostics(op.output, "output"),
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
- if (!hasBidirectionalFixtures(op.fixtures)) {
868
- diagnostics.push({
869
- rule: "fixtures-both-directions",
870
- level: "error",
871
- field: "fixtures",
872
- message: "Fixtures must include both request and response.",
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
- if (isComplexSchema(op.input) && (op.inputExamples?.length ?? 0) < 2) {
877
- diagnostics.push({
878
- rule: "complex-input-has-examples",
879
- level: "warn",
880
- field: "inputExamples",
881
- message: "Complex input schemas should provide at least 2 input examples.",
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
- for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.input, "input"))) {
886
- diagnostics.push({
887
- rule: "sensitive-field-unmarked",
888
- level: "warn",
889
- field,
890
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
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
- for (const field of uniqueFields(collectUnmarkedSensitiveFields(op.output, "output"))) {
895
- diagnostics.push({
896
- rule: "sensitive-field-unmarked",
897
- level: "warn",
898
- field,
899
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
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 diagnostics;
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
- export function lintProvider(
907
- provider: {
908
- id?: string;
909
- allowedHosts?: readonly string[];
910
- stealth?: unknown;
911
- auth?: ProviderAuthLike;
912
- credential?: {
913
- keys?: readonly string[];
914
- storesReusableSecret?: boolean;
915
- justification?: string;
916
- };
917
- context?: {
918
- keys?: readonly string[];
919
- };
920
- authFlowSource?: string;
921
- providerSourceFiles?: Record<string, string>;
922
- operations?: Record<
923
- string,
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
- if (!provider.operations) {
974
- return diagnostics;
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
- diagnostics.push(
978
- ...Object.entries(provider.operations).flatMap(([operationKey, operation]) =>
979
- [
980
- ...lintOperation({
981
- description: operation.description ?? "",
982
- descriptionKey: operation.descriptionKey,
983
- whenToUse: operation.whenToUse,
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 diagnostics;
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
  }